diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 000000000..2aa2db975 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,8 @@ +pipeline: + test: + image: debian:testing + commands: + - apt-get update + - apt-get install -y python3-socks imagemagick python3-setuptools python3-cryptography python3-dateutil python3-idna python3-requests python3-django-timezone-field libimage-exiftool-perl python3-flake8 python3-pyqrcode python3-png python3-bandit imagemagick gnupg + - python3 epicyon.py --tests + - python3 epicyon.py --testsnetwork diff --git a/Makefile b/Makefile index 2f4863a5e..6b3fdf735 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ VERSION=1.1.0 all: debug: +sbom: + scanoss-py scan . > sbom.json source: rm -f *.*~ *~ rm -f ontology/*~ @@ -17,6 +19,7 @@ source: rm -f ../${APP}*.deb ../${APP}*.changes ../${APP}*.asc ../${APP}*.dsc cd .. && mv ${APP} ${APP}-${VERSION} && tar -zcvf ${APP}_${VERSION}.orig.tar.gz ${APP}-${VERSION}/ && mv ${APP}-${VERSION} ${APP} clean: + rm -f \#* rm -f *.*~ *~ *.dot rm -f orgs/*~ rm -f ontology/*~ @@ -25,9 +28,11 @@ clean: rm -f theme/indymediaclassic/welcome/*~ rm -f theme/indymediamodern/welcome/*~ rm -f website/EN/*~ + rm -f cwlists/*~ rm -f gemini/EN/*~ rm -f scripts/*~ rm -f deploy/*~ rm -f translations/*~ + rm -f flycheck_* rm -rf __pycache__ rm -f calendar.css blog.css epicyon.css follow.css login.css options.css search.css suspended.css diff --git a/README.md b/README.md index 5434eae41..7368e4b44 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,33 @@ Add issues on https://gitlab.com/bashrc2/epicyon/-/issues
Epicyon, meaning "more than a dog". Largest of the Borophaginae which lived in North America 20-5 million years ago.
- + -Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant server implementing both S2S and C2S protocols and suitable for installation on single board computers. It includes features such as moderation tools, post expiry, content warnings, image descriptions, news feed and perimeter defense against adversaries. It contains *no JavaScript* and uses HTML+CSS with a Python backend. +Epicyon is a [fediverse](https://en.wikipedia.org/wiki/Fediverse) server suitable for self-hosting a small number of accounts on low power systems. -[Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Software Architecture](README_architecture.md) - [Code of Conduct](code-of-conduct.md) +Key features: + + * Open standards: HTML, CSS, ActivityPub, RSS, CalDAV. + * Supports common web browsers and [shell browsers](https://lynx.invisible-island.net). + * Will not drain your mobile or laptop battery. + * Customisable themes. It doesn't have to look bland. + * Emoji reactions. + * Geospatial hashtags. + * Does not require much RAM, either on server or client. + * Suitable for installation on single board computers. + * No timeline algorithms. + * No javascript. + * No database. Data stored as ordinary files. + * No fashionable web frameworks. *"Boring by design"*. + * No blockchain garbage. + * Written in Python, with few dependencies. + * AGPL license, which big tech hates. + +Epicyon is for people who are tired of *big anything* and just want to DIY their online social experience without much fuss or expense. Think *water cooler discussions* rather than *shouting into the void*, in which you're mainly just reading and responding to the posts of people that you're following. + +[Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Software Architecture](README_architecture.md) - [Code of Conduct](code-of-conduct.md) - [Principles of Unity](principlesofunity.md) - [C2S Desktop Client](README_desktop_client.md) - [Coding Style](README_coding_style.md) Matrix room: **#epicyon:matrix.libreserver.org** @@ -29,8 +49,8 @@ On Arch/Parabola: ``` bash sudo pacman -S tor python-pip python-pysocks python-cryptography \ imagemagick python-requests \ - perl-image-exiftool python-dateutil \ - certbot flake8 bandit + perl-image-exiftool python-dateutil \ + certbot flake8 bandit sudo pip3 install pyqrcode pypng ``` @@ -55,6 +75,13 @@ In the most common case you'll be using systemd to set up a daemon to run the se The following instructions install Epicyon to the **/opt** directory. It's not essential that it be installed there, and it could be in any other preferred directory. +Clone the repo, or if you downloaded the tarball then extract it into the **/opt** directory. + +``` bash +cd /opt +git clone https://gitlab.com/bashrc2/epicyon +``` + Add a dedicated user so that we don't have to run as root. ``` bash @@ -82,11 +109,32 @@ Type=simple User=epicyon Group=epicyon WorkingDirectory=/opt/epicyon -ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --logLoginFailures +ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --log_login_failures Environment=USER=epicyon Environment=PYTHONUNBUFFERED=true Restart=always StandardError=syslog +CPUQuota=80% +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectKernelLogs=true +ProtectHostname=true +ProtectClock=true +ProtectProc=invisible +ProcSubset=pid +PrivateTmp=true +PrivateUsers=true +PrivateDevices=true +PrivateIPC=true +MemoryDenyWriteExecute=true +NoNewPrivileges=true +LockPersonality=true +RestrictRealtime=true +RestrictSUIDSGID=true +RestrictNamespaces=true +SystemCallArchitectures=native [Install] WantedBy=multi-user.target @@ -134,6 +182,16 @@ server { listen 443 ssl; server_name YOUR_DOMAIN; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/ld+json application/javascript text/xml application/xml application/rdf+xml application/xml+rss text/javascript; + ssl_stapling off; ssl_stapling_verify off; ssl on; @@ -141,19 +199,19 @@ server { ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; #ssl_dhparam /etc/ssl/certs/YOUR_DOMAIN.dhparam; - ssl_session_cache builtin:1000 shared:SSL:10m; - ssl_session_timeout 60m; - ssl_prefer_server_ciphers on; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; + ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + add_header Content-Security-Policy "default-src https:; script-src https: 'unsafe-inline'; style-src https: 'unsafe-inline'"; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; add_header X-XSS-Protection "1; mode=block"; add_header X-Download-Options noopen; add_header X-Permitted-Cross-Domain-Policies none; - - add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; - add_header Strict-Transport-Security max-age=15768000; + add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; access_log /dev/null; error_log /dev/null; @@ -165,6 +223,9 @@ server { try_files $uri =404; } + keepalive_timeout 70; + sendfile on; + location / { proxy_http_version 1.1; client_max_body_size 31M; @@ -184,6 +245,7 @@ server { proxy_request_buffering off; proxy_buffering off; proxy_pass http://localhost:7156; + tcp_nodelay on; } } ``` @@ -197,7 +259,9 @@ ln -s /etc/nginx/sites-available/YOUR_DOMAIN /etc/nginx/sites-enabled/ Generate a LetsEncrypt certificate. ``` bash +systemctl stop nginx certbot certonly -n --server https://acme-v02.api.letsencrypt.org/directory --standalone -d YOUR_DOMAIN --renew-by-default --agree-tos --email YOUR_EMAIL +systemctl start nginx ``` And restart the web server: @@ -278,3 +342,13 @@ To run the network tests. These simulate instances exchanging messages. ``` bash python3 epicyon.py --testsnetwork ``` + +## Software Bill of Materials + +To update the software bill of materials: + +``` bash +sudo pip3 install scanoss +make clean +make sbom +``` diff --git a/README_architecture.md b/README_architecture.md index a7dff92aa..4dcb87bec 100644 --- a/README_architecture.md +++ b/README_architecture.md @@ -16,6 +16,10 @@ Although it can be single user, this is not strictly a single user system. The design of this system is opinionated, and to a large extent informed by years of past experience in the fediverse. There is no claim to neutrality of any sort. Automatic removal of hellthreads and other common griefing tactics is an example of this. +### Privacy Sensitive Defaults + +Follow approval should be required by default. This gives the user a chance to see who wants to follow them and make a decision. Also by default direct messages should not be permitted except with accounts that you are following. This helps to reduce spam and harrassment from random accounts in the wider fediverse. The aim is for the user to have a good experience by default, even if they have not yet built up any sort of block list. + ### Resisting Centralization Centralization is characterized by the typical fixation upon "scale" within the software industry. Systems which scale, in the way which is commonly understood, mean that a few individuals can control the social lives of many, and extract value from them in often cynical and manipulative ways. @@ -24,7 +28,7 @@ In general, methods have been preferred which do not vertically scale. This incl Being hostile towards the common notion of scaling means that this system will be of no interest to "big tech" and can't easily be used within extractive economic models without needing a substantial rewrite. This avoids the typical cooption strategies in which large companies eventually take over what was originally software developed by grassroots activists to address real community needs. -This system should however be able to scale rhizomatically with the deployment of many small instances federated together. Instead of scaling up, scale out. In a network of many small instances nobody has overall control and corporate capture is much more unlikely. Small instances also minimize the bureaucratic requirements for governance processes, which at medium to large scale eventually becomes tyrannical. +This system should however be able to scale rhizomatically with the deployment of many small instances federated together. Instead of scaling up, scale out. In a network of many small instances nobody has overall control and corporate capture is far less feasible. Small instances also minimize the bureaucratic requirements for governance processes, which at medium to large scale eventually becomes tyrannical. ### Roles @@ -32,11 +36,11 @@ The roles within an instance are comparable to the crew roles onboard a ship, wi ### No Javascript -This is so that the system can be accessed and used normally with javascript in the web browser turned off. If you want to have good security then this is useful, since lack of javascript greatly reduces the attack surface and constrains adversaries to a limited number of vectors. +This is so that the system can be accessed and used normally with javascript in the web browser turned off. If you want to have good security then this is useful, since lack of javascript greatly reduces the attack surface and constrains adversaries to a limited number of vectors. Not using javascript also makes this system usable in shell based browsers such as Lynx, or other less common browsers, which helps to avoid being locked in to a browser duopoly. ### Block Crawlers -Ordinarily web crawlers would not be a problem, but in the context of a social network even having crawlers index public posts can create ethical dilemmas in some circumstances. News instances may allow crawlers, but other types of instances should block them. +Ordinarily web crawlers would not be a problem, but in the context of a social network even having crawlers index public posts can create ethical dilemmas in some circumstances. News and blogging instances may allow crawlers, but other types of instances should block them. ### No Local or Federated Timelines @@ -60,6 +64,9 @@ It is usually safe to assume that the federated network beyond your instance is Where Json linked data signatures are supported there should not be arbitrary schema lookups via the web. Instead, recognized contexts should be added to *context.py*. This is in order to follow the principle of *no processing without full recognition*, in which the recognition step is not endlessly extendable by untrusted parties. +### Avoid Web Frameworks + +In general avoid using web frameworks and instead use local modules which are prefixed with *webapp_*. Web frameworks are built for conventional software engineering by large companies who are designing for scale. They typically have database dependencies and contain a lot of hardcoded Google stuff or other things which will leak metadata or be incompatible with onion routing. Keeping up with web frameworks is a constant firefight. They also create a massive attack surface requiring constant vigilance. ## High Level Architecture diff --git a/README_coding_style.md b/README_coding_style.md new file mode 100644 index 000000000..4b4c47742 --- /dev/null +++ b/README_coding_style.md @@ -0,0 +1,21 @@ +# Epicyon Coding Style + +Try to keep to the typical PEP8 coding style supported by Python static analysis systems. + +Variables all lower case and using underscores to separate words (snake case). + +Variables sent via webforms (with name="someVariableName") or within config.json are usually CamelCase, in order to clearly distinguish those from ordinary program variables. + +Procedural style. Think "C style in Python". Avoid classes and objects as far as possible. This avoids *obfuscation via abstractions*. With procedural style everything is maximally obvious/concrete and can be followed through step by step without needing a lot of implicit background knowledge. Procedural style also makes more rigorous static analysis possible, to catch bugs before they happen at runtime. Mantra: "In the long run, obviousness beats clever abstractions". + +Declare all called functions individually at the top of each module. This avoids any possible mistakes with colliding function names, and allows static analysis to explicitly check all dependencies. + +Don't use any features of Python which are not supported by the version of Python within the current Debian stable release. Don't assume that all users are running the latest cutting-edge Python release. + +Before doing a commit run all the unit tests. There are three layers of testing. The first just checks PEP8 compliance. The second runs a more thorough static analysis and unit tests. The third simulates instances communicating with each other. + +```bash +./static_analysis +python3 epicyon.py --tests +python3 epicyon.py --testsnetwork +``` diff --git a/README_commandline.md b/README_commandline.md index 9529671b9..4ce329da9 100644 --- a/README_commandline.md +++ b/README_commandline.md @@ -222,24 +222,32 @@ python3 epicyon.py --nickname [yournick] --domain [name] \ --undolike [url] --password [c2s password] ``` -## Archiving posts +## Archiving and Expiring posts -You can archive old posts with: +As a general rule, all posts will be retained unless otherwise specified. However, on systems with finite and small disk storage running out of space is a show-stopping catastrophe and so clearing down old posts is highly advisable. You can achieve this using the archive commandline option, and optionally also with a cron job. + +You can archive old posts and expire posts as specified within account profile settings with: ``` bash python3 epicyon.py --archive [directory] ``` -Which will move old posts to the given directory. You can also specify the number of weeks after which images will be archived, and the maximum number of posts within in/outboxes. +Which will move old posts to the given directory and delete any expired posts. You can also specify the number of weeks after which images will be archived, and the maximum number of posts within in/outboxes. ``` bash -python3 epicyon.py --archive [directory] --archiveweeks 4 --maxposts 256 +python3 epicyon.py --archive [directory] --archiveweeks 4 --maxposts 32000 ``` If you want old posts to be deleted for data minimization purposes then the archive location can be set to */dev/null*. ``` bash -python3 epicyon.py --archive /dev/null --archiveweeks 4 --maxposts 256 +python3 epicyon.py --archive /dev/null --archiveweeks 4 --maxposts 32000 +``` + +You can put this command into a cron job to ensure that old posts are cleared down regularly. In */etc/crontab* add an entry such as: + +``` bash +*/60 * * * * root cd /opt/epicyon && /usr/bin/python3 epicyon.py --archive /dev/null --archiveweeks 4 --maxposts 32000 ``` ## Blocking and unblocking @@ -372,3 +380,31 @@ To remove a shared item: ``` bash python3 epicyon.py --undoItemName "spanner" --nickname [yournick] --domain [yourdomain] --password [c2s password] ``` + +## Calendar + +The calendar for each account can be accessed via CalDav (RFC4791). This makes it easy to integrate the social calendar into other applications. For example, to obtain events for a month: + +```bash +python3 epicyon.py --dav --nickname [yournick] --domain [yourdomain] --year [year] --month [month number] +``` + +You will be prompted for your login password, or you can use the **--password** option. You can also use the **--day** option to obtain events for a particular day. + +The CalDav endpoint for an account is: + +```bash +yourdomain/calendars/yournick +``` + +## Web Crawlers + +Having search engines index social media posts is not usually considered appropriate, since even if "public" they may contain personally identifiable information. If you are running a news instance then web crawlers will be permitted by the system, but otherwise by default they will be blocked. + +If you want to allow specific web crawlers then when running the daemon (typically with systemd) you can use the **crawlersAllowed** option. It can take a list of bot names, separated by commas. For example: + +```bash +--crawlersAllowed "googlebot, apple" +``` + +Typically web crawlers have names ending in "bot", but partial names can also be used. diff --git a/README_customizations.md b/README_customizations.md index a6f86ad7d..462ee1cdd 100644 --- a/README_customizations.md +++ b/README_customizations.md @@ -26,6 +26,20 @@ When a moderator report is created the message at the top of the screen can be c Extra emoji can be added to the *emoji* directory and you should then update the **emoji/emoji.json** file, which maps the name to the filename (without the .png extension). +Another way to import emoji is to create a text file where each line is the url of the emoji png file and the emoji name, separated by a comma. + +```bash +https://somesite/emoji1.png, :emojiname1: +https://somesite/emoji2.png, :emojiname2: +https://somesite/emoji3.png, :emojiname3: +``` + +Then this can be imported with: + +```bash +python3 epicyon.py --import-emoji [textfile] +``` + ## Themes -If you want to create a new theme then the functions for that are within *theme.py*. These functions take the CSS templates and modify them. You will need to edit *themesDropdown* within *webinterface.py* and add the appropriate translations for the theme name. Themes are selectable from the profile screen of the administrator. +If you want to create a new theme then copy the *default* directory within the *theme* directory, rename it to your new theme name, then you can edit the colors and fonts within *theme.json*, and change the icons and banners. Themes are selectable from the graphic design section of the profile screen of the administrator, or of any accounts having the *artist* role. diff --git a/README_desktop_client.md b/README_desktop_client.md index a1ac25fe6..4b84d4d4c 100644 --- a/README_desktop_client.md +++ b/README_desktop_client.md @@ -1,4 +1,6 @@ -# Desktop client +# C2S Desktop client + + ## Installing and running @@ -26,6 +28,12 @@ Or if you have picospeaker installed: ~/epicyon-client-pico ``` +Or if you have mimic3 installed: + +``` bash +~/epicyon-client-mimic3 +``` + ## Commands The desktop client has a few commands, which may be more convenient than the web interface for some purposes: @@ -85,7 +93,13 @@ Or a quicker version, if you have installed the desktop client as described abov Or if you have [picospeaker](https://gitlab.com/ky1e/picospeaker) installed: ``` bash -python3 epicyon.py --notifyShowNewPosts --screenreader picospeaker --desktop yournickname@yourdomain +~/epicyon-stream-pico +``` + +Or if you have mimic3 installed: + +``` bash +~/epicyon-stream-mimic3 ``` You can also use the **--password** option to provide the password. This will then stay running and incoming posts will be announced as they arrive. diff --git a/README_goals.md b/README_goals.md index 531bc467d..e37b070ca 100644 --- a/README_goals.md +++ b/README_goals.md @@ -45,6 +45,7 @@ The following are considered anti-features of other social network systems, sinc * Algorithmic timelines (i.e. non-chronological) * Direct payment mechanisms, although integration with other services may be possible * Any variety of blockchain + * Non Fungible Token (NFT) features * Anything based upon "proof of stake". The "people who have more, get more" principle should be rejected. * Like counts above some small maximum number. The aim is to avoid people getting addicted to making numbers go up, and especially to avoid the dark market in fake likes. * Sponsored posts diff --git a/README_roadmap.md b/README_roadmap.md index b1a6eb428..5f80b83e3 100644 --- a/README_roadmap.md +++ b/README_roadmap.md @@ -7,7 +7,6 @@ ## Groups * Groups can be defined as having particular roles/skills - * Parse posts from Lemmy groups * Think of a way to display groups. Maybe assign a hashtag and display them like hashtag timelines ## Questions @@ -20,7 +19,5 @@ ## Code * More unit test coverage - * Unit test for federated shared items * Break up large functions into smaller ones - * Architecture diagrams * Code documentation? diff --git a/acceptreject.py b/acceptreject.py index bd54600c3..dc529779f 100644 --- a/acceptreject.py +++ b/acceptreject.py @@ -1,220 +1,231 @@ __filename__ = "acceptreject.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" import os -from utils import hasUsersPath -from utils import getFullDomain -from utils import urlPermitted -from utils import getDomainFromActor -from utils import getNicknameFromActor -from utils import domainPermitted -from utils import followPerson -from utils import hasObjectDict -from utils import acctDir -from utils import hasGroupType -from utils import localActorUrl +from utils import text_in_file +from utils import has_object_string_object +from utils import has_users_path +from utils import get_full_domain +from utils import url_permitted +from utils import get_domain_from_actor +from utils import get_nickname_from_actor +from utils import domain_permitted +from utils import follow_person +from utils import acct_dir +from utils import has_group_type +from utils import local_actor_url +from utils import has_actor +from utils import has_object_string_type -def _createAcceptReject(baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - toUrl: str, ccUrl: str, httpPrefix: str, - objectJson: {}, acceptType: str) -> {}: +def _create_accept_reject(base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + to_url: str, cc_url: str, http_prefix: str, + object_json: {}, accept_type: str) -> {}: """Accepts or rejects something (eg. a follow request or offer) - Typically toUrl will be https://www.w3.org/ns/activitystreams#Public - and ccUrl might be a specific person favorited or repeated and + Typically to_url will be https://www.w3.org/ns/activitystreams#Public + and cc_url might be a specific person favorited or repeated and the followers url objectUrl is typically the url of the message, corresponding to url or atomUri in createPostBase """ - if not objectJson.get('actor'): + if not object_json.get('actor'): return None - if not urlPermitted(objectJson['actor'], federationList): + if not url_permitted(object_json['actor'], federation_list): return None - domain = getFullDomain(domain, port) + domain = get_full_domain(domain, port) - newAccept = { + new_accept = { "@context": "https://www.w3.org/ns/activitystreams", - 'type': acceptType, - 'actor': localActorUrl(httpPrefix, nickname, domain), - 'to': [toUrl], + 'type': accept_type, + 'actor': local_actor_url(http_prefix, nickname, domain), + 'to': [to_url], 'cc': [], - 'object': objectJson + 'object': object_json } - if ccUrl: - if len(ccUrl) > 0: - newAccept['cc'] = [ccUrl] - return newAccept + if cc_url: + if len(cc_url) > 0: + new_accept['cc'] = [cc_url] + return new_accept -def createAccept(baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - toUrl: str, ccUrl: str, httpPrefix: str, - objectJson: {}) -> {}: - return _createAcceptReject(baseDir, federationList, - nickname, domain, port, - toUrl, ccUrl, httpPrefix, - objectJson, 'Accept') +def create_accept(base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + to_url: str, cc_url: str, http_prefix: str, + object_json: {}) -> {}: + return _create_accept_reject(base_dir, federation_list, + nickname, domain, port, + to_url, cc_url, http_prefix, + object_json, 'Accept') -def createReject(baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - toUrl: str, ccUrl: str, httpPrefix: str, - objectJson: {}) -> {}: - return _createAcceptReject(baseDir, federationList, - nickname, domain, port, - toUrl, ccUrl, - httpPrefix, objectJson, 'Reject') +def create_reject(base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + to_url: str, cc_url: str, http_prefix: str, + object_json: {}) -> {}: + return _create_accept_reject(base_dir, federation_list, + nickname, domain, port, + to_url, cc_url, + http_prefix, object_json, 'Reject') -def _acceptFollow(baseDir: str, domain: str, messageJson: {}, - federationList: [], debug: bool) -> None: +def _accept_follow(base_dir: str, message_json: {}, + federation_list: [], debug: bool, + curr_domain: str, + onion_domain: str, i2p_domain: str) -> None: """Receiving a follow Accept activity """ - if not hasObjectDict(messageJson): + if not has_object_string_type(message_json, debug): return - if not messageJson['object'].get('type'): - return - if not messageJson['object']['type'] == 'Follow': - if not messageJson['object']['type'] == 'Join': + if not message_json['object']['type'] == 'Follow': + if not message_json['object']['type'] == 'Join': return if debug: print('DEBUG: receiving Follow activity') - if not messageJson['object'].get('actor'): + if not message_json['object'].get('actor'): print('DEBUG: no actor in Follow activity') return # no, this isn't a mistake - if not messageJson['object'].get('object'): - print('DEBUG: no object within Follow activity') + if not has_object_string_object(message_json, debug): return - if not messageJson.get('to'): + if not message_json.get('to'): if debug: print('DEBUG: No "to" parameter in follow Accept') return if debug: - print('DEBUG: follow Accept received') - thisActor = messageJson['object']['actor'] - nickname = getNicknameFromActor(thisActor) + print('DEBUG: follow Accept received ' + str(message_json)) + this_actor = message_json['object']['actor'] + nickname = get_nickname_from_actor(this_actor) if not nickname: - print('WARN: no nickname found in ' + thisActor) + print('WARN: no nickname found in ' + this_actor) return - acceptedDomain, acceptedPort = getDomainFromActor(thisActor) - if not acceptedDomain: + accepted_domain, accepted_port = get_domain_from_actor(this_actor) + if not accepted_domain: if debug: - print('DEBUG: domain not found in ' + thisActor) + print('DEBUG: domain not found in ' + this_actor) return if not nickname: if debug: - print('DEBUG: nickname not found in ' + thisActor) + print('DEBUG: nickname not found in ' + this_actor) return - if acceptedPort: - if '/' + acceptedDomain + ':' + str(acceptedPort) + \ - '/users/' + nickname not in thisActor: + if accepted_port: + if '/' + accepted_domain + ':' + str(accepted_port) + \ + '/users/' + nickname not in this_actor: if debug: - print('Port: ' + str(acceptedPort)) - print('Expected: /' + acceptedDomain + ':' + - str(acceptedPort) + '/users/' + nickname) - print('Actual: ' + thisActor) - print('DEBUG: unrecognized actor ' + thisActor) + print('Port: ' + str(accepted_port)) + print('Expected: /' + accepted_domain + ':' + + str(accepted_port) + '/users/' + nickname) + print('Actual: ' + this_actor) + print('DEBUG: unrecognized actor ' + this_actor) return else: - if not '/' + acceptedDomain + '/users/' + nickname in thisActor: + if not '/' + accepted_domain + '/users/' + nickname in this_actor: if debug: - print('Expected: /' + acceptedDomain + '/users/' + nickname) - print('Actual: ' + thisActor) - print('DEBUG: unrecognized actor ' + thisActor) + print('Expected: /' + accepted_domain + '/users/' + nickname) + print('Actual: ' + this_actor) + print('DEBUG: unrecognized actor ' + this_actor) return - followedActor = messageJson['object']['object'] - followedDomain, port = getDomainFromActor(followedActor) - if not followedDomain: + followed_actor = message_json['object']['object'] + followed_domain, port = get_domain_from_actor(followed_actor) + if not followed_domain: print('DEBUG: no domain found within Follow activity object ' + - followedActor) + followed_actor) return - followedDomainFull = followedDomain + followed_domain_full = followed_domain if port: - followedDomainFull = followedDomain + ':' + str(port) - followedNickname = getNicknameFromActor(followedActor) - if not followedNickname: + followed_domain_full = followed_domain + ':' + str(port) + followed_nickname = get_nickname_from_actor(followed_actor) + if not followed_nickname: print('DEBUG: no nickname found within Follow activity object ' + - followedActor) + followed_actor) return - acceptedDomainFull = acceptedDomain - if acceptedPort: - acceptedDomainFull = acceptedDomain + ':' + str(acceptedPort) + # convert from onion/i2p to clearnet accepted domain + if onion_domain: + if accepted_domain.endswith('.onion') and \ + not curr_domain.endswith('.onion'): + accepted_domain = curr_domain + if i2p_domain: + if accepted_domain.endswith('.i2p') and \ + not curr_domain.endswith('.i2p'): + accepted_domain = curr_domain + + accepted_domain_full = accepted_domain + if accepted_port: + accepted_domain_full = accepted_domain + ':' + str(accepted_port) # has this person already been unfollowed? - unfollowedFilename = \ - acctDir(baseDir, nickname, acceptedDomainFull) + '/unfollowed.txt' - if os.path.isfile(unfollowedFilename): - if followedNickname + '@' + followedDomainFull in \ - open(unfollowedFilename).read(): + unfollowed_filename = \ + acct_dir(base_dir, nickname, accepted_domain_full) + '/unfollowed.txt' + if os.path.isfile(unfollowed_filename): + if text_in_file(followed_nickname + '@' + followed_domain_full, + unfollowed_filename): if debug: print('DEBUG: follow accept arrived for ' + - nickname + '@' + acceptedDomainFull + - ' from ' + followedNickname + '@' + followedDomainFull + + nickname + '@' + accepted_domain_full + + ' from ' + + followed_nickname + '@' + followed_domain_full + ' but they have been unfollowed') return # does the url path indicate that this is a group actor - groupAccount = hasGroupType(baseDir, followedActor, None, debug) + group_account = has_group_type(base_dir, followed_actor, None, debug) if debug: - print('Accepted follow is a group: ' + str(groupAccount) + - ' ' + followedActor + ' ' + baseDir) + print('Accepted follow is a group: ' + str(group_account) + + ' ' + followed_actor + ' ' + base_dir) - if followPerson(baseDir, - nickname, acceptedDomainFull, - followedNickname, followedDomainFull, - federationList, debug, groupAccount): + if follow_person(base_dir, + nickname, accepted_domain_full, + followed_nickname, followed_domain_full, + federation_list, debug, group_account): if debug: - print('DEBUG: ' + nickname + '@' + acceptedDomainFull + - ' followed ' + followedNickname + '@' + followedDomainFull) + print('DEBUG: ' + nickname + '@' + accepted_domain_full + + ' followed ' + + followed_nickname + '@' + followed_domain_full) else: if debug: print('DEBUG: Unable to create follow - ' + - nickname + '@' + acceptedDomain + ' -> ' + - followedNickname + '@' + followedDomain) + nickname + '@' + accepted_domain + ' -> ' + + followed_nickname + '@' + followed_domain) -def receiveAcceptReject(session, baseDir: str, - httpPrefix: str, domain: str, port: int, - sendThreads: [], postLog: [], cachedWebfingers: {}, - personCache: {}, messageJson: {}, federationList: [], - debug: bool) -> bool: +def receive_accept_reject(base_dir: str, domain: str, message_json: {}, + federation_list: [], debug: bool, curr_domain: str, + onion_domain: str, i2p_domain: str) -> bool: """Receives an Accept or Reject within the POST section of HTTPServer """ - if messageJson['type'] != 'Accept' and messageJson['type'] != 'Reject': + if message_json['type'] != 'Accept' and message_json['type'] != 'Reject': return False - if not messageJson.get('actor'): - if debug: - print('DEBUG: ' + messageJson['type'] + ' has no actor') + if not has_actor(message_json, debug): return False - if not hasUsersPath(messageJson['actor']): + if not has_users_path(message_json['actor']): if debug: print('DEBUG: "users" or "profile" missing from actor in ' + - messageJson['type'] + '. Assuming single user instance.') - domain, tempPort = getDomainFromActor(messageJson['actor']) - if not domainPermitted(domain, federationList): + message_json['type'] + '. Assuming single user instance.') + domain, _ = get_domain_from_actor(message_json['actor']) + if not domain_permitted(domain, federation_list): if debug: - print('DEBUG: ' + messageJson['type'] + + print('DEBUG: ' + message_json['type'] + ' from domain not permitted - ' + domain) return False - nickname = getNicknameFromActor(messageJson['actor']) + nickname = get_nickname_from_actor(message_json['actor']) if not nickname: # single user instance nickname = 'dev' if debug: - print('DEBUG: ' + messageJson['type'] + + print('DEBUG: ' + message_json['type'] + ' does not contain a nickname. ' + 'Assuming single user instance.') # receive follow accept - _acceptFollow(baseDir, domain, messageJson, federationList, debug) + _accept_follow(base_dir, message_json, federation_list, debug, + curr_domain, onion_domain, i2p_domain) if debug: - print('DEBUG: Uh, ' + messageJson['type'] + ', I guess') + print('DEBUG: Uh, ' + message_json['type'] + ', I guess') return True diff --git a/announce.py b/announce.py index 603fd8af1..1bc3c538f 100644 --- a/announce.py +++ b/announce.py @@ -1,433 +1,445 @@ __filename__ = "announce.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" -from utils import hasGroupType -from utils import removeDomainPort -from utils import hasObjectDict -from utils import removeIdEnding -from utils import hasUsersPath -from utils import getFullDomain -from utils import getStatusNumber -from utils import createOutboxDir -from utils import urlPermitted -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import locatePost -from utils import saveJson -from utils import undoAnnounceCollectionEntry -from utils import updateAnnounceCollection -from utils import localActorUrl -from utils import replaceUsersWithAt -from posts import sendSignedJson -from posts import getPersonBox -from session import postJson -from webfinger import webfingerHandle -from auth import createBasicAuthHeader +from utils import has_object_string_object +from utils import has_group_type +from utils import has_object_dict +from utils import remove_domain_port +from utils import remove_id_ending +from utils import has_users_path +from utils import get_full_domain +from utils import get_status_number +from utils import create_outbox_dir +from utils import url_permitted +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import locate_post +from utils import save_json +from utils import undo_announce_collection_entry +from utils import update_announce_collection +from utils import local_actor_url +from utils import replace_users_with_at +from utils import has_actor +from utils import has_object_string_type +from posts import send_signed_json +from posts import get_person_box +from session import post_json +from webfinger import webfinger_handle +from auth import create_basic_auth_header -def isSelfAnnounce(postJsonObject: {}) -> bool: +def no_of_announces(post_json_object: {}) -> int: + """Returns the number of announces on a given post + """ + obj = post_json_object + if has_object_dict(post_json_object): + obj = post_json_object['object'] + if not obj.get('shares'): + return 0 + if not isinstance(obj['shares'], dict): + return 0 + if not obj['shares'].get('items'): + obj['shares']['items'] = [] + obj['shares']['totalItems'] = 0 + return len(obj['shares']['items']) + + +def is_self_announce(post_json_object: {}) -> bool: """Is the given post a self announce? """ - if not postJsonObject.get('actor'): + if not post_json_object.get('actor'): return False - if not postJsonObject.get('type'): + if not post_json_object.get('type'): return False - if postJsonObject['type'] != 'Announce': + if post_json_object['type'] != 'Announce': return False - if not postJsonObject.get('object'): + if not post_json_object.get('object'): return False - if not isinstance(postJsonObject['actor'], str): + if not isinstance(post_json_object['actor'], str): return False - if not isinstance(postJsonObject['object'], str): + if not isinstance(post_json_object['object'], str): return False - return postJsonObject['actor'] in postJsonObject['object'] + return post_json_object['actor'] in post_json_object['object'] -def outboxAnnounce(recentPostsCache: {}, - baseDir: str, messageJson: {}, debug: bool) -> bool: +def outbox_announce(recent_posts_cache: {}, + base_dir: str, message_json: {}, debug: bool) -> bool: """ Adds or removes announce entries from the shares collection within a given post """ - if not messageJson.get('actor'): + if not has_actor(message_json, debug): return False - if not isinstance(messageJson['actor'], str): + if not isinstance(message_json['actor'], str): return False - if not messageJson.get('type'): + if not message_json.get('type'): return False - if not messageJson.get('object'): + if not message_json.get('object'): return False - if messageJson['type'] == 'Announce': - if not isinstance(messageJson['object'], str): + if message_json['type'] == 'Announce': + if not isinstance(message_json['object'], str): return False - if isSelfAnnounce(messageJson): + if is_self_announce(message_json): return False - nickname = getNicknameFromActor(messageJson['actor']) + nickname = get_nickname_from_actor(message_json['actor']) if not nickname: - print('WARN: no nickname found in ' + messageJson['actor']) + print('WARN: no nickname found in ' + message_json['actor']) return False - domain, port = getDomainFromActor(messageJson['actor']) - postFilename = locatePost(baseDir, nickname, domain, - messageJson['object']) - if postFilename: - updateAnnounceCollection(recentPostsCache, baseDir, postFilename, - messageJson['actor'], - nickname, domain, debug) + domain, _ = get_domain_from_actor(message_json['actor']) + post_filename = locate_post(base_dir, nickname, domain, + message_json['object']) + if post_filename: + update_announce_collection(recent_posts_cache, + base_dir, post_filename, + message_json['actor'], + nickname, domain, debug) return True - elif messageJson['type'] == 'Undo': - if not hasObjectDict(messageJson): + elif message_json['type'] == 'Undo': + if not has_object_string_type(message_json, debug): return False - if not messageJson['object'].get('type'): - return False - if messageJson['object']['type'] == 'Announce': - if not isinstance(messageJson['object']['object'], str): + if message_json['object']['type'] == 'Announce': + if not isinstance(message_json['object']['object'], str): return False - nickname = getNicknameFromActor(messageJson['actor']) + nickname = get_nickname_from_actor(message_json['actor']) if not nickname: - print('WARN: no nickname found in ' + messageJson['actor']) + print('WARN: no nickname found in ' + message_json['actor']) return False - domain, port = getDomainFromActor(messageJson['actor']) - postFilename = locatePost(baseDir, nickname, domain, - messageJson['object']['object']) - if postFilename: - undoAnnounceCollectionEntry(recentPostsCache, - baseDir, postFilename, - messageJson['actor'], - domain, debug) + domain, _ = get_domain_from_actor(message_json['actor']) + post_filename = locate_post(base_dir, nickname, domain, + message_json['object']['object']) + if post_filename: + undo_announce_collection_entry(recent_posts_cache, + base_dir, post_filename, + message_json['actor'], + domain, debug) return True return False -def announcedByPerson(isAnnounced: bool, postActor: str, - nickname: str, domainFull: str) -> bool: +def announced_by_person(is_announced: bool, post_actor: str, + nickname: str, domain_full: str) -> bool: """Returns True if the given post is announced by the given person """ - if not postActor: + if not post_actor: return False - if isAnnounced and \ - postActor.endswith(domainFull + '/users/' + nickname): + if is_announced and \ + post_actor.endswith(domain_full + '/users/' + nickname): return True return False -def createAnnounce(session, baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - toUrl: str, ccUrl: str, httpPrefix: str, - objectUrl: str, saveToFile: bool, - clientToServer: bool, - sendThreads: [], postLog: [], - personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def create_announce(session, base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + to_url: str, cc_url: str, http_prefix: str, + object_url: str, save_to_file: bool, + client_to_server: bool, + send_threads: [], post_log: [], + person_cache: {}, cached_webfingers: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str, + curr_domain: str, + onion_domain: str, i2p_domain: str) -> {}: """Creates an announce message - Typically toUrl will be https://www.w3.org/ns/activitystreams#Public - and ccUrl might be a specific person favorited or repeated and the - followers url objectUrl is typically the url of the message, + Typically to_url will be https://www.w3.org/ns/activitystreams#Public + and cc_url might be a specific person favorited or repeated and the + followers url object_url is typically the url of the message, corresponding to url or atomUri in createPostBase """ - if not urlPermitted(objectUrl, federationList): + if not url_permitted(object_url, federation_list): return None - domain = removeDomainPort(domain) - fullDomain = getFullDomain(domain, port) + domain = remove_domain_port(domain) + full_domain = get_full_domain(domain, port) - statusNumber, published = getStatusNumber() - newAnnounceId = httpPrefix + '://' + fullDomain + \ - '/users/' + nickname + '/statuses/' + statusNumber - atomUriStr = localActorUrl(httpPrefix, nickname, fullDomain) + \ - '/statuses/' + statusNumber - newAnnounce = { + status_number, published = get_status_number() + new_announce_id = http_prefix + '://' + full_domain + \ + '/users/' + nickname + '/statuses/' + status_number + atom_uri_str = local_actor_url(http_prefix, nickname, full_domain) + \ + '/statuses/' + status_number + new_announce = { "@context": "https://www.w3.org/ns/activitystreams", - 'actor': localActorUrl(httpPrefix, nickname, fullDomain), - 'atomUri': atomUriStr, + 'actor': local_actor_url(http_prefix, nickname, full_domain), + 'atomUri': atom_uri_str, 'cc': [], - 'id': newAnnounceId + '/activity', - 'object': objectUrl, + 'id': new_announce_id + '/activity', + 'object': object_url, 'published': published, - 'to': [toUrl], + 'to': [to_url], 'type': 'Announce' } - if ccUrl: - if len(ccUrl) > 0: - newAnnounce['cc'] = [ccUrl] - if saveToFile: - outboxDir = createOutboxDir(nickname, domain, baseDir) - filename = outboxDir + '/' + newAnnounceId.replace('/', '#') + '.json' - saveJson(newAnnounce, filename) + if cc_url: + if len(cc_url) > 0: + new_announce['cc'] = [cc_url] + if save_to_file: + outbox_dir = create_outbox_dir(nickname, domain, base_dir) + filename = \ + outbox_dir + '/' + new_announce_id.replace('/', '#') + '.json' + save_json(new_announce, filename) - announceNickname = None - announceDomain = None - announcePort = None - groupAccount = False - if hasUsersPath(objectUrl): - announceNickname = getNicknameFromActor(objectUrl) - announceDomain, announcePort = getDomainFromActor(objectUrl) - if '/' + str(announceNickname) + '/' in objectUrl: - announceActor = \ - objectUrl.split('/' + announceNickname + '/')[0] + \ - '/' + announceNickname - if hasGroupType(baseDir, announceActor, personCache): - groupAccount = True + announce_nickname = None + announce_domain = None + announce_port = None + group_account = False + if has_users_path(object_url): + announce_nickname = get_nickname_from_actor(object_url) + if announce_nickname: + announce_domain, announce_port = get_domain_from_actor(object_url) + if '/' + str(announce_nickname) + '/' in object_url: + announce_actor = \ + object_url.split('/' + announce_nickname + '/')[0] + \ + '/' + announce_nickname + if has_group_type(base_dir, announce_actor, person_cache): + group_account = True - if announceNickname and announceDomain: - sendSignedJson(newAnnounce, session, baseDir, - nickname, domain, port, - announceNickname, announceDomain, announcePort, None, - httpPrefix, True, clientToServer, federationList, - sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion, None, groupAccount, - signingPrivateKeyPem, 639633) + if announce_nickname and announce_domain: + send_signed_json(new_announce, session, base_dir, + nickname, domain, port, + announce_nickname, announce_domain, + announce_port, + http_prefix, client_to_server, federation_list, + send_threads, post_log, cached_webfingers, + person_cache, + debug, project_version, None, group_account, + signing_priv_key_pem, 639633, + curr_domain, onion_domain, i2p_domain) - return newAnnounce + return new_announce -def announcePublic(session, baseDir: str, federationList: [], - nickname: str, domain: str, port: int, httpPrefix: str, - objectUrl: str, clientToServer: bool, - sendThreads: [], postLog: [], - personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def announce_public(session, base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, http_prefix: str, + object_url: str, client_to_server: bool, + send_threads: [], post_log: [], + person_cache: {}, cached_webfingers: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str, + curr_domain: str, + onion_domain: str, i2p_domain: str) -> {}: """Makes a public announcement """ - fromDomain = getFullDomain(domain, port) + from_domain = get_full_domain(domain, port) - toUrl = 'https://www.w3.org/ns/activitystreams#Public' - ccUrl = localActorUrl(httpPrefix, nickname, fromDomain) + '/followers' - return createAnnounce(session, baseDir, federationList, - nickname, domain, port, - toUrl, ccUrl, httpPrefix, - objectUrl, True, clientToServer, - sendThreads, postLog, - personCache, cachedWebfingers, - debug, projectVersion, - signingPrivateKeyPem) + to_url = 'https://www.w3.org/ns/activitystreams#Public' + cc_url = local_actor_url(http_prefix, nickname, from_domain) + '/followers' + return create_announce(session, base_dir, federation_list, + nickname, domain, port, + to_url, cc_url, http_prefix, + object_url, True, client_to_server, + send_threads, post_log, + person_cache, cached_webfingers, + debug, project_version, + signing_priv_key_pem, curr_domain, + onion_domain, i2p_domain) -def sendAnnounceViaServer(baseDir: str, session, - fromNickname: str, password: str, - fromDomain: str, fromPort: int, - httpPrefix: str, repeatObjectUrl: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def send_announce_via_server(base_dir: str, session, + from_nickname: str, password: str, + from_domain: str, from_port: int, + http_prefix: str, repeat_object_url: str, + cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str) -> {}: """Creates an announce message via c2s """ if not session: - print('WARN: No session for sendAnnounceViaServer') + print('WARN: No session for send_announce_via_server') return 6 - fromDomainFull = getFullDomain(fromDomain, fromPort) + from_domain_full = get_full_domain(from_domain, from_port) - toUrl = 'https://www.w3.org/ns/activitystreams#Public' - actorStr = localActorUrl(httpPrefix, fromNickname, fromDomainFull) - ccUrl = actorStr + '/followers' + to_url = 'https://www.w3.org/ns/activitystreams#Public' + actor_str = local_actor_url(http_prefix, from_nickname, from_domain_full) + cc_url = actor_str + '/followers' - statusNumber, published = getStatusNumber() - newAnnounceId = actorStr + '/statuses/' + statusNumber - newAnnounceJson = { + status_number, published = get_status_number() + new_announce_id = actor_str + '/statuses/' + status_number + new_announce_json = { "@context": "https://www.w3.org/ns/activitystreams", - 'actor': actorStr, - 'atomUri': newAnnounceId, - 'cc': [ccUrl], - 'id': newAnnounceId + '/activity', - 'object': repeatObjectUrl, + 'actor': actor_str, + 'atomUri': new_announce_id, + 'cc': [cc_url], + 'id': new_announce_id + '/activity', + 'object': repeat_object_url, 'published': published, - 'to': [toUrl], + 'to': [to_url], 'type': 'Announce' } - handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname + handle = http_prefix + '://' + from_domain_full + '/@' + from_nickname # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - fromDomain, projectVersion, debug, False, - signingPrivateKeyPem) - if not wfRequest: + wf_request = webfinger_handle(session, handle, http_prefix, + cached_webfingers, + from_domain, project_version, debug, False, + signing_priv_key_pem) + if not wf_request: if debug: print('DEBUG: announce webfinger failed for ' + handle) return 1 - if not isinstance(wfRequest, dict): + if not isinstance(wf_request, dict): print('WARN: announce webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) + ' did not return a dict. ' + str(wf_request)) return 1 - postToBox = 'outbox' + post_to_box = 'outbox' # get the actor inbox for the To handle - originDomain = fromDomain - (inboxUrl, pubKeyId, pubKey, fromPersonId, - sharedInbox, avatarUrl, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - fromNickname, fromDomain, - postToBox, 73528) + origin_domain = from_domain + (inbox_url, _, _, from_person_id, + _, _, _, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, wf_request, + person_cache, + project_version, http_prefix, + from_nickname, from_domain, + post_to_box, 73528) - if not inboxUrl: + if not inbox_url: if debug: - print('DEBUG: announce no ' + postToBox + + print('DEBUG: announce no ' + post_to_box + ' was found for ' + handle) return 3 - if not fromPersonId: + if not from_person_id: if debug: print('DEBUG: announce no actor was found for ' + handle) return 4 - authHeader = createBasicAuthHeader(fromNickname, password) + auth_header = create_basic_auth_header(from_nickname, password) headers = { - 'host': fromDomain, + 'host': from_domain, 'Content-type': 'application/json', - 'Authorization': authHeader + 'Authorization': auth_header } - postResult = postJson(httpPrefix, fromDomainFull, - session, newAnnounceJson, [], inboxUrl, - headers, 3, True) - if not postResult: + post_result = post_json(http_prefix, from_domain_full, + session, new_announce_json, [], inbox_url, + headers, 3, True) + if not post_result: print('WARN: announce not posted') if debug: print('DEBUG: c2s POST announce success') - return newAnnounceJson + return new_announce_json -def sendUndoAnnounceViaServer(baseDir: str, session, - undoPostJsonObject: {}, - nickname: str, password: str, - domain: str, port: int, - httpPrefix: str, repeatObjectUrl: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def send_undo_announce_via_server(base_dir: str, session, + undo_post_json_object: {}, + nickname: str, password: str, + domain: str, port: int, http_prefix: str, + cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str) -> {}: """Undo an announce message via c2s """ if not session: - print('WARN: No session for sendUndoAnnounceViaServer') + print('WARN: No session for send_undo_announce_via_server') return 6 - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) - actor = localActorUrl(httpPrefix, nickname, domainFull) - handle = replaceUsersWithAt(actor) + actor = local_actor_url(http_prefix, nickname, domain_full) + handle = replace_users_with_at(actor) - statusNumber, published = getStatusNumber() - unAnnounceJson = { + status_number, _ = get_status_number() + unannounce_json = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': actor + '/statuses/' + str(statusNumber) + '/undo', + 'id': actor + '/statuses/' + str(status_number) + '/undo', 'type': 'Undo', 'actor': actor, - 'object': undoPostJsonObject['object'] + 'object': undo_post_json_object['object'] } # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - domain, projectVersion, debug, False, - signingPrivateKeyPem) - if not wfRequest: + wf_request = webfinger_handle(session, handle, http_prefix, + cached_webfingers, + domain, project_version, debug, False, + signing_priv_key_pem) + if not wf_request: if debug: print('DEBUG: undo announce webfinger failed for ' + handle) return 1 - if not isinstance(wfRequest, dict): + if not isinstance(wf_request, dict): print('WARN: undo announce webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) + ' did not return a dict. ' + str(wf_request)) return 1 - postToBox = 'outbox' + post_to_box = 'outbox' # get the actor inbox for the To handle - originDomain = domain - (inboxUrl, pubKeyId, pubKey, fromPersonId, - sharedInbox, avatarUrl, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 73528) + origin_domain = domain + (inbox_url, _, _, from_person_id, + _, _, _, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, wf_request, + person_cache, + project_version, http_prefix, + nickname, domain, + post_to_box, 73528) - if not inboxUrl: + if not inbox_url: if debug: - print('DEBUG: undo announce no ' + postToBox + + print('DEBUG: undo announce no ' + post_to_box + ' was found for ' + handle) return 3 - if not fromPersonId: + if not from_person_id: if debug: print('DEBUG: undo announce no actor was found for ' + handle) return 4 - authHeader = createBasicAuthHeader(nickname, password) + auth_header = create_basic_auth_header(nickname, password) headers = { 'host': domain, 'Content-type': 'application/json', - 'Authorization': authHeader + 'Authorization': auth_header } - postResult = postJson(httpPrefix, domainFull, - session, unAnnounceJson, [], inboxUrl, - headers, 3, True) - if not postResult: + post_result = post_json(http_prefix, domain_full, + session, unannounce_json, [], inbox_url, + headers, 3, True) + if not post_result: print('WARN: undo announce not posted') if debug: print('DEBUG: c2s POST undo announce success') - return unAnnounceJson + return unannounce_json -def outboxUndoAnnounce(recentPostsCache: {}, - baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> None: +def outbox_undo_announce(recent_posts_cache: {}, + base_dir: str, nickname: str, domain: str, + message_json: {}, debug: bool) -> None: """ When an undo announce is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): return - if not messageJson['type'] == 'Undo': + if not message_json['type'] == 'Undo': return - if not hasObjectDict(messageJson): - if debug: - print('DEBUG: undo like object is not dict') + if not has_object_string_type(message_json, debug): return - if not messageJson['object'].get('type'): - if debug: - print('DEBUG: undo like - no type') - return - if not messageJson['object']['type'] == 'Announce': + if not message_json['object']['type'] == 'Announce': if debug: print('DEBUG: not a undo announce') return - if not messageJson['object'].get('object'): - if debug: - print('DEBUG: no object in undo announce') - return - if not isinstance(messageJson['object']['object'], str): - if debug: - print('DEBUG: undo announce object is not string') + if not has_object_string_object(message_json, debug): return if debug: print('DEBUG: c2s undo announce request arrived in outbox') - messageId = removeIdEnding(messageJson['object']['object']) - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + message_id = remove_id_ending(message_json['object']['object']) + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: if debug: print('DEBUG: c2s undo announce post not found in inbox or outbox') - print(messageId) + print(message_id) return True - undoAnnounceCollectionEntry(recentPostsCache, baseDir, postFilename, - messageJson['actor'], domain, debug) + undo_announce_collection_entry(recent_posts_cache, base_dir, post_filename, + message_json['actor'], domain, debug) if debug: - print('DEBUG: post undo announce via c2s - ' + postFilename) + print('DEBUG: post undo announce via c2s - ' + post_filename) diff --git a/architecture/epicyon_groups_ActivityPub.png b/architecture/epicyon_groups_ActivityPub.png index fb0e822e7..6dbf8724e 100644 Binary files a/architecture/epicyon_groups_ActivityPub.png and b/architecture/epicyon_groups_ActivityPub.png differ diff --git a/architecture/epicyon_groups_ActivityPub_Core.png b/architecture/epicyon_groups_ActivityPub_Core.png index 41efb024a..4b7a4be12 100644 Binary files a/architecture/epicyon_groups_ActivityPub_Core.png and b/architecture/epicyon_groups_ActivityPub_Core.png differ diff --git a/architecture/epicyon_groups_ActivityPub_Security.png b/architecture/epicyon_groups_ActivityPub_Security.png index 965224e6d..99a49ab9a 100644 Binary files a/architecture/epicyon_groups_ActivityPub_Security.png and b/architecture/epicyon_groups_ActivityPub_Security.png differ diff --git a/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png index b1551db4c..004a20293 100644 Binary files a/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png and b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png differ diff --git a/architecture/epicyon_groups_Commandline-Interface_Core.png b/architecture/epicyon_groups_Commandline-Interface_Core.png index 3e200a7a1..8bb44e215 100644 Binary files a/architecture/epicyon_groups_Commandline-Interface_Core.png and b/architecture/epicyon_groups_Commandline-Interface_Core.png differ diff --git a/architecture/epicyon_groups_Core.png b/architecture/epicyon_groups_Core.png index ff28efe66..06c28b79c 100644 Binary files a/architecture/epicyon_groups_Core.png and b/architecture/epicyon_groups_Core.png differ diff --git a/architecture/epicyon_groups_Core_Accessibility.png b/architecture/epicyon_groups_Core_Accessibility.png index 63fd67379..b69a32978 100644 Binary files a/architecture/epicyon_groups_Core_Accessibility.png and b/architecture/epicyon_groups_Core_Accessibility.png differ diff --git a/architecture/epicyon_groups_Core_Security.png b/architecture/epicyon_groups_Core_Security.png index 2b53d14b7..ac81d9b66 100644 Binary files a/architecture/epicyon_groups_Core_Security.png and b/architecture/epicyon_groups_Core_Security.png differ diff --git a/architecture/epicyon_groups_Timeline_Core.png b/architecture/epicyon_groups_Timeline_Core.png index 4e23a02bb..e06e80c57 100644 Binary files a/architecture/epicyon_groups_Timeline_Core.png and b/architecture/epicyon_groups_Timeline_Core.png differ diff --git a/architecture/epicyon_groups_Timeline_Security.png b/architecture/epicyon_groups_Timeline_Security.png index 2c1193750..dcef13d2f 100644 Binary files a/architecture/epicyon_groups_Timeline_Security.png and b/architecture/epicyon_groups_Timeline_Security.png differ diff --git a/architecture/epicyon_groups_Web-Interface-Columns_Core.png b/architecture/epicyon_groups_Web-Interface-Columns_Core.png index 5678cf445..a1a23ed58 100644 Binary files a/architecture/epicyon_groups_Web-Interface-Columns_Core.png and b/architecture/epicyon_groups_Web-Interface-Columns_Core.png differ diff --git a/architecture/epicyon_groups_Web-Interface_Accessibility.png b/architecture/epicyon_groups_Web-Interface_Accessibility.png index 738fd1f73..f937f2030 100644 Binary files a/architecture/epicyon_groups_Web-Interface_Accessibility.png and b/architecture/epicyon_groups_Web-Interface_Accessibility.png differ diff --git a/architecture/epicyon_groups_Web-Interface_Core.png b/architecture/epicyon_groups_Web-Interface_Core.png index 9943358a9..90febf28d 100644 Binary files a/architecture/epicyon_groups_Web-Interface_Core.png and b/architecture/epicyon_groups_Web-Interface_Core.png differ diff --git a/auth.py b/auth.py index 0305dca68..9d67ad972 100644 --- a/auth.py +++ b/auth.py @@ -1,7 +1,7 @@ __filename__ = "auth.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -13,11 +13,13 @@ import binascii import os import secrets import datetime -from utils import isSystemAccount -from utils import hasUsersPath +from utils import is_system_account +from utils import has_users_path +from utils import text_in_file +from utils import remove_eol -def _hashPassword(password: str) -> str: +def _hash_password(password: str) -> str: """Hash a password for storing """ salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') @@ -28,17 +30,17 @@ def _hashPassword(password: str) -> str: return (salt + pwdhash).decode('ascii') -def _getPasswordHash(salt: str, providedPassword: str) -> str: +def _get_password_hash(salt: str, provided_password: str) -> str: """Returns the hash of a password """ pwdhash = hashlib.pbkdf2_hmac('sha512', - providedPassword.encode('utf-8'), + provided_password.encode('utf-8'), salt.encode('ascii'), 100000) return binascii.hexlify(pwdhash).decode('ascii') -def constantTimeStringCheck(string1: str, string2: str) -> bool: +def constant_time_string_check(string1: str, string2: str) -> bool: """Compares two string and returns if they are the same using a constant amount of time See https://sqreen.github.io/DevelopersSecurityBestPractices/ @@ -49,8 +51,8 @@ def constantTimeStringCheck(string1: str, string2: str) -> bool: return False ctr = 0 matched = True - for ch in string1: - if ch != string2[ctr]: + for char in string1: + if char != string2[ctr]: matched = False else: # this is to make the timing more even @@ -60,199 +62,244 @@ def constantTimeStringCheck(string1: str, string2: str) -> bool: return matched -def _verifyPassword(storedPassword: str, providedPassword: str) -> bool: +def _verify_password(stored_password: str, provided_password: str) -> bool: """Verify a stored password against one provided by user """ - if not storedPassword: + if not stored_password: return False - if not providedPassword: + if not provided_password: return False - salt = storedPassword[:64] - storedPassword = storedPassword[64:] - pwHash = _getPasswordHash(salt, providedPassword) - return constantTimeStringCheck(pwHash, storedPassword) + salt = stored_password[:64] + stored_password = stored_password[64:] + pw_hash = _get_password_hash(salt, provided_password) + return constant_time_string_check(pw_hash, stored_password) -def createBasicAuthHeader(nickname: str, password: str) -> str: +def create_basic_auth_header(nickname: str, password: str) -> str: """This is only used by tests """ - authStr = \ - nickname.replace('\n', '').replace('\r', '') + \ + auth_str = \ + remove_eol(nickname) + \ ':' + \ - password.replace('\n', '').replace('\r', '') - return 'Basic ' + base64.b64encode(authStr.encode('utf-8')).decode('utf-8') + remove_eol(password) + return 'Basic ' + \ + base64.b64encode(auth_str.encode('utf-8')).decode('utf-8') -def authorizeBasic(baseDir: str, path: str, authHeader: str, - debug: bool) -> bool: +def authorize_basic(base_dir: str, path: str, auth_header: str, + debug: bool) -> bool: """HTTP basic auth """ - if ' ' not in authHeader: + if ' ' not in auth_header: if debug: print('DEBUG: basic auth - Authorisation header does not ' + 'contain a space character') return False - if not hasUsersPath(path): - if debug: - print('DEBUG: basic auth - ' + - 'path for Authorization does not contain a user') - return False - pathUsersSection = path.split('/users/')[1] - if '/' not in pathUsersSection: - if debug: - print('DEBUG: basic auth - this is not a users endpoint') - return False - nicknameFromPath = pathUsersSection.split('/')[0] - if isSystemAccount(nicknameFromPath): + if not has_users_path(path): + if not path.startswith('/calendars/'): + if debug: + print('DEBUG: basic auth - ' + + 'path for Authorization does not contain a user') + return False + if path.startswith('/calendars/'): + path_users_section = path.split('/calendars/')[1] + nickname_from_path = path_users_section + if '/' in nickname_from_path: + nickname_from_path = nickname_from_path.split('/')[0] + if '?' in nickname_from_path: + nickname_from_path = nickname_from_path.split('?')[0] + else: + path_users_section = path.split('/users/')[1] + if '/' not in path_users_section: + if debug: + print('DEBUG: basic auth - this is not a users endpoint') + return False + nickname_from_path = path_users_section.split('/')[0] + if is_system_account(nickname_from_path): print('basic auth - attempted login using system account ' + - nicknameFromPath + ' in path') + nickname_from_path + ' in path') return False - base64Str = \ - authHeader.split(' ')[1].replace('\n', '').replace('\r', '') - plain = base64.b64decode(base64Str).decode('utf-8') + base64_str1 = auth_header.split(' ')[1] + base64_str = remove_eol(base64_str1) + plain = base64.b64decode(base64_str).decode('utf-8') if ':' not in plain: if debug: print('DEBUG: basic auth header does not contain a ":" ' + 'separator for username:password') return False nickname = plain.split(':')[0] - if isSystemAccount(nickname): + if is_system_account(nickname): print('basic auth - attempted login using system account ' + nickname + ' in Auth header') return False - if nickname != nicknameFromPath: + if nickname != nickname_from_path: if debug: - print('DEBUG: Nickname given in the path (' + nicknameFromPath + + print('DEBUG: Nickname given in the path (' + nickname_from_path + ') does not match the one in the Authorization header (' + nickname + ')') return False - passwordFile = baseDir + '/accounts/passwords' - if not os.path.isfile(passwordFile): + password_file = base_dir + '/accounts/passwords' + if not os.path.isfile(password_file): if debug: print('DEBUG: passwords file missing') return False - providedPassword = plain.split(':')[1] - with open(passwordFile, 'r') as passfile: - for line in passfile: - if not line.startswith(nickname + ':'): - continue - storedPassword = \ - line.split(':')[1].replace('\n', '').replace('\r', '') - success = _verifyPassword(storedPassword, providedPassword) - if not success: - if debug: - print('DEBUG: Password check failed for ' + nickname) - return success + provided_password = plain.split(':')[1] + try: + with open(password_file, 'r', encoding='utf-8') as passfile: + for line in passfile: + if not line.startswith(nickname + ':'): + continue + stored_password_base = line.split(':')[1] + stored_password = remove_eol(stored_password_base) + success = _verify_password(stored_password, provided_password) + if not success: + if debug: + print('DEBUG: Password check failed for ' + nickname) + return success + except OSError: + print('EX: failed to open password file') + return False print('DEBUG: Did not find credentials for ' + nickname + - ' in ' + passwordFile) + ' in ' + password_file) return False -def storeBasicCredentials(baseDir: str, nickname: str, password: str) -> bool: +def store_basic_credentials(base_dir: str, + nickname: str, password: str) -> bool: """Stores login credentials to a file """ if ':' in nickname or ':' in password: return False - nickname = nickname.replace('\n', '').replace('\r', '').strip() - password = password.replace('\n', '').replace('\r', '').strip() + nickname = remove_eol(nickname).strip() + password = remove_eol(password).strip() - if not os.path.isdir(baseDir + '/accounts'): - os.mkdir(baseDir + '/accounts') + if not os.path.isdir(base_dir + '/accounts'): + os.mkdir(base_dir + '/accounts') - passwordFile = baseDir + '/accounts/passwords' - storeStr = nickname + ':' + _hashPassword(password) - if os.path.isfile(passwordFile): - if nickname + ':' in open(passwordFile).read(): - with open(passwordFile, 'r') as fin: - with open(passwordFile + '.new', 'w+') as fout: - for line in fin: - if not line.startswith(nickname + ':'): - fout.write(line) - else: - fout.write(storeStr + '\n') - os.rename(passwordFile + '.new', passwordFile) + password_file = base_dir + '/accounts/passwords' + store_str = nickname + ':' + _hash_password(password) + if os.path.isfile(password_file): + if text_in_file(nickname + ':', password_file): + try: + with open(password_file, 'r', encoding='utf-8') as fin: + with open(password_file + '.new', 'w+', + encoding='utf-8') as fout: + for line in fin: + if not line.startswith(nickname + ':'): + fout.write(line) + else: + fout.write(store_str + '\n') + except OSError as ex: + print('EX: unable to save password ' + password_file + + ' ' + str(ex)) + return False + + try: + os.rename(password_file + '.new', password_file) + except OSError: + print('EX: unable to save password 2') + return False else: # append to password file - with open(passwordFile, 'a+') as passfile: - passfile.write(storeStr + '\n') + try: + with open(password_file, 'a+', encoding='utf-8') as passfile: + passfile.write(store_str + '\n') + except OSError: + print('EX: unable to append password') + return False else: - with open(passwordFile, 'w+') as passfile: - passfile.write(storeStr + '\n') + try: + with open(password_file, 'w+', encoding='utf-8') as passfile: + passfile.write(store_str + '\n') + except OSError: + print('EX: unable to create password file') + return False return True -def removePassword(baseDir: str, nickname: str) -> None: +def remove_password(base_dir: str, nickname: str) -> None: """Removes the password entry for the given nickname This is called during account removal """ - passwordFile = baseDir + '/accounts/passwords' - if os.path.isfile(passwordFile): - with open(passwordFile, 'r') as fin: - with open(passwordFile + '.new', 'w+') as fout: - for line in fin: - if not line.startswith(nickname + ':'): - fout.write(line) - os.rename(passwordFile + '.new', passwordFile) + password_file = base_dir + '/accounts/passwords' + if os.path.isfile(password_file): + try: + with open(password_file, 'r', encoding='utf-8') as fin: + with open(password_file + '.new', 'w+', + encoding='utf-8') as fout: + for line in fin: + if not line.startswith(nickname + ':'): + fout.write(line) + except OSError as ex: + print('EX: unable to remove password from file ' + str(ex)) + return + + try: + os.rename(password_file + '.new', password_file) + except OSError: + print('EX: unable to remove password from file 2') + return -def authorize(baseDir: str, path: str, authHeader: str, debug: bool) -> bool: +def authorize(base_dir: str, path: str, auth_header: str, debug: bool) -> bool: """Authorize using http header """ - if authHeader.lower().startswith('basic '): - return authorizeBasic(baseDir, path, authHeader, debug) + if auth_header.lower().startswith('basic '): + return authorize_basic(base_dir, path, auth_header, debug) return False -def createPassword(length: int = 10): - validChars = 'abcdefghijklmnopqrstuvwxyz' + \ +def create_password(length: int): + valid_chars = 'abcdefghijklmnopqrstuvwxyz' + \ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - return ''.join((secrets.choice(validChars) for i in range(length))) + return ''.join((secrets.choice(valid_chars) for i in range(length))) -def recordLoginFailure(baseDir: str, ipAddress: str, - countDict: {}, failTime: int, - logToFile: bool) -> None: +def record_login_failure(base_dir: str, ip_address: str, + count_dict: {}, fail_time: int, + log_to_file: bool) -> None: """Keeps ip addresses and the number of times login failures occured for them in a dict """ - if not countDict.get(ipAddress): - while len(countDict.items()) > 100: - oldestTime = 0 - oldestIP = None - for ipAddr, ipItem in countDict.items(): - if oldestTime == 0 or ipItem['time'] < oldestTime: - oldestTime = ipItem['time'] - oldestIP = ipAddr - if oldestIP: - del countDict[oldestIP] - countDict[ipAddress] = { + if not count_dict.get(ip_address): + while len(count_dict.items()) > 100: + oldest_time = 0 + oldest_ip = None + for ip_addr, ip_item in count_dict.items(): + if oldest_time == 0 or ip_item['time'] < oldest_time: + oldest_time = ip_item['time'] + oldest_ip = ip_addr + if oldest_ip: + del count_dict[oldest_ip] + count_dict[ip_address] = { "count": 1, - "time": failTime + "time": fail_time } else: - countDict[ipAddress]['count'] += 1 - countDict[ipAddress]['time'] = failTime - failCount = countDict[ipAddress]['count'] - if failCount > 4: - print('WARN: ' + str(ipAddress) + ' failed to log in ' + - str(failCount) + ' times') + count_dict[ip_address]['count'] += 1 + count_dict[ip_address]['time'] = fail_time + fail_count = count_dict[ip_address]['count'] + if fail_count > 4: + print('WARN: ' + str(ip_address) + ' failed to log in ' + + str(fail_count) + ' times') - if not logToFile: + if not log_to_file: return - failureLog = baseDir + '/accounts/loginfailures.log' - writeType = 'a+' - if not os.path.isfile(failureLog): - writeType = 'w+' - currTime = datetime.datetime.utcnow() + failure_log = base_dir + '/accounts/loginfailures.log' + write_type = 'a+' + if not os.path.isfile(failure_log): + write_type = 'w+' + curr_time = datetime.datetime.utcnow() + curr_time_str = curr_time.strftime("%Y-%m-%d %H:%M:%SZ") try: - with open(failureLog, writeType) as fp: + with open(failure_log, write_type, encoding='utf-8') as fp_fail: # here we use a similar format to an ssh log, so that # systems such as fail2ban can parse it - fp.write(currTime.strftime("%Y-%m-%d %H:%M:%SZ") + ' ' + - 'ip-127-0-0-1 sshd[20710]: ' + - 'Disconnecting invalid user epicyon ' + - ipAddress + ' port 443: ' + - 'Too many authentication failures [preauth]\n') - except BaseException: - pass + fp_fail.write(curr_time_str + ' ' + + 'ip-127-0-0-1 sshd[20710]: ' + + 'Disconnecting invalid user epicyon ' + + ip_address + ' port 443: ' + + 'Too many authentication failures [preauth]\n') + except OSError: + print('EX: record_login_failure failed ' + str(failure_log)) diff --git a/availability.py b/availability.py index ee6c2d5d6..fdb913749 100644 --- a/availability.py +++ b/availability.py @@ -1,160 +1,162 @@ __filename__ = "availability.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" import os -from webfinger import webfingerHandle -from auth import createBasicAuthHeader -from posts import getPersonBox -from session import postJson -from utils import getFullDomain -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import loadJson -from utils import saveJson -from utils import acctDir -from utils import localActorUrl +from webfinger import webfinger_handle +from auth import create_basic_auth_header +from posts import get_person_box +from session import post_json +from utils import has_object_string +from utils import get_full_domain +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import load_json +from utils import save_json +from utils import acct_dir +from utils import local_actor_url +from utils import has_actor -def setAvailability(baseDir: str, nickname: str, domain: str, - status: str) -> bool: +def set_availability(base_dir: str, nickname: str, domain: str, + status: str) -> bool: """Set an availability status """ # avoid giant strings if len(status) > 128: return False - actorFilename = acctDir(baseDir, nickname, domain) + '.json' - if not os.path.isfile(actorFilename): + actor_filename = acct_dir(base_dir, nickname, domain) + '.json' + if not os.path.isfile(actor_filename): return False - actorJson = loadJson(actorFilename) - if actorJson: - actorJson['availability'] = status - saveJson(actorJson, actorFilename) + actor_json = load_json(actor_filename) + if actor_json: + actor_json['availability'] = status + save_json(actor_json, actor_filename) return True -def getAvailability(baseDir: str, nickname: str, domain: str) -> str: +def get_availability(base_dir: str, nickname: str, domain: str) -> str: """Returns the availability for a given person """ - actorFilename = acctDir(baseDir, nickname, domain) + '.json' - if not os.path.isfile(actorFilename): + actor_filename = acct_dir(base_dir, nickname, domain) + '.json' + if not os.path.isfile(actor_filename): return False - actorJson = loadJson(actorFilename) - if actorJson: - if not actorJson.get('availability'): + actor_json = load_json(actor_filename) + if actor_json: + if not actor_json.get('availability'): return None - return actorJson['availability'] + return actor_json['availability'] return None -def outboxAvailability(baseDir: str, nickname: str, messageJson: {}, - debug: bool) -> bool: +def outbox_availability(base_dir: str, nickname: str, message_json: {}, + debug: bool) -> bool: """Handles receiving an availability update """ - if not messageJson.get('type'): + if not message_json.get('type'): return False - if not messageJson['type'] == 'Availability': + if not message_json['type'] == 'Availability': return False - if not messageJson.get('actor'): + if not has_actor(message_json, debug): return False - if not messageJson.get('object'): - return False - if not isinstance(messageJson['object'], str): + if not has_object_string(message_json, debug): return False - actorNickname = getNicknameFromActor(messageJson['actor']) - if actorNickname != nickname: + actor_nickname = get_nickname_from_actor(message_json['actor']) + if not actor_nickname: return False - domain, port = getDomainFromActor(messageJson['actor']) - status = messageJson['object'].replace('"', '') + if actor_nickname != nickname: + return False + domain, _ = get_domain_from_actor(message_json['actor']) + status = message_json['object'].replace('"', '') - return setAvailability(baseDir, nickname, domain, status) + return set_availability(base_dir, nickname, domain, status) -def sendAvailabilityViaServer(baseDir: str, session, - nickname: str, password: str, - domain: str, port: int, - httpPrefix: str, - status: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def send_availability_via_server(base_dir: str, session, + nickname: str, password: str, + domain: str, port: int, + http_prefix: str, + status: str, + cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str) -> {}: """Sets the availability for a person via c2s """ if not session: - print('WARN: No session for sendAvailabilityViaServer') + print('WARN: No session for send_availability_via_server') return 6 - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) - toUrl = localActorUrl(httpPrefix, nickname, domainFull) - ccUrl = toUrl + '/followers' + to_url = local_actor_url(http_prefix, nickname, domain_full) + cc_url = to_url + '/followers' - newAvailabilityJson = { + new_availability_json = { 'type': 'Availability', - 'actor': toUrl, + 'actor': to_url, 'object': '"' + status + '"', - 'to': [toUrl], - 'cc': [ccUrl] + 'to': [to_url], + 'cc': [cc_url] } - handle = httpPrefix + '://' + domainFull + '/@' + nickname + handle = http_prefix + '://' + domain_full + '/@' + nickname # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - domain, projectVersion, debug, False, - signingPrivateKeyPem) - if not wfRequest: + wf_request = webfinger_handle(session, handle, http_prefix, + cached_webfingers, + domain, project_version, debug, False, + signing_priv_key_pem) + if not wf_request: if debug: print('DEBUG: availability webfinger failed for ' + handle) return 1 - if not isinstance(wfRequest, dict): + if not isinstance(wf_request, dict): print('WARN: availability webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) + ' did not return a dict. ' + str(wf_request)) return 1 - postToBox = 'outbox' + post_to_box = 'outbox' # get the actor inbox for the To handle - originDomain = domain - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, nickname, - domain, postToBox, 57262) + origin_domain = domain + (inbox_url, _, _, from_person_id, _, _, + _, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, wf_request, + person_cache, project_version, + http_prefix, nickname, + domain, post_to_box, 57262) - if not inboxUrl: + if not inbox_url: if debug: - print('DEBUG: availability no ' + postToBox + + print('DEBUG: availability no ' + post_to_box + ' was found for ' + handle) return 3 - if not fromPersonId: + if not from_person_id: if debug: print('DEBUG: availability no actor was found for ' + handle) return 4 - authHeader = createBasicAuthHeader(nickname, password) + auth_header = create_basic_auth_header(nickname, password) headers = { 'host': domain, 'Content-type': 'application/json', - 'Authorization': authHeader + 'Authorization': auth_header } - postResult = postJson(httpPrefix, domainFull, - session, newAvailabilityJson, [], - inboxUrl, headers, 30, True) - if not postResult: + post_result = post_json(http_prefix, domain_full, + session, new_availability_json, [], + inbox_url, headers, 30, True) + if not post_result: print('WARN: availability failed to post') if debug: print('DEBUG: c2s POST availability success') - return newAvailabilityJson + return new_availability_json diff --git a/blocking.py b/blocking.py index ddfb688c6..aea6a5d02 100644 --- a/blocking.py +++ b/blocking.py @@ -1,7 +1,7 @@ __filename__ = "blocking.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -11,490 +11,570 @@ import os import json import time from datetime import datetime -from utils import removeDomainPort -from utils import hasObjectDict -from utils import isAccountDir -from utils import getCachedPostFilename -from utils import loadJson -from utils import saveJson -from utils import fileLastModified -from utils import setConfigParam -from utils import hasUsersPath -from utils import getFullDomain -from utils import removeIdEnding -from utils import isEvil -from utils import locatePost -from utils import evilIncarnate -from utils import getDomainFromActor -from utils import getNicknameFromActor -from utils import acctDir -from utils import localActorUrl -from conversation import muteConversation -from conversation import unmuteConversation +from utils import remove_eol +from utils import has_object_string +from utils import has_object_string_object +from utils import has_object_string_type +from utils import remove_domain_port +from utils import has_object_dict +from utils import is_account_dir +from utils import get_cached_post_filename +from utils import load_json +from utils import save_json +from utils import file_last_modified +from utils import set_config_param +from utils import has_users_path +from utils import get_full_domain +from utils import remove_id_ending +from utils import is_evil +from utils import locate_post +from utils import evil_incarnate +from utils import get_domain_from_actor +from utils import get_nickname_from_actor +from utils import acct_dir +from utils import local_actor_url +from utils import has_actor +from utils import text_in_file +from conversation import mute_conversation +from conversation import unmute_conversation -def addGlobalBlock(baseDir: str, - blockNickname: str, blockDomain: str) -> bool: +def add_global_block(base_dir: str, + block_nickname: str, block_domain: str) -> bool: """Global block which applies to all accounts """ - blockingFilename = baseDir + '/accounts/blocking.txt' - if not blockNickname.startswith('#'): + blocking_filename = base_dir + '/accounts/blocking.txt' + if not block_nickname.startswith('#'): # is the handle already blocked? - blockHandle = blockNickname + '@' + blockDomain - if os.path.isfile(blockingFilename): - if blockHandle in open(blockingFilename).read(): + block_handle = block_nickname + '@' + block_domain + if os.path.isfile(blocking_filename): + if text_in_file(block_handle, blocking_filename): return False # block an account handle or domain - with open(blockingFilename, 'a+') as blockFile: - blockFile.write(blockHandle + '\n') + try: + with open(blocking_filename, 'a+', encoding='utf-8') as block_file: + block_file.write(block_handle + '\n') + except OSError: + print('EX: unable to save blocked handle ' + block_handle) + return False else: - blockHashtag = blockNickname + block_hashtag = block_nickname # is the hashtag already blocked? - if os.path.isfile(blockingFilename): - if blockHashtag + '\n' in open(blockingFilename).read(): + if os.path.isfile(blocking_filename): + if text_in_file(block_hashtag + '\n', blocking_filename): return False # block a hashtag - with open(blockingFilename, 'a+') as blockFile: - blockFile.write(blockHashtag + '\n') + try: + with open(blocking_filename, 'a+', encoding='utf-8') as block_file: + block_file.write(block_hashtag + '\n') + except OSError: + print('EX: unable to save blocked hashtag ' + block_hashtag) + return False return True -def addBlock(baseDir: str, nickname: str, domain: str, - blockNickname: str, blockDomain: str) -> bool: +def add_block(base_dir: str, nickname: str, domain: str, + block_nickname: str, block_domain: str) -> bool: """Block the given account """ - if blockDomain.startswith(domain) and nickname == blockNickname: + if block_domain.startswith(domain) and nickname == block_nickname: # don't block self return False - domain = removeDomainPort(domain) - blockingFilename = acctDir(baseDir, nickname, domain) + '/blocking.txt' - blockHandle = blockNickname + '@' + blockDomain - if os.path.isfile(blockingFilename): - if blockHandle + '\n' in open(blockingFilename).read(): + domain = remove_domain_port(domain) + blocking_filename = acct_dir(base_dir, nickname, domain) + '/blocking.txt' + block_handle = block_nickname + '@' + block_domain + if os.path.isfile(blocking_filename): + if text_in_file(block_handle + '\n', blocking_filename): return False # if we are following then unfollow - followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' - if os.path.isfile(followingFilename): - if blockHandle + '\n' in open(followingFilename).read(): - followingStr = '' - with open(followingFilename, 'r') as followingFile: - followingStr = followingFile.read() - followingStr = followingStr.replace(blockHandle + '\n', '') - with open(followingFilename, 'w+') as followingFile: - followingFile.write(followingStr) + following_filename = \ + acct_dir(base_dir, nickname, domain) + '/following.txt' + if os.path.isfile(following_filename): + if text_in_file(block_handle + '\n', following_filename): + following_str = '' + try: + with open(following_filename, 'r', + encoding='utf-8') as foll_file: + following_str = foll_file.read() + except OSError: + print('EX: Unable to read following ' + following_filename) + return False + + if following_str: + following_str = following_str.replace(block_handle + '\n', '') + + try: + with open(following_filename, 'w+', + encoding='utf-8') as foll_file: + foll_file.write(following_str) + except OSError: + print('EX: Unable to write following ' + following_str) + return False # if they are a follower then remove them - followersFilename = acctDir(baseDir, nickname, domain) + '/followers.txt' - if os.path.isfile(followersFilename): - if blockHandle + '\n' in open(followersFilename).read(): - followersStr = '' - with open(followersFilename, 'r') as followersFile: - followersStr = followersFile.read() - followersStr = followersStr.replace(blockHandle + '\n', '') - with open(followersFilename, 'w+') as followersFile: - followersFile.write(followersStr) + followers_filename = \ + acct_dir(base_dir, nickname, domain) + '/followers.txt' + if os.path.isfile(followers_filename): + if text_in_file(block_handle + '\n', followers_filename): + followers_str = '' + try: + with open(followers_filename, 'r', + encoding='utf-8') as foll_file: + followers_str = foll_file.read() + except OSError: + print('EX: Unable to read followers ' + followers_filename) + return False - with open(blockingFilename, 'a+') as blockFile: - blockFile.write(blockHandle + '\n') + if followers_str: + followers_str = followers_str.replace(block_handle + '\n', '') + + try: + with open(followers_filename, 'w+', + encoding='utf-8') as foll_file: + foll_file.write(followers_str) + except OSError: + print('EX: Unable to write followers ' + followers_str) + return False + + try: + with open(blocking_filename, 'a+', encoding='utf-8') as block_file: + block_file.write(block_handle + '\n') + except OSError: + print('EX: unable to append block handle ' + block_handle) + return False return True -def removeGlobalBlock(baseDir: str, - unblockNickname: str, - unblockDomain: str) -> bool: +def remove_global_block(base_dir: str, + unblock_nickname: str, + unblock_domain: str) -> bool: """Unblock the given global block """ - unblockingFilename = baseDir + '/accounts/blocking.txt' - if not unblockNickname.startswith('#'): - unblockHandle = unblockNickname + '@' + unblockDomain - if os.path.isfile(unblockingFilename): - if unblockHandle in open(unblockingFilename).read(): - with open(unblockingFilename, 'r') as fp: - with open(unblockingFilename + '.new', 'w+') as fpnew: - for line in fp: - handle = line.replace('\n', '').replace('\r', '') - if unblockHandle not in line: - fpnew.write(handle + '\n') - if os.path.isfile(unblockingFilename + '.new'): - os.rename(unblockingFilename + '.new', unblockingFilename) + unblocking_filename = base_dir + '/accounts/blocking.txt' + if not unblock_nickname.startswith('#'): + unblock_handle = unblock_nickname + '@' + unblock_domain + if os.path.isfile(unblocking_filename): + if text_in_file(unblock_handle, unblocking_filename): + try: + with open(unblocking_filename, 'r', + encoding='utf-8') as fp_unblock: + with open(unblocking_filename + '.new', 'w+', + encoding='utf-8') as fpnew: + for line in fp_unblock: + handle = remove_eol(line) + if unblock_handle not in line: + fpnew.write(handle + '\n') + except OSError as ex: + print('EX: failed to remove global block ' + + unblocking_filename + ' ' + str(ex)) + return False + + if os.path.isfile(unblocking_filename + '.new'): + try: + os.rename(unblocking_filename + '.new', + unblocking_filename) + except OSError: + print('EX: unable to rename ' + unblocking_filename) + return False return True else: - unblockHashtag = unblockNickname - if os.path.isfile(unblockingFilename): - if unblockHashtag + '\n' in open(unblockingFilename).read(): - with open(unblockingFilename, 'r') as fp: - with open(unblockingFilename + '.new', 'w+') as fpnew: - for line in fp: - blockLine = \ - line.replace('\n', '').replace('\r', '') - if unblockHashtag not in line: - fpnew.write(blockLine + '\n') - if os.path.isfile(unblockingFilename + '.new'): - os.rename(unblockingFilename + '.new', unblockingFilename) + unblock_hashtag = unblock_nickname + if os.path.isfile(unblocking_filename): + if text_in_file(unblock_hashtag + '\n', unblocking_filename): + try: + with open(unblocking_filename, 'r', + encoding='utf-8') as fp_unblock: + with open(unblocking_filename + '.new', 'w+', + encoding='utf-8') as fpnew: + for line in fp_unblock: + block_line = remove_eol(line) + if unblock_hashtag not in line: + fpnew.write(block_line + '\n') + except OSError as ex: + print('EX: failed to remove global hashtag block ' + + unblocking_filename + ' ' + str(ex)) + return False + + if os.path.isfile(unblocking_filename + '.new'): + try: + os.rename(unblocking_filename + '.new', + unblocking_filename) + except OSError: + print('EX: unable to rename 2 ' + unblocking_filename) + return False return True return False -def removeBlock(baseDir: str, nickname: str, domain: str, - unblockNickname: str, unblockDomain: str) -> bool: +def remove_block(base_dir: str, nickname: str, domain: str, + unblock_nickname: str, unblock_domain: str) -> bool: """Unblock the given account """ - domain = removeDomainPort(domain) - unblockingFilename = acctDir(baseDir, nickname, domain) + '/blocking.txt' - unblockHandle = unblockNickname + '@' + unblockDomain - if os.path.isfile(unblockingFilename): - if unblockHandle in open(unblockingFilename).read(): - with open(unblockingFilename, 'r') as fp: - with open(unblockingFilename + '.new', 'w+') as fpnew: - for line in fp: - handle = line.replace('\n', '').replace('\r', '') - if unblockHandle not in line: - fpnew.write(handle + '\n') - if os.path.isfile(unblockingFilename + '.new'): - os.rename(unblockingFilename + '.new', unblockingFilename) + domain = remove_domain_port(domain) + unblocking_filename = \ + acct_dir(base_dir, nickname, domain) + '/blocking.txt' + unblock_handle = unblock_nickname + '@' + unblock_domain + if os.path.isfile(unblocking_filename): + if text_in_file(unblock_handle, unblocking_filename): + try: + with open(unblocking_filename, 'r', + encoding='utf-8') as fp_unblock: + with open(unblocking_filename + '.new', 'w+', + encoding='utf-8') as fpnew: + for line in fp_unblock: + handle = remove_eol(line) + if unblock_handle not in line: + fpnew.write(handle + '\n') + except OSError as ex: + print('EX: failed to remove block ' + + unblocking_filename + ' ' + str(ex)) + return False + + if os.path.isfile(unblocking_filename + '.new'): + try: + os.rename(unblocking_filename + '.new', + unblocking_filename) + except OSError: + print('EX: unable to rename 3 ' + unblocking_filename) + return False return True return False -def isBlockedHashtag(baseDir: str, hashtag: str) -> bool: +def is_blocked_hashtag(base_dir: str, hashtag: str) -> bool: """Is the given hashtag blocked? """ # avoid very long hashtags if len(hashtag) > 32: return True - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if os.path.isfile(globalBlockingFilename): + global_blocking_filename = base_dir + '/accounts/blocking.txt' + if os.path.isfile(global_blocking_filename): hashtag = hashtag.strip('\n').strip('\r') if not hashtag.startswith('#'): hashtag = '#' + hashtag - if hashtag + '\n' in open(globalBlockingFilename).read(): + if text_in_file(hashtag + '\n', global_blocking_filename): return True return False -def getDomainBlocklist(baseDir: str) -> str: +def get_domain_blocklist(base_dir: str) -> str: """Returns all globally blocked domains as a string This can be used for fast matching to mitigate flooding """ - blockedStr = '' + blocked_str = '' - evilDomains = evilIncarnate() - for evil in evilDomains: - blockedStr += evil + '\n' + evil_domains = evil_incarnate() + for evil in evil_domains: + blocked_str += evil + '\n' - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if not os.path.isfile(globalBlockingFilename): - return blockedStr - with open(globalBlockingFilename, 'r') as fpBlocked: - blockedStr += fpBlocked.read() - return blockedStr + global_blocking_filename = base_dir + '/accounts/blocking.txt' + if not os.path.isfile(global_blocking_filename): + return blocked_str + try: + with open(global_blocking_filename, 'r', + encoding='utf-8') as fp_blocked: + blocked_str += fp_blocked.read() + except OSError: + print('EX: unable to read ' + global_blocking_filename) + return blocked_str -def updateBlockedCache(baseDir: str, - blockedCache: [], - blockedCacheLastUpdated: int, - blockedCacheUpdateSecs: int) -> int: +def update_blocked_cache(base_dir: str, + blocked_cache: [], + blocked_cache_last_updated: int, + blocked_cache_update_secs: int) -> int: """Updates the cache of globally blocked domains held in memory """ - currTime = int(time.time()) - if blockedCacheLastUpdated > currTime: + curr_time = int(time.time()) + if blocked_cache_last_updated > curr_time: print('WARN: Cache updated in the future') - blockedCacheLastUpdated = 0 - secondsSinceLastUpdate = currTime - blockedCacheLastUpdated - if secondsSinceLastUpdate < blockedCacheUpdateSecs: - return blockedCacheLastUpdated - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if not os.path.isfile(globalBlockingFilename): - return blockedCacheLastUpdated - with open(globalBlockingFilename, 'r') as fpBlocked: - blockedLines = fpBlocked.readlines() - # remove newlines - for index in range(len(blockedLines)): - blockedLines[index] = blockedLines[index].replace('\n', '') - # update the cache - blockedCache.clear() - blockedCache += blockedLines - return currTime + blocked_cache_last_updated = 0 + seconds_since_last_update = curr_time - blocked_cache_last_updated + if seconds_since_last_update < blocked_cache_update_secs: + return blocked_cache_last_updated + global_blocking_filename = base_dir + '/accounts/blocking.txt' + if not os.path.isfile(global_blocking_filename): + return blocked_cache_last_updated + try: + with open(global_blocking_filename, 'r', + encoding='utf-8') as fp_blocked: + blocked_lines = fp_blocked.readlines() + # remove newlines + for index, _ in enumerate(blocked_lines): + blocked_lines[index] = remove_eol(blocked_lines[index]) + # update the cache + blocked_cache.clear() + blocked_cache += blocked_lines + except OSError as ex: + print('EX: unable to read ' + global_blocking_filename + ' ' + str(ex)) + return curr_time -def _getShortDomain(domain: str) -> str: +def _get_short_domain(domain: str) -> str: """ by checking a shorter version we can thwart adversaries who constantly change their subdomain e.g. subdomain123.mydomain.com becomes mydomain.com """ sections = domain.split('.') - noOfSections = len(sections) - if noOfSections > 2: - return sections[noOfSections-2] + '.' + sections[-1] + no_of_sections = len(sections) + if no_of_sections > 2: + return sections[no_of_sections-2] + '.' + sections[-1] return None -def isBlockedDomain(baseDir: str, domain: str, - blockedCache: [] = None) -> bool: +def is_blocked_domain(base_dir: str, domain: str, + blocked_cache: [] = None) -> bool: """Is the given domain blocked? """ if '.' not in domain: return False - if isEvil(domain): + if is_evil(domain): return True - shortDomain = _getShortDomain(domain) + short_domain = _get_short_domain(domain) - if not brochModeIsActive(baseDir): - if blockedCache: - for blockedStr in blockedCache: - if '*@' + domain in blockedStr: + if not broch_mode_is_active(base_dir): + if blocked_cache: + for blocked_str in blocked_cache: + if blocked_str == '*@' + domain: return True - if shortDomain: - if '*@' + shortDomain in blockedStr: + if short_domain: + if blocked_str == '*@' + short_domain: return True else: # instance block list - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if os.path.isfile(globalBlockingFilename): - with open(globalBlockingFilename, 'r') as fpBlocked: - blockedStr = fpBlocked.read() - if '*@' + domain in blockedStr: - return True - if shortDomain: - if '*@' + shortDomain in blockedStr: + global_blocking_filename = base_dir + '/accounts/blocking.txt' + if os.path.isfile(global_blocking_filename): + try: + with open(global_blocking_filename, 'r', + encoding='utf-8') as fp_blocked: + blocked_str = fp_blocked.read() + if '*@' + domain + '\n' in blocked_str: return True + if short_domain: + if '*@' + short_domain + '\n' in blocked_str: + return True + except OSError as ex: + print('EX: unable to read ' + global_blocking_filename + + ' ' + str(ex)) else: - allowFilename = baseDir + '/accounts/allowedinstances.txt' + allow_filename = base_dir + '/accounts/allowedinstances.txt' # instance allow list - if not shortDomain: - if domain not in open(allowFilename).read(): + if not short_domain: + if not text_in_file(domain, allow_filename): return True else: - if shortDomain not in open(allowFilename).read(): + if not text_in_file(short_domain, allow_filename): return True return False -def isBlocked(baseDir: str, nickname: str, domain: str, - blockNickname: str, blockDomain: str, - blockedCache: [] = None) -> bool: +def is_blocked(base_dir: str, nickname: str, domain: str, + block_nickname: str, block_domain: str, + blocked_cache: [] = None) -> bool: """Is the given nickname blocked? """ - if isEvil(blockDomain): + if is_evil(block_domain): return True - blockHandle = None - if blockNickname and blockDomain: - blockHandle = blockNickname + '@' + blockDomain + block_handle = None + if block_nickname and block_domain: + block_handle = block_nickname + '@' + block_domain - if not brochModeIsActive(baseDir): + if not broch_mode_is_active(base_dir): # instance level block list - if blockedCache: - for blockedStr in blockedCache: - if '*@' + domain in blockedStr: + if blocked_cache: + for blocked_str in blocked_cache: + if '*@' + domain in blocked_str: return True - if blockHandle: - if blockHandle in blockedStr: + if block_handle: + if blocked_str == block_handle: return True else: - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if os.path.isfile(globalBlockingFilename): - if '*@' + blockDomain in open(globalBlockingFilename).read(): + global_blocks_filename = base_dir + '/accounts/blocking.txt' + if os.path.isfile(global_blocks_filename): + if text_in_file('*@' + block_domain, global_blocks_filename): return True - if blockHandle: - if blockHandle in open(globalBlockingFilename).read(): + if block_handle: + block_str = block_handle + '\n' + if text_in_file(block_str, global_blocks_filename): return True else: # instance allow list - allowFilename = baseDir + '/accounts/allowedinstances.txt' - shortDomain = _getShortDomain(blockDomain) - if not shortDomain: - if blockDomain not in open(allowFilename).read(): + allow_filename = base_dir + '/accounts/allowedinstances.txt' + short_domain = _get_short_domain(block_domain) + if not short_domain: + if not text_in_file(block_domain + '\n', allow_filename): return True else: - if shortDomain not in open(allowFilename).read(): + if not text_in_file(short_domain + '\n', allow_filename): return True # account level allow list - accountDir = acctDir(baseDir, nickname, domain) - allowFilename = accountDir + '/allowedinstances.txt' - if os.path.isfile(allowFilename): - if blockDomain not in open(allowFilename).read(): + account_dir = acct_dir(base_dir, nickname, domain) + allow_filename = account_dir + '/allowedinstances.txt' + if os.path.isfile(allow_filename): + if not text_in_file(block_domain + '\n', allow_filename): return True # account level block list - blockingFilename = accountDir + '/blocking.txt' - if os.path.isfile(blockingFilename): - if '*@' + blockDomain in open(blockingFilename).read(): + blocking_filename = account_dir + '/blocking.txt' + if os.path.isfile(blocking_filename): + if text_in_file('*@' + block_domain + '\n', blocking_filename): return True - if blockHandle: - if blockHandle in open(blockingFilename).read(): + if block_handle: + if text_in_file(block_handle + '\n', blocking_filename): return True return False -def outboxBlock(baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> bool: +def outbox_block(base_dir: str, nickname: str, domain: str, + message_json: {}, debug: bool) -> bool: """ When a block request is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): if debug: print('DEBUG: block - no type') return False - if not messageJson['type'] == 'Block': + if not message_json['type'] == 'Block': if debug: print('DEBUG: not a block') return False - if not messageJson.get('object'): - if debug: - print('DEBUG: no object in block') - return False - if not isinstance(messageJson['object'], str): - if debug: - print('DEBUG: block object is not string') + if not has_object_string(message_json, debug): return False if debug: print('DEBUG: c2s block request arrived in outbox') - messageId = removeIdEnding(messageJson['object']) - if '/statuses/' not in messageId: + message_id = remove_id_ending(message_json['object']) + if '/statuses/' not in message_id: if debug: print('DEBUG: c2s block object is not a status') return False - if not hasUsersPath(messageId): + if not has_users_path(message_id): if debug: print('DEBUG: c2s block object has no nickname') return False - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: if debug: print('DEBUG: c2s block post not found in inbox or outbox') - print(messageId) + print(message_id) return False - nicknameBlocked = getNicknameFromActor(messageJson['object']) - if not nicknameBlocked: - print('WARN: unable to find nickname in ' + messageJson['object']) + nickname_blocked = get_nickname_from_actor(message_json['object']) + if not nickname_blocked: + print('WARN: unable to find nickname in ' + message_json['object']) return False - domainBlocked, portBlocked = getDomainFromActor(messageJson['object']) - domainBlockedFull = getFullDomain(domainBlocked, portBlocked) + domain_blocked, port_blocked = \ + get_domain_from_actor(message_json['object']) + domain_blocked_full = get_full_domain(domain_blocked, port_blocked) - addBlock(baseDir, nickname, domain, - nicknameBlocked, domainBlockedFull) + add_block(base_dir, nickname, domain, + nickname_blocked, domain_blocked_full) if debug: - print('DEBUG: post blocked via c2s - ' + postFilename) + print('DEBUG: post blocked via c2s - ' + post_filename) return True -def outboxUndoBlock(baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> None: +def outbox_undo_block(base_dir: str, nickname: str, domain: str, + message_json: {}, debug: bool) -> None: """ When an undo block request is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): if debug: print('DEBUG: undo block - no type') return - if not messageJson['type'] == 'Undo': + if not message_json['type'] == 'Undo': if debug: print('DEBUG: not an undo block') return - if not hasObjectDict(messageJson): - if debug: - print('DEBUG: undo block object is not string') - return - if not messageJson['object'].get('type'): - if debug: - print('DEBUG: undo block - no type') + if not has_object_string_type(message_json, debug): return - if not messageJson['object']['type'] == 'Block': + if not message_json['object']['type'] == 'Block': if debug: print('DEBUG: not an undo block') return - if not messageJson['object'].get('object'): - if debug: - print('DEBUG: no object in undo block') - return - if not isinstance(messageJson['object']['object'], str): - if debug: - print('DEBUG: undo block object is not string') + if not has_object_string_object(message_json, debug): return if debug: print('DEBUG: c2s undo block request arrived in outbox') - messageId = removeIdEnding(messageJson['object']['object']) - if '/statuses/' not in messageId: + message_id = remove_id_ending(message_json['object']['object']) + if '/statuses/' not in message_id: if debug: print('DEBUG: c2s undo block object is not a status') return - if not hasUsersPath(messageId): + if not has_users_path(message_id): if debug: print('DEBUG: c2s undo block object has no nickname') return - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: if debug: print('DEBUG: c2s undo block post not found in inbox or outbox') - print(messageId) + print(message_id) return - nicknameBlocked = getNicknameFromActor(messageJson['object']['object']) - if not nicknameBlocked: + nickname_blocked = \ + get_nickname_from_actor(message_json['object']['object']) + if not nickname_blocked: print('WARN: unable to find nickname in ' + - messageJson['object']['object']) + message_json['object']['object']) return - domainObject = messageJson['object']['object'] - domainBlocked, portBlocked = getDomainFromActor(domainObject) - domainBlockedFull = getFullDomain(domainBlocked, portBlocked) + domain_object = message_json['object']['object'] + domain_blocked, port_blocked = get_domain_from_actor(domain_object) + domain_blocked_full = get_full_domain(domain_blocked, port_blocked) - removeBlock(baseDir, nickname, domain, - nicknameBlocked, domainBlockedFull) + remove_block(base_dir, nickname, domain, + nickname_blocked, domain_blocked_full) if debug: - print('DEBUG: post undo blocked via c2s - ' + postFilename) + print('DEBUG: post undo blocked via c2s - ' + post_filename) -def mutePost(baseDir: str, nickname: str, domain: str, port: int, - httpPrefix: str, postId: str, recentPostsCache: {}, - debug: bool) -> None: +def mute_post(base_dir: str, nickname: str, domain: str, port: int, + http_prefix: str, post_id: str, recent_posts_cache: {}, + debug: bool) -> None: """ Mutes the given post """ - print('mutePost: postId ' + postId) - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: - print('mutePost: file not found ' + postId) + print('mute_post: post_id ' + post_id) + post_filename = locate_post(base_dir, nickname, domain, post_id) + if not post_filename: + print('mute_post: file not found ' + post_id) return - postJsonObject = loadJson(postFilename) - if not postJsonObject: - print('mutePost: object not loaded ' + postId) + post_json_object = load_json(post_filename) + if not post_json_object: + print('mute_post: object not loaded ' + post_id) return - print('mutePost: ' + str(postJsonObject)) + print('mute_post: ' + str(post_json_object)) - postJsonObj = postJsonObject - alsoUpdatePostId = None - if hasObjectDict(postJsonObject): - postJsonObj = postJsonObject['object'] + post_json_obj = post_json_object + also_update_post_id = None + if has_object_dict(post_json_object): + post_json_obj = post_json_object['object'] else: - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], str): - alsoUpdatePostId = removeIdEnding(postJsonObject['object']) + if has_object_string(post_json_object, debug): + also_update_post_id = remove_id_ending(post_json_object['object']) - domainFull = getFullDomain(domain, port) - actor = localActorUrl(httpPrefix, nickname, domainFull) + domain_full = get_full_domain(domain, port) + actor = local_actor_url(http_prefix, nickname, domain_full) - if postJsonObj.get('conversation'): - muteConversation(baseDir, nickname, domain, - postJsonObj['conversation']) + if post_json_obj.get('conversation'): + mute_conversation(base_dir, nickname, domain, + post_json_obj['conversation']) # does this post have ignores on it from differenent actors? - if not postJsonObj.get('ignores'): + if not post_json_obj.get('ignores'): if debug: - print('DEBUG: Adding initial mute to ' + postId) - ignoresJson = { + print('DEBUG: Adding initial mute to ' + post_id) + ignores_json = { "@context": "https://www.w3.org/ns/activitystreams", - 'id': postId, + 'id': post_id, 'type': 'Collection', "totalItems": 1, 'items': [{ @@ -502,316 +582,326 @@ def mutePost(baseDir: str, nickname: str, domain: str, port: int, 'actor': actor }] } - postJsonObj['ignores'] = ignoresJson + post_json_obj['ignores'] = ignores_json else: - if not postJsonObj['ignores'].get('items'): - postJsonObj['ignores']['items'] = [] - itemsList = postJsonObj['ignores']['items'] - for ignoresItem in itemsList: - if ignoresItem.get('actor'): - if ignoresItem['actor'] == actor: + if not post_json_obj['ignores'].get('items'): + post_json_obj['ignores']['items'] = [] + items_list = post_json_obj['ignores']['items'] + for ignores_item in items_list: + if ignores_item.get('actor'): + if ignores_item['actor'] == actor: return - newIgnore = { + new_ignore = { 'type': 'Ignore', 'actor': actor } - igIt = len(itemsList) - itemsList.append(newIgnore) - postJsonObj['ignores']['totalItems'] = igIt - postJsonObj['muted'] = True - if saveJson(postJsonObject, postFilename): - print('mutePost: saved ' + postFilename) + ig_it = len(items_list) + items_list.append(new_ignore) + post_json_obj['ignores']['totalItems'] = ig_it + post_json_obj['muted'] = True + if save_json(post_json_object, post_filename): + print('mute_post: saved ' + post_filename) # remove cached post so that the muted version gets recreated # without its content text and/or image - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - print('MUTE: cached post removed ' + cachedPostFilename) - except BaseException: - pass + os.remove(cached_post_filename) + print('MUTE: cached post removed ' + cached_post_filename) + except OSError: + print('EX: MUTE cached post not removed ' + + cached_post_filename) else: - print('MUTE: cached post not found ' + cachedPostFilename) + print('MUTE: cached post not found ' + cached_post_filename) - with open(postFilename + '.muted', 'w+') as muteFile: - muteFile.write('\n') - print('MUTE: ' + postFilename + '.muted file added') + try: + with open(post_filename + '.muted', 'w+', + encoding='utf-8') as mute_file: + mute_file.write('\n') + except OSError: + print('EX: Failed to save mute file ' + post_filename + '.muted') + return + print('MUTE: ' + post_filename + '.muted file added') # if the post is in the recent posts cache then mark it as muted - if recentPostsCache.get('index'): - postId = \ - removeIdEnding(postJsonObject['id']).replace('/', '#') - if postId in recentPostsCache['index']: - print('MUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache.get('json'): - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - print('MUTE: ' + postId + + if recent_posts_cache.get('index'): + post_id = \ + remove_id_ending(post_json_object['id']).replace('/', '#') + if post_id in recent_posts_cache['index']: + print('MUTE: ' + post_id + ' is in recent posts cache') + if recent_posts_cache.get('json'): + recent_posts_cache['json'][post_id] = json.dumps(post_json_object) + print('MUTE: ' + post_id + ' marked as muted in recent posts memory cache') - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('MUTE: ' + postId + ' removed cached html') + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(post_id): + del recent_posts_cache['html'][post_id] + print('MUTE: ' + post_id + ' removed cached html') - if alsoUpdatePostId: - postFilename = locatePost(baseDir, nickname, domain, alsoUpdatePostId) - if os.path.isfile(postFilename): - postJsonObj = loadJson(postFilename) - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, - postJsonObj) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + if also_update_post_id: + post_filename = locate_post(base_dir, nickname, domain, + also_update_post_id) + if os.path.isfile(post_filename): + post_json_obj = load_json(post_filename) + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, + post_json_obj) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) + os.remove(cached_post_filename) print('MUTE: cached referenced post removed ' + - cachedPostFilename) - except BaseException: - pass + cached_post_filename) + except OSError: + print('EX: ' + + 'MUTE cached referenced post not removed ' + + cached_post_filename) - if recentPostsCache.get('json'): - if recentPostsCache['json'].get(alsoUpdatePostId): - del recentPostsCache['json'][alsoUpdatePostId] - print('MUTE: ' + alsoUpdatePostId + ' removed referenced json') - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(alsoUpdatePostId): - del recentPostsCache['html'][alsoUpdatePostId] - print('MUTE: ' + alsoUpdatePostId + ' removed referenced html') + if recent_posts_cache.get('json'): + if recent_posts_cache['json'].get(also_update_post_id): + del recent_posts_cache['json'][also_update_post_id] + print('MUTE: ' + also_update_post_id + + ' removed referenced json') + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(also_update_post_id): + del recent_posts_cache['html'][also_update_post_id] + print('MUTE: ' + also_update_post_id + + ' removed referenced html') -def unmutePost(baseDir: str, nickname: str, domain: str, port: int, - httpPrefix: str, postId: str, recentPostsCache: {}, - debug: bool) -> None: +def unmute_post(base_dir: str, nickname: str, domain: str, port: int, + http_prefix: str, post_id: str, recent_posts_cache: {}, + debug: bool) -> None: """ Unmutes the given post """ - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, post_id) + if not post_filename: return - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return - muteFilename = postFilename + '.muted' - if os.path.isfile(muteFilename): + mute_filename = post_filename + '.muted' + if os.path.isfile(mute_filename): try: - os.remove(muteFilename) - except BaseException: - pass - print('UNMUTE: ' + muteFilename + ' file removed') + os.remove(mute_filename) + except OSError: + if debug: + print('EX: unmute_post mute filename not deleted ' + + str(mute_filename)) + print('UNMUTE: ' + mute_filename + ' file removed') - postJsonObj = postJsonObject - alsoUpdatePostId = None - if hasObjectDict(postJsonObject): - postJsonObj = postJsonObject['object'] + post_json_obj = post_json_object + also_update_post_id = None + if has_object_dict(post_json_object): + post_json_obj = post_json_object['object'] else: - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], str): - alsoUpdatePostId = removeIdEnding(postJsonObject['object']) + if has_object_string(post_json_object, debug): + also_update_post_id = remove_id_ending(post_json_object['object']) - if postJsonObj.get('conversation'): - unmuteConversation(baseDir, nickname, domain, - postJsonObj['conversation']) + if post_json_obj.get('conversation'): + unmute_conversation(base_dir, nickname, domain, + post_json_obj['conversation']) - if postJsonObj.get('ignores'): - domainFull = getFullDomain(domain, port) - actor = localActorUrl(httpPrefix, nickname, domainFull) - totalItems = 0 - if postJsonObj['ignores'].get('totalItems'): - totalItems = postJsonObj['ignores']['totalItems'] - itemsList = postJsonObj['ignores']['items'] - for ignoresItem in itemsList: - if ignoresItem.get('actor'): - if ignoresItem['actor'] == actor: + if post_json_obj.get('ignores'): + domain_full = get_full_domain(domain, port) + actor = local_actor_url(http_prefix, nickname, domain_full) + total_items = 0 + if post_json_obj['ignores'].get('totalItems'): + total_items = post_json_obj['ignores']['totalItems'] + items_list = post_json_obj['ignores']['items'] + for ignores_item in items_list: + if ignores_item.get('actor'): + if ignores_item['actor'] == actor: if debug: print('DEBUG: mute was removed for ' + actor) - itemsList.remove(ignoresItem) + items_list.remove(ignores_item) break - if totalItems == 1: + if total_items == 1: if debug: print('DEBUG: mute was removed from post') - del postJsonObj['ignores'] + del post_json_obj['ignores'] else: - igItLen = len(postJsonObj['ignores']['items']) - postJsonObj['ignores']['totalItems'] = igItLen - postJsonObj['muted'] = False - saveJson(postJsonObject, postFilename) + ig_it_len = len(post_json_obj['ignores']['items']) + post_json_obj['ignores']['totalItems'] = ig_it_len + post_json_obj['muted'] = False + save_json(post_json_object, post_filename) # remove cached post so that the muted version gets recreated # with its content text and/or image - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass + os.remove(cached_post_filename) + except OSError: + if debug: + print('EX: unmute_post cached post not deleted ' + + str(cached_post_filename)) # if the post is in the recent posts cache then mark it as unmuted - if recentPostsCache.get('index'): - postId = \ - removeIdEnding(postJsonObject['id']).replace('/', '#') - if postId in recentPostsCache['index']: - print('UNMUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache.get('json'): - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - print('UNMUTE: ' + postId + + if recent_posts_cache.get('index'): + post_id = \ + remove_id_ending(post_json_object['id']).replace('/', '#') + if post_id in recent_posts_cache['index']: + print('UNMUTE: ' + post_id + ' is in recent posts cache') + if recent_posts_cache.get('json'): + recent_posts_cache['json'][post_id] = json.dumps(post_json_object) + print('UNMUTE: ' + post_id + ' marked as unmuted in recent posts cache') - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('UNMUTE: ' + postId + ' removed cached html') - if alsoUpdatePostId: - postFilename = locatePost(baseDir, nickname, domain, alsoUpdatePostId) - if os.path.isfile(postFilename): - postJsonObj = loadJson(postFilename) - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, - postJsonObj) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(post_id): + del recent_posts_cache['html'][post_id] + print('UNMUTE: ' + post_id + ' removed cached html') + if also_update_post_id: + post_filename = locate_post(base_dir, nickname, domain, + also_update_post_id) + if os.path.isfile(post_filename): + post_json_obj = load_json(post_filename) + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, + post_json_obj) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) + os.remove(cached_post_filename) print('MUTE: cached referenced post removed ' + - cachedPostFilename) - except BaseException: - pass + cached_post_filename) + except OSError: + if debug: + print('EX: ' + + 'unmute_post cached ref post not removed ' + + str(cached_post_filename)) - if recentPostsCache.get('json'): - if recentPostsCache['json'].get(alsoUpdatePostId): - del recentPostsCache['json'][alsoUpdatePostId] + if recent_posts_cache.get('json'): + if recent_posts_cache['json'].get(also_update_post_id): + del recent_posts_cache['json'][also_update_post_id] print('UNMUTE: ' + - alsoUpdatePostId + ' removed referenced json') - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(alsoUpdatePostId): - del recentPostsCache['html'][alsoUpdatePostId] + also_update_post_id + ' removed referenced json') + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(also_update_post_id): + del recent_posts_cache['html'][also_update_post_id] print('UNMUTE: ' + - alsoUpdatePostId + ' removed referenced html') + also_update_post_id + ' removed referenced html') -def outboxMute(baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool, - recentPostsCache: {}) -> None: +def outbox_mute(base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + message_json: {}, debug: bool, + recent_posts_cache: {}) -> None: """When a mute is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): return - if not messageJson.get('actor'): + if not has_actor(message_json, debug): return - domainFull = getFullDomain(domain, port) - if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): + domain_full = get_full_domain(domain, port) + if not message_json['actor'].endswith(domain_full + '/users/' + nickname): return - if not messageJson['type'] == 'Ignore': + if not message_json['type'] == 'Ignore': return - if not messageJson.get('object'): - if debug: - print('DEBUG: no object in mute') - return - if not isinstance(messageJson['object'], str): - if debug: - print('DEBUG: mute object is not string') + if not has_object_string(message_json, debug): return if debug: print('DEBUG: c2s mute request arrived in outbox') - messageId = removeIdEnding(messageJson['object']) - if '/statuses/' not in messageId: + message_id = remove_id_ending(message_json['object']) + if '/statuses/' not in message_id: if debug: print('DEBUG: c2s mute object is not a status') return - if not hasUsersPath(messageId): + if not has_users_path(message_id): if debug: print('DEBUG: c2s mute object has no nickname') return - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: if debug: print('DEBUG: c2s mute post not found in inbox or outbox') - print(messageId) + print(message_id) return - nicknameMuted = getNicknameFromActor(messageJson['object']) - if not nicknameMuted: - print('WARN: unable to find nickname in ' + messageJson['object']) + nickname_muted = get_nickname_from_actor(message_json['object']) + if not nickname_muted: + print('WARN: unable to find nickname in ' + message_json['object']) return - mutePost(baseDir, nickname, domain, port, - httpPrefix, messageJson['object'], recentPostsCache, - debug) + mute_post(base_dir, nickname, domain, port, + http_prefix, message_json['object'], recent_posts_cache, + debug) if debug: - print('DEBUG: post muted via c2s - ' + postFilename) + print('DEBUG: post muted via c2s - ' + post_filename) -def outboxUndoMute(baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool, - recentPostsCache: {}) -> None: +def outbox_undo_mute(base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + message_json: {}, debug: bool, + recent_posts_cache: {}) -> None: """When an undo mute is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): return - if not messageJson.get('actor'): + if not has_actor(message_json, debug): return - domainFull = getFullDomain(domain, port) - if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): + domain_full = get_full_domain(domain, port) + if not message_json['actor'].endswith(domain_full + '/users/' + nickname): return - if not messageJson['type'] == 'Undo': + if not message_json['type'] == 'Undo': return - if not hasObjectDict(messageJson): + if not has_object_string_type(message_json, debug): return - if not messageJson['object'].get('type'): + if message_json['object']['type'] != 'Ignore': return - if messageJson['object']['type'] != 'Ignore': - return - if not isinstance(messageJson['object']['object'], str): + if not isinstance(message_json['object']['object'], str): if debug: print('DEBUG: undo mute object is not a string') return if debug: print('DEBUG: c2s undo mute request arrived in outbox') - messageId = removeIdEnding(messageJson['object']['object']) - if '/statuses/' not in messageId: + message_id = remove_id_ending(message_json['object']['object']) + if '/statuses/' not in message_id: if debug: print('DEBUG: c2s undo mute object is not a status') return - if not hasUsersPath(messageId): + if not has_users_path(message_id): if debug: print('DEBUG: c2s undo mute object has no nickname') return - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: if debug: print('DEBUG: c2s undo mute post not found in inbox or outbox') - print(messageId) + print(message_id) return - nicknameMuted = getNicknameFromActor(messageJson['object']['object']) - if not nicknameMuted: + nickname_muted = get_nickname_from_actor(message_json['object']['object']) + if not nickname_muted: print('WARN: unable to find nickname in ' + - messageJson['object']['object']) + message_json['object']['object']) return - unmutePost(baseDir, nickname, domain, port, - httpPrefix, messageJson['object']['object'], - recentPostsCache, debug) + unmute_post(base_dir, nickname, domain, port, + http_prefix, message_json['object']['object'], + recent_posts_cache, debug) if debug: - print('DEBUG: post undo mute via c2s - ' + postFilename) + print('DEBUG: post undo mute via c2s - ' + post_filename) -def brochModeIsActive(baseDir: str) -> bool: +def broch_mode_is_active(base_dir: str) -> bool: """Returns true if broch mode is active """ - allowFilename = baseDir + '/accounts/allowedinstances.txt' - return os.path.isfile(allowFilename) + allow_filename = base_dir + '/accounts/allowedinstances.txt' + return os.path.isfile(allow_filename) -def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None: +def set_broch_mode(base_dir: str, domain_full: str, enabled: bool) -> None: """Broch mode can be used to lock down the instance during a period of time when it is temporarily under attack. For example, where an adversary is constantly spinning up new @@ -820,81 +910,197 @@ def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None: to construct an instance level allow list. Anything arriving which is then not from one of the allowed domains will be dropped """ - allowFilename = baseDir + '/accounts/allowedinstances.txt' + allow_filename = base_dir + '/accounts/allowedinstances.txt' if not enabled: # remove instance allow list - if os.path.isfile(allowFilename): + if os.path.isfile(allow_filename): try: - os.remove(allowFilename) - except BaseException: - pass + os.remove(allow_filename) + except OSError: + print('EX: set_broch_mode allow file not deleted ' + + str(allow_filename)) print('Broch mode turned off') else: - if os.path.isfile(allowFilename): - lastModified = fileLastModified(allowFilename) - print('Broch mode already activated ' + lastModified) + if os.path.isfile(allow_filename): + last_modified = file_last_modified(allow_filename) + print('Broch mode already activated ' + last_modified) return # generate instance allow list - allowedDomains = [domainFull] - followFiles = ('following.txt', 'followers.txt') - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + allowed_domains = [domain_full] + follow_files = ('following.txt', 'followers.txt') + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = os.path.join(baseDir + '/accounts', acct) - for followFileType in followFiles: - followingFilename = accountDir + '/' + followFileType - if not os.path.isfile(followingFilename): + account_dir = os.path.join(base_dir + '/accounts', acct) + for follow_file_type in follow_files: + following_filename = account_dir + '/' + follow_file_type + if not os.path.isfile(following_filename): continue - with open(followingFilename, 'r') as f: - followList = f.readlines() - for handle in followList: - if '@' not in handle: - continue - handle = handle.replace('\n', '') - handleDomain = handle.split('@')[1] - if handleDomain not in allowedDomains: - allowedDomains.append(handleDomain) + try: + with open(following_filename, 'r', + encoding='utf-8') as foll_file: + follow_list = foll_file.readlines() + for handle in follow_list: + if '@' not in handle: + continue + handle = remove_eol(handle) + handle_domain = handle.split('@')[1] + if handle_domain not in allowed_domains: + allowed_domains.append(handle_domain) + except OSError as ex: + print('EX: failed to read ' + following_filename + + ' ' + str(ex)) break # write the allow file - with open(allowFilename, 'w+') as allowFile: - allowFile.write(domainFull + '\n') - for d in allowedDomains: - allowFile.write(d + '\n') - print('Broch mode enabled') + try: + with open(allow_filename, 'w+', + encoding='utf-8') as allow_file: + allow_file.write(domain_full + '\n') + for allowed in allowed_domains: + allow_file.write(allowed + '\n') + print('Broch mode enabled') + except OSError as ex: + print('EX: Broch mode not enabled due to file write ' + str(ex)) + return - setConfigParam(baseDir, "brochMode", enabled) + set_config_param(base_dir, "brochMode", enabled) -def brochModeLapses(baseDir: str, lapseDays: int = 7) -> bool: +def broch_modeLapses(base_dir: str, lapseDays: int) -> bool: """After broch mode is enabled it automatically elapses after a period of time """ - allowFilename = baseDir + '/accounts/allowedinstances.txt' - if not os.path.isfile(allowFilename): + allow_filename = base_dir + '/accounts/allowedinstances.txt' + if not os.path.isfile(allow_filename): return False - lastModified = fileLastModified(allowFilename) - modifiedDate = None + last_modified = file_last_modified(allow_filename) + modified_date = None try: - modifiedDate = \ - datetime.strptime(lastModified, "%Y-%m-%dT%H:%M:%SZ") + modified_date = \ + datetime.strptime(last_modified, "%Y-%m-%dT%H:%M:%SZ") except BaseException: + print('EX: broch_modeLapses date not parsed ' + str(last_modified)) return False - if not modifiedDate: + if not modified_date: return False - currTime = datetime.datetime.utcnow() - daysSinceBroch = (currTime - modifiedDate).days - if daysSinceBroch >= lapseDays: + curr_time = datetime.datetime.utcnow() + days_since_broch = (curr_time - modified_date).days + if days_since_broch >= lapseDays: removed = False try: - os.remove(allowFilename) + os.remove(allow_filename) removed = True - except BaseException: - pass + except OSError: + print('EX: broch_modeLapses allow file not deleted ' + + str(allow_filename)) if removed: - setConfigParam(baseDir, "brochMode", False) + set_config_param(base_dir, "brochMode", False) print('Broch mode has elapsed') return True return False + + +def load_cw_lists(base_dir: str, verbose: bool) -> {}: + """Load lists used for content warnings + """ + if not os.path.isdir(base_dir + '/cwlists'): + return {} + result = {} + # NOTE: here we do want to allow recursive walk through + # possible subdirectories + for _, _, files in os.walk(base_dir + '/cwlists'): + for fname in files: + if not fname.endswith('.json'): + continue + list_filename = os.path.join(base_dir + '/cwlists', fname) + print('list_filename: ' + list_filename) + list_json = load_json(list_filename, 0, 1) + if not list_json: + continue + if not list_json.get('name'): + continue + if not list_json.get('words') and not list_json.get('domains'): + continue + name = list_json['name'] + if verbose: + print('List: ' + name) + result[name] = list_json + return result + + +def add_cw_from_lists(post_json_object: {}, cw_lists: {}, translate: {}, + lists_enabled: str, system_language: str) -> None: + """Adds content warnings by matching the post content + against domains or keywords + """ + if not lists_enabled: + return + if not post_json_object['object'].get('content'): + if not post_json_object['object'].get('contentMap'): + return + cw_text = '' + if post_json_object['object'].get('summary'): + cw_text = post_json_object['object']['summary'] + + content = None + if post_json_object['object'].get('contentMap'): + if post_json_object['object']['contentMap'].get(system_language): + content = \ + post_json_object['object']['contentMap'][system_language] + if not content: + if post_json_object['object'].get('content'): + content = post_json_object['object']['content'] + if not content: + return + for name, item in cw_lists.items(): + if name not in lists_enabled: + continue + if not item.get('warning'): + continue + warning = item['warning'] + + # is there a translated version of the warning? + if translate.get(warning): + warning = translate[warning] + + # is the warning already in the CW? + if warning in cw_text: + continue + + matched = False + + # match domains within the content + if item.get('domains'): + for domain in item['domains']: + if domain in content: + if cw_text: + cw_text = warning + ' / ' + cw_text + else: + cw_text = warning + matched = True + break + + if matched: + continue + + # match words within the content + if item.get('words'): + for word_str in item['words']: + if word_str in content or word_str.title() in content: + if cw_text: + cw_text = warning + ' / ' + cw_text + else: + cw_text = warning + break + if cw_text: + post_json_object['object']['summary'] = cw_text + post_json_object['object']['sensitive'] = True + + +def get_cw_list_variable(list_name: str) -> str: + """Returns the variable associated with a CW list + """ + return 'list' + list_name.replace(' ', '').replace("'", '') diff --git a/blog.py b/blog.py index fbdcadad0..2ba055066 100644 --- a/blog.py +++ b/blog.py @@ -1,7 +1,7 @@ __filename__ = "blog.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -10,403 +10,436 @@ __module_group__ = "ActivityPub" import os from datetime import datetime -from content import replaceEmojiFromTags -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlHeaderWithBlogMarkup -from webapp_utils import htmlFooter -from webapp_utils import getPostAttachmentsAsHtml -from webapp_utils import editTextArea -from webapp_media import addEmbeddedElements -from utils import localActorUrl -from utils import getActorLanguagesList -from utils import getBaseContentFromPost -from utils import getContentFromPost -from utils import isAccountDir -from utils import removeHtml -from utils import getConfigParam -from utils import getFullDomain -from utils import getMediaFormats -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import locatePost -from utils import loadJson -from utils import firstParagraphFromString -from utils import getActorPropertyUrl -from utils import acctDir -from posts import createBlogsTimeline -from newswire import rss2Header -from newswire import rss2Footer -from cache import getPersonFromCache +from content import replace_emoji_from_tags +from webapp_utils import html_header_with_external_style +from webapp_utils import html_header_with_blog_markup +from webapp_utils import html_footer +from webapp_utils import get_post_attachments_as_html +from webapp_utils import edit_text_area +from webapp_media import add_embedded_elements +from utils import remove_eol +from utils import text_in_file +from utils import local_actor_url +from utils import get_actor_languages_list +from utils import get_base_content_from_post +from utils import get_content_from_post +from utils import is_account_dir +from utils import remove_html +from utils import get_config_param +from utils import get_full_domain +from utils import get_media_formats +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import locate_post +from utils import load_json +from utils import first_paragraph_from_string +from utils import get_actor_property_url +from utils import acct_dir +from posts import create_blogs_timeline +from newswire import rss2header +from newswire import rss2footer +from cache import get_person_from_cache -def _noOfBlogReplies(baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postId: str, depth=0) -> int: +def _no_of_blog_replies(base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, domain_full: str, + post_id: str, depth: int = 0) -> int: """Returns the number of replies on the post This is recursive, so can handle replies to replies """ if depth > 4: return 0 - if not postId: + if not post_id: return 0 - tryPostBox = ('tlblogs', 'inbox', 'outbox') - boxFound = False - for postBox in tryPostBox: - postFilename = \ - acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \ - postId.replace('/', '#') + '.replies' - if os.path.isfile(postFilename): - boxFound = True + try_post_box = ('tlblogs', 'inbox', 'outbox') + box_found = False + for post_box in try_post_box: + post_filename = \ + acct_dir(base_dir, nickname, domain) + '/' + post_box + '/' + \ + post_id.replace('/', '#') + '.replies' + if os.path.isfile(post_filename): + box_found = True break - if not boxFound: + if not box_found: # post may exist but has no replies - for postBox in tryPostBox: - postFilename = \ - acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \ - postId.replace('/', '#') - if os.path.isfile(postFilename): + for post_box in try_post_box: + post_filename = \ + acct_dir(base_dir, nickname, domain) + '/' + post_box + '/' + \ + post_id.replace('/', '#') + if os.path.isfile(post_filename): return 1 return 0 removals = [] replies = 0 lines = [] - with open(postFilename, 'r') as f: - lines = f.readlines() - for replyPostId in lines: - replyPostId = replyPostId.replace('\n', '').replace('\r', '') - replyPostId = replyPostId.replace('.json', '') - if locatePost(baseDir, nickname, domain, replyPostId): - replyPostId = replyPostId.replace('.replies', '') - replies += \ - 1 + _noOfBlogReplies(baseDir, httpPrefix, translate, - nickname, domain, domainFull, - replyPostId, depth+1) - else: - # remove post which no longer exists - removals.append(replyPostId) + try: + with open(post_filename, 'r', encoding='utf-8') as post_file: + lines = post_file.readlines() + except OSError: + print('EX: failed to read blog ' + post_filename) + + for reply_post_id in lines: + reply_post_id = remove_eol(reply_post_id) + reply_post_id = reply_post_id.replace('.json', '') + if locate_post(base_dir, nickname, domain, reply_post_id): + reply_post_id = reply_post_id.replace('.replies', '') + replies += \ + 1 + _no_of_blog_replies(base_dir, http_prefix, translate, + nickname, domain, domain_full, + reply_post_id, depth+1) + else: + # remove post which no longer exists + removals.append(reply_post_id) # remove posts from .replies file if they don't exist if lines and removals: - print('Rewriting ' + postFilename + ' to remove ' + + print('Rewriting ' + post_filename + ' to remove ' + str(len(removals)) + ' entries') - with open(postFilename, 'w+') as f: - for replyPostId in lines: - replyPostId = replyPostId.replace('\n', '').replace('\r', '') - if replyPostId not in removals: - f.write(replyPostId + '\n') + try: + with open(post_filename, 'w+', encoding='utf-8') as post_file: + for reply_post_id in lines: + reply_post_id = remove_eol(reply_post_id) + if reply_post_id not in removals: + post_file.write(reply_post_id + '\n') + except OSError as ex: + print('EX: unable to remove replies from post ' + + post_filename + ' ' + str(ex)) return replies -def _getBlogReplies(baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postId: str, depth=0) -> str: +def _get_blog_replies(base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, domain_full: str, + post_id: str, depth: int = 0) -> str: """Returns a string containing html blog posts """ if depth > 4: return '' - if not postId: + if not post_id: return '' - tryPostBox = ('tlblogs', 'inbox', 'outbox') - boxFound = False - for postBox in tryPostBox: - postFilename = \ - acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \ - postId.replace('/', '#') + '.replies' - if os.path.isfile(postFilename): - boxFound = True + try_post_box = ('tlblogs', 'inbox', 'outbox') + box_found = False + for post_box in try_post_box: + post_filename = \ + acct_dir(base_dir, nickname, domain) + '/' + post_box + '/' + \ + post_id.replace('/', '#') + '.replies' + if os.path.isfile(post_filename): + box_found = True break - if not boxFound: + if not box_found: # post may exist but has no replies - for postBox in tryPostBox: - postFilename = \ - acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \ - postId.replace('/', '#') + '.json' - if os.path.isfile(postFilename): - postFilename = acctDir(baseDir, nickname, domain) + \ + for post_box in try_post_box: + post_filename = \ + acct_dir(base_dir, nickname, domain) + '/' + post_box + '/' + \ + post_id.replace('/', '#') + '.json' + if os.path.isfile(post_filename): + post_filename = acct_dir(base_dir, nickname, domain) + \ '/postcache/' + \ - postId.replace('/', '#') + '.html' - if os.path.isfile(postFilename): - with open(postFilename, 'r') as postFile: - return postFile.read() + '\n' + post_id.replace('/', '#') + '.html' + if os.path.isfile(post_filename): + try: + with open(post_filename, 'r', + encoding='utf-8') as post_file: + return post_file.read() + '\n' + except OSError: + print('EX: unable to read blog 3 ' + post_filename) return '' - with open(postFilename, 'r') as f: - lines = f.readlines() - repliesStr = '' - for replyPostId in lines: - replyPostId = replyPostId.replace('\n', '').replace('\r', '') - replyPostId = replyPostId.replace('.json', '') - replyPostId = replyPostId.replace('.replies', '') - postFilename = acctDir(baseDir, nickname, domain) + \ + lines = [] + try: + with open(post_filename, 'r', encoding='utf-8') as post_file: + lines = post_file.readlines() + except OSError: + print('EX: unable to read blog 4 ' + post_filename) + + if lines: + replies_str = '' + for reply_post_id in lines: + reply_post_id = remove_eol(reply_post_id) + reply_post_id = reply_post_id.replace('.json', '') + reply_post_id = reply_post_id.replace('.replies', '') + post_filename = acct_dir(base_dir, nickname, domain) + \ '/postcache/' + \ - replyPostId.replace('/', '#') + '.html' - if not os.path.isfile(postFilename): + reply_post_id.replace('/', '#') + '.html' + if not os.path.isfile(post_filename): continue - with open(postFilename, 'r') as postFile: - repliesStr += postFile.read() + '\n' - rply = _getBlogReplies(baseDir, httpPrefix, translate, - nickname, domain, domainFull, - replyPostId, depth+1) - if rply not in repliesStr: - repliesStr += rply + try: + with open(post_filename, 'r', encoding='utf-8') as post_file: + replies_str += post_file.read() + '\n' + except OSError: + print('EX: unable to read blog replies ' + post_filename) + rply = _get_blog_replies(base_dir, http_prefix, translate, + nickname, domain, domain_full, + reply_post_id, depth+1) + if rply not in replies_str: + replies_str += rply # indicate the reply indentation level - indentStr = '>' - for indentLevel in range(depth): - indentStr += ' >' + indent_str = '>' + indent_level = 0 + while indent_level < depth: + indent_str += ' >' + indent_level += 1 - repliesStr = repliesStr.replace(translate['SHOW MORE'], indentStr) - return repliesStr.replace('?tl=outbox', '?tl=tlblogs') + replies_str = replies_str.replace(translate['SHOW MORE'], indent_str) + return replies_str.replace('?tl=outbox', '?tl=tlblogs') return '' -def _htmlBlogPostContent(authorized: bool, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postJsonObject: {}, - handle: str, restrictToDomain: bool, - peertubeInstances: [], - systemLanguage: str, - personCache: {}, - blogSeparator: str = '
') -> str: +def _html_blog_post_content(debug: bool, session, authorized: bool, + base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, domain_full: str, + post_json_object: {}, + handle: str, restrict_to_domain: bool, + peertube_instances: [], + system_language: str, + person_cache: {}, + blog_separator: str = '
') -> str: """Returns the content for a single blog post """ - linkedAuthor = False + linked_author = False actor = '' - blogStr = '' - messageLink = '' - if postJsonObject['object'].get('id'): - messageLink = postJsonObject['object']['id'].replace('/statuses/', '/') - titleStr = '' - articleAdded = False - if postJsonObject['object'].get('summary'): - titleStr = postJsonObject['object']['summary'] - blogStr += '

' + \ - titleStr + '

\n' - articleAdded = True + blog_str = '' + message_link = '' + if post_json_object['object'].get('id'): + message_link = \ + post_json_object['object']['id'].replace('/statuses/', '/') + title_str = '' + article_added = False + if post_json_object['object'].get('summary'): + title_str = post_json_object['object']['summary'] + blog_str += '

' + \ + title_str + '

\n' + article_added = True # get the handle of the author - if postJsonObject['object'].get('attributedTo'): - authorNickname = None - if isinstance(postJsonObject['object']['attributedTo'], str): - actor = postJsonObject['object']['attributedTo'] - authorNickname = getNicknameFromActor(actor) - if authorNickname: - authorDomain, authorPort = getDomainFromActor(actor) - if authorDomain: + if post_json_object['object'].get('attributedTo'): + author_nickname = None + if isinstance(post_json_object['object']['attributedTo'], str): + actor = post_json_object['object']['attributedTo'] + author_nickname = get_nickname_from_actor(actor) + if author_nickname: + author_domain, _ = get_domain_from_actor(actor) + if author_domain: # author must be from the given domain - if restrictToDomain and authorDomain != domain: + if restrict_to_domain and author_domain != domain: return '' - handle = authorNickname + '@' + authorDomain + handle = author_nickname + '@' + author_domain else: # posts from the domain are expected to have an attributedTo field - if restrictToDomain: + if restrict_to_domain: return '' - if postJsonObject['object'].get('published'): - if 'T' in postJsonObject['object']['published']: - blogStr += '

' + \ - postJsonObject['object']['published'].split('T')[0] + if post_json_object['object'].get('published'): + if 'T' in post_json_object['object']['published']: + blog_str += '

' + \ + post_json_object['object']['published'].split('T')[0] if handle: if handle.startswith(nickname + '@' + domain): - blogStr += ' ' + handle + '' - linkedAuthor = True + linked_author = True else: if actor: - blogStr += ' ' + \ + blog_str += ' ' + \ handle + '' - linkedAuthor = True + linked_author = True else: - blogStr += ' ' + handle - blogStr += '

\n' + blog_str += ' ' + handle + blog_str += '\n' - avatarLink = '' - replyStr = '' - announceStr = '' - likeStr = '' - bookmarkStr = '' - deleteStr = '' - muteStr = '' - isMuted = False - attachmentStr, galleryStr = getPostAttachmentsAsHtml(postJsonObject, - 'tlblogs', translate, - isMuted, avatarLink, - replyStr, announceStr, - likeStr, bookmarkStr, - deleteStr, muteStr) - if attachmentStr: - blogStr += '
' + attachmentStr + '
' + avatar_link = '' + reply_str = '' + announce_str = '' + like_str = '' + bookmark_str = '' + delete_str = '' + mute_str = '' + is_muted = False - personUrl = localActorUrl(httpPrefix, nickname, domainFull) - actorJson = \ - getPersonFromCache(baseDir, personUrl, personCache, False) - languagesUnderstood = [] - if actorJson: - languagesUnderstood = getActorLanguagesList(actorJson) - jsonContent = getContentFromPost(postJsonObject, systemLanguage, - languagesUnderstood) - if jsonContent: - contentStr = addEmbeddedElements(translate, jsonContent, - peertubeInstances) - if postJsonObject['object'].get('tag'): - contentStr = replaceEmojiFromTags(contentStr, - postJsonObject['object']['tag'], - 'content') - if articleAdded: - blogStr += '
' + contentStr + '
\n' + person_url = local_actor_url(http_prefix, nickname, domain_full) + actor_json = \ + get_person_from_cache(base_dir, person_url, person_cache) + languages_understood = [] + if actor_json: + languages_understood = get_actor_languages_list(actor_json) + json_content = get_content_from_post(post_json_object, system_language, + languages_understood) + attachment_str, _ = \ + get_post_attachments_as_html(base_dir, nickname, domain, + domain_full, post_json_object, + 'tlblogs', translate, + is_muted, avatar_link, + reply_str, announce_str, + like_str, bookmark_str, + delete_str, mute_str, + json_content) + if attachment_str: + blog_str += '
' + attachment_str + '
' + if json_content: + content_str = add_embedded_elements(translate, json_content, + peertube_instances) + if post_json_object['object'].get('tag'): + post_json_object_tags = post_json_object['object']['tag'] + content_str = replace_emoji_from_tags(session, base_dir, + content_str, + post_json_object_tags, + 'content', debug, True) + if article_added: + blog_str += '
' + content_str + '
\n' else: - blogStr += '
' + contentStr + '
\n' + blog_str += '
' + content_str + '
\n' - citationsStr = '' - if postJsonObject['object'].get('tag'): - for tagJson in postJsonObject['object']['tag']: - if not isinstance(tagJson, dict): + citations_str = '' + if post_json_object['object'].get('tag'): + for tag_json in post_json_object['object']['tag']: + if not isinstance(tag_json, dict): continue - if not tagJson.get('type'): + if not tag_json.get('type'): continue - if tagJson['type'] != 'Article': + if tag_json['type'] != 'Article': continue - if not tagJson.get('name'): + if not tag_json.get('name'): continue - if not tagJson.get('url'): + if not tag_json.get('url'): continue - citationsStr += \ - '
  • ' + \ - '' + tagJson['name'] + '
  • \n' - if citationsStr: - citationsStr = '

    ' + translate['Citations'] + \ + citations_str += \ + '

  • ' + \ + '' + tag_json['name'] + '
  • \n' + if citations_str: + citations_str = '

    ' + translate['Citations'] + \ ':

    ' + \ - '\n' + '\n' + citations_str + '\n' - blogStr += '
    \n' + citationsStr + blog_str += '
    \n' + citations_str - if not linkedAuthor: - blogStr += '

    ' + translate['About the author'] + \ '

    \n' - replies = _noOfBlogReplies(baseDir, httpPrefix, translate, - nickname, domain, domainFull, - postJsonObject['object']['id']) + replies = _no_of_blog_replies(base_dir, http_prefix, translate, + nickname, domain, domain_full, + post_json_object['object']['id']) # separator between blogs should be centered - if '
    ' not in blogSeparator: - blogSeparator = '
    ' + blogSeparator + '
    ' + if '
    ' not in blog_separator: + blog_separator = '
    ' + blog_separator + '
    ' if replies == 0: - blogStr += blogSeparator + '\n' - return blogStr + blog_str += blog_separator + '\n' + return blog_str if not authorized: - blogStr += '

    ' + \ + blog_str += '

    ' + \ translate['Replies'].lower() + ': ' + str(replies) + '

    ' - blogStr += '


    ' + blogSeparator + '\n' + blog_str += '


    ' + blog_separator + '\n' else: - blogStr += blogSeparator + '

    ' + translate['Replies'] + '

    \n' - if not titleStr: - blogStr += _getBlogReplies(baseDir, httpPrefix, translate, - nickname, domain, domainFull, - postJsonObject['object']['id']) + blog_str += blog_separator + '

    ' + translate['Replies'] + '

    \n' + if not title_str: + blog_str += \ + _get_blog_replies(base_dir, http_prefix, translate, + nickname, domain, domain_full, + post_json_object['object']['id']) else: - blogRepliesStr = _getBlogReplies(baseDir, httpPrefix, translate, - nickname, domain, domainFull, - postJsonObject['object']['id']) - blogStr += blogRepliesStr.replace('>' + titleStr + '<', '') + obj_id = post_json_object['object']['id'] + blog_replies_str = \ + _get_blog_replies(base_dir, http_prefix, + translate, nickname, + domain, domain_full, obj_id) + blog_str += blog_replies_str.replace('>' + title_str + '<', '') - return blogStr + return blog_str -def _htmlBlogPostRSS2(authorized: bool, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postJsonObject: {}, - handle: str, restrictToDomain: bool, - systemLanguage: str) -> str: +def _html_blog_post_rss2(domain: str, post_json_object: {}, + restrict_to_domain: bool, + system_language: str) -> str: """Returns the RSS version 2 feed for a single blog post """ - rssStr = '' - messageLink = '' - if postJsonObject['object'].get('id'): - messageLink = postJsonObject['object']['id'].replace('/statuses/', '/') - if not restrictToDomain or \ - (restrictToDomain and '/' + domain in messageLink): - if postJsonObject['object'].get('summary') and \ - postJsonObject['object'].get('published'): - published = postJsonObject['object']['published'] - pubDate = datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ") - titleStr = postJsonObject['object']['summary'] - rssDateStr = pubDate.strftime("%a, %d %b %Y %H:%M:%S UT") + rss_str = '' + message_link = '' + if post_json_object['object'].get('id'): + message_link = \ + post_json_object['object']['id'].replace('/statuses/', '/') + if not restrict_to_domain or \ + (restrict_to_domain and '/' + domain in message_link): + if post_json_object['object'].get('summary') and \ + post_json_object['object'].get('published'): + published = post_json_object['object']['published'] + pub_date = datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ") + title_str = post_json_object['object']['summary'] + rss_date_str = pub_date.strftime("%a, %d %b %Y %H:%M:%S UT") content = \ - getBaseContentFromPost(postJsonObject, systemLanguage) - description = firstParagraphFromString(content) - rssStr = ' ' - rssStr += ' ' + titleStr + '' - rssStr += ' ' + messageLink + '' - rssStr += \ + get_base_content_from_post(post_json_object, + system_language) + description = first_paragraph_from_string(content) + rss_str = ' ' + rss_str += ' ' + title_str + '' + rss_str += ' ' + message_link + '' + rss_str += \ ' ' + description + '' - rssStr += ' ' + rssDateStr + '' - rssStr += ' ' - return rssStr + rss_str += ' ' + rss_date_str + '' + rss_str += ' ' + return rss_str -def _htmlBlogPostRSS3(authorized: bool, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postJsonObject: {}, - handle: str, restrictToDomain: bool, - systemLanguage: str) -> str: +def _html_blog_post_rss3(domain: str, post_json_object: {}, + restrict_to_domain: bool, + system_language: str) -> str: """Returns the RSS version 3 feed for a single blog post """ - rssStr = '' - messageLink = '' - if postJsonObject['object'].get('id'): - messageLink = postJsonObject['object']['id'].replace('/statuses/', '/') - if not restrictToDomain or \ - (restrictToDomain and '/' + domain in messageLink): - if postJsonObject['object'].get('summary') and \ - postJsonObject['object'].get('published'): - published = postJsonObject['object']['published'] - pubDate = datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ") - titleStr = postJsonObject['object']['summary'] - rssDateStr = pubDate.strftime("%a, %d %b %Y %H:%M:%S UT") + rss_str = '' + message_link = '' + if post_json_object['object'].get('id'): + message_link = \ + post_json_object['object']['id'].replace('/statuses/', '/') + if not restrict_to_domain or \ + (restrict_to_domain and '/' + domain in message_link): + if post_json_object['object'].get('summary') and \ + post_json_object['object'].get('published'): + published = post_json_object['object']['published'] + pub_date = datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ") + title_str = post_json_object['object']['summary'] + rss_date_str = pub_date.strftime("%a, %d %b %Y %H:%M:%S UT") content = \ - getBaseContentFromPost(postJsonObject, systemLanguage) - description = firstParagraphFromString(content) - rssStr = 'title: ' + titleStr + '\n' - rssStr += 'link: ' + messageLink + '\n' - rssStr += 'description: ' + description + '\n' - rssStr += 'created: ' + rssDateStr + '\n\n' - return rssStr + get_base_content_from_post(post_json_object, + system_language) + description = first_paragraph_from_string(content) + rss_str = 'title: ' + title_str + '\n' + rss_str += 'link: ' + message_link + '\n' + rss_str += 'description: ' + description + '\n' + rss_str += 'created: ' + rss_date_str + '\n\n' + return rss_str -def _htmlBlogRemoveCwButton(blogStr: str, translate: {}) -> str: +def _html_blog_remove_cw_button(blog_str: str, translate: {}) -> str: """Removes the CW button from blog posts, where the summary field is instead used as the blog title """ - blogStr = blogStr.replace('
    ', '') - blogStr = blogStr.replace('
    ', '') - blogStr = blogStr.replace('', '') - blogStr = blogStr.replace('', '') - blogStr = blogStr.replace(translate['SHOW MORE'], '') - return blogStr + blog_str = blog_str.replace('
    ', '') + blog_str = blog_str.replace('
    ', '') + blog_str = blog_str.replace('', '') + blog_str = blog_str.replace('', '') + blog_str = blog_str.replace(translate['SHOW MORE'], '') + return blog_str -def _getSnippetFromBlogContent(postJsonObject: {}, systemLanguage: str) -> str: +def _get_snippet_from_blog_content(post_json_object: {}, + system_language: str) -> str: """Returns a snippet of text from the blog post as a preview """ - content = getBaseContentFromPost(postJsonObject, systemLanguage) + content = get_base_content_from_post(post_json_object, system_language) if '

    ' in content: content = content.split('

    ', 1)[1] if '

    ' in content: content = content.split('

    ', 1)[0] - content = removeHtml(content) + content = remove_html(content) if '\n' in content: content = content.split('\n')[0] if len(content) >= 256: @@ -414,500 +447,498 @@ def _getSnippetFromBlogContent(postJsonObject: {}, systemLanguage: str) -> str: return content -def htmlBlogPost(authorized: bool, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, domainFull: str, - postJsonObject: {}, - peertubeInstances: [], - systemLanguage: str, personCache: {}) -> str: +def html_blog_post(session, authorized: bool, + base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, domain_full: str, + post_json_object: {}, + peertube_instances: [], + system_language: str, person_cache: {}, + debug: bool, content_license_url: str) -> str: """Returns a html blog post """ - blogStr = '' + blog_str = '' - cssFilename = baseDir + '/epicyon-blog.css' - if os.path.isfile(baseDir + '/blog.css'): - cssFilename = baseDir + '/blog.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - published = postJsonObject['object']['published'] - title = postJsonObject['object']['summary'] - snippet = _getSnippetFromBlogContent(postJsonObject, systemLanguage) - blogStr = htmlHeaderWithBlogMarkup(cssFilename, instanceTitle, - httpPrefix, domainFull, nickname, - systemLanguage, published, - title, snippet) - _htmlBlogRemoveCwButton(blogStr, translate) + css_filename = base_dir + '/epicyon-blog.css' + if os.path.isfile(base_dir + '/blog.css'): + css_filename = base_dir + '/blog.css' + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + published = post_json_object['object']['published'] + modified = published + if post_json_object['object'].get('updated'): + modified = post_json_object['object']['updated'] + title = post_json_object['object']['summary'] + url = '' + if post_json_object['object'].get('url'): + url = post_json_object['object']['url'] + snippet = _get_snippet_from_blog_content(post_json_object, + system_language) + blog_str = html_header_with_blog_markup(css_filename, instance_title, + http_prefix, domain_full, nickname, + system_language, published, + modified, + title, snippet, translate, url, + content_license_url) + _html_blog_remove_cw_button(blog_str, translate) - blogStr += _htmlBlogPostContent(authorized, baseDir, - httpPrefix, translate, - nickname, domain, - domainFull, postJsonObject, - None, False, - peertubeInstances, systemLanguage, - personCache) + blog_str += _html_blog_post_content(debug, session, authorized, base_dir, + http_prefix, translate, + nickname, domain, + domain_full, post_json_object, + None, False, + peertube_instances, system_language, + person_cache) # show rss links - blogStr += '

    ' + blog_str += '

    ' - blogStr += '' - blogStr += 'RSS 2.0' + blog_str += 'RSS 2.0' - # blogStr += '' - # blogStr += 'RSS 3.0' + # blog_str += 'RSS 3.0' - blogStr += '

    ' + blog_str += '

    ' - return blogStr + htmlFooter() + return blog_str + html_footer() -def htmlBlogPage(authorized: bool, session, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, port: int, - noOfItems: int, pageNumber: int, - peertubeInstances: [], systemLanguage: str, - personCache: {}) -> str: +def html_blog_page(authorized: bool, session, + base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, port: int, + no_of_items: int, page_number: int, + peertube_instances: [], system_language: str, + person_cache: {}, debug: bool) -> str: """Returns a html blog page containing posts """ if ' ' in nickname or '@' in nickname or \ '\n' in nickname or '\r' in nickname: return None - blogStr = '' + blog_str = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - blogStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - _htmlBlogRemoveCwButton(blogStr, translate) + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + blog_str = \ + html_header_with_external_style(css_filename, instance_title, None) + _html_blog_remove_cw_button(blog_str, translate) - blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index' - if not os.path.isfile(blogsIndex): - return blogStr + htmlFooter() + blogs_index = acct_dir(base_dir, nickname, domain) + '/tlblogs.index' + if not os.path.isfile(blogs_index): + return blog_str + html_footer() - timelineJson = createBlogsTimeline(session, baseDir, - nickname, domain, port, - httpPrefix, - noOfItems, False, - pageNumber) + timeline_json = \ + create_blogs_timeline(base_dir, + nickname, domain, port, http_prefix, + no_of_items, False, page_number) - if not timelineJson: - return blogStr + htmlFooter() + if not timeline_json: + return blog_str + html_footer() - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) # show previous and next buttons - if pageNumber is not None: - navigateStr = '

    ' - if pageNumber > 1: + if page_number is not None: + navigate_str = '

    ' + if page_number > 1: # show previous button - navigateStr += '' + \ - '<' + \ + '<\n' - if len(timelineJson['orderedItems']) >= noOfItems: + if len(timeline_json['orderedItems']) >= no_of_items: # show next button - navigateStr += '' + \ - '>' + \ + '>\n' - navigateStr += '

    ' - blogStr += navigateStr + navigate_str += '

    ' + blog_str += navigate_str - for item in timelineJson['orderedItems']: + for item in timeline_json['orderedItems']: if item['type'] != 'Create': continue - blogStr += _htmlBlogPostContent(authorized, baseDir, - httpPrefix, translate, - nickname, domain, - domainFull, item, - None, True, - peertubeInstances, - systemLanguage, - personCache) + blog_str += \ + _html_blog_post_content(debug, session, authorized, + base_dir, http_prefix, translate, + nickname, domain, domain_full, item, + None, True, peertube_instances, + system_language, person_cache) - if len(timelineJson['orderedItems']) >= noOfItems: - blogStr += navigateStr + if len(timeline_json['orderedItems']) >= no_of_items: + blog_str += navigate_str # show rss link - blogStr += '

    ' + blog_str += '

    ' - blogStr += '' - blogStr += 'RSS 2.0' + blog_str += 'RSS 2.0' - # blogStr += '' - # blogStr += 'RSS 3.0' + # blog_str += 'RSS 3.0' - blogStr += '

    ' - return blogStr + htmlFooter() + blog_str += '

    ' + return blog_str + html_footer() -def htmlBlogPageRSS2(authorized: bool, session, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, port: int, - noOfItems: int, pageNumber: int, - includeHeader: bool, systemLanguage: str) -> str: +def html_blog_page_rss2(base_dir: str, http_prefix: str, translate: {}, + nickname: str, domain: str, port: int, + no_of_items: int, page_number: int, + include_header: bool, system_language: str) -> str: """Returns an RSS version 2 feed containing posts """ if ' ' in nickname or '@' in nickname or \ '\n' in nickname or '\r' in nickname: return None - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) - blogRSS2 = '' - if includeHeader: - blogRSS2 = rss2Header(httpPrefix, nickname, domainFull, - 'Blog', translate) + blog_rss2 = '' + if include_header: + blog_rss2 = rss2header(http_prefix, nickname, domain_full, + 'Blog', translate) - blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index' - if not os.path.isfile(blogsIndex): - if includeHeader: - return blogRSS2 + rss2Footer() - else: - return blogRSS2 + blogs_index = acct_dir(base_dir, nickname, domain) + '/tlblogs.index' + if not os.path.isfile(blogs_index): + if include_header: + return blog_rss2 + rss2footer() + return blog_rss2 - timelineJson = createBlogsTimeline(session, baseDir, - nickname, domain, port, - httpPrefix, - noOfItems, False, - pageNumber) + timeline_json = create_blogs_timeline(base_dir, + nickname, domain, port, + http_prefix, + no_of_items, False, + page_number) - if not timelineJson: - if includeHeader: - return blogRSS2 + rss2Footer() - else: - return blogRSS2 + if not timeline_json: + if include_header: + return blog_rss2 + rss2footer() + return blog_rss2 - if pageNumber is not None: - for item in timelineJson['orderedItems']: + if page_number is not None: + for item in timeline_json['orderedItems']: if item['type'] != 'Create': continue - blogRSS2 += \ - _htmlBlogPostRSS2(authorized, baseDir, - httpPrefix, translate, - nickname, domain, - domainFull, item, - None, True, systemLanguage) + blog_rss2 += \ + _html_blog_post_rss2(domain, item, True, system_language) - if includeHeader: - return blogRSS2 + rss2Footer() - else: - return blogRSS2 + if include_header: + return blog_rss2 + rss2footer() + return blog_rss2 -def htmlBlogPageRSS3(authorized: bool, session, - baseDir: str, httpPrefix: str, translate: {}, - nickname: str, domain: str, port: int, - noOfItems: int, pageNumber: int, - systemLanguage: str) -> str: +def html_blog_page_rss3(base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + no_of_items: int, page_number: int, + system_language: str) -> str: """Returns an RSS version 3 feed containing posts """ if ' ' in nickname or '@' in nickname or \ '\n' in nickname or '\r' in nickname: return None - domainFull = getFullDomain(domain, port) + blog_rss3 = '' - blogRSS3 = '' + blogs_index = acct_dir(base_dir, nickname, domain) + '/tlblogs.index' + if not os.path.isfile(blogs_index): + return blog_rss3 - blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index' - if not os.path.isfile(blogsIndex): - return blogRSS3 + timeline_json = \ + create_blogs_timeline(base_dir, + nickname, domain, port, http_prefix, + no_of_items, False, page_number) - timelineJson = createBlogsTimeline(session, baseDir, - nickname, domain, port, - httpPrefix, - noOfItems, False, - pageNumber) + if not timeline_json: + return blog_rss3 - if not timelineJson: - return blogRSS3 - - if pageNumber is not None: - for item in timelineJson['orderedItems']: + if page_number is not None: + for item in timeline_json['orderedItems']: if item['type'] != 'Create': continue - blogRSS3 += \ - _htmlBlogPostRSS3(authorized, baseDir, - httpPrefix, translate, - nickname, domain, - domainFull, item, - None, True, - systemLanguage) + blog_rss3 += \ + _html_blog_post_rss3(domain, item, True, system_language) - return blogRSS3 + return blog_rss3 -def _noOfBlogAccounts(baseDir: str) -> int: +def _no_of_blog_accounts(base_dir: str) -> int: """Returns the number of blog accounts """ ctr = 0 - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = os.path.join(baseDir + '/accounts', acct) - blogsIndex = accountDir + '/tlblogs.index' - if os.path.isfile(blogsIndex): + account_dir = os.path.join(base_dir + '/accounts', acct) + blogs_index = account_dir + '/tlblogs.index' + if os.path.isfile(blogs_index): ctr += 1 break return ctr -def _singleBlogAccountNickname(baseDir: str) -> str: +def _single_blog_account_nickname(base_dir: str) -> str: """Returns the nickname of a single blog account """ - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = os.path.join(baseDir + '/accounts', acct) - blogsIndex = accountDir + '/tlblogs.index' - if os.path.isfile(blogsIndex): + account_dir = os.path.join(base_dir + '/accounts', acct) + blogs_index = account_dir + '/tlblogs.index' + if os.path.isfile(blogs_index): return acct.split('@')[0] break return None -def htmlBlogView(authorized: bool, - session, baseDir: str, httpPrefix: str, - translate: {}, domain: str, port: int, - noOfItems: int, - peertubeInstances: [], systemLanguage: str, - personCache: {}) -> str: +def html_blog_view(authorized: bool, + session, base_dir: str, http_prefix: str, + translate: {}, domain: str, port: int, + no_of_items: int, + peertube_instances: [], system_language: str, + person_cache: {}, debug: bool) -> str: """Show the blog main page """ - blogStr = '' + blog_str = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - blogStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + blog_str = \ + html_header_with_external_style(css_filename, instance_title, None) - if _noOfBlogAccounts(baseDir) <= 1: - nickname = _singleBlogAccountNickname(baseDir) + if _no_of_blog_accounts(base_dir) <= 1: + nickname = _single_blog_account_nickname(base_dir) if nickname: - return htmlBlogPage(authorized, session, - baseDir, httpPrefix, translate, - nickname, domain, port, - noOfItems, 1, peertubeInstances, - systemLanguage, personCache) + return html_blog_page(authorized, session, + base_dir, http_prefix, translate, + nickname, domain, port, + no_of_items, 1, peertube_instances, + system_language, person_cache, debug) - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = os.path.join(baseDir + '/accounts', acct) - blogsIndex = accountDir + '/tlblogs.index' - if os.path.isfile(blogsIndex): - blogStr += '

    ' - blogStr += '' + blog_str += '' + acct + '' - blogStr += '

    ' + blog_str += '

    ' break - return blogStr + htmlFooter() + return blog_str + html_footer() -def htmlEditBlog(mediaInstance: bool, translate: {}, - baseDir: str, httpPrefix: str, - path: str, - pageNumber: int, - nickname: str, domain: str, - postUrl: str, systemLanguage: str) -> str: +def html_edit_blog(media_instance: bool, translate: {}, + base_dir: str, path: str, page_number: int, + nickname: str, domain: str, + post_url: str, system_language: str) -> str: """Edit a blog post after it was created """ - postFilename = locatePost(baseDir, nickname, domain, postUrl) - if not postFilename: - print('Edit blog: Filename not found for ' + postUrl) + post_filename = locate_post(base_dir, nickname, domain, post_url) + if not post_filename: + print('Edit blog: filename not found for ' + post_url) return None - postJsonObject = loadJson(postFilename) - if not postJsonObject: - print('Edit blog: json not loaded for ' + postFilename) + post_json_object = load_json(post_filename) + if not post_json_object: + print('Edit blog: json not loaded for ' + post_filename) return None - editBlogText = '' + translate['Write your post text below.'] + '' + edit_blog_text = \ + '' + translate['Write your post text below.'] + '' - if os.path.isfile(baseDir + '/accounts/newpost.txt'): - with open(baseDir + '/accounts/newpost.txt', 'r') as file: - editBlogText = '

    ' + file.read() + '

    ' + if os.path.isfile(base_dir + '/accounts/newpost.txt'): + try: + with open(base_dir + '/accounts/newpost.txt', 'r', + encoding='utf-8') as file: + edit_blog_text = '

    ' + file.read() + '

    ' + except OSError: + print('EX: unable to read ' + base_dir + '/accounts/newpost.txt') - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' if '?' in path: path = path.split('?')[0] - pathBase = path + path_base = path - editBlogImageSection = '
    ' - editBlogImageSection += '
    ' - placeholderMessage = translate['Write something'] + '...' + placeholder_message = translate['Write something'] + '...' endpoint = 'editblogpost' - placeholderSubject = translate['Title'] - scopeIcon = 'scope_blog.png' - scopeDescription = translate['Blog'] + placeholder_subject = translate['Title'] + scope_icon = 'scope_blog.png' + scope_description = translate['Blog'] - dateAndLocation = '' - dateAndLocation = '
    ' + date_and_location = '' + date_and_location = '
    ' - dateAndLocation += \ + date_and_location += \ '

    ' - dateAndLocation += \ - '

    ' - dateAndLocation += \ + date_and_location += \ '' - dateAndLocation += '' - dateAndLocation += '

    ' - dateAndLocation += '
    ' - dateAndLocation += '
    ' - dateAndLocation += \ + date_and_location += '' + date_and_location += \ + '

    ' + date_and_location += '
    ' + date_and_location += '
    ' + date_and_location += \ '
    ' - dateAndLocation += '' - dateAndLocation += '
    ' + date_and_location += '' + date_and_location += '
    ' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - editBlogForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + edit_blog_form = \ + html_header_with_external_style(css_filename, instance_title, None) - editBlogForm += \ + edit_blog_form += \ '
    ' - editBlogForm += \ - ' ' - editBlogForm += \ + path_base + '?' + endpoint + '?page=' + str(page_number) + '">' + edit_blog_form += \ + ' ' + edit_blog_form += \ ' ' - editBlogForm += '
    ' - editBlogForm += \ - ' ' - editBlogForm += '
    ' + str(page_number) + '">' + edit_blog_form += '
    ' + edit_blog_form += \ + ' ' + edit_blog_form += '
    ' - editBlogForm += '
    ' - editBlogForm += \ - ' ' + \ - scopeDescription + '' - editBlogForm += '
    ' + edit_blog_form += '
    ' + edit_blog_form += \ + ' ' + \ + scope_description + '' + edit_blog_form += '
    ' - editBlogForm += ' ' + \
         translate['Search for emoji'] + '' - editBlogForm += '
    ' - editBlogForm += '
    ' - editBlogForm += '
    ' + edit_blog_form += ' ' - editBlogForm += ' ' - editBlogForm += '
    ' - if mediaInstance: - editBlogForm += editBlogImageSection - editBlogForm += \ - '
    ' - titleStr = '' - if postJsonObject['object'].get('summary'): - titleStr = postJsonObject['object']['summary'] - editBlogForm += \ - ' ' - editBlogForm += '' - editBlogForm += '
    ' - messageBoxHeight = 800 + edit_blog_form += \ + ' ' + edit_blog_form += '
    ' + if media_instance: + edit_blog_form += edit_blog_image_section + edit_blog_form += \ + '
    ' + title_str = '' + if post_json_object['object'].get('summary'): + title_str = post_json_object['object']['summary'] + edit_blog_form += \ + ' ' + edit_blog_form += '' + edit_blog_form += '
    ' + message_box_height = 800 - contentStr = getBaseContentFromPost(postJsonObject, systemLanguage) - contentStr = contentStr.replace('

    ', '').replace('

    ', '\n') + content_str = get_base_content_from_post(post_json_object, system_language) + content_str = content_str.replace('

    ', '').replace('

    ', '\n') - editBlogForm += \ - editTextArea(placeholderMessage, 'message', contentStr, - messageBoxHeight, '', True) - editBlogForm += dateAndLocation - if not mediaInstance: - editBlogForm += editBlogImageSection - editBlogForm += ' ' - editBlogForm += '' + edit_blog_form += \ + edit_text_area(placeholder_message, None, 'message', content_str, + message_box_height, '', True) + edit_blog_form += date_and_location + if not media_instance: + edit_blog_form += edit_blog_image_section + edit_blog_form += ' ' + edit_blog_form += '' - editBlogForm = editBlogForm.replace('', - '') - - editBlogForm += htmlFooter() - return editBlogForm + edit_blog_form += html_footer() + return edit_blog_form -def pathContainsBlogLink(baseDir: str, - httpPrefix: str, domain: str, - domainFull: str, path: str) -> (str, str): +def path_contains_blog_link(base_dir: str, + http_prefix: str, domain: str, + domain_full: str, path: str) -> (str, str): """If the path contains a blog entry then return its filename """ if '/users/' not in path: return None, None - userEnding = path.split('/users/', 1)[1] - if '/' not in userEnding: + user_ending = path.split('/users/', 1)[1] + if '/' not in user_ending: return None, None - userEnding2 = userEnding.split('/') - nickname = userEnding2[0] - if len(userEnding2) != 2: + user_ending2 = user_ending.split('/') + nickname = user_ending2[0] + if len(user_ending2) != 2: return None, None - if len(userEnding2[1]) < 14: + if len(user_ending2[1]) < 14: return None, None - userEnding2[1] = userEnding2[1].strip() - if not userEnding2[1].isdigit(): + user_ending2[1] = user_ending2[1].strip() + if not user_ending2[1].isdigit(): return None, None # check for blog posts - blogIndexFilename = acctDir(baseDir, nickname, domain) + '/tlblogs.index' - if not os.path.isfile(blogIndexFilename): + blog_index_filename = \ + acct_dir(base_dir, nickname, domain) + '/tlblogs.index' + if not os.path.isfile(blog_index_filename): return None, None - if '#' + userEnding2[1] + '.' not in open(blogIndexFilename).read(): + if not text_in_file('#' + user_ending2[1] + '.', blog_index_filename): return None, None - messageId = localActorUrl(httpPrefix, nickname, domainFull) + \ - '/statuses/' + userEnding2[1] - return locatePost(baseDir, nickname, domain, messageId), nickname + message_id = local_actor_url(http_prefix, nickname, domain_full) + \ + '/statuses/' + user_ending2[1] + return locate_post(base_dir, nickname, domain, message_id), nickname -def getBlogAddress(actorJson: {}) -> str: +def get_blog_address(actor_json: {}) -> str: """Returns blog address for the given actor """ - return getActorPropertyUrl(actorJson, 'Blog') + return get_actor_property_url(actor_json, 'Blog') diff --git a/bookmarks.py b/bookmarks.py index fb0c3f769..b14453d90 100644 --- a/bookmarks.py +++ b/bookmarks.py @@ -1,7 +1,7 @@ __filename__ = "bookmarks.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,649 +9,662 @@ __module_group__ = "Timeline" import os from pprint import pprint -from webfinger import webfingerHandle -from auth import createBasicAuthHeader -from utils import removeDomainPort -from utils import hasUsersPath -from utils import getFullDomain -from utils import removeIdEnding -from utils import removePostFromCache -from utils import urlPermitted -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import locatePost -from utils import getCachedPostFilename -from utils import loadJson -from utils import saveJson -from utils import hasObjectDict -from utils import acctDir -from utils import localActorUrl -from posts import getPersonBox -from session import postJson +from webfinger import webfinger_handle +from auth import create_basic_auth_header +from utils import remove_domain_port +from utils import has_users_path +from utils import get_full_domain +from utils import remove_id_ending +from utils import remove_post_from_cache +from utils import url_permitted +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import locate_post +from utils import get_cached_post_filename +from utils import load_json +from utils import save_json +from utils import has_object_dict +from utils import acct_dir +from utils import local_actor_url +from utils import has_actor +from utils import has_object_string_type +from utils import text_in_file +from utils import remove_eol +from posts import get_person_box +from session import post_json -def undoBookmarksCollectionEntry(recentPostsCache: {}, - baseDir: str, postFilename: str, - objectUrl: str, - actor: str, domain: str, debug: bool) -> None: +def undo_bookmarks_collection_entry(recent_posts_cache: {}, + base_dir: str, post_filename: str, + actor: str, domain: str, + debug: bool) -> None: """Undoes a bookmark for a particular actor """ - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return # remove any cached version of this post so that the # bookmark icon is changed - nickname = getNicknameFromActor(actor) - cachedPostFilename = getCachedPostFilename(baseDir, nickname, - domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + nickname = get_nickname_from_actor(actor) + if not nickname: + return + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, + domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass - removePostFromCache(postJsonObject, recentPostsCache) + os.remove(cached_post_filename) + except OSError: + if debug: + print('EX: undo_bookmarks_collection_entry ' + + 'unable to delete cached post file ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) # remove from the index - bookmarksIndexFilename = \ - acctDir(baseDir, nickname, domain) + '/bookmarks.index' - if not os.path.isfile(bookmarksIndexFilename): + bookmarks_index_filename = \ + acct_dir(base_dir, nickname, domain) + '/bookmarks.index' + if not os.path.isfile(bookmarks_index_filename): return - if '/' in postFilename: - bookmarkIndex = postFilename.split('/')[-1].strip() + if '/' in post_filename: + bookmark_index = post_filename.split('/')[-1].strip() else: - bookmarkIndex = postFilename.strip() - bookmarkIndex = bookmarkIndex.replace('\n', '').replace('\r', '') - if bookmarkIndex not in open(bookmarksIndexFilename).read(): + bookmark_index = post_filename.strip() + bookmark_index = remove_eol(bookmark_index) + if not text_in_file(bookmark_index, bookmarks_index_filename): return - indexStr = '' - with open(bookmarksIndexFilename, 'r') as indexFile: - indexStr = indexFile.read().replace(bookmarkIndex + '\n', '') - with open(bookmarksIndexFilename, 'w+') as bookmarksIndexFile: - bookmarksIndexFile.write(indexStr) - - if not postJsonObject.get('type'): + index_str = '' + try: + with open(bookmarks_index_filename, 'r', + encoding='utf-8') as index_file: + index_str = index_file.read().replace(bookmark_index + '\n', '') + except OSError: + print('EX: unable to read ' + bookmarks_index_filename) + if index_str: + try: + with open(bookmarks_index_filename, 'w+', + encoding='utf-8') as bmi_file: + bmi_file.write(index_str) + except OSError: + print('EX: unable to write bookmarks index ' + + bookmarks_index_filename) + if not post_json_object.get('type'): return - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): if debug: print('DEBUG: bookmarked post has no object ' + - str(postJsonObject)) + str(post_json_object)) return - if not postJsonObject['object'].get('bookmarks'): + if not post_json_object['object'].get('bookmarks'): return - if not isinstance(postJsonObject['object']['bookmarks'], dict): + if not isinstance(post_json_object['object']['bookmarks'], dict): return - if not postJsonObject['object']['bookmarks'].get('items'): + if not post_json_object['object']['bookmarks'].get('items'): return - totalItems = 0 - if postJsonObject['object']['bookmarks'].get('totalItems'): - totalItems = postJsonObject['object']['bookmarks']['totalItems'] - itemFound = False - for bookmarkItem in postJsonObject['object']['bookmarks']['items']: - if bookmarkItem.get('actor'): - if bookmarkItem['actor'] == actor: + total_items = 0 + if post_json_object['object']['bookmarks'].get('totalItems'): + total_items = post_json_object['object']['bookmarks']['totalItems'] + item_found = False + for bookmark_item in post_json_object['object']['bookmarks']['items']: + if bookmark_item.get('actor'): + if bookmark_item['actor'] == actor: if debug: print('DEBUG: bookmark was removed for ' + actor) - bmIt = bookmarkItem - postJsonObject['object']['bookmarks']['items'].remove(bmIt) - itemFound = True + bm_it = bookmark_item + post_json_object['object']['bookmarks']['items'].remove(bm_it) + item_found = True break - if not itemFound: + if not item_found: return - if totalItems == 1: + if total_items == 1: if debug: print('DEBUG: bookmarks was removed from post') - del postJsonObject['object']['bookmarks'] + del post_json_object['object']['bookmarks'] else: - bmItLen = len(postJsonObject['object']['bookmarks']['items']) - postJsonObject['object']['bookmarks']['totalItems'] = bmItLen - saveJson(postJsonObject, postFilename) + bm_it_len = len(post_json_object['object']['bookmarks']['items']) + post_json_object['object']['bookmarks']['totalItems'] = bm_it_len + save_json(post_json_object, post_filename) -def bookmarkedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: +def bookmarked_by_person(post_json_object: {}, + nickname: str, domain: str) -> bool: """Returns True if the given post is bookmarked by the given person """ - if _noOfBookmarks(postJsonObject) == 0: + if _no_of_bookmarks(post_json_object) == 0: return False - actorMatch = domain + '/users/' + nickname - for item in postJsonObject['object']['bookmarks']['items']: - if item['actor'].endswith(actorMatch): + actor_match = domain + '/users/' + nickname + for item in post_json_object['object']['bookmarks']['items']: + if item['actor'].endswith(actor_match): return True return False -def _noOfBookmarks(postJsonObject: {}) -> int: +def _no_of_bookmarks(post_json_object: {}) -> int: """Returns the number of bookmarks ona given post """ - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return 0 - if not postJsonObject['object'].get('bookmarks'): + if not post_json_object['object'].get('bookmarks'): return 0 - if not isinstance(postJsonObject['object']['bookmarks'], dict): + if not isinstance(post_json_object['object']['bookmarks'], dict): return 0 - if not postJsonObject['object']['bookmarks'].get('items'): - postJsonObject['object']['bookmarks']['items'] = [] - postJsonObject['object']['bookmarks']['totalItems'] = 0 - return len(postJsonObject['object']['bookmarks']['items']) + if not post_json_object['object']['bookmarks'].get('items'): + post_json_object['object']['bookmarks']['items'] = [] + post_json_object['object']['bookmarks']['totalItems'] = 0 + return len(post_json_object['object']['bookmarks']['items']) -def updateBookmarksCollection(recentPostsCache: {}, - baseDir: str, postFilename: str, - objectUrl: str, - actor: str, domain: str, debug: bool) -> None: +def update_bookmarks_collection(recent_posts_cache: {}, + base_dir: str, post_filename: str, + object_url: str, + actor: str, domain: str, debug: bool) -> None: """Updates the bookmarks collection within a post """ - postJsonObject = loadJson(postFilename) - if postJsonObject: - # remove any cached version of this post so that the - # bookmark icon is changed - nickname = getNicknameFromActor(actor) - cachedPostFilename = getCachedPostFilename(baseDir, nickname, - domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): - try: - os.remove(cachedPostFilename) - except BaseException: - pass - removePostFromCache(postJsonObject, recentPostsCache) + post_json_object = load_json(post_filename) + if not post_json_object: + return - if not postJsonObject.get('object'): - if debug: - print('DEBUG: no object in bookmarked post ' + - str(postJsonObject)) - return - if not objectUrl.endswith('/bookmarks'): - objectUrl = objectUrl + '/bookmarks' - # does this post have bookmarks on it from differenent actors? - if not postJsonObject['object'].get('bookmarks'): - if debug: - print('DEBUG: Adding initial bookmarks to ' + objectUrl) - bookmarksJson = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': objectUrl, - 'type': 'Collection', - "totalItems": 1, - 'items': [{ - 'type': 'Bookmark', - 'actor': actor - }] - } - postJsonObject['object']['bookmarks'] = bookmarksJson - else: - if not postJsonObject['object']['bookmarks'].get('items'): - postJsonObject['object']['bookmarks']['items'] = [] - for bookmarkItem in postJsonObject['object']['bookmarks']['items']: - if bookmarkItem.get('actor'): - if bookmarkItem['actor'] == actor: - return - newBookmark = { + # remove any cached version of this post so that the + # bookmark icon is changed + nickname = get_nickname_from_actor(actor) + if not nickname: + return + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, + domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): + try: + os.remove(cached_post_filename) + except OSError: + if debug: + print('EX: update_bookmarks_collection ' + + 'unable to delete cached post ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) + + if not post_json_object.get('object'): + if debug: + print('DEBUG: no object in bookmarked post ' + + str(post_json_object)) + return + if not object_url.endswith('/bookmarks'): + object_url = object_url + '/bookmarks' + # does this post have bookmarks on it from differenent actors? + if not post_json_object['object'].get('bookmarks'): + if debug: + print('DEBUG: Adding initial bookmarks to ' + object_url) + bookmarks_json = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': object_url, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ 'type': 'Bookmark', 'actor': actor - } - nb = newBookmark - bmIt = len(postJsonObject['object']['bookmarks']['items']) - postJsonObject['object']['bookmarks']['items'].append(nb) - postJsonObject['object']['bookmarks']['totalItems'] = bmIt + }] + } + post_json_object['object']['bookmarks'] = bookmarks_json + else: + if not post_json_object['object']['bookmarks'].get('items'): + post_json_object['object']['bookmarks']['items'] = [] + bm_items = post_json_object['object']['bookmarks']['items'] + for bookmark_item in bm_items: + if bookmark_item.get('actor'): + if bookmark_item['actor'] == actor: + return + new_bookmark = { + 'type': 'Bookmark', + 'actor': actor + } + nbook = new_bookmark + bm_it = len(post_json_object['object']['bookmarks']['items']) + post_json_object['object']['bookmarks']['items'].append(nbook) + post_json_object['object']['bookmarks']['totalItems'] = bm_it - if debug: - print('DEBUG: saving post with bookmarks added') - pprint(postJsonObject) + if debug: + print('DEBUG: saving post with bookmarks added') + pprint(post_json_object) - saveJson(postJsonObject, postFilename) + save_json(post_json_object, post_filename) - # prepend to the index - bookmarksIndexFilename = \ - acctDir(baseDir, nickname, domain) + '/bookmarks.index' - bookmarkIndex = postFilename.split('/')[-1] - if os.path.isfile(bookmarksIndexFilename): - if bookmarkIndex not in open(bookmarksIndexFilename).read(): - try: - with open(bookmarksIndexFilename, 'r+') as bmIndexFile: - content = bmIndexFile.read() - if bookmarkIndex + '\n' not in content: - bmIndexFile.seek(0, 0) - bmIndexFile.write(bookmarkIndex + '\n' + content) - if debug: - print('DEBUG: bookmark added to index') - except Exception as e: - print('WARN: Failed to write entry to bookmarks index ' + - bookmarksIndexFilename + ' ' + str(e)) - else: - with open(bookmarksIndexFilename, 'w+') as bookmarksIndexFile: - bookmarksIndexFile.write(bookmarkIndex + '\n') + # prepend to the index + bookmarks_index_filename = \ + acct_dir(base_dir, nickname, domain) + '/bookmarks.index' + bookmark_index = post_filename.split('/')[-1] + if os.path.isfile(bookmarks_index_filename): + if not text_in_file(bookmark_index, bookmarks_index_filename): + try: + with open(bookmarks_index_filename, 'r+', + encoding='utf-8') as bmi_file: + content = bmi_file.read() + if bookmark_index + '\n' not in content: + bmi_file.seek(0, 0) + bmi_file.write(bookmark_index + '\n' + content) + if debug: + print('DEBUG: bookmark added to index') + except OSError as ex: + print('WARN: Failed to write entry to bookmarks index ' + + bookmarks_index_filename + ' ' + str(ex)) + else: + try: + with open(bookmarks_index_filename, 'w+', + encoding='utf-8') as bm_file: + bm_file.write(bookmark_index + '\n') + except OSError: + print('EX: unable to write bookmarks index ' + + bookmarks_index_filename) -def bookmark(recentPostsCache: {}, - session, baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - ccList: [], httpPrefix: str, - objectUrl: str, actorBookmarked: str, - clientToServer: bool, - sendThreads: [], postLog: [], - personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: +def bookmark_post(recent_posts_cache: {}, + base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + cc_list: [], http_prefix: str, + object_url: str, actor_bookmarked: str, + debug: bool) -> {}: """Creates a bookmark actor is the person doing the bookmarking 'to' might be a specific person (actor) whose post was bookmarked object is typically the url of the message which was bookmarked """ - if not urlPermitted(objectUrl, federationList): + if not url_permitted(object_url, federation_list): return None - fullDomain = getFullDomain(domain, port) + full_domain = get_full_domain(domain, port) - newBookmarkJson = { + new_bookmark_json = { "@context": "https://www.w3.org/ns/activitystreams", 'type': 'Bookmark', - 'actor': localActorUrl(httpPrefix, nickname, fullDomain), - 'object': objectUrl + 'actor': local_actor_url(http_prefix, nickname, full_domain), + 'object': object_url } - if ccList: - if len(ccList) > 0: - newBookmarkJson['cc'] = ccList + if cc_list: + if len(cc_list) > 0: + new_bookmark_json['cc'] = cc_list # Extract the domain and nickname from a statuses link - bookmarkedPostNickname = None - bookmarkedPostDomain = None - bookmarkedPostPort = None - if actorBookmarked: - acBm = actorBookmarked - bookmarkedPostNickname = getNicknameFromActor(acBm) - bookmarkedPostDomain, bookmarkedPostPort = getDomainFromActor(acBm) + bookmarked_post_nickname = None + if actor_bookmarked: + ac_bm = actor_bookmarked + bookmarked_post_nickname = get_nickname_from_actor(ac_bm) + _, _ = get_domain_from_actor(ac_bm) else: - if hasUsersPath(objectUrl): - ou = objectUrl - bookmarkedPostNickname = getNicknameFromActor(ou) - bookmarkedPostDomain, bookmarkedPostPort = getDomainFromActor(ou) + if has_users_path(object_url): + ourl = object_url + bookmarked_post_nickname = get_nickname_from_actor(ourl) + _, _ = get_domain_from_actor(ourl) - if bookmarkedPostNickname: - postFilename = locatePost(baseDir, nickname, domain, objectUrl) - if not postFilename: - print('DEBUG: bookmark baseDir: ' + baseDir) + if bookmarked_post_nickname: + post_filename = locate_post(base_dir, nickname, domain, object_url) + if not post_filename: + print('DEBUG: bookmark base_dir: ' + base_dir) print('DEBUG: bookmark nickname: ' + nickname) print('DEBUG: bookmark domain: ' + domain) - print('DEBUG: bookmark objectUrl: ' + objectUrl) + print('DEBUG: bookmark object_url: ' + object_url) return None - updateBookmarksCollection(recentPostsCache, - baseDir, postFilename, objectUrl, - newBookmarkJson['actor'], domain, debug) + update_bookmarks_collection(recent_posts_cache, + base_dir, post_filename, object_url, + new_bookmark_json['actor'], domain, debug) - return newBookmarkJson + return new_bookmark_json -def undoBookmark(recentPostsCache: {}, - session, baseDir: str, federationList: [], - nickname: str, domain: str, port: int, - ccList: [], httpPrefix: str, - objectUrl: str, actorBookmarked: str, - clientToServer: bool, - sendThreads: [], postLog: [], - personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: +def undo_bookmark_post(recent_posts_cache: {}, + base_dir: str, federation_list: [], + nickname: str, domain: str, port: int, + cc_list: [], http_prefix: str, + object_url: str, actor_bookmarked: str, + debug: bool) -> {}: """Removes a bookmark actor is the person doing the bookmarking 'to' might be a specific person (actor) whose post was bookmarked object is typically the url of the message which was bookmarked """ - if not urlPermitted(objectUrl, federationList): + if not url_permitted(object_url, federation_list): return None - fullDomain = getFullDomain(domain, port) + full_domain = get_full_domain(domain, port) - newUndoBookmarkJson = { + new_undo_bookmark_json = { "@context": "https://www.w3.org/ns/activitystreams", 'type': 'Undo', - 'actor': localActorUrl(httpPrefix, nickname, fullDomain), + 'actor': local_actor_url(http_prefix, nickname, full_domain), 'object': { 'type': 'Bookmark', - 'actor': localActorUrl(httpPrefix, nickname, fullDomain), - 'object': objectUrl + 'actor': local_actor_url(http_prefix, nickname, full_domain), + 'object': object_url } } - if ccList: - if len(ccList) > 0: - newUndoBookmarkJson['cc'] = ccList - newUndoBookmarkJson['object']['cc'] = ccList + if cc_list: + if len(cc_list) > 0: + new_undo_bookmark_json['cc'] = cc_list + new_undo_bookmark_json['object']['cc'] = cc_list # Extract the domain and nickname from a statuses link - bookmarkedPostNickname = None - bookmarkedPostDomain = None - bookmarkedPostPort = None - if actorBookmarked: - acBm = actorBookmarked - bookmarkedPostNickname = getNicknameFromActor(acBm) - bookmarkedPostDomain, bookmarkedPostPort = getDomainFromActor(acBm) + bookmarked_post_nickname = None + if actor_bookmarked: + ac_bm = actor_bookmarked + bookmarked_post_nickname = get_nickname_from_actor(ac_bm) + _, _ = get_domain_from_actor(ac_bm) else: - if hasUsersPath(objectUrl): - ou = objectUrl - bookmarkedPostNickname = getNicknameFromActor(ou) - bookmarkedPostDomain, bookmarkedPostPort = getDomainFromActor(ou) + if has_users_path(object_url): + ourl = object_url + bookmarked_post_nickname = get_nickname_from_actor(ourl) + _, _ = get_domain_from_actor(ourl) - if bookmarkedPostNickname: - postFilename = locatePost(baseDir, nickname, domain, objectUrl) - if not postFilename: + if bookmarked_post_nickname: + post_filename = locate_post(base_dir, nickname, domain, object_url) + if not post_filename: return None - undoBookmarksCollectionEntry(recentPostsCache, - baseDir, postFilename, objectUrl, - newUndoBookmarkJson['actor'], - domain, debug) + undo_bookmarks_collection_entry(recent_posts_cache, + base_dir, post_filename, + new_undo_bookmark_json['actor'], + domain, debug) else: return None - return newUndoBookmarkJson + return new_undo_bookmark_json -def sendBookmarkViaServer(baseDir: str, session, - nickname: str, password: str, - domain: str, fromPort: int, - httpPrefix: str, bookmarkUrl: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def send_bookmark_via_server(base_dir: str, session, + nickname: str, password: str, + domain: str, from_port: int, + http_prefix: str, bookmark_url: str, + cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str) -> {}: """Creates a bookmark via c2s """ if not session: - print('WARN: No session for sendBookmarkViaServer') + print('WARN: No session for send_bookmark_via_server') return 6 - domainFull = getFullDomain(domain, fromPort) + domain_full = get_full_domain(domain, from_port) - actor = localActorUrl(httpPrefix, nickname, domainFull) + actor = local_actor_url(http_prefix, nickname, domain_full) - newBookmarkJson = { + new_bookmark_json = { "@context": "https://www.w3.org/ns/activitystreams", "type": "Add", "actor": actor, "to": [actor], "object": { "type": "Document", - "url": bookmarkUrl, + "url": bookmark_url, "to": [actor] }, "target": actor + "/tlbookmarks" } - handle = httpPrefix + '://' + domainFull + '/@' + nickname + handle = http_prefix + '://' + domain_full + '/@' + nickname # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - domain, projectVersion, debug, False, - signingPrivateKeyPem) - if not wfRequest: + wf_request = \ + webfinger_handle(session, handle, http_prefix, + cached_webfingers, + domain, project_version, debug, False, + signing_priv_key_pem) + if not wf_request: if debug: print('DEBUG: bookmark webfinger failed for ' + handle) return 1 - if not isinstance(wfRequest, dict): + if not isinstance(wf_request, dict): print('WARN: bookmark webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) + ' did not return a dict. ' + str(wf_request)) return 1 - postToBox = 'outbox' + post_to_box = 'outbox' # get the actor inbox for the To handle - originDomain = domain - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 58391) + origin_domain = domain + (inbox_url, _, _, from_person_id, _, _, + _, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, wf_request, + person_cache, + project_version, http_prefix, + nickname, domain, + post_to_box, 58391) - if not inboxUrl: + if not inbox_url: if debug: - print('DEBUG: bookmark no ' + postToBox + + print('DEBUG: bookmark no ' + post_to_box + ' was found for ' + handle) return 3 - if not fromPersonId: + if not from_person_id: if debug: print('DEBUG: bookmark no actor was found for ' + handle) return 4 - authHeader = createBasicAuthHeader(nickname, password) + auth_header = create_basic_auth_header(nickname, password) headers = { 'host': domain, 'Content-type': 'application/json', - 'Authorization': authHeader + 'Authorization': auth_header } - postResult = postJson(httpPrefix, domainFull, - session, newBookmarkJson, [], inboxUrl, - headers, 3, True) - if not postResult: + post_result = post_json(http_prefix, domain_full, + session, new_bookmark_json, [], inbox_url, + headers, 3, True) + if not post_result: if debug: - print('WARN: POST bookmark failed for c2s to ' + inboxUrl) + print('WARN: POST bookmark failed for c2s to ' + inbox_url) return 5 if debug: print('DEBUG: c2s POST bookmark success') - return newBookmarkJson + return new_bookmark_json -def sendUndoBookmarkViaServer(baseDir: str, session, - nickname: str, password: str, - domain: str, fromPort: int, - httpPrefix: str, bookmarkUrl: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - signingPrivateKeyPem: str) -> {}: +def send_undo_bookmark_via_server(base_dir: str, session, + nickname: str, password: str, + domain: str, from_port: int, + http_prefix: str, bookmark_url: str, + cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + signing_priv_key_pem: str) -> {}: """Removes a bookmark via c2s """ if not session: - print('WARN: No session for sendUndoBookmarkViaServer') + print('WARN: No session for send_undo_bookmark_via_server') return 6 - domainFull = getFullDomain(domain, fromPort) + domain_full = get_full_domain(domain, from_port) - actor = localActorUrl(httpPrefix, nickname, domainFull) + actor = local_actor_url(http_prefix, nickname, domain_full) - newBookmarkJson = { + new_bookmark_json = { "@context": "https://www.w3.org/ns/activitystreams", "type": "Remove", "actor": actor, "to": [actor], "object": { "type": "Document", - "url": bookmarkUrl, + "url": bookmark_url, "to": [actor] }, "target": actor + "/tlbookmarks" } - handle = httpPrefix + '://' + domainFull + '/@' + nickname + handle = http_prefix + '://' + domain_full + '/@' + nickname # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - domain, projectVersion, debug, False, - signingPrivateKeyPem) - if not wfRequest: + wf_request = \ + webfinger_handle(session, handle, http_prefix, + cached_webfingers, + domain, project_version, debug, False, + signing_priv_key_pem) + if not wf_request: if debug: print('DEBUG: unbookmark webfinger failed for ' + handle) return 1 - if not isinstance(wfRequest, dict): + if not isinstance(wf_request, dict): print('WARN: unbookmark webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) + ' did not return a dict. ' + str(wf_request)) return 1 - postToBox = 'outbox' + post_to_box = 'outbox' # get the actor inbox for the To handle - originDomain = domain - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 52594) + origin_domain = domain + (inbox_url, _, _, from_person_id, _, _, + _, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, wf_request, + person_cache, + project_version, http_prefix, + nickname, domain, + post_to_box, 52594) - if not inboxUrl: + if not inbox_url: if debug: - print('DEBUG: unbookmark no ' + postToBox + + print('DEBUG: unbookmark no ' + post_to_box + ' was found for ' + handle) return 3 - if not fromPersonId: + if not from_person_id: if debug: print('DEBUG: unbookmark no actor was found for ' + handle) return 4 - authHeader = createBasicAuthHeader(nickname, password) + auth_header = create_basic_auth_header(nickname, password) headers = { 'host': domain, 'Content-type': 'application/json', - 'Authorization': authHeader + 'Authorization': auth_header } - postResult = postJson(httpPrefix, domainFull, - session, newBookmarkJson, [], inboxUrl, - headers, 3, True) - if not postResult: + post_result = post_json(http_prefix, domain_full, + session, new_bookmark_json, [], inbox_url, + headers, 3, True) + if not post_result: if debug: - print('WARN: POST unbookmark failed for c2s to ' + inboxUrl) + print('WARN: POST unbookmark failed for c2s to ' + inbox_url) return 5 if debug: print('DEBUG: c2s POST unbookmark success') - return newBookmarkJson + return new_bookmark_json -def outboxBookmark(recentPostsCache: {}, - baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> None: +def outbox_bookmark(recent_posts_cache: {}, + base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + message_json: {}, debug: bool) -> None: """ When a bookmark request is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): return - if messageJson['type'] != 'Add': + if message_json['type'] != 'Add': return - if not messageJson.get('actor'): - if debug: - print('DEBUG: no actor in bookmark Add') + if not has_actor(message_json, debug): return - if not hasObjectDict(messageJson): - if debug: - print('DEBUG: no object in bookmark Add') - return - if not messageJson.get('target'): + if not message_json.get('target'): if debug: print('DEBUG: no target in bookmark Add') return - if not messageJson['object'].get('type'): - if debug: - print('DEBUG: no object type in bookmark Add') + if not has_object_string_type(message_json, debug): return - if not isinstance(messageJson['target'], str): + if not isinstance(message_json['target'], str): if debug: print('DEBUG: bookmark Add target is not string') return - domainFull = getFullDomain(domain, port) - if not messageJson['target'].endswith('://' + domainFull + - '/users/' + nickname + - '/tlbookmarks'): + domain_full = get_full_domain(domain, port) + expected_target = \ + http_prefix + '://' + domain_full + \ + '/users/' + nickname + '/tlbookmarks' + if message_json['target'] != expected_target: if debug: print('DEBUG: bookmark Add target invalid ' + - messageJson['target']) + message_json['target']) return - if messageJson['object']['type'] != 'Document': + if message_json['object']['type'] != 'Document': if debug: print('DEBUG: bookmark Add type is not Document') return - if not messageJson['object'].get('url'): + if not message_json['object'].get('url'): if debug: print('DEBUG: bookmark Add missing url') return if debug: print('DEBUG: c2s bookmark Add request arrived in outbox') - messageUrl = removeIdEnding(messageJson['object']['url']) - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageUrl) - if not postFilename: + message_url = remove_id_ending(message_json['object']['url']) + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_url) + if not post_filename: if debug: print('DEBUG: c2s like post not found in inbox or outbox') - print(messageUrl) + print(message_url) return True - updateBookmarksCollection(recentPostsCache, - baseDir, postFilename, messageUrl, - messageJson['actor'], domain, debug) + update_bookmarks_collection(recent_posts_cache, + base_dir, post_filename, message_url, + message_json['actor'], domain, debug) if debug: - print('DEBUG: post bookmarked via c2s - ' + postFilename) + print('DEBUG: post bookmarked via c2s - ' + post_filename) -def outboxUndoBookmark(recentPostsCache: {}, - baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> None: +def outbox_undo_bookmark(recent_posts_cache: {}, + base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + message_json: {}, debug: bool) -> None: """ When an undo bookmark request is received by the outbox from c2s """ - if not messageJson.get('type'): + if not message_json.get('type'): return - if messageJson['type'] != 'Remove': + if message_json['type'] != 'Remove': return - if not messageJson.get('actor'): - if debug: - print('DEBUG: no actor in unbookmark Remove') + if not has_actor(message_json, debug): return - if not hasObjectDict(messageJson): - if debug: - print('DEBUG: no object in unbookmark Remove') - return - if not messageJson.get('target'): + if not message_json.get('target'): if debug: print('DEBUG: no target in unbookmark Remove') return - if not messageJson['object'].get('type'): - if debug: - print('DEBUG: no object type in bookmark Remove') + if not has_object_string_type(message_json, debug): return - if not isinstance(messageJson['target'], str): + if not isinstance(message_json['target'], str): if debug: print('DEBUG: unbookmark Remove target is not string') return - domainFull = getFullDomain(domain, port) - if not messageJson['target'].endswith('://' + domainFull + - '/users/' + nickname + - '/tlbookmarks'): + domain_full = get_full_domain(domain, port) + expected_target = \ + http_prefix + '://' + domain_full + \ + '/users/' + nickname + '/tlbookmarks' + if message_json['target'] != expected_target: if debug: print('DEBUG: unbookmark Remove target invalid ' + - messageJson['target']) + message_json['target']) return - if messageJson['object']['type'] != 'Document': + if message_json['object']['type'] != 'Document': if debug: print('DEBUG: unbookmark Remove type is not Document') return - if not messageJson['object'].get('url'): + if not message_json['object'].get('url'): if debug: print('DEBUG: unbookmark Remove missing url') return if debug: print('DEBUG: c2s unbookmark Remove request arrived in outbox') - messageUrl = removeIdEnding(messageJson['object']['url']) - domain = removeDomainPort(domain) - postFilename = locatePost(baseDir, nickname, domain, messageUrl) - if not postFilename: + message_url = remove_id_ending(message_json['object']['url']) + domain = remove_domain_port(domain) + post_filename = locate_post(base_dir, nickname, domain, message_url) + if not post_filename: if debug: print('DEBUG: c2s unbookmark post not found in inbox or outbox') - print(messageUrl) + print(message_url) return True - updateBookmarksCollection(recentPostsCache, - baseDir, postFilename, messageUrl, - messageJson['actor'], domain, debug) + update_bookmarks_collection(recent_posts_cache, + base_dir, post_filename, message_url, + message_json['actor'], domain, debug) if debug: - print('DEBUG: post unbookmarked via c2s - ' + postFilename) + print('DEBUG: post unbookmarked via c2s - ' + post_filename) diff --git a/briar.py b/briar.py index 76369208b..811467155 100644 --- a/briar.py +++ b/briar.py @@ -1,104 +1,129 @@ __filename__ = "briar.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" -def getBriarAddress(actorJson: {}) -> str: +from utils import get_attachment_property_value + + +def get_briar_address(actor_json: {}) -> str: """Returns briar address for the given actor """ - if not actorJson.get('attachment'): + if not actor_json.get('attachment'): return '' - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue['name'].lower().startswith('briar'): + if not name_value.lower().startswith('briar'): continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue.get('value'): + prop_value_name, prop_value = \ + get_attachment_property_value(property_value) + if not prop_value: continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - propertyValue['value'] = propertyValue['value'].strip() - if len(propertyValue['value']) < 50: + property_value[prop_value_name] = prop_value.strip() + if len(property_value[prop_value_name]) < 50: continue - if not propertyValue['value'].startswith('briar://'): + if not property_value[prop_value_name].startswith('briar://'): continue - if propertyValue['value'].lower() != propertyValue['value']: + if property_value[prop_value_name].lower() != \ + property_value[prop_value_name]: continue - if '"' in propertyValue['value']: + if '"' in property_value[prop_value_name]: continue - if ' ' in propertyValue['value']: + if ' ' in property_value[prop_value_name]: continue - if ',' in propertyValue['value']: + if ',' in property_value[prop_value_name]: continue - if '.' in propertyValue['value']: + if '.' in property_value[prop_value_name]: continue - return propertyValue['value'] + return property_value[prop_value_name] return '' -def setBriarAddress(actorJson: {}, briarAddress: str) -> None: +def set_briar_address(actor_json: {}, briar_address: str) -> None: """Sets an briar address for the given actor """ - notBriarAddress = False + not_briar_address = False - if len(briarAddress) < 50: - notBriarAddress = True - if not briarAddress.startswith('briar://'): - notBriarAddress = True - if briarAddress.lower() != briarAddress: - notBriarAddress = True - if '"' in briarAddress: - notBriarAddress = True - if ' ' in briarAddress: - notBriarAddress = True - if '.' in briarAddress: - notBriarAddress = True - if ',' in briarAddress: - notBriarAddress = True - if '<' in briarAddress: - notBriarAddress = True + if len(briar_address) < 50: + not_briar_address = True + if not briar_address.startswith('briar://'): + not_briar_address = True + if briar_address.lower() != briar_address: + not_briar_address = True + if '"' in briar_address: + not_briar_address = True + if ' ' in briar_address: + not_briar_address = True + if '.' in briar_address: + not_briar_address = True + if ',' in briar_address: + not_briar_address = True + if '<' in briar_address: + not_briar_address = True - if not actorJson.get('attachment'): - actorJson['attachment'] = [] + if not actor_json.get('attachment'): + actor_json['attachment'] = [] # remove any existing value - propertyFound = None - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + property_found = None + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue['name'].lower().startswith('briar'): + if not name_value.lower().startswith('briar'): continue - propertyFound = propertyValue + property_found = property_value break - if propertyFound: - actorJson['attachment'].remove(propertyFound) - if notBriarAddress: + if property_found: + actor_json['attachment'].remove(property_found) + if not_briar_address: return - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue['name'].lower().startswith('briar'): + if not name_value.lower().startswith('briar'): continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - propertyValue['value'] = briarAddress + prop_value_name, _ = \ + get_attachment_property_value(property_value) + if not prop_value_name: + continue + property_value[prop_value_name] = briar_address return - newBriarAddress = { + new_briar_address = { "name": "Briar", "type": "PropertyValue", - "value": briarAddress + "value": briar_address } - actorJson['attachment'].append(newBriarAddress) + actor_json['attachment'].append(new_briar_address) diff --git a/cache.py b/cache.py index 7b4654944..0ead2f336 100644 --- a/cache.py +++ b/cache.py @@ -1,7 +1,7 @@ __filename__ = "cache.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,179 +9,194 @@ __module_group__ = "Core" import os import datetime -from session import urlExists -from session import getJson -from utils import loadJson -from utils import saveJson -from utils import getFileCaseInsensitive -from utils import getUserPaths +from session import url_exists +from session import get_json +from utils import load_json +from utils import save_json +from utils import get_file_case_insensitive +from utils import get_user_paths -def _removePersonFromCache(baseDir: str, personUrl: str, - personCache: {}) -> bool: +def _remove_person_from_cache(base_dir: str, person_url: str, + person_cache: {}) -> bool: """Removes an actor from the cache """ - cacheFilename = baseDir + '/cache/actors/' + \ - personUrl.replace('/', '#') + '.json' - if os.path.isfile(cacheFilename): + cache_filename = base_dir + '/cache/actors/' + \ + person_url.replace('/', '#') + '.json' + if os.path.isfile(cache_filename): try: - os.remove(cacheFilename) - except BaseException: - pass - if personCache.get(personUrl): - del personCache[personUrl] + os.remove(cache_filename) + except OSError: + print('EX: unable to delete cached actor ' + str(cache_filename)) + if person_cache.get(person_url): + del person_cache[person_url] -def checkForChangedActor(session, baseDir: str, - httpPrefix: str, domainFull: str, - personUrl: str, avatarUrl: str, personCache: {}, - timeoutSec: int): +def check_for_changed_actor(session, base_dir: str, + http_prefix: str, domain_full: str, + person_url: str, avatar_url: str, person_cache: {}, + timeout_sec: int): """Checks if the avatar url exists and if not then the actor has probably changed without receiving an actor/Person Update. So clear the actor from the cache and it will be refreshed when the next post from them is sent """ - if not session or not avatarUrl: + if not session or not avatar_url: return - if domainFull in avatarUrl: + if domain_full in avatar_url: return - if urlExists(session, avatarUrl, timeoutSec, httpPrefix, domainFull): + if url_exists(session, avatar_url, timeout_sec, http_prefix, domain_full): return - _removePersonFromCache(baseDir, personUrl, personCache) + _remove_person_from_cache(base_dir, person_url, person_cache) -def storePersonInCache(baseDir: str, personUrl: str, - personJson: {}, personCache: {}, - allowWriteToFile: bool) -> None: +def store_person_in_cache(base_dir: str, person_url: str, + person_json: {}, person_cache: {}, + allow_write_to_file: bool) -> None: """Store an actor in the cache """ - if 'statuses' in personUrl or personUrl.endswith('/actor'): + if 'statuses' in person_url or person_url.endswith('/actor'): # This is not an actor or person account return - currTime = datetime.datetime.utcnow() - personCache[personUrl] = { - "actor": personJson, - "timestamp": currTime.strftime("%Y-%m-%dT%H:%M:%SZ") + curr_time = datetime.datetime.utcnow() + person_cache[person_url] = { + "actor": person_json, + "timestamp": curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") } - if not baseDir: + if not base_dir: return # store to file - if not allowWriteToFile: + if not allow_write_to_file: return - if os.path.isdir(baseDir + '/cache/actors'): - cacheFilename = baseDir + '/cache/actors/' + \ - personUrl.replace('/', '#') + '.json' - if not os.path.isfile(cacheFilename): - saveJson(personJson, cacheFilename) + if os.path.isdir(base_dir + '/cache/actors'): + cache_filename = base_dir + '/cache/actors/' + \ + person_url.replace('/', '#') + '.json' + if not os.path.isfile(cache_filename): + save_json(person_json, cache_filename) -def getPersonFromCache(baseDir: str, personUrl: str, personCache: {}, - allowWriteToFile: bool) -> {}: +def get_person_from_cache(base_dir: str, person_url: str, + person_cache: {}) -> {}: """Get an actor from the cache """ # if the actor is not in memory then try to load it from file - loadedFromFile = False - if not personCache.get(personUrl): + loaded_from_file = False + if not person_cache.get(person_url): # does the person exist as a cached file? - cacheFilename = baseDir + '/cache/actors/' + \ - personUrl.replace('/', '#') + '.json' - actorFilename = getFileCaseInsensitive(cacheFilename) - if actorFilename: - personJson = loadJson(actorFilename) - if personJson: - storePersonInCache(baseDir, personUrl, personJson, - personCache, False) - loadedFromFile = True + cache_filename = base_dir + '/cache/actors/' + \ + person_url.replace('/', '#') + '.json' + actor_filename = get_file_case_insensitive(cache_filename) + if actor_filename: + person_json = load_json(actor_filename) + if person_json: + store_person_in_cache(base_dir, person_url, person_json, + person_cache, False) + loaded_from_file = True - if personCache.get(personUrl): - if not loadedFromFile: + if person_cache.get(person_url): + if not loaded_from_file: # update the timestamp for the last time the actor was retrieved - currTime = datetime.datetime.utcnow() - currTimeStr = currTime.strftime("%Y-%m-%dT%H:%M:%SZ") - personCache[personUrl]['timestamp'] = currTimeStr - return personCache[personUrl]['actor'] + curr_time = datetime.datetime.utcnow() + curr_time_str = curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") + person_cache[person_url]['timestamp'] = curr_time_str + return person_cache[person_url]['actor'] return None -def expirePersonCache(personCache: {}): +def expire_person_cache(person_cache: {}): """Expires old entries from the cache in memory """ - currTime = datetime.datetime.utcnow() + curr_time = datetime.datetime.utcnow() removals = [] - for personUrl, cacheJson in personCache.items(): - cacheTime = datetime.datetime.strptime(cacheJson['timestamp'], - "%Y-%m-%dT%H:%M:%SZ") - daysSinceCached = (currTime - cacheTime).days - if daysSinceCached > 2: - removals.append(personUrl) + for person_url, cache_json in person_cache.items(): + cache_time = datetime.datetime.strptime(cache_json['timestamp'], + "%Y-%m-%dT%H:%M:%SZ") + days_since_cached = (curr_time - cache_time).days + if days_since_cached > 2: + removals.append(person_url) if len(removals) > 0: - for personUrl in removals: - del personCache[personUrl] + for person_url in removals: + del person_cache[person_url] print(str(len(removals)) + ' actors were expired from the cache') -def storeWebfingerInCache(handle: str, wf, cachedWebfingers: {}) -> None: +def store_webfinger_in_cache(handle: str, webfing, + cached_webfingers: {}) -> None: """Store a webfinger endpoint in the cache """ - cachedWebfingers[handle] = wf + cached_webfingers[handle] = webfing -def getWebfingerFromCache(handle: str, cachedWebfingers: {}) -> {}: +def get_webfinger_from_cache(handle: str, cached_webfingers: {}) -> {}: """Get webfinger endpoint from the cache """ - if cachedWebfingers.get(handle): - return cachedWebfingers[handle] + if cached_webfingers.get(handle): + return cached_webfingers[handle] return None -def getPersonPubKey(baseDir: str, session, personUrl: str, - personCache: {}, debug: bool, - projectVersion: str, httpPrefix: str, - domain: str, onionDomain: str, - signingPrivateKeyPem: str) -> str: - if not personUrl: +def get_person_pub_key(base_dir: str, session, person_url: str, + person_cache: {}, debug: bool, + project_version: str, http_prefix: str, + domain: str, onion_domain: str, + i2p_domain: str, + signing_priv_key_pem: str) -> str: + """Get the public key for an actor + """ + if not person_url: return None - personUrl = personUrl.replace('#main-key', '') - usersPaths = getUserPaths() - for possibleUsersPath in usersPaths: - if personUrl.endswith(possibleUsersPath + 'inbox'): + if '#/publicKey' in person_url: + person_url = person_url.replace('#/publicKey', '') + elif '/main-key' in person_url: + person_url = person_url.replace('/main-key', '') + else: + person_url = person_url.replace('#main-key', '') + users_paths = get_user_paths() + for possible_users_path in users_paths: + if person_url.endswith(possible_users_path + 'inbox'): if debug: print('DEBUG: Obtaining public key for shared inbox') - personUrl = \ - personUrl.replace(possibleUsersPath + 'inbox', '/inbox') + person_url = \ + person_url.replace(possible_users_path + 'inbox', '/inbox') break - personJson = \ - getPersonFromCache(baseDir, personUrl, personCache, True) - if not personJson: + person_json = \ + get_person_from_cache(base_dir, person_url, person_cache) + if not person_json: if debug: - print('DEBUG: Obtaining public key for ' + personUrl) - personDomain = domain - if onionDomain: - if '.onion/' in personUrl: - personDomain = onionDomain - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' + print('DEBUG: Obtaining public key for ' + person_url) + person_domain = domain + if onion_domain: + if '.onion/' in person_url: + person_domain = onion_domain + elif i2p_domain: + if '.i2p/' in person_url: + person_domain = i2p_domain + profile_str = 'https://www.w3.org/ns/activitystreams' + accept_str = \ + 'application/activity+json; profile="' + profile_str + '"' + as_header = { + 'Accept': accept_str } - personJson = \ - getJson(signingPrivateKeyPem, - session, personUrl, asHeader, None, debug, - projectVersion, httpPrefix, personDomain) - if not personJson: + person_json = \ + get_json(signing_priv_key_pem, + session, person_url, as_header, None, debug, + project_version, http_prefix, person_domain) + if not person_json: return None - pubKey = None - if personJson.get('publicKey'): - if personJson['publicKey'].get('publicKeyPem'): - pubKey = personJson['publicKey']['publicKeyPem'] + pub_key = None + if person_json.get('publicKey'): + if person_json['publicKey'].get('publicKeyPem'): + pub_key = person_json['publicKey']['publicKeyPem'] else: - if personJson.get('publicKeyPem'): - pubKey = personJson['publicKeyPem'] + if person_json.get('publicKeyPem'): + pub_key = person_json['publicKeyPem'] - if not pubKey: + if not pub_key: if debug: - print('DEBUG: Public key not found for ' + personUrl) + print('DEBUG: Public key not found for ' + person_url) - storePersonInCache(baseDir, personUrl, personJson, personCache, True) - return pubKey + store_person_in_cache(base_dir, person_url, person_json, + person_cache, True) + return pub_key diff --git a/caddy.example.conf b/caddy.example.conf index 615501443..3efed5a76 100644 --- a/caddy.example.conf +++ b/caddy.example.conf @@ -1,23 +1,28 @@ -# Caddy configuration file for running epicyon on example.com +# Example configuration file for running Caddy2 in front of Epicyon -example.com { - tls { - # Valid values are rsa2048, rsa4096, rsa8192, p256, and p384. - # Default is currently p256. - key_type p384 - } - header / Strict-Transport-Security "max-age=31556925" - header / X-Content-Type-Options "nosniff" - header / X-Download-Options "noopen" - header / X-Frame-Options "DENY" - header / X-Permitted-Cross-Domain-Policies "none" - header / X-Robots-Tag "noindex" - header / X-XSS-Protection "1; mode=block" +YOUR_DOMAIN { + tls USER@YOUR_DOMAIN - proxy / http://localhost:7156 { - transparent - timeout 10800s + header { + Strict-Transport-Security "max-age=31556925" + Content-Security-Policy "default-src https:; script-src https: 'unsafe-inline'; style-src https: 'unsafe-inline'" + X-Content-Type-Options "nosniff" + X-Download-Options "noopen" + X-Frame-Options "DENY" + X-Permitted-Cross-Domain-Policies "none" + X-XSS-Protection "1; mode=block" } + + route /newsmirror/* { + root * /var/www/YOUR_DOMAIN + file_server + } + + route /* { + reverse_proxy http://127.0.0.1:7156 + } + + encode zstd gzip } -# eof +# eof \ No newline at end of file diff --git a/categories.py b/categories.py index f2834e9c2..1ee488e63 100644 --- a/categories.py +++ b/categories.py @@ -1,7 +1,7 @@ __filename__ = "categories.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -10,118 +10,131 @@ __module_group__ = "RSS Feeds" import os import datetime +MAX_TAG_LENGTH = 42 -def getHashtagCategory(baseDir: str, hashtag: str) -> str: +INVALID_HASHTAG_CHARS = (',', ' ', '<', ';', '\\', '"', '&', '#') + + +def get_hashtag_category(base_dir: str, hashtag: str) -> str: """Returns the category for the hashtag """ - categoryFilename = baseDir + '/tags/' + hashtag + '.category' - if not os.path.isfile(categoryFilename): - categoryFilename = baseDir + '/tags/' + hashtag.title() + '.category' - if not os.path.isfile(categoryFilename): - categoryFilename = \ - baseDir + '/tags/' + hashtag.upper() + '.category' - if not os.path.isfile(categoryFilename): + category_filename = base_dir + '/tags/' + hashtag + '.category' + if not os.path.isfile(category_filename): + category_filename = base_dir + '/tags/' + hashtag.title() + '.category' + if not os.path.isfile(category_filename): + category_filename = \ + base_dir + '/tags/' + hashtag.upper() + '.category' + if not os.path.isfile(category_filename): return '' - with open(categoryFilename, 'r') as fp: - categoryStr = fp.read() - if categoryStr: - return categoryStr + category_str = None + try: + with open(category_filename, 'r', encoding='utf-8') as category_file: + category_str = category_file.read() + except OSError: + print('EX: unable to read category ' + category_filename) + if category_str: + return category_str return '' -def getHashtagCategories(baseDir: str, - recent: bool = False, category: str = None) -> None: +def get_hashtag_categories(base_dir: str, + recent: bool = False, + category: str = None) -> None: """Returns a dictionary containing hashtag categories """ - maxTagLength = 42 - hashtagCategories = {} + hashtag_categories = {} if recent: - currTime = datetime.datetime.utcnow() - daysSinceEpoch = (currTime - datetime.datetime(1970, 1, 1)).days - recently = daysSinceEpoch - 1 + curr_time = datetime.datetime.utcnow() + days_since_epoch = (curr_time - datetime.datetime(1970, 1, 1)).days + recently = days_since_epoch - 1 - for subdir, dirs, files in os.walk(baseDir + '/tags'): - for f in files: - if not f.endswith('.category'): + for _, _, files in os.walk(base_dir + '/tags'): + for catfile in files: + if not catfile.endswith('.category'): continue - categoryFilename = os.path.join(baseDir + '/tags', f) - if not os.path.isfile(categoryFilename): + category_filename = os.path.join(base_dir + '/tags', catfile) + if not os.path.isfile(category_filename): continue - hashtag = f.split('.')[0] - if len(hashtag) > maxTagLength: + hashtag = catfile.split('.')[0] + if len(hashtag) > MAX_TAG_LENGTH: continue - with open(categoryFilename, 'r') as fp: - categoryStr = fp.read() + with open(category_filename, 'r', encoding='utf-8') as fp_category: + category_str = fp_category.read() - if not categoryStr: + if not category_str: continue if category: # only return a dictionary for a specific category - if categoryStr != category: + if category_str != category: continue if recent: - tagsFilename = baseDir + '/tags/' + hashtag + '.txt' - if not os.path.isfile(tagsFilename): + tags_filename = base_dir + '/tags/' + hashtag + '.txt' + if not os.path.isfile(tags_filename): continue - modTimesinceEpoc = \ - os.path.getmtime(tagsFilename) - lastModifiedDate = \ - datetime.datetime.fromtimestamp(modTimesinceEpoc) - fileDaysSinceEpoch = \ - (lastModifiedDate - + mod_time_since_epoc = \ + os.path.getmtime(tags_filename) + last_modified_date = \ + datetime.datetime.fromtimestamp(mod_time_since_epoc) + file_days_since_epoch = \ + (last_modified_date - datetime.datetime(1970, 1, 1)).days - if fileDaysSinceEpoch < recently: + if file_days_since_epoch < recently: continue - if not hashtagCategories.get(categoryStr): - hashtagCategories[categoryStr] = [hashtag] + if not hashtag_categories.get(category_str): + hashtag_categories[category_str] = [hashtag] else: - if hashtag not in hashtagCategories[categoryStr]: - hashtagCategories[categoryStr].append(hashtag) + if hashtag not in hashtag_categories[category_str]: + hashtag_categories[category_str].append(hashtag) break - return hashtagCategories + return hashtag_categories -def updateHashtagCategories(baseDir: str) -> None: +def update_hashtag_categories(base_dir: str) -> None: """Regenerates the list of hashtag categories """ - categoryListFilename = baseDir + '/accounts/categoryList.txt' - hashtagCategories = getHashtagCategories(baseDir) - if not hashtagCategories: - if os.path.isfile(categoryListFilename): + category_list_filename = base_dir + '/accounts/categoryList.txt' + hashtag_categories = get_hashtag_categories(base_dir) + if not hashtag_categories: + if os.path.isfile(category_list_filename): try: - os.remove(categoryListFilename) - except BaseException: - pass + os.remove(category_list_filename) + except OSError: + print('EX: update_hashtag_categories ' + + 'unable to delete cached category list ' + + category_list_filename) return - categoryList = [] - for categoryStr, hashtagList in hashtagCategories.items(): - categoryList.append(categoryStr) - categoryList.sort() + category_list = [] + for category_str, _ in hashtag_categories.items(): + category_list.append(category_str) + category_list.sort() - categoryListStr = '' - for categoryStr in categoryList: - categoryListStr += categoryStr + '\n' + category_list_str = '' + for category_str in category_list: + category_list_str += category_str + '\n' # save a list of available categories for quick lookup - with open(categoryListFilename, 'w+') as fp: - fp.write(categoryListStr) + try: + with open(category_list_filename, 'w+', + encoding='utf-8') as fp_category: + fp_category.write(category_list_str) + except OSError: + print('EX: unable to write category ' + category_list_filename) -def _validHashtagCategory(category: str) -> bool: +def _valid_hashtag_category(category: str) -> bool: """Returns true if the category name is valid """ if not category: return False - invalidChars = (',', ' ', '<', ';', '\\', '"', '&', '#') - for ch in invalidChars: - if ch in category: + for char in INVALID_HASHTAG_CHARS: + if char in category: return False # too long @@ -131,52 +144,61 @@ def _validHashtagCategory(category: str) -> bool: return True -def setHashtagCategory(baseDir: str, hashtag: str, category: str, - update: bool, force: bool = False) -> bool: +def set_hashtag_category(base_dir: str, hashtag: str, category: str, + update: bool, force: bool = False) -> bool: """Sets the category for the hashtag """ - if not _validHashtagCategory(category): + if not _valid_hashtag_category(category): return False if not force: - hashtagFilename = baseDir + '/tags/' + hashtag + '.txt' - if not os.path.isfile(hashtagFilename): + hashtag_filename = base_dir + '/tags/' + hashtag + '.txt' + if not os.path.isfile(hashtag_filename): hashtag = hashtag.title() - hashtagFilename = baseDir + '/tags/' + hashtag + '.txt' - if not os.path.isfile(hashtagFilename): + hashtag_filename = base_dir + '/tags/' + hashtag + '.txt' + if not os.path.isfile(hashtag_filename): hashtag = hashtag.upper() - hashtagFilename = baseDir + '/tags/' + hashtag + '.txt' - if not os.path.isfile(hashtagFilename): + hashtag_filename = base_dir + '/tags/' + hashtag + '.txt' + if not os.path.isfile(hashtag_filename): return False - if not os.path.isdir(baseDir + '/tags'): - os.mkdir(baseDir + '/tags') - categoryFilename = baseDir + '/tags/' + hashtag + '.category' + if not os.path.isdir(base_dir + '/tags'): + os.mkdir(base_dir + '/tags') + category_filename = base_dir + '/tags/' + hashtag + '.category' if force: # don't overwrite any existing categories - if os.path.isfile(categoryFilename): + if os.path.isfile(category_filename): return False - with open(categoryFilename, 'w+') as fp: - fp.write(category) + + category_written = False + try: + with open(category_filename, 'w+', encoding='utf-8') as fp_category: + fp_category.write(category) + category_written = True + except OSError as ex: + print('EX: unable to write category ' + category_filename + + ' ' + str(ex)) + + if category_written: if update: - updateHashtagCategories(baseDir) + update_hashtag_categories(base_dir) return True return False -def guessHashtagCategory(tagName: str, hashtagCategories: {}) -> str: +def guess_hashtag_category(tagName: str, hashtag_categories: {}) -> str: """Tries to guess a category for the given hashtag. This works by trying to find the longest similar hashtag """ if len(tagName) < 4: return '' - categoryMatched = '' - tagMatchedLen = 0 + category_matched = '' + tag_matched_len = 0 - for categoryStr, hashtagList in hashtagCategories.items(): - for hashtag in hashtagList: + for category_str, hashtag_list in hashtag_categories.items(): + for hashtag in hashtag_list: if len(hashtag) < 4: # avoid matching very small strings which often # lead to spurious categories @@ -184,13 +206,13 @@ def guessHashtagCategory(tagName: str, hashtagCategories: {}) -> str: if hashtag not in tagName: if tagName not in hashtag: continue - if not categoryMatched: - tagMatchedLen = len(hashtag) - categoryMatched = categoryStr + if not category_matched: + tag_matched_len = len(hashtag) + category_matched = category_str else: # match the longest tag - if len(hashtag) > tagMatchedLen: - categoryMatched = categoryStr - if not categoryMatched: + if len(hashtag) > tag_matched_len: + category_matched = category_str + if not category_matched: return '' - return categoryMatched + return category_matched diff --git a/city.py b/city.py index b486c2de0..88867c8cb 100644 --- a/city.py +++ b/city.py @@ -1,7 +1,7 @@ __filename__ = "city.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -12,7 +12,8 @@ import datetime import random import math from random import randint -from utils import acctDir +from utils import acct_dir +from utils import remove_eol # states which the simulated city dweller can be in PERSON_SLEEP = 0 @@ -22,8 +23,10 @@ PERSON_SHOP = 3 PERSON_EVENING = 4 PERSON_PARTY = 5 +BUSY_STATES = (PERSON_WORK, PERSON_SHOP, PERSON_PLAY, PERSON_PARTY) -def _getDecoyCamera(decoySeed: int) -> (str, str, int): + +def _get_decoy_camera(decoy_seed: int) -> (str, str, int): """Returns a decoy camera make and model which took the photo """ cameras = [ @@ -37,10 +40,16 @@ def _getDecoyCamera(decoySeed: int) -> (str, str, int): ["Apple", "iPhone 12"], ["Apple", "iPhone 12 Mini"], ["Apple", "iPhone 12 Pro Max"], + ["Apple", "iPhone 13"], + ["Apple", "iPhone 13 Mini"], + ["Apple", "iPhone 13 Pro"], ["Samsung", "Galaxy Note 20 Ultra"], ["Samsung", "Galaxy S20 Plus"], ["Samsung", "Galaxy S20 FE 5G"], ["Samsung", "Galaxy Z FOLD 2"], + ["Samsung", "Galaxy S12 Plus"], + ["Samsung", "Galaxy S12"], + ["Samsung", "Galaxy S11 Plus"], ["Samsung", "Galaxy S10 Plus"], ["Samsung", "Galaxy S10e"], ["Samsung", "Galaxy Z Flip"], @@ -50,8 +59,13 @@ def _getDecoyCamera(decoySeed: int) -> (str, str, int): ["Samsung", "Galaxy S10e"], ["Samsung", "Galaxy S10 5G"], ["Samsung", "Galaxy A60"], + ["Samsung", "Note 12"], + ["Samsung", "Note 12 Plus"], + ["Samsung", "Note 11"], + ["Samsung", "Note 11 Plus"], ["Samsung", "Note 10"], ["Samsung", "Note 10 Plus"], + ["Samsung", "Galaxy S22 Ultra"], ["Samsung", "Galaxy S21 Ultra"], ["Samsung", "Galaxy Note 20 Ultra"], ["Samsung", "Galaxy S21"], @@ -60,6 +74,8 @@ def _getDecoyCamera(decoySeed: int) -> (str, str, int): ["Samsung", "Galaxy Z Fold 2"], ["Samsung", "Galaxy A52 5G"], ["Samsung", "Galaxy A71 5G"], + ["Google", "Pixel 6 Pro"], + ["Google", "Pixel 6"], ["Google", "Pixel 5"], ["Google", "Pixel 4a"], ["Google", "Pixel 4 XL"], @@ -69,13 +85,13 @@ def _getDecoyCamera(decoySeed: int) -> (str, str, int): ["Google", "Pixel 3"], ["Google", "Pixel 3a"] ] - randgen = random.Random(decoySeed) + randgen = random.Random(decoy_seed) index = randgen.randint(0, len(cameras) - 1) - serialNumber = randgen.randint(100000000000, 999999999999999999999999) - return cameras[index][0], cameras[index][1], serialNumber + serial_number = randgen.randint(100000000000, 999999999999999999999999) + return cameras[index][0], cameras[index][1], serial_number -def _getCityPulse(currTimeOfDay, decoySeed: int) -> (float, float): +def _get_city_pulse(curr_time_of_day, decoy_seed: int) -> (float, float): """This simulates expected average patterns of movement in a city. Jane or Joe average lives and works in the city, commuting in and out of the central district for work. They have a unique @@ -84,143 +100,149 @@ def _getCityPulse(currTimeOfDay, decoySeed: int) -> (float, float): Distance from the city centre is in the range 0.0 - 1.0 Angle is in radians """ - randgen = random.Random(decoySeed) + randgen = random.Random(decoy_seed) variance = 3 - busyStates = (PERSON_WORK, PERSON_SHOP, PERSON_PLAY, PERSON_PARTY) - dataDecoyState = PERSON_SLEEP - weekday = currTimeOfDay.weekday() - minHour = 7 + randint(0, variance) - maxHour = 17 + randint(0, variance) - if currTimeOfDay.hour > minHour: - if currTimeOfDay.hour <= maxHour: + data_decoy_state = PERSON_SLEEP + weekday = curr_time_of_day.weekday() + min_hour = 7 + randint(0, variance) + max_hour = 17 + randint(0, variance) + if curr_time_of_day.hour > min_hour: + if curr_time_of_day.hour <= max_hour: if weekday < 5: - dataDecoyState = PERSON_WORK + data_decoy_state = PERSON_WORK elif weekday == 5: - dataDecoyState = PERSON_SHOP + data_decoy_state = PERSON_SHOP else: - dataDecoyState = PERSON_PLAY + data_decoy_state = PERSON_PLAY else: if weekday < 5: - dataDecoyState = PERSON_EVENING + data_decoy_state = PERSON_EVENING else: - dataDecoyState = PERSON_PARTY - randgen2 = random.Random(decoySeed + dataDecoyState) - angleRadians = \ + data_decoy_state = PERSON_PARTY + randgen2 = random.Random(decoy_seed + data_decoy_state) + angle_radians = \ (randgen2.randint(0, 100000) / 100000) * 2 * math.pi # some people are quite random, others have more predictable habits - decoyRandomness = randgen.randint(1, 3) + decoy_randomness = randgen.randint(1, 3) # occasionally throw in a wildcard to keep the machine learning guessing - if randint(0, 100) < decoyRandomness: - distanceFromCityCenter = (randint(0, 100000) / 100000) - angleRadians = (randint(0, 100000) / 100000) * 2 * math.pi + if randint(0, 100) < decoy_randomness: + distance_from_city_center = (randint(0, 100000) / 100000) + angle_radians = (randint(0, 100000) / 100000) * 2 * math.pi else: # what consitutes the central district is fuzzy - centralDistrictFuzz = (randgen.randint(0, 100000) / 100000) * 0.1 - busyRadius = 0.3 + centralDistrictFuzz - if dataDecoyState in busyStates: + central_district_fuzz = (randgen.randint(0, 100000) / 100000) * 0.1 + busy_radius = 0.3 + central_district_fuzz + if data_decoy_state in BUSY_STATES: # if we are busy then we're somewhere in the city center - distanceFromCityCenter = \ - (randgen.randint(0, 100000) / 100000) * busyRadius + distance_from_city_center = \ + (randgen.randint(0, 100000) / 100000) * busy_radius else: # otherwise we're in the burbs - distanceFromCityCenter = busyRadius + \ - ((1.0 - busyRadius) * (randgen.randint(0, 100000) / 100000)) - return distanceFromCityCenter, angleRadians + distance_from_city_center = busy_radius + \ + ((1.0 - busy_radius) * (randgen.randint(0, 100000) / 100000)) + return distance_from_city_center, angle_radians -def parseNogoString(nogoLine: str) -> []: +def parse_nogo_string(nogo_line: str) -> []: """Parses a line from locations_nogo.txt and returns the polygon """ - nogoLine = nogoLine.replace('\n', '').replace('\r', '') - polygonStr = nogoLine.split(':', 1)[1] - if ';' in polygonStr: - pts = polygonStr.split(';') + nogo_line = remove_eol(nogo_line) + polygon_str = nogo_line.split(':', 1)[1] + if ';' in polygon_str: + pts = polygon_str.split(';') else: - pts = polygonStr.split(',') + pts = polygon_str.split(',') if len(pts) <= 4: return [] polygon = [] for index in range(int(len(pts)/2)): if index*2 + 1 >= len(pts): break - longitudeStr = pts[index*2].strip() - latitudeStr = pts[index*2 + 1].strip() - if 'E' in latitudeStr or 'W' in latitudeStr: - longitudeStr = pts[index*2 + 1].strip() - latitudeStr = pts[index*2].strip() - if 'E' in longitudeStr: - longitudeStr = \ - longitudeStr.replace('E', '') - longitude = float(longitudeStr) - elif 'W' in longitudeStr: - longitudeStr = \ - longitudeStr.replace('W', '') - longitude = -float(longitudeStr) + longitude_str = pts[index*2].strip() + latitude_str = pts[index*2 + 1].strip() + if 'E' in latitude_str or 'W' in latitude_str: + longitude_str = pts[index*2 + 1].strip() + latitude_str = pts[index*2].strip() + if 'E' in longitude_str: + longitude_str = \ + longitude_str.replace('E', '') + longitude = float(longitude_str) + elif 'W' in longitude_str: + longitude_str = \ + longitude_str.replace('W', '') + longitude = -float(longitude_str) else: - longitude = float(longitudeStr) - latitude = float(latitudeStr) + longitude = float(longitude_str) + latitude = float(latitude_str) polygon.append([latitude, longitude]) return polygon -def spoofGeolocation(baseDir: str, - city: str, currTime, decoySeed: int, - citiesList: [], - nogoList: []) -> (float, float, str, str, - str, str, int): +def spoof_geolocation(base_dir: str, + city: str, curr_time, decoy_seed: int, + cities_list: [], + nogo_list: []) -> (float, float, str, str, + str, str, int): """Given a city and the current time spoofs the location for an image returns latitude, longitude, N/S, E/W, camera make, camera model, camera serial number """ - locationsFilename = baseDir + '/custom_locations.txt' - if not os.path.isfile(locationsFilename): - locationsFilename = baseDir + '/locations.txt' + locations_filename = base_dir + '/custom_locations.txt' + if not os.path.isfile(locations_filename): + locations_filename = base_dir + '/locations.txt' - nogoFilename = baseDir + '/custom_locations_nogo.txt' - if not os.path.isfile(nogoFilename): - nogoFilename = baseDir + '/locations_nogo.txt' + nogo_filename = base_dir + '/custom_locations_nogo.txt' + if not os.path.isfile(nogo_filename): + nogo_filename = base_dir + '/locations_nogo.txt' - manCityRadius = 0.1 - varianceAtLocation = 0.0004 + man_city_radius = 0.1 + variance_at_location = 0.0004 default_latitude = 51.8744 default_longitude = 0.368333 default_latdirection = 'N' default_longdirection = 'W' - if citiesList: - cities = citiesList + if cities_list: + cities = cities_list else: - if not os.path.isfile(locationsFilename): + if not os.path.isfile(locations_filename): return (default_latitude, default_longitude, default_latdirection, default_longdirection, "", "", 0) cities = [] - with open(locationsFilename, 'r') as f: - cities = f.readlines() + try: + with open(locations_filename, 'r', encoding='utf-8') as loc_file: + cities = loc_file.readlines() + except OSError: + print('EX: unable to read locations ' + locations_filename) nogo = [] - if nogoList: - nogo = nogoList + if nogo_list: + nogo = nogo_list else: - if os.path.isfile(nogoFilename): - with open(nogoFilename, 'r') as f: - nogoList = f.readlines() - for line in nogoList: - if line.startswith(city + ':'): - polygon = parseNogoString(line) - if polygon: - nogo.append(polygon) + if os.path.isfile(nogo_filename): + nogo_list = [] + try: + with open(nogo_filename, 'r', encoding='utf-8') as nogo_file: + nogo_list = nogo_file.readlines() + except OSError: + print('EX: unable to read ' + nogo_filename) + for line in nogo_list: + if line.startswith(city + ':'): + polygon = parse_nogo_string(line) + if polygon: + nogo.append(polygon) city = city.lower() - for cityName in cities: - if city in cityName.lower(): - cityFields = cityName.split(':') - latitude = cityFields[1] - longitude = cityFields[2] - areaKm2 = 0 - if len(cityFields) > 3: - areaKm2 = int(cityFields[3]) + for city_name in cities: + if city in city_name.lower(): + city_fields = city_name.split(':') + latitude = city_fields[1] + longitude = city_fields[2] + area_km2 = 0 + if len(city_fields) > 3: + area_km2 = int(city_fields[3]) latdirection = 'N' longdirection = 'E' if 'S' in latitude: @@ -232,99 +254,108 @@ def spoofGeolocation(baseDir: str, latitude = float(latitude) longitude = float(longitude) # get the time of day at the city - approxTimeZone = int(longitude / 15.0) + approx_time_zone = int(longitude / 15.0) if longdirection == 'E': - approxTimeZone = -approxTimeZone - currTimeAdjusted = currTime - \ - datetime.timedelta(hours=approxTimeZone) - camMake, camModel, camSerialNumber = \ - _getDecoyCamera(decoySeed) - validCoord = False - seedOffset = 0 - while not validCoord: + approx_time_zone = -approx_time_zone + curr_time_adjusted = curr_time - \ + datetime.timedelta(hours=approx_time_zone) + cam_make, cam_model, cam_serial_number = \ + _get_decoy_camera(decoy_seed) + valid_coord = False + seed_offset = 0 + while not valid_coord: # patterns of activity change in the city over time - (distanceFromCityCenter, angleRadians) = \ - _getCityPulse(currTimeAdjusted, decoySeed + seedOffset) + (distance_from_city_center, angle_radians) = \ + _get_city_pulse(curr_time_adjusted, + decoy_seed + seed_offset) # The city radius value is in longitude and the reference # is Manchester. Adjust for the radius of the chosen city. - if areaKm2 > 1: - manRadius = math.sqrt(1276 / math.pi) - radius = math.sqrt(areaKm2 / math.pi) - cityRadiusDeg = (radius / manRadius) * manCityRadius + if area_km2 > 1: + man_radius = math.sqrt(1276 / math.pi) + radius = math.sqrt(area_km2 / math.pi) + city_radius_deg = (radius / man_radius) * man_city_radius else: - cityRadiusDeg = manCityRadius + city_radius_deg = man_city_radius # Get the position within the city, with some randomness added latitude += \ - distanceFromCityCenter * cityRadiusDeg * \ - math.cos(angleRadians) + distance_from_city_center * city_radius_deg * \ + math.cos(angle_radians) longitude += \ - distanceFromCityCenter * cityRadiusDeg * \ - math.sin(angleRadians) + distance_from_city_center * city_radius_deg * \ + math.sin(angle_radians) longval = longitude if longdirection == 'W': longval = -longitude - validCoord = not pointInNogo(nogo, latitude, longval) - if not validCoord: - seedOffset += 1 - if seedOffset > 100: + valid_coord = not point_in_nogo(nogo, latitude, longval) + if not valid_coord: + seed_offset += 1 + if seed_offset > 100: break # add a small amount of variance around the location fraction = randint(0, 100000) / 100000 - distanceFromLocation = fraction * fraction * varianceAtLocation + distance_from_location = fraction * fraction * variance_at_location fraction = randint(0, 100000) / 100000 - angleFromLocation = fraction * 2 * math.pi - latitude += distanceFromLocation * math.cos(angleFromLocation) - longitude += distanceFromLocation * math.sin(angleFromLocation) + angle_from_location = fraction * 2 * math.pi + latitude += distance_from_location * math.cos(angle_from_location) + longitude += distance_from_location * math.sin(angle_from_location) # gps locations aren't transcendental, so round to a fixed # number of decimal places latitude = int(latitude * 100000) / 100000.0 longitude = int(longitude * 100000) / 100000.0 return (latitude, longitude, latdirection, longdirection, - camMake, camModel, camSerialNumber) + cam_make, cam_model, cam_serial_number) return (default_latitude, default_longitude, default_latdirection, default_longdirection, "", "", 0) -def getSpoofedCity(city: str, baseDir: str, nickname: str, domain: str) -> str: +def get_spoofed_city(city: str, base_dir: str, + nickname: str, domain: str) -> str: """Returns the name of the city to use as a GPS spoofing location for image metadata """ city = '' - cityFilename = acctDir(baseDir, nickname, domain) + '/city.txt' - if os.path.isfile(cityFilename): - with open(cityFilename, 'r') as fp: - city = fp.read().replace('\n', '') + city_filename = acct_dir(base_dir, nickname, domain) + '/city.txt' + if os.path.isfile(city_filename): + try: + with open(city_filename, 'r', encoding='utf-8') as city_file: + city1 = city_file.read() + city = remove_eol(city1) + except OSError: + print('EX: unable to read ' + city_filename) return city -def _pointInPolygon(poly: [], x: float, y: float) -> bool: +def _point_in_polygon(poly: [], x_coord: float, y_coord: float) -> bool: """Returns true if the given point is inside the given polygon """ - n = len(poly) + num = len(poly) inside = False p2x = 0.0 p2y = 0.0 xints = 0.0 p1x, p1y = poly[0] - for i in range(n + 1): - p2x, p2y = poly[i % n] - if y > min(p1y, p2y): - if y <= max(p1y, p2y): - if x <= max(p1x, p2x): + for i in range(num + 1): + p2x, p2y = poly[i % num] + if y_coord > min(p1y, p2y): + if y_coord <= max(p1y, p2y): + if x_coord <= max(p1x, p2x): if p1y != p2y: - xints = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x - if p1x == p2x or x <= xints: + xints = \ + (y_coord - p1y) * (p2x - p1x) / (p2y - p1y) + p1x + if p1x == p2x or x_coord <= xints: inside = not inside p1x, p1y = p2x, p2y return inside -def pointInNogo(nogo: [], latitude: float, longitude: float) -> bool: +def point_in_nogo(nogo: [], latitude: float, longitude: float) -> bool: + """Returns true of the given geolocation is within a nogo area + """ for polygon in nogo: - if _pointInPolygon(polygon, latitude, longitude): + if _point_in_polygon(polygon, latitude, longitude): return True return False diff --git a/code-of-conduct.md b/code-of-conduct.md index 83ed3d4d1..5dc766847 100644 --- a/code-of-conduct.md +++ b/code-of-conduct.md @@ -38,7 +38,7 @@ No insults, harassment (sexual or otherwise), condescension, ad hominem, threats Condescension means treating others as inferior. Subtle condescension still violates the Code of Conduct even if not blatantly demeaning. -No stereotyping of or promoting prejudice or discrimination against particular groups or classes/castes of people, including sexism, racism, homophobia, transphobia, age discrimination or discrimination based upon nationality. +No stereotyping of or promoting prejudice or discrimination against particular groups or classes/castes of people, including sexism, racism, homophobia, transphobia, denying people their right to join or create a trade union, age discrimination or discrimination based upon nationality. In cases where criticism of ideology or culture remains on-topic, respectfully discuss the ideas. diff --git a/content.py b/content.py index fd3fb7626..b7e386de9 100644 --- a/content.py +++ b/content.py @@ -1,50 +1,103 @@ __filename__ = "content.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" +import difflib +import math +import html import os import email.parser import urllib.parse from shutil import copyfile -from utils import dangerousSVG -from utils import removeDomainPort -from utils import isValidLanguage -from utils import getImageExtensions -from utils import loadJson -from utils import fileLastModified -from utils import getLinkPrefixes -from utils import dangerousMarkup -from utils import isPGPEncrypted -from utils import containsPGPPublicKey -from utils import acctDir -from utils import isfloat -from utils import getCurrencies -from petnames import getPetName +from dateutil.parser import parse +from utils import get_user_paths +from utils import convert_published_to_local_timezone +from utils import has_object_dict +from utils import valid_hash_tag +from utils import dangerous_svg +from utils import remove_domain_port +from utils import get_image_extensions +from utils import load_json +from utils import save_json +from utils import file_last_modified +from utils import get_link_prefixes +from utils import dangerous_markup +from utils import is_pgp_encrypted +from utils import contains_pgp_public_key +from utils import acct_dir +from utils import is_float +from utils import get_currencies +from utils import remove_html +from utils import remove_eol +from petnames import get_pet_name +from session import download_image + +MUSIC_SITES = ('soundcloud.com', 'bandcamp.com') + +MAX_LINK_LENGTH = 40 + +REMOVE_MARKUP = ( + 'b', 'i', 'ul', 'ol', 'li', 'em', 'strong', + 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5' +) + +INVALID_CONTENT_STRINGS = ( + 'mute', 'unmute', 'editeventpost', 'notifypost', + 'delete', 'options', 'page', 'repeat', + 'bm', 'tl', 'actor', 'unrepeat', 'eventid', + 'unannounce', 'like', 'unlike', 'bookmark', + 'unbookmark', 'likedBy', 'time', + 'year', 'month', 'day', 'editnewpost', + 'graph', 'showshare', 'category', 'showwanted', + 'rmshare', 'rmwanted', 'repeatprivate', + 'unrepeatprivate', 'replyto', + 'replyfollowers', 'replydm', 'replychat', 'editblogpost', + 'handle', 'blockdomain' +) -def removeHtmlTag(htmlStr: str, tag: str) -> str: +def valid_url_lengths(content: str, max_url_length: int) -> bool: + """Returns true if the given content contains urls which are too long + """ + if '://' not in content: + return True + sections = content.split('://') + ctr = 0 + for text in sections: + if ctr == 0: + ctr += 1 + continue + if '"' in text: + url = text.split('"')[0] + if '<' not in url and '>' not in url: + if len(url) > max_url_length: + return False + return True + + +def remove_html_tag(html_str: str, tag: str) -> str: """Removes a given tag from a html string """ - tagFound = True - while tagFound: - matchStr = ' ' + tag + '="' - if matchStr not in htmlStr: - tagFound = False + tag_found = True + while tag_found: + match_str = ' ' + tag + '="' + if match_str not in html_str: + tag_found = False break - sections = htmlStr.split(matchStr, 1) + sections = html_str.split(match_str, 1) if '"' not in sections[1]: - tagFound = False + tag_found = False break - htmlStr = sections[0] + sections[1].split('"', 1)[1] - return htmlStr + html_str = sections[0] + sections[1].split('"', 1)[1] + return html_str -def _removeQuotesWithinQuotes(content: str) -> str: +def _remove_quotes_within_quotes(content: str) -> str: """Removes any blockquote inside blockquote """ if '
    ' not in content: @@ -55,25 +108,25 @@ def _removeQuotesWithinQuotes(content: str) -> str: found = True while found: prefix = content.split('
    ', ctr)[0] + '
    ' - quotedStr = content.split('
    ', ctr)[1] - if '
    ' not in quotedStr: + quoted_str = content.split('
    ', ctr)[1] + if '
    ' not in quoted_str: found = False else: - endStr = quotedStr.split('
    ')[1] - quotedStr = quotedStr.split('
    ')[0] - if '
    ' not in endStr: + end_str = quoted_str.split('
    ')[1] + quoted_str = quoted_str.split('
    ')[0] + if '
    ' not in end_str: found = False - if '
    ' in quotedStr: - quotedStr = quotedStr.replace('
    ', '') - content = prefix + quotedStr + '
    ' + endStr + if '
    ' in quoted_str: + quoted_str = quoted_str.replace('
    ', '') + content = prefix + quoted_str + '
    ' + end_str ctr += 1 return content -def htmlReplaceEmailQuote(content: str) -> str: +def html_replace_email_quote(content: str) -> str: """Replaces an email style quote "> Some quote" with html blockquote """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content # replace quote paragraph if '

    "' in content: @@ -89,34 +142,34 @@ def htmlReplaceEmailQuote(content: str) -> str: # replace email style quote if '>> ' not in content: return content - contentStr = content.replace('

    ', '') - contentLines = contentStr.split('

    ') - newContent = '' - for lineStr in contentLines: - if not lineStr: + content_str = content.replace('

    ', '') + content_lines = content_str.split('

    ') + new_content = '' + for line_str in content_lines: + if not line_str: continue - if '>> ' not in lineStr: - if lineStr.startswith('> '): - lineStr = lineStr.replace('> ', '
    ') - lineStr = lineStr.replace('>', '
    ') - newContent += '

    ' + lineStr + '

    ' + if '>> ' not in line_str: + if line_str.startswith('> '): + line_str = line_str.replace('> ', '
    ') + line_str = line_str.replace('>', '
    ') + new_content += '

    ' + line_str + '

    ' else: - newContent += '

    ' + lineStr + '

    ' + new_content += '

    ' + line_str + '

    ' else: - lineStr = lineStr.replace('>> ', '>
    ') - if lineStr.startswith('>'): - lineStr = lineStr.replace('>', '
    ', 1) + line_str = line_str.replace('>> ', '>
    ') + if line_str.startswith('>'): + line_str = line_str.replace('>', '
    ', 1) else: - lineStr = lineStr.replace('>', '
    ') - newContent += '

    ' + lineStr + '

    ' - return _removeQuotesWithinQuotes(newContent) + line_str = line_str.replace('>', '
    ') + new_content += '

    ' + line_str + '

    ' + return _remove_quotes_within_quotes(new_content) -def htmlReplaceQuoteMarks(content: str) -> str: +def html_replace_quote_marks(content: str) -> str: """Replaces quotes with html formatting "hello" becomes hello """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content if '"' not in content: if '"' not in content: @@ -128,178 +181,360 @@ def htmlReplaceQuoteMarks(content: str) -> str: if content.count('"') > 4: return content - newContent = content + new_content = content if '"' in content: sections = content.split('"') if len(sections) > 1: - newContent = '' - openQuote = True + new_content = '' + open_quote = True markup = False - for ch in content: - currChar = ch - if ch == '<': + for char in content: + curr_char = char + if char == '<': markup = True - elif ch == '>': + elif char == '>': markup = False - elif ch == '"' and not markup: - if openQuote: - currChar = '“' + elif char == '"' and not markup: + if open_quote: + curr_char = '“' else: - currChar = '”' - openQuote = not openQuote - newContent += currChar + curr_char = '”' + open_quote = not open_quote + new_content += curr_char - if '"' in newContent: - openQuote = True - content = newContent - newContent = '' + if '"' in new_content: + open_quote = True + content = new_content + new_content = '' ctr = 0 sections = content.split('"') - noOfSections = len(sections) - for s in sections: - newContent += s - if ctr < noOfSections - 1: - if openQuote: - newContent += '“' + no_of_sections = len(sections) + for sec in sections: + new_content += sec + if ctr < no_of_sections - 1: + if open_quote: + new_content += '“' else: - newContent += '”' - openQuote = not openQuote + new_content += '”' + open_quote = not open_quote ctr += 1 - return newContent + return new_content -def dangerousCSS(filename: str, allowLocalNetworkAccess: bool) -> bool: +def dangerous_css(filename: str, allow_local_network_access: bool) -> bool: """Returns true is the css file contains code which can create security problems """ if not os.path.isfile(filename): return False - with open(filename, 'r') as fp: - content = fp.read().lower() + content = None + try: + with open(filename, 'r', encoding='utf-8') as css_file: + content = css_file.read().lower() + except OSError: + print('EX: unable to read css file ' + filename) - cssMatches = ('behavior:', ':expression', '?php', '.php', - 'google', 'regexp', 'localhost', - '127.0.', '192.168', '10.0.', '@import') - for match in cssMatches: - if match in content: - return True + if not content: + return False - # search for non-local web links - if 'url(' in content: - urlList = content.split('url(') - ctr = 0 - for urlStr in urlList: - if ctr > 0: - if ')' in urlStr: - urlStr = urlStr.split(')')[0] - if 'http' in urlStr: - print('ERROR: non-local web link in CSS ' + - filename) - return True - ctr += 1 - - # an attacker can include html inside of the css - # file as a comment and this may then be run from the html - if dangerousMarkup(content, allowLocalNetworkAccess): + css_matches = ( + 'behavior:', ':expression', '?php', '.php', + 'google', 'regexp', 'localhost', + '127.0.', '192.168', '10.0.', '@import' + ) + for cssmatch in css_matches: + if cssmatch in content: return True + + # search for non-local web links + if 'url(' in content: + url_list = content.split('url(') + ctr = 0 + for url_str in url_list: + if ctr > 0: + if ')' in url_str: + url_str = url_str.split(')')[0] + if 'http' in url_str or \ + 'ipfs' in url_str or \ + 'ipns' in url_str: + print('ERROR: non-local web link in CSS ' + + filename) + return True + ctr += 1 + + # an attacker can include html inside of the css + # file as a comment and this may then be run from the html + if dangerous_markup(content, allow_local_network_access): + return True return False -def switchWords(baseDir: str, nickname: str, domain: str, content: str, - rules: [] = []) -> str: +def switch_words(base_dir: str, nickname: str, domain: str, content: str, + rules: [] = []) -> str: """Performs word replacements. eg. Trump -> The Orange Menace """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content if not rules: - switchWordsFilename = \ - acctDir(baseDir, nickname, domain) + '/replacewords.txt' - if not os.path.isfile(switchWordsFilename): + switch_words_filename = \ + acct_dir(base_dir, nickname, domain) + '/replacewords.txt' + if not os.path.isfile(switch_words_filename): return content - with open(switchWordsFilename, 'r') as fp: - rules = fp.readlines() + try: + with open(switch_words_filename, 'r', + encoding='utf-8') as words_file: + rules = words_file.readlines() + except OSError: + print('EX: unable to read switches ' + switch_words_filename) for line in rules: - replaceStr = line.replace('\n', '').replace('\r', '') + replace_str = remove_eol(line) splitters = ('->', ':', ',', ';', '-') - wordTransform = None - for splitStr in splitters: - if splitStr in replaceStr: - wordTransform = replaceStr.split(splitStr) + word_transform = None + for split_str in splitters: + if split_str in replace_str: + word_transform = replace_str.split(split_str) break - if not wordTransform: + if not word_transform: continue - if len(wordTransform) == 2: - replaceStr1 = wordTransform[0].strip().replace('"', '') - replaceStr2 = wordTransform[1].strip().replace('"', '') - content = content.replace(replaceStr1, replaceStr2) + if len(word_transform) == 2: + replace_str1 = word_transform[0].strip().replace('"', '') + replace_str2 = word_transform[1].strip().replace('"', '') + content = content.replace(replace_str1, replace_str2) return content -def replaceEmojiFromTags(content: str, tag: [], messageType: str) -> str: +def _save_custom_emoji(session, base_dir: str, emojiName: str, url: str, + debug: bool) -> None: + """Saves custom emoji to file + """ + if not session: + if debug: + print('EX: _save_custom_emoji no session') + return + if '.' not in url: + return + ext = url.split('.')[-1] + if ext != 'png': + if debug: + print('EX: Custom emoji is wrong format ' + url) + return + emojiName = emojiName.replace(':', '').strip().lower() + custom_emoji_dir = base_dir + '/emojicustom' + if not os.path.isdir(custom_emoji_dir): + os.mkdir(custom_emoji_dir) + emoji_image_filename = custom_emoji_dir + '/' + emojiName + '.' + ext + if not download_image(session, url, + emoji_image_filename, debug, False): + if debug: + print('EX: custom emoji not downloaded ' + url) + return + emoji_json_filename = custom_emoji_dir + '/emoji.json' + emoji_json = {} + if os.path.isfile(emoji_json_filename): + emoji_json = load_json(emoji_json_filename, 0, 1) + if not emoji_json: + emoji_json = {} + if not emoji_json.get(emojiName): + emoji_json[emojiName] = emojiName + save_json(emoji_json, emoji_json_filename) + if debug: + print('EX: Saved custom emoji ' + emoji_json_filename) + elif debug: + print('EX: cusom emoji already saved') + + +def _get_emoji_name_from_code(base_dir: str, emoji_code: str) -> str: + """Returns the emoji name from its code + """ + emojis_filename = base_dir + '/emoji/emoji.json' + if not os.path.isfile(emojis_filename): + emojis_filename = base_dir + '/emoji/default_emoji.json' + if not os.path.isfile(emojis_filename): + return None + emojis_json = load_json(emojis_filename) + if not emojis_json: + return None + for emoji_name, code in emojis_json.items(): + if code == emoji_code: + return emoji_name + return None + + +def _update_common_emoji(base_dir: str, emoji_content: str) -> None: + """Updates the list of commonly used emoji + """ + if '.' in emoji_content: + emoji_content = emoji_content.split('.')[0] + emoji_content = emoji_content.replace(':', '') + if emoji_content.startswith('0x'): + # lookup the name for an emoji code + emoji_code = emoji_content[2:] + emoji_content = _get_emoji_name_from_code(base_dir, emoji_code) + if not emoji_content: + return + common_emoji_filename = base_dir + '/accounts/common_emoji.txt' + common_emoji = None + if os.path.isfile(common_emoji_filename): + try: + with open(common_emoji_filename, 'r', + encoding='utf-8') as fp_emoji: + common_emoji = fp_emoji.readlines() + except OSError: + print('EX: unable to load common emoji file') + if common_emoji: + new_common_emoji = [] + emoji_found = False + for line in common_emoji: + if ' ' + emoji_content in line: + if not emoji_found: + emoji_found = True + counter = 1 + count_str = line.split(' ')[0] + if count_str.isdigit(): + counter = int(count_str) + 1 + count_str = str(counter).zfill(16) + line = count_str + ' ' + emoji_content + new_common_emoji.append(line) + else: + line1 = remove_eol(line) + new_common_emoji.append(line1) + if not emoji_found: + new_common_emoji.append(str(1).zfill(16) + ' ' + emoji_content) + new_common_emoji.sort(reverse=True) + try: + with open(common_emoji_filename, 'w+', + encoding='utf-8') as fp_emoji: + for line in new_common_emoji: + fp_emoji.write(line + '\n') + except OSError: + print('EX: error writing common emoji 1') + return + else: + line = str(1).zfill(16) + ' ' + emoji_content + '\n' + try: + with open(common_emoji_filename, 'w+', + encoding='utf-8') as fp_emoji: + fp_emoji.write(line) + except OSError: + print('EX: error writing common emoji 2') + return + + +def replace_emoji_from_tags(session, base_dir: str, + content: str, tag: [], message_type: str, + debug: bool, screen_readable: bool) -> str: """Uses the tags to replace :emoji: with html image markup """ - for tagItem in tag: - if not tagItem.get('type'): + for tag_item in tag: + if not tag_item.get('type'): continue - if tagItem['type'] != 'Emoji': + if tag_item['type'] != 'Emoji': continue - if not tagItem.get('name'): + if not tag_item.get('name'): continue - if not tagItem.get('icon'): + if not tag_item.get('icon'): continue - if not tagItem['icon'].get('url'): + if not tag_item['icon'].get('url'): continue - if '/' not in tagItem['icon']['url']: + if '/' not in tag_item['icon']['url']: continue - if tagItem['name'] not in content: + if tag_item['name'] not in content: continue - iconName = tagItem['icon']['url'].split('/')[-1] - if iconName: - if len(iconName) > 1: - if iconName[0].isdigit(): - if '.' in iconName: - iconName = iconName.split('.')[0] + icon_name = tag_item['icon']['url'].split('/')[-1] + if icon_name: + if len(icon_name) > 1: + if icon_name[0].isdigit(): + if '.' in icon_name: + icon_name = icon_name.split('.')[0] # see https://unicode.org/ # emoji/charts/full-emoji-list.html - if '-' not in iconName: + if '-' not in icon_name: # a single code + replaced = False try: - replaceChar = chr(int("0x" + iconName, 16)) - content = content.replace(tagItem['name'], - replaceChar) + replace_char = chr(int("0x" + icon_name, 16)) + if not screen_readable: + replace_char = \ + '' + content = \ + content.replace(tag_item['name'], + replace_char) + replaced = True except BaseException: - pass + if debug: + print('EX: replace_emoji_from_tags 1 ' + + 'no conversion of ' + + str(icon_name) + ' to chr ' + + tag_item['name'] + ' ' + + tag_item['icon']['url']) + if not replaced: + _save_custom_emoji(session, base_dir, + tag_item['name'], + tag_item['icon']['url'], + debug) + _update_common_emoji(base_dir, + icon_name) + else: + _update_common_emoji(base_dir, + "0x" + icon_name) else: # sequence of codes - iconCodes = iconName.split('-') - iconCodeSequence = '' - for icode in iconCodes: + icon_codes = icon_name.split('-') + icon_code_sequence = '' + for icode in icon_codes: + replaced = False try: - iconCodeSequence += chr(int("0x" + - icode, 16)) + icon_code_sequence += chr(int("0x" + + icode, 16)) + replaced = True except BaseException: - iconCodeSequence = '' - break - if iconCodeSequence: - content = content.replace(tagItem['name'], - iconCodeSequence) + icon_code_sequence = '' + if debug: + print('EX: ' + + 'replace_emoji_from_tags 2 ' + + 'no conversion of ' + + str(icode) + ' to chr ' + + tag_item['name'] + ' ' + + tag_item['icon']['url']) + if not replaced: + _save_custom_emoji(session, base_dir, + tag_item['name'], + tag_item['icon']['url'], + debug) + _update_common_emoji(base_dir, + icon_name) + else: + _update_common_emoji(base_dir, + "0x" + icon_name) + if icon_code_sequence: + if not screen_readable: + icon_code_sequence = \ + '' + content = content.replace(tag_item['name'], + icon_code_sequence) - htmlClass = 'emoji' - if messageType == 'post header': - htmlClass = 'emojiheader' - if messageType == 'profile': - htmlClass = 'emojiprofile' - emojiHtml = "\""" - content = content.replace(tagItem['name'], emojiHtml) + html_class = 'emoji' + if message_type == 'post header': + html_class = 'emojiheader' + if message_type == 'profile': + html_class = 'emojiprofile' + if screen_readable: + emoji_tag_name = tag_item['name'].replace(':', '') + else: + emoji_tag_name = '' + emoji_html = "\""" + content = content.replace(tag_item['name'], emoji_html) return content -def _addMusicTag(content: str, tag: str) -> str: +def _add_music_tag(content: str, tag: str) -> str: """If a music link is found then ensure that the post is tagged appropriately """ @@ -309,75 +544,105 @@ def _addMusicTag(content: str, tag: str) -> str: tag = '#' + tag if tag in content: return content - musicSites = ('soundcloud.com', 'bandcamp.com') - musicSiteFound = False - for site in musicSites: + music_site_found = False + for site in MUSIC_SITES: if site + '/' in content: - musicSiteFound = True + music_site_found = True break - if not musicSiteFound: + if not music_site_found: return content return ':music: ' + content + ' ' + tag + ' ' -def addWebLinks(content: str) -> str: +def _shorten_linked_urls(content: str) -> str: + """If content comes with a web link included then make sure + that it is short enough + """ + if 'href=' not in content: + return content + if '>' not in content: + return content + if '<' not in content: + return content + sections = content.split('>') + ctr = 0 + for section_text in sections: + if ctr == 0: + ctr += 1 + continue + if '<' not in section_text: + ctr += 1 + continue + section_text = section_text.split('<')[0] + if ' ' in section_text: + continue + if len(section_text) > MAX_LINK_LENGTH: + content = content.replace('>' + section_text + '<', + '>' + + section_text[:MAX_LINK_LENGTH-1] + '<') + ctr += 1 + return content + + +def add_web_links(content: str) -> str: """Adds markup for web links """ + content = _shorten_linked_urls(content) + if ':' not in content: return content - prefixes = getLinkPrefixes() + prefixes = get_link_prefixes() # do any of these prefixes exist within the content? - prefixFound = False + prefix_found = False for prefix in prefixes: if prefix in content: - prefixFound = True + prefix_found = True break # if there are no prefixes then just keep the content we have - if not prefixFound: + if not prefix_found: return content - maxLinkLength = 40 content = content.replace('\r', '') words = content.replace('\n', ' --linebreak-- ').split(' ') - replaceDict = {} - for w in words: - if ':' not in w: + replace_dict = {} + for wrd in words: + if ':' not in wrd: continue # does the word begin with a prefix? - prefixFound = False + prefix_found = False for prefix in prefixes: - if w.startswith(prefix): - prefixFound = True + if wrd.startswith(prefix): + prefix_found = True break - if not prefixFound: + if not prefix_found: continue # the word contains a prefix - if w.endswith('.') or w.endswith(';'): - w = w[:-1] - markup = '' + if wrd.endswith('.') or wrd.endswith(';'): + wrd = wrd[:-1] + markup = '' for prefix in prefixes: - if w.startswith(prefix): + if wrd.startswith(prefix): markup += '' break - linkText = w + link_text = wrd for prefix in prefixes: - linkText = linkText.replace(prefix, '') + link_text = link_text.replace(prefix, '') # prevent links from becoming too long - if len(linkText) > maxLinkLength: + if len(link_text) > MAX_LINK_LENGTH: markup += '' + \ - linkText[:maxLinkLength] + '' + link_text[:MAX_LINK_LENGTH] + '' markup += '' + link_text[MAX_LINK_LENGTH:] + '' else: - markup += '' + linkText + '' - replaceDict[w] = markup + markup += '' + link_text + '' + replace_dict[wrd] = markup # do the replacements - for url, markup in replaceDict.items(): + for url, markup in replace_dict.items(): content = content.replace(url, markup) # replace any line breaks @@ -386,93 +651,90 @@ def addWebLinks(content: str) -> str: return content -def validHashTag(hashtag: str) -> bool: - """Returns true if the give hashtag contains valid characters +def safe_web_text(arbitrary_html: str) -> str: + """Turns arbitrary html into something safe. + So if the arbitrary html contains attack scripts those will be removed """ - # long hashtags are not valid - if len(hashtag) >= 32: - return False - validChars = set('0123456789' + - 'abcdefghijklmnopqrstuvwxyz' + - 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - '¡¿ÄäÀàÁáÂâÃãÅåǍǎĄąĂăÆæĀā' + - 'ÇçĆćĈĉČčĎđĐďðÈèÉéÊêËëĚěĘęĖėĒē' + - 'ĜĝĢģĞğĤĥÌìÍíÎîÏïıĪīĮįĴĵĶķ' + - 'ĹĺĻļŁłĽľĿŀÑñŃńŇňŅņÖöÒòÓóÔôÕõŐőØøŒœ' + - 'ŔŕŘřẞߌśŜŝŞşŠšȘșŤťŢţÞþȚțÜüÙùÚúÛûŰűŨũŲųŮůŪū' + - 'ŴŵÝýŸÿŶŷŹźŽžŻż') - if set(hashtag).issubset(validChars): - return True - if isValidLanguage(hashtag): - return True - return False + # first remove the markup, so that we have something safe + safe_text = remove_html(arbitrary_html) + if not safe_text: + return '' + # remove any spurious characters found in podcast descriptions + remove_chars = ('Œ', 'â€', 'ğŸ', '�', ']]', '__') + for remchar in remove_chars: + safe_text = safe_text.replace(remchar, '') + # recreate any url links safely + return add_web_links(safe_text) -def _addHashTags(wordStr: str, httpPrefix: str, domain: str, - replaceHashTags: {}, postHashtags: {}) -> bool: +def _add_hash_tags(word_str: str, http_prefix: str, domain: str, + replace_hashtags: {}, post_hashtags: {}) -> bool: """Detects hashtags and adds them to the replacements dict Also updates the hashtags list to be added to the post """ - if replaceHashTags.get(wordStr): + if replace_hashtags.get(word_str): return True - hashtag = wordStr[1:] - if not validHashTag(hashtag): + hashtag = word_str[1:] + if not valid_hash_tag(hashtag): return False - hashtagUrl = httpPrefix + "://" + domain + "/tags/" + hashtag - postHashtags[hashtag] = { - 'href': hashtagUrl, + hashtag_url = http_prefix + "://" + domain + "/tags/" + hashtag + post_hashtags[hashtag] = { + 'href': hashtag_url, 'name': '#' + hashtag, 'type': 'Hashtag' } - replaceHashTags[wordStr] = "#" + \ + replace_hashtags[word_str] = "#" + \ hashtag + "" return True -def _addEmoji(baseDir: str, wordStr: str, - httpPrefix: str, domain: str, - replaceEmoji: {}, postTags: {}, - emojiDict: {}) -> bool: +def _add_emoji(base_dir: str, word_str: str, + http_prefix: str, domain: str, + replace_emoji: {}, post_tags: {}, + emoji_dict: {}) -> bool: """Detects Emoji and adds them to the replacements dict Also updates the tags list to be added to the post """ - if not wordStr.startswith(':'): + if not word_str.startswith(':'): return False - if not wordStr.endswith(':'): + if not word_str.endswith(':'): return False - if len(wordStr) < 3: + if len(word_str) < 3: return False - if replaceEmoji.get(wordStr): + if replace_emoji.get(word_str): return True # remove leading and trailing : characters - emoji = wordStr[1:] + emoji = word_str[1:] emoji = emoji[:-1] # is the text of the emoji valid? - if not validHashTag(emoji): + if not valid_hash_tag(emoji): return False - if not emojiDict.get(emoji): + if not emoji_dict.get(emoji): return False - emojiFilename = baseDir + '/emoji/' + emojiDict[emoji] + '.png' - if not os.path.isfile(emojiFilename): - return False - emojiUrl = httpPrefix + "://" + domain + \ - "/emoji/" + emojiDict[emoji] + '.png' - postTags[emoji] = { + emoji_filename = base_dir + '/emoji/' + emoji_dict[emoji] + '.png' + if not os.path.isfile(emoji_filename): + emoji_filename = \ + base_dir + '/emojicustom/' + emoji_dict[emoji] + '.png' + if not os.path.isfile(emoji_filename): + return False + emoji_url = http_prefix + "://" + domain + \ + "/emoji/" + emoji_dict[emoji] + '.png' + post_tags[emoji] = { 'icon': { 'mediaType': 'image/png', 'type': 'Image', - 'url': emojiUrl + 'url': emoji_url }, 'name': ':' + emoji + ':', - "updated": fileLastModified(emojiFilename), - "id": emojiUrl.replace('.png', ''), + "updated": file_last_modified(emoji_filename), + "id": emoji_url.replace('.png', ''), 'type': 'Emoji' } return True -def tagExists(tagType: str, tagName: str, tags: {}) -> bool: +def post_tag_exists(tagType: str, tagName: str, tags: {}) -> bool: """Returns true if a tag exists in the given dict """ for tag in tags: @@ -481,122 +743,144 @@ def tagExists(tagType: str, tagName: str, tags: {}) -> bool: return False -def _addMention(wordStr: str, httpPrefix: str, following: str, petnames: str, - replaceMentions: {}, recipients: [], tags: {}) -> bool: +def _mention_to_url(base_dir: str, http_prefix: str, + domain: str, nickname: str) -> str: + """Convert https://somedomain/@somenick to + https://somedomain/users/somenick + This uses the hack of trying the cache directory to see if + there is a matching actor + """ + possible_paths = get_user_paths() + cache_dir = base_dir + '/cache/actors' + cache_path_start = cache_dir + '/' + http_prefix + ':##' + domain + for users_path in possible_paths: + users_path = users_path.replace('/', '#') + possible_cache_entry = \ + cache_path_start + users_path + nickname + '.json' + if os.path.isfile(possible_cache_entry): + return http_prefix + '://' + \ + domain + users_path.replace('#', '/') + nickname + return http_prefix + '://' + domain + '/users/' + nickname + + +def _add_mention(base_dir: str, word_str: str, http_prefix: str, + following: [], petnames: [], replace_mentions: {}, + recipients: [], tags: {}) -> bool: """Detects mentions and adds them to the replacements dict and recipients list """ - possibleHandle = wordStr[1:] + possible_handle = word_str[1:] # @nick - if following and '@' not in possibleHandle: + if following and '@' not in possible_handle: # fall back to a best effort match against the following list # if no domain was specified. eg. @nick - possibleNickname = possibleHandle + possible_nickname = possible_handle for follow in following: if '@' not in follow: continue - followNick = follow.split('@')[0] - if possibleNickname == followNick: - followStr = follow.replace('\n', '').replace('\r', '') - replaceDomain = followStr.split('@')[1] - recipientActor = httpPrefix + "://" + \ - replaceDomain + "/@" + possibleNickname - if recipientActor not in recipients: - recipients.append(recipientActor) - tags[wordStr] = { - 'href': recipientActor, - 'name': wordStr, + follow_nick = follow.split('@')[0] + if possible_nickname == follow_nick: + follow_str = remove_eol(follow) + replace_domain = follow_str.split('@')[1] + recipient_actor = \ + _mention_to_url(base_dir, http_prefix, + replace_domain, possible_nickname) + if recipient_actor not in recipients: + recipients.append(recipient_actor) + tags[word_str] = { + 'href': recipient_actor, + 'name': word_str, 'type': 'Mention' } - replaceMentions[wordStr] = \ - "@" + possibleNickname + \ - "" + replace_mentions[word_str] = \ + "@" + \ + possible_nickname + "" return True # try replacing petnames with mentions - followCtr = 0 + follow_ctr = 0 for follow in following: if '@' not in follow: - followCtr += 1 + follow_ctr += 1 continue - pet = petnames[followCtr].replace('\n', '') + pet = remove_eol(petnames[follow_ctr]) if pet: - if possibleNickname == pet: - followStr = follow.replace('\n', '').replace('\r', '') - replaceNickname = followStr.split('@')[0] - replaceDomain = followStr.split('@')[1] - recipientActor = httpPrefix + "://" + \ - replaceDomain + "/@" + replaceNickname - if recipientActor not in recipients: - recipients.append(recipientActor) - tags[wordStr] = { - 'href': recipientActor, - 'name': wordStr, + if possible_nickname == pet: + follow_str = remove_eol(follow) + replace_nickname = follow_str.split('@')[0] + replace_domain = follow_str.split('@')[1] + recipient_actor = \ + _mention_to_url(base_dir, http_prefix, + replace_domain, replace_nickname) + if recipient_actor not in recipients: + recipients.append(recipient_actor) + tags[word_str] = { + 'href': recipient_actor, + 'name': word_str, 'type': 'Mention' } - replaceMentions[wordStr] = \ - "@" + \ - replaceNickname + "" + replace_mentions[word_str] = \ + "@" + \ + replace_nickname + "" return True - followCtr += 1 + follow_ctr += 1 return False - possibleNickname = None - possibleDomain = None - if '@' not in possibleHandle: + possible_nickname = None + possible_domain = None + if '@' not in possible_handle: return False - possibleNickname = possibleHandle.split('@')[0] - if not possibleNickname: + possible_nickname = possible_handle.split('@')[0] + if not possible_nickname: return False - possibleDomain = \ - possibleHandle.split('@')[1].strip('\n').strip('\r') - if not possibleDomain: + possible_domain = \ + possible_handle.split('@')[1].strip('\n').strip('\r') + if not possible_domain: return False if following: for follow in following: - if follow.replace('\n', '').replace('\r', '') != possibleHandle: + if remove_eol(follow) != possible_handle: continue - recipientActor = httpPrefix + "://" + \ - possibleDomain + "/@" + possibleNickname - if recipientActor not in recipients: - recipients.append(recipientActor) - tags[wordStr] = { - 'href': recipientActor, - 'name': wordStr, + recipient_actor = \ + _mention_to_url(base_dir, http_prefix, + possible_domain, possible_nickname) + if recipient_actor not in recipients: + recipients.append(recipient_actor) + tags[word_str] = { + 'href': recipient_actor, + 'name': word_str, 'type': 'Mention' } - replaceMentions[wordStr] = \ - "@" + possibleNickname + \ - "" + replace_mentions[word_str] = \ + "@" + \ + possible_nickname + "" return True # @nick@domain - if not (possibleDomain == 'localhost' or '.' in possibleDomain): + if not (possible_domain == 'localhost' or '.' in possible_domain): return False - recipientActor = httpPrefix + "://" + \ - possibleDomain + "/@" + possibleNickname - if recipientActor not in recipients: - recipients.append(recipientActor) - tags[wordStr] = { - 'href': recipientActor, - 'name': wordStr, + recipient_actor = \ + _mention_to_url(base_dir, http_prefix, + possible_domain, possible_nickname) + if recipient_actor not in recipients: + recipients.append(recipient_actor) + tags[word_str] = { + 'href': recipient_actor, + 'name': word_str, 'type': 'Mention' } - replaceMentions[wordStr] = \ - "@" + possibleNickname + \ - "" + replace_mentions[word_str] = \ + "@" + \ + possible_nickname + "" return True -def replaceContentDuplicates(content: str) -> str: +def replace_content_duplicates(content: str) -> str: """Replaces invalid duplicates within content """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content while '<<' in content: content = content.replace('<<', '<') @@ -606,16 +890,17 @@ def replaceContentDuplicates(content: str) -> str: return content -def removeTextFormatting(content: str) -> str: +def remove_text_formatting(content: str, bold_reading: bool) -> str: """Removes markup for bold, italics, etc """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content if '<' not in content: return content - removeMarkup = ('b', 'i', 'ul', 'ol', 'li', 'em', 'strong', - 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5') - for markup in removeMarkup: + for markup in REMOVE_MARKUP: + if bold_reading: + if markup == 'b': + continue content = content.replace('<' + markup + '>', '') content = content.replace('', '') content = content.replace('<' + markup.upper() + '>', '') @@ -623,345 +908,531 @@ def removeTextFormatting(content: str) -> str: return content -def removeLongWords(content: str, maxWordLength: int, - longWordsList: []) -> str: +def remove_long_words(content: str, max_word_length: int, + long_words_list: []) -> str: """Breaks up long words so that on mobile screens this doesn't disrupt the layout """ - if isPGPEncrypted(content) or containsPGPPublicKey(content): + if is_pgp_encrypted(content) or contains_pgp_public_key(content): return content - content = replaceContentDuplicates(content) + content = replace_content_duplicates(content) if ' ' not in content: # handle a single very long string with no spaces - contentStr = content.replace('

    ', '').replace(r'<\p>', '') - if '://' not in contentStr: - if len(contentStr) > maxWordLength: + content_str = content.replace('

    ', '').replace(r'<\p>', '') + if '://' not in content_str: + if len(content_str) > max_word_length: if '

    ' in content: - content = '

    ' + contentStr[:maxWordLength] + r'<\p>' + content = '

    ' + content_str[:max_word_length] + r'<\p>' else: - content = content[:maxWordLength] + content = content[:max_word_length] return content + content = content.replace('

    ', '

    ') words = content.split(' ') - if not longWordsList: - longWordsList = [] - for wordStr in words: - if len(wordStr) > maxWordLength: - if wordStr not in longWordsList: - longWordsList.append(wordStr) - for wordStr in longWordsList: - if wordStr.startswith('

    '): - wordStr = wordStr.replace('

    ', '') - if wordStr.startswith('<'): + if not long_words_list: + long_words_list = [] + for word_str in words: + if len(word_str) > max_word_length: + if word_str not in long_words_list: + long_words_list.append(word_str) + for word_str in long_words_list: + if word_str.startswith('

    '): + word_str = word_str.replace('

    ', '') + if word_str.startswith('<'): continue - if len(wordStr) == 76: - if wordStr.upper() == wordStr: + if len(word_str) == 76: + if word_str.upper() == word_str: # tox address continue - if '=\"' in wordStr: + if '=\"' in word_str: continue - if '@' in wordStr: - if '@@' not in wordStr: + if '@' in word_str: + if '@@' not in word_str: continue - if '=.ed25519' in wordStr: + if '=.ed25519' in word_str: continue - if '.onion' in wordStr: + if '.onion' in word_str: continue - if '.i2p' in wordStr: + if '.i2p' in word_str: continue - if 'https:' in wordStr: + if 'https:' in word_str: continue - elif 'http:' in wordStr: + if 'http:' in word_str: continue - elif 'i2p:' in wordStr: + if 'i2p:' in word_str: continue - elif 'gnunet:' in wordStr: + if 'gnunet:' in word_str: continue - elif 'dat:' in wordStr: + if 'dat:' in word_str: continue - elif 'rad:' in wordStr: + if 'rad:' in word_str: continue - elif 'hyper:' in wordStr: + if 'hyper:' in word_str: continue - elif 'briar:' in wordStr: + if 'briar:' in word_str: continue - if '<' in wordStr: - replaceWord = wordStr.split('<', 1)[0] - # if len(replaceWord) > maxWordLength: - # replaceWord = replaceWord[:maxWordLength] - content = content.replace(wordStr, replaceWord) - wordStr = replaceWord - if '/' in wordStr: + if '<' in word_str: + replace_word = word_str.split('<', 1)[0] + # if len(replace_word) > max_word_length: + # replace_word = replace_word[:max_word_length] + content = content.replace(word_str, replace_word) + word_str = replace_word + if '/' in word_str: continue - if len(wordStr[maxWordLength:]) < maxWordLength: - content = content.replace(wordStr, - wordStr[:maxWordLength] + '\n' + - wordStr[maxWordLength:]) + if len(word_str[max_word_length:]) < max_word_length: + content = content.replace(word_str, + word_str[:max_word_length] + '\n' + + word_str[max_word_length:]) else: - content = content.replace(wordStr, - wordStr[:maxWordLength]) + content = content.replace(word_str, + word_str[:max_word_length]) if content.startswith('

    '): if not content.endswith('

    '): content = content.strip() + '

    ' + content = content.replace('

    ', '

    ') return content -def _loadAutoTags(baseDir: str, nickname: str, domain: str) -> []: +def _load_auto_tags(base_dir: str, nickname: str, domain: str) -> []: """Loads automatic tags file and returns a list containing the lines of the file """ - filename = acctDir(baseDir, nickname, domain) + '/autotags.txt' + filename = acct_dir(base_dir, nickname, domain) + '/autotags.txt' if not os.path.isfile(filename): return [] - with open(filename, 'r') as f: - return f.readlines() + try: + with open(filename, 'r', encoding='utf-8') as tags_file: + return tags_file.readlines() + except OSError: + print('EX: unable to read auto tags ' + filename) return [] -def _autoTag(baseDir: str, nickname: str, domain: str, - wordStr: str, autoTagList: [], - appendTags: []): +def _auto_tag(base_dir: str, nickname: str, domain: str, + word_str: str, auto_tag_list: [], + append_tags: []): """Generates a list of tags to be automatically appended to the content """ - for tagRule in autoTagList: - if wordStr not in tagRule: + for tag_rule in auto_tag_list: + if word_str not in tag_rule: continue - if '->' not in tagRule: + if '->' not in tag_rule: continue - match = tagRule.split('->')[0].strip() - if match != wordStr: + rulematch = tag_rule.split('->')[0].strip() + if rulematch != word_str: continue - tagName = tagRule.split('->')[1].strip() - if tagName.startswith('#'): - if tagName not in appendTags: - appendTags.append(tagName) + tag_name = tag_rule.split('->')[1].strip() + if tag_name.startswith('#'): + if tag_name not in append_tags: + append_tags.append(tag_name) else: - if '#' + tagName not in appendTags: - appendTags.append('#' + tagName) + if '#' + tag_name not in append_tags: + append_tags.append('#' + tag_name) -def addHtmlTags(baseDir: str, httpPrefix: str, - nickname: str, domain: str, content: str, - recipients: [], hashtags: {}, - isJsonContent: bool = False) -> str: +def _get_simplified_content(content: str) -> str: + """Returns a simplified version of the content suitable for + splitting up into individual words + """ + content_simplified = \ + content.replace(',', ' ').replace(';', ' ').replace('- ', ' ') + content_simplified = content_simplified.replace('. ', ' ').strip() + if content_simplified.endswith('.'): + content_simplified = content_simplified[:len(content_simplified)-1] + return content_simplified + + +def detect_dogwhistles(content: str, dogwhistles: {}) -> {}: + """Returns a dict containing any detected dogwhistle words + """ + content = remove_html(content).lower() + result = {} + words = _get_simplified_content(content).split(' ') + for whistle, category in dogwhistles.items(): + if not category: + continue + ending = False + starting = False + whistle = whistle.lower() + + if whistle.startswith('x-'): + whistle = whistle[2:] + ending = True + elif (whistle.startswith('*') or + whistle.startswith('~') or + whistle.startswith('-')): + whistle = whistle[1:] + ending = True + + if ending: + prev_wrd = '' + for wrd in words: + wrd2 = (prev_wrd + ' ' + wrd).strip() + if wrd.endswith(whistle) or wrd2.endswith(whistle): + if not result.get(whistle): + result[whistle] = { + "count": 1, + "category": category + } + else: + result[whistle]['count'] += 1 + prev_wrd = wrd + continue + + if whistle.lower().endswith('-x'): + whistle = whistle[:len(whistle)-2] + starting = True + elif (whistle.endswith('*') or + whistle.endswith('~') or + whistle.endswith('-')): + whistle = whistle[:len(whistle)-1] + starting = True + + if starting: + prev_wrd = '' + for wrd in words: + wrd2 = (prev_wrd + ' ' + wrd).strip() + if wrd.startswith(whistle) or wrd2.startswith(whistle): + if not result.get(whistle): + result[whistle] = { + "count": 1, + "category": category + } + else: + result[whistle]['count'] += 1 + prev_wrd = wrd + continue + + if '*' in whistle: + whistle_start = whistle.split('*', 1)[0] + whistle_end = whistle.split('*', 1)[1] + prev_wrd = '' + for wrd in words: + wrd2 = (prev_wrd + ' ' + wrd).strip() + if ((wrd.startswith(whistle_start) and + wrd.endswith(whistle_end)) or + (wrd2.startswith(whistle_start) and + wrd2.endswith(whistle_end))): + if not result.get(whistle): + result[whistle] = { + "count": 1, + "category": category + } + else: + result[whistle]['count'] += 1 + prev_wrd = wrd + continue + + prev_wrd = '' + for wrd in words: + wrd2 = (prev_wrd + ' ' + wrd).strip() + if whistle in (wrd, wrd2): + if not result.get(whistle): + result[whistle] = { + "count": 1, + "category": category + } + else: + result[whistle]['count'] += 1 + prev_wrd = wrd + return result + + +def load_dogwhistles(filename: str) -> {}: + """Loads a list of dogwhistles from file + """ + if not os.path.isfile(filename): + return {} + dogwhistle_lines = [] + try: + with open(filename, 'r', encoding='utf-8') as fp_dogwhistles: + dogwhistle_lines = fp_dogwhistles.readlines() + except OSError: + print('EX: unable to load dogwhistles from ' + filename) + return {} + separators = ('->', '=>', ',', ';', '|', '=') + dogwhistles = {} + for line in dogwhistle_lines: + line = remove_eol(line).strip() + if not line: + continue + if line.startswith('#'): + continue + whistle = None + category = None + for sep in separators: + if sep in line: + whistle = line.split(sep, 1)[0].strip() + category = line.split(sep, 1)[1].strip() + break + if not whistle: + whistle = line + dogwhistles[whistle] = category + return dogwhistles + + +def add_html_tags(base_dir: str, http_prefix: str, + nickname: str, domain: str, content: str, + recipients: [], hashtags: {}, translate: {}, + is_json_content: bool = False) -> str: """ Replaces plaintext mentions such as @nick@domain into html by matching against known following accounts """ if content.startswith('

    '): - content = htmlReplaceEmailQuote(content) - return htmlReplaceQuoteMarks(content) - maxWordLength = 40 + content = html_replace_email_quote(content) + return html_replace_quote_marks(content) + max_word_length = 40 content = content.replace('\r', '') content = content.replace('\n', ' --linebreak-- ') - content = _addMusicTag(content, 'nowplaying') - contentSimplified = \ - content.replace(',', ' ').replace(';', ' ').replace('- ', ' ') - contentSimplified = contentSimplified.replace('. ', ' ').strip() - if contentSimplified.endswith('.'): - contentSimplified = contentSimplified[:len(contentSimplified)-1] - words = contentSimplified.split(' ') + now_playing_str = 'NowPlaying' + if translate.get(now_playing_str): + now_playing_str = translate[now_playing_str] + now_playing_lower_str = 'nowplaying' + if translate.get(now_playing_lower_str): + now_playing_lower_str = translate[now_playing_lower_str] + if '#' + now_playing_lower_str in content: + content = content.replace('#' + now_playing_lower_str, + '#' + now_playing_str) + content = _add_music_tag(content, now_playing_str) + words = _get_simplified_content(content).split(' ') # remove . for words which are not mentions - newWords = [] - for wordIndex in range(0, len(words)): - wordStr = words[wordIndex] - if wordStr.endswith('.'): - if not wordStr.startswith('@'): - wordStr = wordStr[:-1] - if wordStr.startswith('.'): - wordStr = wordStr[1:] - newWords.append(wordStr) - words = newWords + new_words = [] + for word_index in range(0, len(words)): + word_str = words[word_index] + if word_str.endswith('.'): + if not word_str.startswith('@'): + word_str = word_str[:-1] + if word_str.startswith('.'): + word_str = word_str[1:] + new_words.append(word_str) + words = new_words - replaceMentions = {} - replaceHashTags = {} - replaceEmoji = {} - emojiDict = {} - originalDomain = domain - domain = removeDomainPort(domain) - followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' + replace_mentions = {} + replace_hashtags = {} + replace_emoji = {} + emoji_dict = {} + original_domain = domain + domain = remove_domain_port(domain) + following_filename = \ + acct_dir(base_dir, nickname, domain) + '/following.txt' # read the following list so that we can detect just @nick # in addition to @nick@domain following = None petnames = None if '@' in words: - if os.path.isfile(followingFilename): - with open(followingFilename, 'r') as f: - following = f.readlines() - for handle in following: - pet = getPetName(baseDir, nickname, domain, handle) - if pet: - petnames.append(pet + '\n') + if os.path.isfile(following_filename): + following = [] + try: + with open(following_filename, 'r', + encoding='utf-8') as foll_file: + following = foll_file.readlines() + except OSError: + print('EX: unable to read ' + following_filename) + for handle in following: + pet = get_pet_name(base_dir, nickname, domain, handle) + if pet: + petnames.append(pet + '\n') # extract mentions and tags from words - longWordsList = [] - prevWordStr = '' - autoTagsList = _loadAutoTags(baseDir, nickname, domain) - appendTags = [] - for wordStr in words: - wordLen = len(wordStr) - if wordLen > 2: - if wordLen > maxWordLength: - longWordsList.append(wordStr) - firstChar = wordStr[0] - if firstChar == '@': - if _addMention(wordStr, httpPrefix, following, petnames, - replaceMentions, recipients, hashtags): - prevWordStr = '' + long_words_list = [] + prev_word_str = '' + auto_tags_list = _load_auto_tags(base_dir, nickname, domain) + append_tags = [] + for word_str in words: + word_len = len(word_str) + if word_len > 2: + if word_len > max_word_length: + long_words_list.append(word_str) + first_char = word_str[0] + if first_char == '@': + if _add_mention(base_dir, word_str, http_prefix, following, + petnames, replace_mentions, recipients, + hashtags): + prev_word_str = '' continue - elif firstChar == '#': + elif first_char == '#': # remove any endings from the hashtag - hashTagEndings = ('.', ':', ';', '-', '\n') - for ending in hashTagEndings: - if wordStr.endswith(ending): - wordStr = wordStr[:len(wordStr) - 1] + hash_tag_endings = ('.', ':', ';', '-', '\n') + for ending in hash_tag_endings: + if word_str.endswith(ending): + word_str = word_str[:len(word_str) - 1] break - if _addHashTags(wordStr, httpPrefix, originalDomain, - replaceHashTags, hashtags): - prevWordStr = '' + if _add_hash_tags(word_str, http_prefix, original_domain, + replace_hashtags, hashtags): + prev_word_str = '' continue - elif ':' in wordStr: - wordStr2 = wordStr.split(':')[1] -# print('TAG: emoji located - ' + wordStr) - if not emojiDict: + elif ':' in word_str: + word_str2 = word_str.split(':')[1] +# print('TAG: emoji located - ' + word_str) + if not emoji_dict: # emoji.json is generated so that it can be customized and # the changes will be retained even if default_emoji.json # is subsequently updated - if not os.path.isfile(baseDir + '/emoji/emoji.json'): - copyfile(baseDir + '/emoji/default_emoji.json', - baseDir + '/emoji/emoji.json') - emojiDict = loadJson(baseDir + '/emoji/emoji.json') + if not os.path.isfile(base_dir + '/emoji/emoji.json'): + copyfile(base_dir + '/emoji/default_emoji.json', + base_dir + '/emoji/emoji.json') + emoji_dict = load_json(base_dir + '/emoji/emoji.json') -# print('TAG: looking up emoji for :' + wordStr2 + ':') - _addEmoji(baseDir, ':' + wordStr2 + ':', httpPrefix, - originalDomain, replaceEmoji, hashtags, - emojiDict) + # append custom emoji to the dict + custom_emoji_filename = base_dir + '/emojicustom/emoji.json' + if os.path.isfile(custom_emoji_filename): + custom_emoji_dict = load_json(custom_emoji_filename) + if custom_emoji_dict: + # combine emoji dicts one by one + for ename, eitem in custom_emoji_dict.items(): + if ename and eitem: + if not emoji_dict.get(ename): + emoji_dict[ename] = eitem + +# print('TAG: looking up emoji for :' + word_str2 + ':') + _add_emoji(base_dir, ':' + word_str2 + ':', http_prefix, + original_domain, replace_emoji, hashtags, + emoji_dict) else: - if _autoTag(baseDir, nickname, domain, wordStr, - autoTagsList, appendTags): - prevWordStr = '' + if _auto_tag(base_dir, nickname, domain, word_str, + auto_tags_list, append_tags): + prev_word_str = '' continue - if prevWordStr: - if _autoTag(baseDir, nickname, domain, - prevWordStr + ' ' + wordStr, - autoTagsList, appendTags): - prevWordStr = '' + if prev_word_str: + if _auto_tag(base_dir, nickname, domain, + prev_word_str + ' ' + word_str, + auto_tags_list, append_tags): + prev_word_str = '' continue - prevWordStr = wordStr + prev_word_str = word_str # add any auto generated tags - for appended in appendTags: + for appended in append_tags: content = content + ' ' + appended - _addHashTags(appended, httpPrefix, originalDomain, - replaceHashTags, hashtags) + _add_hash_tags(appended, http_prefix, original_domain, + replace_hashtags, hashtags) # replace words with their html versions - for wordStr, replaceStr in replaceMentions.items(): - content = content.replace(wordStr, replaceStr) - for wordStr, replaceStr in replaceHashTags.items(): - content = content.replace(wordStr, replaceStr) - if not isJsonContent: - for wordStr, replaceStr in replaceEmoji.items(): - content = content.replace(wordStr, replaceStr) + for word_str, replace_str in replace_mentions.items(): + content = content.replace(word_str, replace_str) + for word_str, replace_str in replace_hashtags.items(): + content = content.replace(word_str, replace_str) + if not is_json_content: + for word_str, replace_str in replace_emoji.items(): + content = content.replace(word_str, replace_str) - content = addWebLinks(content) - if longWordsList: - content = removeLongWords(content, maxWordLength, longWordsList) - content = limitRepeatedWords(content, 6) + content = add_web_links(content) + if long_words_list: + content = remove_long_words(content, max_word_length, long_words_list) + content = limit_repeated_words(content, 6) content = content.replace(' --linebreak-- ', '

    ') - content = htmlReplaceEmailQuote(content) - return '

    ' + htmlReplaceQuoteMarks(content) + '

    ' + content = html_replace_email_quote(content) + return '

    ' + html_replace_quote_marks(content) + '

    ' -def getMentionsFromHtml(htmlText: str, - matchStr=" []: +def get_mentions_from_html(html_text: str, match_str: str) -> []: """Extracts mentioned actors from the given html content string """ mentions = [] - if matchStr not in htmlText: + if match_str not in html_text: return mentions - mentionsList = htmlText.split(matchStr) - for mentionStr in mentionsList: - if '"' not in mentionStr: + mentions_list = html_text.split(match_str) + for mention_str in mentions_list: + if '"' not in mention_str: continue - actorStr = mentionStr.split('"')[0] - if actorStr.startswith('http') or \ - actorStr.startswith('gnunet') or \ - actorStr.startswith('i2p') or \ - actorStr.startswith('hyper') or \ - actorStr.startswith('dat:'): - if actorStr not in mentions: - mentions.append(actorStr) + actor_str = mention_str.split('"')[0] + if actor_str.startswith('http') or \ + actor_str.startswith('gnunet') or \ + actor_str.startswith('i2p') or \ + actor_str.startswith('ipfs') or \ + actor_str.startswith('ipns') or \ + actor_str.startswith('hyper') or \ + actor_str.startswith('dat:'): + if actor_str not in mentions: + mentions.append(actor_str) return mentions -def extractMediaInFormPOST(postBytes, boundary, name: str): +def extract_media_in_form_post(post_bytes, boundary, name: str): """Extracts the binary encoding for image/video/audio within a http form POST Returns the media bytes and the remaining bytes """ - imageStartBoundary = b'Content-Disposition: form-data; name="' + \ + image_start_boundary = b'Content-Disposition: form-data; name="' + \ name.encode('utf8', 'ignore') + b'";' - imageStartLocation = postBytes.find(imageStartBoundary) - if imageStartLocation == -1: - return None, postBytes + image_start_location = post_bytes.find(image_start_boundary) + if image_start_location == -1: + return None, post_bytes # bytes after the start boundary appears - mediaBytes = postBytes[imageStartLocation:] + media_bytes = post_bytes[image_start_location:] # look for the next boundary - imageEndBoundary = boundary.encode('utf8', 'ignore') - imageEndLocation = mediaBytes.find(imageEndBoundary) - if imageEndLocation == -1: + image_end_boundary = boundary.encode('utf8', 'ignore') + image_end_location = media_bytes.find(image_end_boundary) + if image_end_location == -1: # no ending boundary - return mediaBytes, postBytes[:imageStartLocation] + return media_bytes, post_bytes[:image_start_location] # remaining bytes after the end of the image - remainder = mediaBytes[imageEndLocation:] + remainder = media_bytes[image_end_location:] # remove bytes after the end boundary - mediaBytes = mediaBytes[:imageEndLocation] + media_bytes = media_bytes[:image_end_location] # return the media and the before+after bytes - return mediaBytes, postBytes[:imageStartLocation] + remainder + return media_bytes, post_bytes[:image_start_location] + remainder -def saveMediaInFormPOST(mediaBytes, debug: bool, - filenameBase: str = None) -> (str, str): +def _valid_follows_csv(content: str) -> bool: + """is the given content a valid csv file containing imported follows? + """ + if ',' not in content: + return False + if 'Account address,' not in content: + return False + return True + + +def save_media_in_form_post(media_bytes, debug: bool, + filename_base: str = None) -> (str, str): """Saves the given media bytes extracted from http form POST Returns the filename and attachment type """ - if not mediaBytes: - if filenameBase: + if not media_bytes: + if filename_base: # remove any existing files - extensionTypes = getImageExtensions() - for ex in extensionTypes: - possibleOtherFormat = filenameBase + '.' + ex - if os.path.isfile(possibleOtherFormat): + extension_types = get_image_extensions() + for ex in extension_types: + possible_other_format = filename_base + '.' + ex + if os.path.isfile(possible_other_format): try: - os.remove(possibleOtherFormat) - except BaseException: - pass - if os.path.isfile(filenameBase): + os.remove(possible_other_format) + except OSError: + if debug: + print('EX: save_media_in_form_post ' + + 'unable to delete other ' + + str(possible_other_format)) + if os.path.isfile(filename_base): try: - os.remove(filenameBase) - except BaseException: - pass + os.remove(filename_base) + except OSError: + if debug: + print('EX: save_media_in_form_post ' + + 'unable to delete ' + + str(filename_base)) if debug: print('DEBUG: No media found within POST') return None, None - mediaLocation = -1 - searchStr = '' + media_location = -1 + search_str = '' filename = None # directly search the binary array for the beginning - # of an image - extensionList = { + # of an image, zip or csv + extension_list = { 'png': 'image/png', 'jpeg': 'image/jpeg', + 'jxl': 'image/jxl', 'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp', @@ -970,24 +1441,30 @@ def saveMediaInFormPOST(mediaBytes, debug: bool, 'ogv': 'video/ogv', 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg', + 'opus': 'audio/opus', 'flac': 'audio/flac', - 'zip': 'application/zip' + 'zip': 'application/zip', + 'csv': 'text/csv', + 'csv2': 'text/plain' } - detectedExtension = None - for extension, contentType in extensionList.items(): - searchStr = b'Content-Type: ' + contentType.encode('utf8', 'ignore') - mediaLocation = mediaBytes.find(searchStr) - if mediaLocation > -1: + detected_extension = None + for extension, content_type in extension_list.items(): + search_str = b'Content-Type: ' + content_type.encode('utf8', 'ignore') + media_location = media_bytes.find(search_str) + if media_location > -1: # image/video/audio binaries if extension == 'jpeg': extension = 'jpg' elif extension == 'mpeg': extension = 'mp3' - if filenameBase: - filename = filenameBase + '.' + extension - attachmentMediaType = \ - searchStr.decode().split('/')[0].replace('Content-Type: ', '') - detectedExtension = extension + elif extension == 'csv2': + extension = 'csv' + if filename_base: + filename = filename_base + '.' + extension + search_lst = search_str.decode().split('/', maxsplit=1) + attachment_media_type = \ + search_lst[0].replace('Content-Type: ', '') + detected_extension = extension break if not filename: @@ -995,141 +1472,512 @@ def saveMediaInFormPOST(mediaBytes, debug: bool, # locate the beginning of the image, after any # carriage returns - startPos = mediaLocation + len(searchStr) + start_pos = media_location + len(search_str) for offset in range(1, 8): - if mediaBytes[startPos+offset] != 10: - if mediaBytes[startPos+offset] != 13: - startPos += offset + if media_bytes[start_pos+offset] != 10: + if media_bytes[start_pos+offset] != 13: + start_pos += offset break # remove any existing image files with a different format - if detectedExtension != 'zip': - extensionTypes = getImageExtensions() - for ex in extensionTypes: - if ex == detectedExtension: + if detected_extension != 'zip': + extension_types = get_image_extensions() + for ex in extension_types: + if ex == detected_extension: continue - possibleOtherFormat = \ + possible_other_format = \ filename.replace('.temp', '').replace('.' + - detectedExtension, '.' + + detected_extension, '.' + ex) - if os.path.isfile(possibleOtherFormat): + if os.path.isfile(possible_other_format): try: - os.remove(possibleOtherFormat) - except BaseException: - pass + os.remove(possible_other_format) + except OSError: + if debug: + print('EX: save_media_in_form_post ' + + 'unable to delete other 2 ' + + str(possible_other_format)) # don't allow scripts within svg files - if detectedExtension == 'svg': - svgStr = mediaBytes[startPos:] - svgStr = svgStr.decode() - if dangerousSVG(svgStr, False): + if detected_extension == 'svg': + svg_str = media_bytes[start_pos:] + svg_str = svg_str.decode() + if dangerous_svg(svg_str, False): + return None, None + elif detected_extension == 'csv': + csv_str = media_bytes[start_pos:] + csv_str = csv_str.decode() + if not _valid_follows_csv(csv_str): return None, None - with open(filename, 'wb') as fp: - fp.write(mediaBytes[startPos:]) + try: + with open(filename, 'wb') as fp_media: + fp_media.write(media_bytes[start_pos:]) + except OSError: + print('EX: unable to write media') if not os.path.isfile(filename): print('WARN: Media file could not be written to file: ' + filename) return None, None print('Uploaded media file written: ' + filename) - return filename, attachmentMediaType + return filename, attachment_media_type -def extractTextFieldsInPOST(postBytes, boundary: str, debug: bool, - unitTestData: str = None) -> {}: +def combine_textarea_lines(text: str) -> str: + """Combines separate lines + """ + result = '' + ctr = 0 + paragraphs = text.split('\n\n') + for para in paragraphs: + para = para.replace('\n* ', '***BULLET POINT*** ') + para = para.replace('\n * ', '***BULLET POINT*** ') + para = para.replace('\n- ', '***DASH POINT*** ') + para = para.replace('\n - ', '***DASH POINT*** ') + para = para.replace('\n', ' ') + para = para.replace(' ', ' ') + para = para.replace('***BULLET POINT*** ', '\n* ') + para = para.replace('***DASH POINT*** ', '\n- ') + if ctr > 0: + result += '

    ' + result += para + ctr += 1 + return result + + +def extract_text_fields_in_post(post_bytes, boundary: str, debug: bool, + unit_test_data: str = None) -> {}: """Returns a dictionary containing the text fields of a http form POST The boundary argument comes from the http header """ - if not unitTestData: - msgBytes = email.parser.BytesParser().parsebytes(postBytes) - messageFields = msgBytes.get_payload(decode=True).decode('utf-8') + if boundary == 'LYNX': + if debug: + print('POST from lynx browser') + boundary = '--LYNX' + + if not unit_test_data: + msg_bytes = email.parser.BytesParser().parsebytes(post_bytes) + message_fields = msg_bytes.get_payload(decode=True).decode('utf-8') else: - messageFields = unitTestData + message_fields = unit_test_data if debug: - print('DEBUG: POST arriving ' + messageFields) + if 'password' not in message_fields: + print('DEBUG: POST arriving ' + message_fields) - messageFields = messageFields.split(boundary) + message_fields = message_fields.split(boundary) fields = {} - fieldsWithSemicolonAllowed = ( + fields_with_semicolon_allowed = ( 'message', 'bio', 'autoCW', 'password', 'passwordconfirm', 'instanceDescription', 'instanceDescriptionShort', 'subject', 'location', 'imageDescription' ) + if debug: + if 'password' not in message_fields: + print('DEBUG: POST message_fields: ' + str(message_fields)) + lynx_content_type = 'Content-Type: text/plain; charset=utf-8\r\n' # examine each section of the POST, separated by the boundary - for f in messageFields: - if f == '--': + for fld in message_fields: + if fld == '--': continue - if ' name="' not in f: + if ' name="' not in fld: continue - postStr = f.split(' name="', 1)[1] - if '"' not in postStr: + post_str = fld.split(' name="', 1)[1] + if '"' not in post_str: continue - postKey = postStr.split('"', 1)[0] - postValueStr = postStr.split('"', 1)[1] - if ';' in postValueStr: - if postKey not in fieldsWithSemicolonAllowed and \ - not postKey.startswith('edited'): + post_key = post_str.split('"', 1)[0] + if debug: + print('post_key: ' + post_key) + post_value_str = post_str.split('"', 1)[1] + if boundary == '--LYNX': + post_value_str = \ + post_value_str.replace(lynx_content_type, '') + if debug and 'password' not in post_key: + print('boundary: ' + boundary) + print('post_value_str1: ' + post_value_str) + if ';' in post_value_str: + if post_key not in fields_with_semicolon_allowed and \ + not post_key.startswith('edited'): + if debug: + print('extract_text_fields_in_post exit 1') continue - if '\r\n' not in postValueStr: + if debug and 'password' not in post_key: + print('post_value_str2: ' + post_value_str) + if '\r\n' not in post_value_str: + if debug: + print('extract_text_fields_in_post exit 2') continue - postLines = postValueStr.split('\r\n') - postValue = '' - if len(postLines) > 2: - for line in range(2, len(postLines)-1): + post_lines = post_value_str.split('\r\n') + if debug and 'password' not in post_key: + print('post_lines: ' + str(post_lines)) + post_value = '' + if len(post_lines) > 2: + for line in range(2, len(post_lines)-1): if line > 2: - postValue += '\n' - postValue += postLines[line] - fields[postKey] = urllib.parse.unquote(postValue) + post_value += '\n' + post_value += post_lines[line] + fields[post_key] = urllib.parse.unquote(post_value) + if boundary == '--LYNX' and post_key in ('message', 'bio'): + fields[post_key] = combine_textarea_lines(fields[post_key]) return fields -def limitRepeatedWords(text: str, maxRepeats: int) -> str: +def limit_repeated_words(text: str, max_repeats: int) -> str: """Removes words which are repeated many times """ words = text.replace('\n', ' ').split(' ') - repeatCtr = 0 - repeatedText = '' + repeat_ctr = 0 + repeated_text = '' replacements = {} - prevWord = '' + prev_word = '' for word in words: - if word == prevWord: - repeatCtr += 1 - if repeatedText: - repeatedText += ' ' + word + if word == prev_word: + repeat_ctr += 1 + if repeated_text: + repeated_text += ' ' + word else: - repeatedText = word + ' ' + word + repeated_text = word + ' ' + word else: - if repeatCtr > maxRepeats: - newText = ((prevWord + ' ') * maxRepeats).strip() - replacements[prevWord] = [repeatedText, newText] - repeatCtr = 0 - repeatedText = '' - prevWord = word + if repeat_ctr > max_repeats: + new_text = ((prev_word + ' ') * max_repeats).strip() + replacements[prev_word] = [repeated_text, new_text] + repeat_ctr = 0 + repeated_text = '' + prev_word = word - if repeatCtr > maxRepeats: - newText = ((prevWord + ' ') * maxRepeats).strip() - replacements[prevWord] = [repeatedText, newText] + if repeat_ctr > max_repeats: + new_text = ((prev_word + ' ') * max_repeats).strip() + replacements[prev_word] = [repeated_text, new_text] for word, item in replacements.items(): text = text.replace(item[0], item[1]) return text -def getPriceFromString(priceStr: str) -> (str, str): +def get_price_from_string(priceStr: str) -> (str, str): """Returns the item price and currency """ - currencies = getCurrencies() + currencies = get_currencies() for symbol, name in currencies.items(): if symbol in priceStr: price = priceStr.replace(symbol, '') - if isfloat(price): + if is_float(price): return price, name elif name in priceStr: price = priceStr.replace(name, '') - if isfloat(price): + if is_float(price): return price, name - if isfloat(priceStr): + if is_float(priceStr): return priceStr, "EUR" return "0.00", "EUR" + + +def _words_similarity_histogram(words: []) -> {}: + """Returns a histogram for word combinations + """ + histogram = {} + for index in range(1, len(words)): + combined_words = words[index - 1] + words[index] + if histogram.get(combined_words): + histogram[combined_words] += 1 + else: + histogram[combined_words] = 1 + return histogram + + +def _words_similarity_words_list(content: str) -> []: + """Returns a list of words for the given content + """ + remove_punctuation = ('.', ',', ';', '-', ':', '"') + content = remove_html(content).lower() + for punc in remove_punctuation: + content = content.replace(punc, ' ') + content = content.replace(' ', ' ') + return content.split(' ') + + +def words_similarity(content1: str, content2: str, min_words: int) -> int: + """Returns percentage similarity + """ + if content1 == content2: + return 100 + + words1 = _words_similarity_words_list(content1) + if len(words1) < min_words: + return 0 + + words2 = _words_similarity_words_list(content2) + if len(words2) < min_words: + return 0 + + histogram1 = _words_similarity_histogram(words1) + histogram2 = _words_similarity_histogram(words2) + + diff = 0 + for combined_words, _ in histogram1.items(): + if not histogram2.get(combined_words): + diff += 1 + else: + diff += \ + abs(histogram2[combined_words] - histogram1[combined_words]) + return 100 - int(diff * 100 / len(histogram1.items())) + + +def contains_invalid_local_links(content: str) -> bool: + """Returns true if the given content has invalid links + """ + for inv_str in INVALID_CONTENT_STRINGS: + if '?' + inv_str + '=' in content: + return True + return False + + +def bold_reading_string(text: str) -> str: + """Returns bold reading formatted text + """ + text = html.unescape(text) + add_paragraph_markup = False + if '

    ' in text: + text = text.replace('

    ', '\n').replace('

    ', '') + add_paragraph_markup = True + paragraphs = text.split('\n') + parag_ctr = 0 + new_text = '' + for parag in paragraphs: + words = parag.split(' ') + new_parag = '' + reading_markup = False + for wrd in words: + if '<' in wrd: + reading_markup = True + if reading_markup and '>' in wrd: + reading_markup = False + wrd_len = len(wrd) + if not reading_markup and wrd_len > 1 and \ + '<' not in wrd and '>' not in wrd and \ + '&' not in wrd and '=' not in wrd and \ + not wrd.startswith(':'): + + prefix = '' + postfix = '' + if wrd.startswith('"'): + prefix = '"' + wrd = wrd[1:] + if wrd.endswith('"'): + postfix = '"' + wrd = wrd[:wrd_len - 1] + + initial_chars = int(math.ceil(wrd_len / 2.0)) + new_parag += \ + prefix + '' + wrd[:initial_chars] + '' + \ + wrd[initial_chars:] + postfix + ' ' + else: + new_parag += wrd + ' ' + parag_ctr += 1 + new_parag = new_parag.strip() + if not new_parag: + continue + if parag_ctr < len(paragraphs): + if not add_paragraph_markup: + new_text += new_parag + '\n' + else: + new_text += '

    ' + new_parag + '

    ' + else: + if not add_paragraph_markup: + new_text += new_parag + else: + new_text += '

    ' + new_parag + '

    ' + + return new_text + + +def import_emoji(base_dir: str, import_filename: str, session) -> None: + """Imports emoji from the given filename + Each line should be [emoji url], :emojiname: + """ + if not os.path.isfile(import_filename): + return + emoji_dict = load_json(base_dir + '/emoji/default_emoji.json', 0, 1) + added = 0 + with open(import_filename, "r", encoding='utf-8') as fp_emoji: + lines = fp_emoji.readlines() + for line in lines: + url = line.split(', ')[0] + tag = line.split(', ')[1].strip() + tag = tag.split(':')[1] + if emoji_dict.get(tag): + continue + emoji_image_filename = base_dir + '/emoji/' + tag + '.png' + if os.path.isfile(emoji_image_filename): + continue + if download_image(session, url, + emoji_image_filename, True, False): + emoji_dict[tag] = tag + added += 1 + save_json(emoji_dict, base_dir + '/emoji/default_emoji.json') + print(str(added) + ' custom emoji added') + + +def content_diff(content: str, prev_content: str) -> str: + """Returns a diff for the given content + """ + cdiff = difflib.Differ() + text1_lines = content.splitlines() + text1_sentences = [] + for line in text1_lines: + sentences = line.split('.') + for sentence in sentences: + text1_sentences.append(sentence.strip()) + + text2_lines = prev_content.splitlines() + text2_sentences = [] + for line in text2_lines: + sentences = line.split('.') + for sentence in sentences: + text2_sentences.append(sentence.strip()) + + diff = cdiff.compare(text1_sentences, text2_sentences) + + diff_text = '' + for line in diff: + if line.startswith('- '): + if not diff_text: + diff_text = '

    ' + else: + diff_text += '
    ' + diff_text += '' + elif line.startswith('+ '): + if not diff_text: + diff_text = '

    ' + else: + diff_text += '
    ' + diff_text += \ + '' + if diff_text: + diff_text += '

    ' + return diff_text + + +def create_edits_html(edits_json: {}, post_json_object: {}, + translate: {}, timezone: str, + system_language: str) -> str: + """ Creates html showing historical edits made to a post + """ + if not edits_json: + return '' + if not has_object_dict(post_json_object): + return '' + if not post_json_object['object'].get('content'): + if not post_json_object['object'].get('contentMap'): + return '' + edit_dates_list = [] + for modified, _ in edits_json.items(): + edit_dates_list.append(modified) + edit_dates_list.sort(reverse=True) + edits_str = '' + content = None + if post_json_object['object'].get('contentMap'): + if post_json_object['object']['contentMap'].get(system_language): + content = \ + post_json_object['object']['contentMap'][system_language] + if not content: + if post_json_object['object'].get('content'): + content = post_json_object['object']['content'] + if not content: + return '' + content = remove_html(content) + for modified in edit_dates_list: + prev_json = edits_json[modified] + if not has_object_dict(prev_json): + continue + prev_content = None + if not prev_json['object'].get('content'): + if not prev_json['object'].get('contentMap'): + continue + if prev_json['object'].get('contentMap'): + if prev_json['object']['contentMap'].get(system_language): + prev_content = \ + prev_json['object']['contentMap'][system_language] + if not prev_content: + if prev_json['object'].get('content'): + prev_content = prev_json['object']['content'] + if not prev_content: + continue + prev_content = remove_html(prev_content) + if content == prev_content: + continue + diff = content_diff(content, prev_content) + if not diff: + continue + diff = diff.replace('\n', '

    ') + # convert to local time + datetime_object = parse(modified) + datetime_object = \ + convert_published_to_local_timezone(datetime_object, timezone) + modified_str = datetime_object.strftime("%a %b %d, %H:%M") + diff = '

    ' + modified_str + '

    ' + diff + edits_str += diff + content = prev_content + if not edits_str: + return '' + return '
    ' + \ + translate['SHOW EDITS'] + '' + \ + edits_str + '
    ' + + +def remove_script(content: str, log_filename: str, + actor: str, url: str) -> str: + """Removes

    ' - assert(dangerousMarkup(content, allowLocalNetworkAccess)) + assert dangerous_markup(content, allow_local_network_access) content = '

    This is a valid-looking message. But wait... ' + \ '<script>document.getElementById("concentrated")' + \ '.innerHTML = "evil";</script>

    ' - assert(dangerousMarkup(content, allowLocalNetworkAccess)) + assert dangerous_markup(content, allow_local_network_access) content = '

    This html contains more than you expected... ' + \ '

    ' - assert(dangerousMarkup(content, allowLocalNetworkAccess)) + assert dangerous_markup(content, allow_local_network_access) content = '

    This is a valid-looking message. But wait... ' + \ '' + expected_text = 'Some text with some script' + safe_text = safe_web_text(web_text) + if expected_text != safe_text: + print('Original html: ' + web_text) + print('Expected html: ' + expected_text) + print('Actual html: ' + safe_text) + assert expected_text == safe_text + + +def _test_published_to_local_timezone() -> None: + print('published_to_local_timezone') + published_str = '2022-02-25T20:15:00Z' + timezone = 'Europe/Berlin' + published = \ + datetime.datetime.strptime(published_str, "%Y-%m-%dT%H:%M:%SZ") + datetime_object = \ + convert_published_to_local_timezone(published, timezone) + local_time_str = datetime_object.strftime("%a %b %d, %H:%M") + assert local_time_str == 'Fri Feb 25, 21:15' + + timezone = 'Asia/Seoul' + published = \ + datetime.datetime.strptime(published_str, "%Y-%m-%dT%H:%M:%SZ") + datetime_object = \ + convert_published_to_local_timezone(published, timezone) + local_time_str = datetime_object.strftime("%a %b %d, %H:%M") + assert local_time_str == 'Sat Feb 26, 05:15' + + +def _test_bold_reading() -> None: + print('bold_reading') + text = "This is a test of emboldening." + text_bold = bold_reading_string(text) + expected = \ + "This is a test of " + \ + "emboldening." + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + text = "

    This is a test of emboldening with paragraph.

    " + text_bold = bold_reading_string(text) + expected = \ + "

    This is a test of " + \ + "emboldening with paragraph.

    " + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + text = \ + "

    This is a test of emboldening

    " + \ + "

    With more than one paragraph.

    " + text_bold = bold_reading_string(text) + expected = \ + "

    This is a test of " + \ + "emboldening

    With more " + \ + "than one paragraph.

    " + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + text = '

    This is a test

    ' + text_bold = bold_reading_string(text) + expected = \ + '

    This is a test ' + \ + '

    ' + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + text = "There's the quoted text here" + text_bold = bold_reading_string(text) + expected = \ + "There's the quoted text here" + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + text = '

    @Someone or other' + \ + ' some text

    ' + text_bold = bold_reading_string(text) + expected = \ + '

    ' + \ + '@Someone or other' + \ + ' some text

    ' + if text_bold != expected: + print(text_bold) + assert text_bold == expected + + +def _test_diff_content() -> None: + print('diff_content') + prev_content = \ + 'Some text before.\n' + \ + 'Starting sentence. This is some content.\nThis is another line.' + content = \ + 'Some text before.\nThis is some more content.\nThis is another line.' + result = content_diff(content, prev_content) + expected = \ + '



    ' + \ + '

    ' + assert result == expected + + content = \ + 'Some text before.\nThis is content.\nThis line.' + result = content_diff(content, prev_content) + expected = \ + '


    ' + \ + '
    ' + \ + '
    ' + \ + '
    ' + \ + '

    ' + assert result == expected + + system_language = "en" + translate = { + "SHOW EDITS": "SHOW EDITS" + } + timezone = 'Europe/Berlin' + content1 = \ + "

    This is some content.

    " + \ + "

    Some other content.

    " + content2 = \ + "

    This is some previous content.

    " + \ + "

    Some other previous content.

    " + content3 = \ + "

    This is some more previous content.

    " + \ + "

    Some other previous content.

    " + post_json_object = { + "object": { + "content": content1, + "published": "2020-12-14T00:08:06Z" + } + } + edits_json = { + "2020-12-14T00:05:19Z": { + "object": { + "content": content3, + "published": "2020-12-14T00:05:19Z" + } + }, + "2020-12-14T00:07:34Z": { + "object": { + "contentMap": { + "en": content2 + }, + "published": "2020-12-14T00:07:34Z" + } + } + } + html_str = \ + create_edits_html(edits_json, post_json_object, translate, + timezone, system_language) + assert html_str + expected = \ + '
    SHOW EDITS' + \ + '

    Mon Dec 14, 01:07



    ' + \ + '
    ' + \ + '

    Mon Dec 14, 01:05

    ' + \ + '

    ' + assert html_str == expected + + +def _test_color_contrast_value(base_dir: str) -> None: + print('test_color_contrast_value') + minimum_color_contrast = 4.5 + background = 'black' + foreground = 'white' + contrast = color_contrast(background, foreground) + assert contrast + assert contrast > 20 + assert contrast < 22 + foreground = 'grey' + contrast = color_contrast(background, foreground) + assert contrast + assert contrast > 5 + assert contrast < 6 + themes = get_themes_list(base_dir) + for theme_name in themes: + theme_filename = base_dir + '/theme/' + theme_name + '/theme.json' + if not os.path.isfile(theme_filename): + continue + theme_json = load_json(theme_filename) + if not theme_json: + continue + if not theme_json.get('main-fg-color'): + continue + if not theme_json.get('main-bg-color'): + continue + foreground = theme_json['main-fg-color'] + background = theme_json['main-bg-color'] + contrast = color_contrast(background, foreground) + if contrast is None: + continue + if contrast < minimum_color_contrast: + print('Theme ' + theme_name + ' has not enough color contrast ' + + str(contrast) + ' < ' + str(minimum_color_contrast)) + assert contrast >= minimum_color_contrast + print('Color contrast is ok for all themes') + + +def _test_remove_end_of_line(): + print('remove_end_of_line') + text = 'some text\r\n' + expected = 'some text' + assert remove_eol(text) == expected + text = 'some text' + assert remove_eol(text) == expected + + +def _test_dogwhistles(): + print('dogwhistles') + dogwhistles = { + "X-hamstered": "hamsterism", + "gerbil": "rodent", + "*snake": "slither", + "start*end": "something" + } + content = 'This text does not contain any dogwhistles' + assert not detect_dogwhistles(content, dogwhistles) + content = 'A gerbil named joe' + assert detect_dogwhistles(content, dogwhistles) + content = 'A rattlesnake.' + assert detect_dogwhistles(content, dogwhistles) + content = 'A startthingend.' + assert detect_dogwhistles(content, dogwhistles) + content = 'This content is unhamstered and yhamstered.' + result = detect_dogwhistles(content, dogwhistles) + assert result + assert result.get('hamstered') + assert result['hamstered']['count'] == 2 + assert result['hamstered']['category'] == "hamsterism" + + +def _test_text_standardize(): + print('text_standardize') + expected = 'This is a test' + + result = standardize_text(expected) + if result != expected: + print(result) + assert result == expected + + text = '𝔗𝔥𝔦𝔰 𝔦𝔰 𝔞 𝔱𝔢𝔰𝔱' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + text = '𝕿𝖍𝖎𝖘 𝖎𝖘 𝖆 𝖙𝖊𝖘𝖙' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + text = '𝓣𝓱𝓲𝓼 𝓲𝓼 𝓪 𝓽𝓮𝓼𝓽' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + text = '𝒯𝒽𝒾𝓈 𝒾𝓈 𝒶 𝓉𝑒𝓈𝓉' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + text = '𝕋𝕙𝕚𝕤 𝕚𝕤 𝕒 𝕥𝕖𝕤𝕥' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + text = 'This is a test' + result = standardize_text(text) + if result != expected: + print(result) + assert result == expected + + +def _test_combine_lines(): + print('combine_lines') + text = 'This is a test' + expected = text + result = combine_textarea_lines(text) + if result != expected: + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + text = 'First line.\n\nSecond line.' + expected = 'First line.

    Second line.' + result = combine_textarea_lines(text) + if result != expected: + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + text = 'First\nline.\n\nSecond\nline.' + expected = 'First line.

    Second line.' + result = combine_textarea_lines(text) + if result != expected: + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + # with extra space + text = 'First\nline.\n\nSecond \nline.' + expected = 'First line.

    Second line.' + result = combine_textarea_lines(text) + if result != expected: + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + text = 'Introduction blurb.\n\n* List item 1\n' + \ + '* List item 2\n* List item 3\n\nFinal blurb.' + expected = 'Introduction blurb.

    * List item 1\n' + \ + '* List item 2\n* List item 3

    Final blurb.' + result = combine_textarea_lines(text) + if result != expected: + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + +def _test_hashtag_maps(): + print('hashtag_maps') + content = \ + "

    This is a test, with a couple of links and a " + \ + "#" + \ + "Hashtag

    " + \ + "https://" + \ + "" + \ + "www.openstreetmap.org/#map=19/52.90860/-3.59917

    " + \ + "" + \ + "https://" + \ + "www.google.com/maps/@52.217291,-3.081186" + \ + "5,20.04z

    " + \ + "#" + \ + "AnotherHashtag

    " + map_links = get_map_links_from_post_content(content) + link = "www.google.com/maps/@52.217291,-3.0811865,20.04z" + assert link in map_links + zoom, latitude, longitude = geocoords_from_map_link(link) + assert zoom == 20 + assert latitude + assert int(latitude * 1000) == 52217 + assert longitude + assert int(longitude * 1000) == -3081 + link = "www.openstreetmap.org/#map=19/52.90860/-3.59917" + assert link in map_links + zoom, latitude, longitude = geocoords_from_map_link(link) + assert zoom == 19 + assert latitude + assert int(latitude * 1000) == 52908 + assert longitude + assert int(longitude * 1000) == -3599 + assert len(map_links) == 2 + + +def _test_uninvert(): + print('test_uninvert') + text = 'ʇsƎʇ ɐ sı sıɥ⊥' + expected = "This is a tEst" + result = remove_inverted_text(text, 'en') + if result != expected: + print('text: ' + text) + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + text = '

    Some ordinary text

    ʇsǝʇ ɐ sı sıɥʇ

    ' + expected = "

    Some ordinary text

    this is a test

    " + result = remove_inverted_text(text, 'en') + if result != expected: + print('text: ' + text) + print('expected: ' + expected) + print('result: ' + result) + assert result == expected + + +def run_all_tests(): + base_dir = os.getcwd() print('Running tests...') - updateDefaultThemesList(os.getcwd()) - _translateOntology(baseDir) - _testGetPriceFromString() - _testFunctions() - _testSignAndVerify() - _testDangerousSVG(baseDir) - _testCanReplyTo(baseDir) - _testDateConversions() - _testAuthorizeSharedItems() - _testValidPassword() - _testGetLinksFromContent() - _testSetActorLanguages() - _testLimitRepetedWords() - _testLimitWordLengths() - _testSwitchWords(baseDir) - _testUserAgentDomain() - _testRoles() - _testSkills() - _testSpoofGeolocation() - _testRemovePostInteractions() - _testExtractPGPPublicKey() - _testEmojiImages() - _testCamelCaseSplit() - _testSpeakerReplaceLinks() - _testExtractTextFieldsInPOST() - _testMarkdownToHtml() - _testValidHashTag() - _testPrepareHtmlPostNickname() - _testDomainHandling() - _testMastoApi() - _testLinksWithinPost(baseDir) - _testReplyToPublicPost(baseDir) - _testGetMentionedPeople(baseDir) - _testGuessHashtagCategory() - _testValidNickname() - _testParseFeedDate() - _testFirstParagraphFromString() - _testGetNewswireTags() - _testHashtagRuleTree() - _testRemoveHtmlTag() - _testReplaceEmailQuote() - _testConstantTimeStringCheck() - _testTranslations(baseDir) - _testValidContentWarning() - _testRemoveIdEnding() - _testJsonPostAllowsComments() - _runHtmlReplaceQuoteMarks() - _testDangerousCSS(baseDir) - _testDangerousMarkup() - _testRemoveHtml() - _testSiteIsActive() - _testJsonld() - _testRemoveTextFormatting() - _testWebLinks() - _testRecentPostsCache() - _testTheme() - _testSaveLoadJson() - _testJsonString() - _testGetStatusNumber() - _testAddEmoji(baseDir) - _testActorParsing() - _testHttpsig(baseDir) - _testHttpSignedGET(baseDir) - _testHttpSigNew() - _testCache() - _testThreads() - _testCreatePerson(baseDir) - _testAuthentication(baseDir) - _testFollowersOfPerson(baseDir) - _testNoOfFollowersOnDomain(baseDir) - _testFollows(baseDir) - _testGroupFollowers(baseDir) + update_default_themes_list(os.getcwd()) + _test_source_contains_no_tabs() + _translate_ontology(base_dir) + _test_get_price_from_string() + _test_post_variable_names() + _test_config_param_names() + _test_post_field_names('daemon.py', ['fields', 'actor_json']) + _test_post_field_names('theme.py', ['config_json']) + _test_post_field_names('inbox.py', + ['queue_json', 'post_json_object', + 'message_json', 'liked_post_json']) + _test_checkbox_names() + _test_thread_functions() + _test_functions() + _test_uninvert() + _test_hashtag_maps() + _test_combine_lines() + _test_text_standardize() + _test_dogwhistles() + _test_remove_end_of_line() + _test_translation_labels() + _test_color_contrast_value(base_dir) + _test_diff_content() + _test_bold_reading() + _test_published_to_local_timezone() + _test_safe_webtext() + _test_link_from_rss_item() + _test_xml_podcast_dict(base_dir) + _test_get_actor_from_in_reply_to() + _test_valid_emoji_content() + _test_add_cw_lists(base_dir) + _test_word_similarity() + _test_seconds_between_publish() + _test_sign_and_verify() + _test_danger_svg(base_dir) + _test_can_replyto(base_dir) + _test_date_conversions() + _test_authorized_shared_items() + _test_valid_password() + _test_get_links_from_content() + _test_set_actor_language() + _test_limit_repeted_words() + _test_word_lengths_limit() + _test_switch_word(base_dir) + _test_useragent_domain() + _test_roles() + _test_skills() + _test_spoofed_geolocation() + _test_remove_interactions() + _test_extract_pgp_public_key() + _test_emoji_images() + _test_camel_case_split() + _test_speaker_replace_link() + _test_extract_text_fields_from_post() + _test_markdown_to_html() + _test_valid_hash_tag() + _test_prepare_html_post_nick() + _test_domain_handling() + _test_mastoapi() + _test_links_within_post(base_dir) + _test_reply_to_public_post(base_dir) + _test_mentioned_people(base_dir) + _test_guess_tag_category() + _test_valid_nick() + _test_parse_newswire_feed_date() + _test_first_paragraph_from_string() + _test_newswire_tags() + _test_hashtag_rules() + _test_strip_html_tag() + _test_replace_email_quote() + _test_constant_time_string() + _test_translations(base_dir) + _test_valid_content_warning() + _test_remove_id_ending() + _test_json_post_allows_comment() + _run_html_replace_quote_marks() + _test_danger_css(base_dir) + _test_danger_markup() + _test_strip_html() + _test_site_active() + _test_jsonld() + _test_remove_txt_formatting() + _test_web_links() + _test_recent_posts_cache() + _test_theme() + _test_save_load_json() + _test_json_string() + _test_get_status_number() + _test_addemoji(base_dir) + _test_actor_parsing() + _test_httpsig(base_dir) + _test_http_signed_get(base_dir) + _test_http_sig_new('rsa-sha256', 'rsa-sha256') + _test_httpsig_base_new(True, base_dir, 'rsa-sha256', 'rsa-sha256') + _test_httpsig_base_new(False, base_dir, 'rsa-sha256', 'rsa-sha256') + _test_cache() + _test_threads() + _test_create_person_account(base_dir) + _test_authentication(base_dir) + _test_followers_of_person(base_dir) + _test_followers_on_domain(base_dir) + _test_follows(base_dir) + _test_group_followers(base_dir) + time.sleep(2) print('Tests succeeded\n') diff --git a/theme.py b/theme.py index 141b3d77e..955a6c986 100644 --- a/theme.py +++ b/theme.py @@ -1,271 +1,283 @@ __filename__ = "theme.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" import os -from utils import isAccountDir -from utils import loadJson -from utils import saveJson -from utils import getImageExtensions +from utils import is_account_dir +from utils import load_json +from utils import save_json +from utils import get_image_extensions from utils import copytree -from utils import acctDir -from utils import dangerousSVG +from utils import acct_dir +from utils import dangerous_svg +from utils import local_actor_url +from utils import remove_html +from utils import text_in_file +from utils import remove_eol from shutil import copyfile from shutil import make_archive from shutil import unpack_archive from shutil import rmtree -from content import dangerousCSS +from content import dangerous_css -def importTheme(baseDir: str, filename: str) -> bool: +def import_theme(base_dir: str, filename: str) -> bool: """Imports a theme """ if not os.path.isfile(filename): return False - tempThemeDir = baseDir + '/imports/files' - if os.path.isdir(tempThemeDir): - rmtree(tempThemeDir) - os.mkdir(tempThemeDir) - unpack_archive(filename, tempThemeDir, 'zip') - essentialThemeFiles = ('name.txt', 'theme.json') - for themeFile in essentialThemeFiles: - if not os.path.isfile(tempThemeDir + '/' + themeFile): - print('WARN: ' + themeFile + + temp_theme_dir = base_dir + '/imports/files' + if os.path.isdir(temp_theme_dir): + rmtree(temp_theme_dir, ignore_errors=False, onerror=None) + os.mkdir(temp_theme_dir) + unpack_archive(filename, temp_theme_dir, 'zip') + essential_theme_files = ('name.txt', 'theme.json') + for theme_file in essential_theme_files: + if not os.path.isfile(temp_theme_dir + '/' + theme_file): + print('WARN: ' + theme_file + ' missing from imported theme') return False - newThemeName = None - with open(tempThemeDir + '/name.txt', 'r') as fp: - newThemeName = fp.read().replace('\n', '').replace('\r', '') - if len(newThemeName) > 20: + new_theme_name = None + with open(temp_theme_dir + '/name.txt', 'r', + encoding='utf-8') as fp_theme: + new_theme_name1 = fp_theme.read() + new_theme_name = remove_eol(new_theme_name1) + if len(new_theme_name) > 20: print('WARN: Imported theme name is too long') return False - if len(newThemeName) < 2: + if len(new_theme_name) < 2: print('WARN: Imported theme name is too short') return False - newThemeName = newThemeName.lower() - forbiddenChars = ( + new_theme_name = new_theme_name.lower() + forbidden_chars = ( ' ', ';', '/', '\\', '?', '!', '#', '@', ':', '%', '&', '"', '+', '<', '>', '$' ) - for ch in forbiddenChars: - if ch in newThemeName: + for char in forbidden_chars: + if char in new_theme_name: print('WARN: theme name contains forbidden character') return False - if not newThemeName: + if not new_theme_name: return False # if the theme name in the default themes list? - defaultThemesFilename = baseDir + '/defaultthemes.txt' - if os.path.isfile(defaultThemesFilename): - if newThemeName.title() + '\n' in open(defaultThemesFilename).read(): - newThemeName = newThemeName + '2' + default_themes_filename = base_dir + '/defaultthemes.txt' + if os.path.isfile(default_themes_filename): + test_str = new_theme_name.title() + '\n' + if text_in_file(test_str, default_themes_filename): + new_theme_name = new_theme_name + '2' - themeDir = baseDir + '/theme/' + newThemeName - if not os.path.isdir(themeDir): - os.mkdir(themeDir) - copytree(tempThemeDir, themeDir) - if os.path.isdir(tempThemeDir): - rmtree(tempThemeDir) - if scanThemesForScripts(themeDir): - rmtree(themeDir) + theme_dir = base_dir + '/theme/' + new_theme_name + if not os.path.isdir(theme_dir): + os.mkdir(theme_dir) + copytree(temp_theme_dir, theme_dir) + if os.path.isdir(temp_theme_dir): + rmtree(temp_theme_dir, ignore_errors=False, onerror=None) + if scan_themes_for_scripts(theme_dir): + rmtree(theme_dir, ignore_errors=False, onerror=None) return False - return os.path.isfile(themeDir + '/theme.json') + return os.path.isfile(theme_dir + '/theme.json') -def exportTheme(baseDir: str, theme: str) -> bool: +def export_theme(base_dir: str, theme: str) -> bool: """Exports a theme as a zip file """ - themeDir = baseDir + '/theme/' + theme - if not os.path.isfile(themeDir + '/theme.json'): + theme_dir = base_dir + '/theme/' + theme + if not os.path.isfile(theme_dir + '/theme.json'): return False - if not os.path.isdir(baseDir + '/exports'): - os.mkdir(baseDir + '/exports') - exportFilename = baseDir + '/exports/' + theme + '.zip' - if os.path.isfile(exportFilename): + if not os.path.isdir(base_dir + '/exports'): + os.mkdir(base_dir + '/exports') + export_filename = base_dir + '/exports/' + theme + '.zip' + if os.path.isfile(export_filename): try: - os.remove(exportFilename) - except BaseException: - pass + os.remove(export_filename) + except OSError: + print('EX: export_theme unable to delete ' + str(export_filename)) try: - make_archive(baseDir + '/exports/' + theme, 'zip', themeDir) + make_archive(base_dir + '/exports/' + theme, 'zip', theme_dir) except BaseException: - pass - return os.path.isfile(exportFilename) + print('EX: export_theme unable to archive ' + + base_dir + '/exports/' + str(theme)) + return os.path.isfile(export_filename) -def _getThemeFiles() -> []: +def _get_theme_files() -> []: """Gets the list of theme style sheets """ return ('epicyon.css', 'login.css', 'follow.css', 'suspended.css', 'calendar.css', 'blog.css', 'options.css', 'search.css', 'links.css', - 'welcome.css') + 'welcome.css', 'graph.css', 'podcast.css') -def isNewsThemeName(baseDir: str, themeName: str) -> bool: +def is_news_theme_name(base_dir: str, theme_name: str) -> bool: """Returns true if the given theme is a news instance """ - themeDir = baseDir + '/theme/' + themeName - if os.path.isfile(themeDir + '/is_news_instance'): + theme_dir = base_dir + '/theme/' + theme_name + if os.path.isfile(theme_dir + '/is_news_instance'): return True return False -def getThemesList(baseDir: str) -> []: +def get_themes_list(base_dir: str) -> []: """Returns the list of available themes Note that these should be capitalized, since they're also used to create the web interface dropdown list and to lookup function names """ themes = [] - for subdir, dirs, files in os.walk(baseDir + '/theme'): - for themeName in dirs: - if '~' not in themeName and \ - themeName != 'icons' and themeName != 'fonts': - themes.append(themeName.title()) + for _, dirs, _ in os.walk(base_dir + '/theme'): + for theme_name in dirs: + if '~' not in theme_name and \ + theme_name != 'icons' and theme_name != 'fonts': + themes.append(theme_name.title()) break themes.sort() print('Themes available: ' + str(themes)) return themes -def _copyThemeHelpFiles(baseDir: str, themeName: str, - systemLanguage: str) -> None: +def _copy_theme_help_files(base_dir: str, theme_name: str, + system_language: str) -> None: """Copies any theme specific help files from the welcome subdirectory """ - if not systemLanguage: - systemLanguage = 'en' - themeDir = baseDir + '/theme/' + themeName + '/welcome' - if not os.path.isdir(themeDir): - themeDir = baseDir + '/defaultwelcome' - for subdir, dirs, files in os.walk(themeDir): - for helpMarkdownFile in files: - if not helpMarkdownFile.endswith('_' + systemLanguage + '.md'): + if not system_language: + system_language = 'en' + theme_dir = base_dir + '/theme/' + theme_name + '/welcome' + if not os.path.isdir(theme_dir): + theme_dir = base_dir + '/defaultwelcome' + for _, _, files in os.walk(theme_dir): + for help_markdown_file in files: + if not help_markdown_file.endswith('_' + system_language + '.md'): continue - destHelpMarkdownFile = \ - helpMarkdownFile.replace('_' + systemLanguage + '.md', '.md') - if destHelpMarkdownFile == 'profile.md' or \ - destHelpMarkdownFile == 'final.md': - destHelpMarkdownFile = 'welcome_' + destHelpMarkdownFile - if os.path.isdir(baseDir + '/accounts'): - copyfile(themeDir + '/' + helpMarkdownFile, - baseDir + '/accounts/' + destHelpMarkdownFile) + dest_help_markdown_file = \ + help_markdown_file.replace('_' + system_language + '.md', + '.md') + if dest_help_markdown_file in ('profile.md', 'final.md'): + dest_help_markdown_file = 'welcome_' + dest_help_markdown_file + if os.path.isdir(base_dir + '/accounts'): + copyfile(theme_dir + '/' + help_markdown_file, + base_dir + '/accounts/' + dest_help_markdown_file) break -def _setThemeInConfig(baseDir: str, name: str) -> bool: +def _set_theme_in_config(base_dir: str, name: str) -> bool: """Sets the theme with the given name within config.json """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['theme'] = name - return saveJson(configJson, configFilename) + config_json['theme'] = name + return save_json(config_json, config_filename) -def _setNewswirePublishAsIcon(baseDir: str, useIcon: bool) -> bool: +def _set_newswire_publish_as_icon(base_dir: str, use_icon: bool) -> bool: """Shows the newswire publish action as an icon or a button """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['showPublishAsIcon'] = useIcon - return saveJson(configJson, configFilename) + config_json['showPublishAsIcon'] = use_icon + return save_json(config_json, config_filename) -def _setIconsAsButtons(baseDir: str, useButtons: bool) -> bool: +def _set_icons_as_buttons(base_dir: str, use_buttons: bool) -> bool: """Whether to show icons in the header (inbox, outbox, etc) as buttons """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['iconsAsButtons'] = useButtons - return saveJson(configJson, configFilename) + config_json['iconsAsButtons'] = use_buttons + return save_json(config_json, config_filename) -def _setRssIconAtTop(baseDir: str, atTop: bool) -> bool: +def _set_rss_icon_at_top(base_dir: str, at_top: bool) -> bool: """Whether to show RSS icon at the top of the timeline """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['rssIconAtTop'] = atTop - return saveJson(configJson, configFilename) + config_json['rssIconAtTop'] = at_top + return save_json(config_json, config_filename) -def _setPublishButtonAtTop(baseDir: str, atTop: bool) -> bool: +def _set_publish_button_at_top(base_dir: str, at_top: bool) -> bool: """Whether to show the publish button above the title image in the newswire column """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['publishButtonAtTop'] = atTop - return saveJson(configJson, configFilename) + config_json['publishButtonAtTop'] = at_top + return save_json(config_json, config_filename) -def _setFullWidthTimelineButtonHeader(baseDir: str, fullWidth: bool) -> bool: +def _set_full_width_timeline_button_header(base_dir: str, + full_width: bool) -> bool: """Shows the timeline button header containing inbox, outbox, calendar, etc as full width """ - configFilename = baseDir + '/config.json' - if not os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if not os.path.isfile(config_filename): return False - configJson = loadJson(configFilename, 0) - if not configJson: + config_json = load_json(config_filename, 0) + if not config_json: return False - configJson['fullWidthTimelineButtonHeader'] = fullWidth - return saveJson(configJson, configFilename) + config_json['fullWidthTlButtonHeader'] = full_width + return save_json(config_json, config_filename) -def getTheme(baseDir: str) -> str: +def get_theme(base_dir: str) -> str: """Gets the current theme name from config.json """ - configFilename = baseDir + '/config.json' - if os.path.isfile(configFilename): - configJson = loadJson(configFilename, 0) - if configJson: - if configJson.get('theme'): - return configJson['theme'] + config_filename = base_dir + '/config.json' + if os.path.isfile(config_filename): + config_json = load_json(config_filename, 0) + if config_json: + if config_json.get('theme'): + return config_json['theme'] return 'default' -def _removeTheme(baseDir: str): +def _remove_theme(base_dir: str): """Removes the current theme style sheets """ - themeFiles = _getThemeFiles() - for filename in themeFiles: - if os.path.isfile(baseDir + '/' + filename): - try: - os.remove(baseDir + '/' + filename) - except BaseException: - pass + theme_files = _get_theme_files() + for filename in theme_files: + if not os.path.isfile(base_dir + '/' + filename): + continue + try: + os.remove(base_dir + '/' + filename) + except OSError: + print('EX: _remove_theme unable to delete ' + + base_dir + '/' + filename) -def setCSSparam(css: str, param: str, value: str) -> str: +def set_css_param(css: str, param: str, value: str) -> str: """Sets a CSS parameter to a given value """ + value = remove_html(value) # is this just a simple string replacement? if ';' in param: return css.replace(param, value) @@ -273,579 +285,667 @@ def setCSSparam(css: str, param: str, value: str) -> str: if param.startswith('rgba('): return css.replace(param, value) # if the parameter begins with * then don't prepend -- - onceOnly = False + once_only = False if param.startswith('*'): if param.startswith('**'): - onceOnly = True - searchStr = param.replace('**', '') + ':' + once_only = True + search_str = param.replace('**', '') + ':' else: - searchStr = param.replace('*', '') + ':' + search_str = param.replace('*', '') + ':' else: - searchStr = '--' + param + ':' - if searchStr not in css: + search_str = '--' + param + ':' + if search_str not in css: return css - if onceOnly: - s = css.split(searchStr, 1) + if once_only: + sstr = css.split(search_str, 1) else: - s = css.split(searchStr) + sstr = css.split(search_str) newcss = '' - for sectionStr in s: + for section_str in sstr: # handle font-family which is a variable - nextSection = sectionStr - if ';' in nextSection: - nextSection = nextSection.split(';')[0] + ';' - if searchStr == 'font-family:' and "var(--" in nextSection: - newcss += searchStr + ' ' + sectionStr + next_section = section_str + if ';' in next_section: + next_section = next_section.split(';')[0] + ';' + if search_str == 'font-family:' and "var(--" in next_section: + newcss += search_str + ' ' + section_str continue if not newcss: - if sectionStr: - newcss = sectionStr + if section_str: + newcss = section_str else: newcss = ' ' else: - if ';' in sectionStr: + if ';' in section_str: newcss += \ - searchStr + ' ' + value + ';' + sectionStr.split(';', 1)[1] + search_str + ' ' + value + ';' + \ + section_str.split(';', 1)[1] else: - newcss += searchStr + ' ' + sectionStr + newcss += search_str + ' ' + section_str return newcss.strip() -def _setThemeFromDict(baseDir: str, name: str, - themeParams: {}, bgParams: {}, - allowLocalNetworkAccess: bool) -> None: +def _set_theme_from_dict(base_dir: str, name: str, + theme_params: {}, bg_params: {}, + allow_local_network_access: bool) -> None: """Uses a dictionary to set a theme """ if name: - _setThemeInConfig(baseDir, name) - themeFiles = _getThemeFiles() - for filename in themeFiles: + _set_theme_in_config(base_dir, name) + theme_files = _get_theme_files() + for filename in theme_files: # check for custom css within the theme directory - templateFilename = baseDir + '/theme/' + name + '/epicyon-' + filename + template_filename = \ + base_dir + '/theme/' + name + '/epicyon-' + filename if filename == 'epicyon.css': - templateFilename = \ - baseDir + '/theme/' + name + '/epicyon-profile.css' + template_filename = \ + base_dir + '/theme/' + name + '/epicyon-profile.css' # Ensure that any custom CSS is mostly harmless. # If not then just use the defaults - if dangerousCSS(templateFilename, allowLocalNetworkAccess) or \ - not os.path.isfile(templateFilename): + if dangerous_css(template_filename, allow_local_network_access) or \ + not os.path.isfile(template_filename): # use default css - templateFilename = baseDir + '/epicyon-' + filename + template_filename = base_dir + '/epicyon-' + filename if filename == 'epicyon.css': - templateFilename = baseDir + '/epicyon-profile.css' + template_filename = base_dir + '/epicyon-profile.css' - if not os.path.isfile(templateFilename): + if not os.path.isfile(template_filename): continue - with open(templateFilename, 'r') as cssfile: + with open(template_filename, 'r', encoding='utf-8') as cssfile: css = cssfile.read() - for paramName, paramValue in themeParams.items(): - if paramName == 'newswire-publish-icon': - if paramValue.lower() == 'true': - _setNewswirePublishAsIcon(baseDir, True) + for param_name, param_value in theme_params.items(): + if param_name == 'newswire-publish-icon': + if param_value.lower() == 'true': + _set_newswire_publish_as_icon(base_dir, True) else: - _setNewswirePublishAsIcon(baseDir, False) + _set_newswire_publish_as_icon(base_dir, False) continue - elif paramName == 'full-width-timeline-buttons': - if paramValue.lower() == 'true': - _setFullWidthTimelineButtonHeader(baseDir, True) + if param_name == 'full-width-timeline-buttons': + if param_value.lower() == 'true': + _set_full_width_timeline_button_header(base_dir, True) else: - _setFullWidthTimelineButtonHeader(baseDir, False) + _set_full_width_timeline_button_header(base_dir, False) continue - elif paramName == 'icons-as-buttons': - if paramValue.lower() == 'true': - _setIconsAsButtons(baseDir, True) + if param_name == 'icons-as-buttons': + if param_value.lower() == 'true': + _set_icons_as_buttons(base_dir, True) else: - _setIconsAsButtons(baseDir, False) + _set_icons_as_buttons(base_dir, False) continue - elif paramName == 'rss-icon-at-top': - if paramValue.lower() == 'true': - _setRssIconAtTop(baseDir, True) + if param_name == 'rss-icon-at-top': + if param_value.lower() == 'true': + _set_rss_icon_at_top(base_dir, True) else: - _setRssIconAtTop(baseDir, False) + _set_rss_icon_at_top(base_dir, False) continue - elif paramName == 'publish-button-at-top': - if paramValue.lower() == 'true': - _setPublishButtonAtTop(baseDir, True) + if param_name == 'publish-button-at-top': + if param_value.lower() == 'true': + _set_publish_button_at_top(base_dir, True) else: - _setPublishButtonAtTop(baseDir, False) + _set_publish_button_at_top(base_dir, False) continue - css = setCSSparam(css, paramName, paramValue) - filename = baseDir + '/' + filename - with open(filename, 'w+') as cssfile: + css = set_css_param(css, param_name, param_value) + filename = base_dir + '/' + filename + with open(filename, 'w+', encoding='utf-8') as cssfile: cssfile.write(css) - screenName = ( + screen_name = ( 'login', 'follow', 'options', 'search', 'welcome' ) - for s in screenName: - if bgParams.get(s): - _setBackgroundFormat(baseDir, name, s, bgParams[s]) + for scr in screen_name: + if bg_params.get(scr): + _set_background_format(base_dir, scr, bg_params[scr]) -def _setBackgroundFormat(baseDir: str, name: str, - backgroundType: str, extension: str) -> None: +def _set_background_format(base_dir: str, + background_type: str, extension: str) -> None: """Sets the background file extension """ if extension == 'jpg': return - cssFilename = baseDir + '/' + backgroundType + '.css' - if not os.path.isfile(cssFilename): + css_filename = base_dir + '/' + background_type + '.css' + if not os.path.isfile(css_filename): return - with open(cssFilename, 'r') as cssfile: + with open(css_filename, 'r', encoding='utf-8') as cssfile: css = cssfile.read() css = css.replace('background.jpg', 'background.' + extension) - with open(cssFilename, 'w+') as cssfile2: + with open(css_filename, 'w+', encoding='utf-8') as cssfile2: cssfile2.write(css) -def enableGrayscale(baseDir: str) -> None: +def enable_grayscale(base_dir: str) -> None: """Enables grayscale for the current theme """ - themeFiles = _getThemeFiles() - for filename in themeFiles: - templateFilename = baseDir + '/' + filename - if not os.path.isfile(templateFilename): + theme_files = _get_theme_files() + for filename in theme_files: + template_filename = base_dir + '/' + filename + if not os.path.isfile(template_filename): continue - with open(templateFilename, 'r') as cssfile: + with open(template_filename, 'r', encoding='utf-8') as cssfile: css = cssfile.read() if 'grayscale' not in css: css = \ css.replace('body, html {', 'body, html {\n filter: grayscale(100%);') - filename = baseDir + '/' + filename - with open(filename, 'w+') as cssfile: + filename = base_dir + '/' + filename + with open(filename, 'w+', encoding='utf-8') as cssfile: cssfile.write(css) - grayscaleFilename = baseDir + '/accounts/.grayscale' - if not os.path.isfile(grayscaleFilename): - with open(grayscaleFilename, 'w+') as grayfile: + grayscale_filename = base_dir + '/accounts/.grayscale' + if not os.path.isfile(grayscale_filename): + with open(grayscale_filename, 'w+', encoding='utf-8') as grayfile: grayfile.write(' ') -def disableGrayscale(baseDir: str) -> None: +def disable_grayscale(base_dir: str) -> None: """Disables grayscale for the current theme """ - themeFiles = _getThemeFiles() - for filename in themeFiles: - templateFilename = baseDir + '/' + filename - if not os.path.isfile(templateFilename): + theme_files = _get_theme_files() + for filename in theme_files: + template_filename = base_dir + '/' + filename + if not os.path.isfile(template_filename): continue - with open(templateFilename, 'r') as cssfile: + with open(template_filename, 'r', encoding='utf-8') as cssfile: css = cssfile.read() if 'grayscale' in css: css = \ css.replace('\n filter: grayscale(100%);', '') - filename = baseDir + '/' + filename - with open(filename, 'w+') as cssfile: + filename = base_dir + '/' + filename + with open(filename, 'w+', encoding='utf-8') as cssfile: cssfile.write(css) - grayscaleFilename = baseDir + '/accounts/.grayscale' - if os.path.isfile(grayscaleFilename): + grayscale_filename = base_dir + '/accounts/.grayscale' + if os.path.isfile(grayscale_filename): try: - os.remove(grayscaleFilename) - except BaseException: - pass + os.remove(grayscale_filename) + except OSError: + print('EX: disable_grayscale unable to delete ' + + grayscale_filename) -def _setCustomFont(baseDir: str): +def _set_dyslexic_font(base_dir: str) -> bool: + """sets the dyslexic font if needed + """ + theme_files = _get_theme_files() + for filename in theme_files: + template_filename = base_dir + '/' + filename + if not os.path.isfile(template_filename): + continue + with open(template_filename, 'r', encoding='utf-8') as cssfile: + css = cssfile.read() + css = \ + set_css_param(css, "*src", + "url('./fonts/OpenDyslexic-Regular.woff2" + + "') format('woff2')") + css = set_css_param(css, "*font-family", "'OpenDyslexic'") + filename = base_dir + '/' + filename + with open(filename, 'w+', encoding='utf-8') as cssfile: + cssfile.write(css) + return False + + +def _set_custom_font(base_dir: str): """Uses a dictionary to set a theme """ - customFontExt = None - customFontType = None - fontExtension = { + custom_font_ext = None + custom_font_type = None + font_extension = { 'woff': 'woff', 'woff2': 'woff2', 'otf': 'opentype', 'ttf': 'truetype' } - for ext, extType in fontExtension.items(): - filename = baseDir + '/fonts/custom.' + ext + for ext, ext_type in font_extension.items(): + filename = base_dir + '/fonts/custom.' + ext if os.path.isfile(filename): - customFontExt = ext - customFontType = extType - if not customFontExt: + custom_font_ext = ext + custom_font_type = ext_type + if not custom_font_ext: return - themeFiles = _getThemeFiles() - for filename in themeFiles: - templateFilename = baseDir + '/' + filename - if not os.path.isfile(templateFilename): + theme_files = _get_theme_files() + for filename in theme_files: + template_filename = base_dir + '/' + filename + if not os.path.isfile(template_filename): continue - with open(templateFilename, 'r') as cssfile: + with open(template_filename, 'r', encoding='utf-8') as cssfile: css = cssfile.read() css = \ - setCSSparam(css, "*src", - "url('./fonts/custom." + - customFontExt + - "') format('" + - customFontType + "')") - css = setCSSparam(css, "*font-family", "'CustomFont'") - filename = baseDir + '/' + filename - with open(filename, 'w+') as cssfile: + set_css_param(css, "*src", + "url('./fonts/custom." + + custom_font_ext + + "') format('" + + custom_font_type + "')") + css = set_css_param(css, "*font-family", "'CustomFont'") + css = set_css_param(css, "header-font", "'CustomFont'") + filename = base_dir + '/' + filename + with open(filename, 'w+', encoding='utf-8') as cssfile: cssfile.write(css) -def _readVariablesFile(baseDir: str, themeName: str, - variablesFile: str, - allowLocalNetworkAccess: bool) -> None: +def set_theme_from_designer(base_dir: str, theme_name: str, domain: str, + theme_params: {}, + allow_local_network_access: bool, + system_language: str, + dyslexic_font: bool): + custom_theme_filename = base_dir + '/accounts/theme.json' + save_json(theme_params, custom_theme_filename) + set_theme(base_dir, theme_name, domain, + allow_local_network_access, system_language, + dyslexic_font, False) + + +def reset_theme_designer_settings(base_dir: str) -> None: + """Resets the theme designer settings + """ + custom_variables_file = base_dir + '/accounts/theme.json' + if os.path.isfile(custom_variables_file): + try: + os.remove(custom_variables_file) + print('Theme designer settings were reset') + except OSError: + print('EX: unable to remove theme designer settings on reset') + + +def _read_variables_file(base_dir: str, theme_name: str, + variables_file: str, + allow_local_network_access: bool) -> None: """Reads variables from a file in the theme directory """ - themeParams = loadJson(variablesFile, 0) - if not themeParams: + theme_params = load_json(variables_file, 0) + if not theme_params: return - bgParams = { + + # set custom theme parameters + custom_variables_file = base_dir + '/accounts/theme.json' + if os.path.isfile(custom_variables_file): + custom_theme_params = load_json(custom_variables_file, 0) + if custom_theme_params: + for variable_name, value in custom_theme_params.items(): + theme_params[variable_name] = value + + bg_params = { "login": "jpg", "follow": "jpg", "options": "jpg", "search": "jpg" } - _setThemeFromDict(baseDir, themeName, themeParams, bgParams, - allowLocalNetworkAccess) + _set_theme_from_dict(base_dir, theme_name, theme_params, bg_params, + allow_local_network_access) -def _setThemeDefault(baseDir: str, allowLocalNetworkAccess: bool): +def _set_theme_default(base_dir: str, allow_local_network_access: bool): name = 'default' - _removeTheme(baseDir) - _setThemeInConfig(baseDir, name) - bgParams = { - "login": "jpg", - "follow": "jpg", - "options": "jpg", - "search": "jpg" - } - themeParams = { - "newswire-publish-icon": True, - "full-width-timeline-buttons": False, - "icons-as-buttons": False, - "rss-icon-at-top": True, - "publish-button-at-top": False, - "banner-height": "20vh", - "banner-height-mobile": "10vh", - "search-banner-height-mobile": "15vh" - } - _setThemeFromDict(baseDir, name, themeParams, bgParams, - allowLocalNetworkAccess) + _remove_theme(base_dir) + _set_theme_in_config(base_dir, name) + + variables_file = base_dir + '/theme/' + name + '/theme.json' + if os.path.isfile(variables_file): + _read_variables_file(base_dir, name, variables_file, + allow_local_network_access) + else: + bg_params = { + "login": "jpg", + "follow": "jpg", + "options": "jpg", + "search": "jpg" + } + theme_params = { + "newswire-publish-icon": True, + "full-width-timeline-buttons": False, + "icons-as-buttons": False, + "rss-icon-at-top": True, + "publish-button-at-top": False, + "banner-height": "20vh", + "banner-height-mobile": "10vh", + "search-banner-height-mobile": "15vh" + } + _set_theme_from_dict(base_dir, name, theme_params, bg_params, + allow_local_network_access) -def _setThemeFonts(baseDir: str, themeName: str) -> None: +def _set_theme_fonts(base_dir: str, theme_name: str) -> None: """Adds custom theme fonts """ - themeNameLower = themeName.lower() - fontsDir = baseDir + '/fonts' - themeFontsDir = \ - baseDir + '/theme/' + themeNameLower + '/fonts' - if not os.path.isdir(themeFontsDir): + theme_name_lower = theme_name.lower() + fonts_dir = base_dir + '/fonts' + theme_fonts_dir = \ + base_dir + '/theme/' + theme_name_lower + '/fonts' + if not os.path.isdir(theme_fonts_dir): return - for subdir, dirs, files in os.walk(themeFontsDir): + for _, _, files in os.walk(theme_fonts_dir): for filename in files: if filename.endswith('.woff2') or \ filename.endswith('.woff') or \ filename.endswith('.ttf') or \ filename.endswith('.otf'): - destFilename = fontsDir + '/' + filename - if os.path.isfile(destFilename): + dest_filename = fonts_dir + '/' + filename + if os.path.isfile(dest_filename): # font already exists in the destination location continue - copyfile(themeFontsDir + '/' + filename, - destFilename) + copyfile(theme_fonts_dir + '/' + filename, + dest_filename) break -def getTextModeBanner(baseDir: str) -> str: +def get_text_mode_banner(base_dir: str) -> str: """Returns the banner used for shell browsers, like Lynx """ - textModeBannerFilename = baseDir + '/accounts/banner.txt' - if os.path.isfile(textModeBannerFilename): - with open(textModeBannerFilename, 'r') as fp: - bannerStr = fp.read() - if bannerStr: - return bannerStr.replace('\n', '
    ') + text_mode_banner_filename = base_dir + '/accounts/banner.txt' + if os.path.isfile(text_mode_banner_filename): + with open(text_mode_banner_filename, 'r', + encoding='utf-8') as fp_text: + banner_str = fp_text.read() + if banner_str: + return banner_str.replace('\n', '
    ') return None -def getTextModeLogo(baseDir: str) -> str: +def get_text_mode_logo(base_dir: str) -> str: """Returns the login screen logo used for shell browsers, like Lynx """ - textModeLogoFilename = baseDir + '/accounts/logo.txt' - if not os.path.isfile(textModeLogoFilename): - textModeLogoFilename = baseDir + '/img/logo.txt' + text_mode_logo_filename = base_dir + '/accounts/logo.txt' + if not os.path.isfile(text_mode_logo_filename): + text_mode_logo_filename = base_dir + '/img/logo.txt' - with open(textModeLogoFilename, 'r') as fp: - logoStr = fp.read() - if logoStr: - return logoStr.replace('\n', '
    ') + with open(text_mode_logo_filename, 'r', encoding='utf-8') as fp_text: + logo_str = fp_text.read() + if logo_str: + return logo_str.replace('\n', '
    ') return None -def _setTextModeTheme(baseDir: str, name: str) -> None: +def _set_text_mode_theme(base_dir: str, name: str) -> None: # set the text mode logo which appears on the login screen # in browsers such as Lynx - textModeLogoFilename = \ - baseDir + '/theme/' + name + '/logo.txt' - if os.path.isfile(textModeLogoFilename): + text_mode_logo_filename = \ + base_dir + '/theme/' + name + '/logo.txt' + if os.path.isfile(text_mode_logo_filename): try: - copyfile(textModeLogoFilename, - baseDir + '/accounts/logo.txt') - except BaseException: - pass + copyfile(text_mode_logo_filename, + base_dir + '/accounts/logo.txt') + except OSError: + print('EX: _set_text_mode_theme unable to copy ' + + text_mode_logo_filename + ' ' + + base_dir + '/accounts/logo.txt') else: try: - copyfile(baseDir + '/img/logo.txt', - baseDir + '/accounts/logo.txt') - except BaseException: - pass + copyfile(base_dir + '/img/logo.txt', + base_dir + '/accounts/logo.txt') + except OSError: + print('EX: _set_text_mode_theme unable to copy ' + + base_dir + '/img/logo.txt ' + + base_dir + '/accounts/logo.txt') # set the text mode banner which appears in browsers such as Lynx - textModeBannerFilename = \ - baseDir + '/theme/' + name + '/banner.txt' - if os.path.isfile(baseDir + '/accounts/banner.txt'): + text_mode_banner_filename = \ + base_dir + '/theme/' + name + '/banner.txt' + if os.path.isfile(base_dir + '/accounts/banner.txt'): try: - os.remove(baseDir + '/accounts/banner.txt') - except BaseException: - pass - if os.path.isfile(textModeBannerFilename): + os.remove(base_dir + '/accounts/banner.txt') + except OSError: + print('EX: _set_text_mode_theme unable to delete ' + + base_dir + '/accounts/banner.txt') + if os.path.isfile(text_mode_banner_filename): try: - copyfile(textModeBannerFilename, - baseDir + '/accounts/banner.txt') - except BaseException: - pass + copyfile(text_mode_banner_filename, + base_dir + '/accounts/banner.txt') + except OSError: + print('EX: _set_text_mode_theme unable to copy ' + + text_mode_banner_filename + ' ' + + base_dir + '/accounts/banner.txt') -def _setThemeImages(baseDir: str, name: str) -> None: +def _set_theme_images(base_dir: str, name: str) -> None: """Changes the profile background image and banner to the defaults """ - themeNameLower = name.lower() + theme_name_lower = name.lower() - profileImageFilename = \ - baseDir + '/theme/' + themeNameLower + '/image.png' - bannerFilename = \ - baseDir + '/theme/' + themeNameLower + '/banner.png' - searchBannerFilename = \ - baseDir + '/theme/' + themeNameLower + '/search_banner.png' - leftColImageFilename = \ - baseDir + '/theme/' + themeNameLower + '/left_col_image.png' - rightColImageFilename = \ - baseDir + '/theme/' + themeNameLower + '/right_col_image.png' + profile_image_filename = \ + base_dir + '/theme/' + theme_name_lower + '/image.png' + banner_filename = \ + base_dir + '/theme/' + theme_name_lower + '/banner.png' + search_banner_filename = \ + base_dir + '/theme/' + theme_name_lower + '/search_banner.png' + left_col_image_filename = \ + base_dir + '/theme/' + theme_name_lower + '/left_col_image.png' + right_col_image_filename = \ + base_dir + '/theme/' + theme_name_lower + '/right_col_image.png' - _setTextModeTheme(baseDir, themeNameLower) + _set_text_mode_theme(base_dir, theme_name_lower) - backgroundNames = ('login', 'shares', 'delete', 'follow', - 'options', 'block', 'search', 'calendar', - 'welcome') - extensions = getImageExtensions() + background_names = ('login', 'shares', 'delete', 'follow', + 'options', 'block', 'search', 'calendar', + 'welcome') + extensions = get_image_extensions() - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = \ - os.path.join(baseDir + '/accounts', acct) + account_dir = os.path.join(base_dir + '/accounts', acct) - for backgroundType in backgroundNames: + for background_type in background_names: for ext in extensions: - if themeNameLower == 'default': - backgroundImageFilename = \ - baseDir + '/theme/default/' + \ - backgroundType + '_background.' + ext + if theme_name_lower == 'default': + background_image_filename = \ + base_dir + '/theme/default/' + \ + background_type + '_background.' + ext else: - backgroundImageFilename = \ - baseDir + '/theme/' + themeNameLower + '/' + \ - backgroundType + '_background' + '.' + ext + background_image_filename = \ + base_dir + '/theme/' + theme_name_lower + '/' + \ + background_type + '_background' + '.' + ext - if os.path.isfile(backgroundImageFilename): + if os.path.isfile(background_image_filename): try: - copyfile(backgroundImageFilename, - baseDir + '/accounts/' + - backgroundType + '-background.' + ext) + copyfile(background_image_filename, + base_dir + '/accounts/' + + background_type + '-background.' + ext) continue - except BaseException: - pass + except OSError: + print('EX: _set_theme_images unable to copy ' + + background_image_filename) # background image was not found # so remove any existing file - if os.path.isfile(baseDir + '/accounts/' + - backgroundType + '-background.' + ext): + if os.path.isfile(base_dir + '/accounts/' + + background_type + '-background.' + ext): try: - os.remove(baseDir + '/accounts/' + - backgroundType + '-background.' + ext) - except BaseException: - pass + os.remove(base_dir + '/accounts/' + + background_type + '-background.' + ext) + except OSError: + print('EX: _set_theme_images unable to delete ' + + base_dir + '/accounts/' + + background_type + '-background.' + ext) - if os.path.isfile(profileImageFilename) and \ - os.path.isfile(bannerFilename): + if os.path.isfile(profile_image_filename) and \ + os.path.isfile(banner_filename): try: - copyfile(profileImageFilename, - accountDir + '/image.png') - except BaseException: - pass + copyfile(profile_image_filename, + account_dir + '/image.png') + except OSError: + print('EX: _set_theme_images unable to copy ' + + profile_image_filename) try: - copyfile(bannerFilename, - accountDir + '/banner.png') - except BaseException: - pass + copyfile(banner_filename, + account_dir + '/banner.png') + except OSError: + print('EX: _set_theme_images unable to copy ' + + banner_filename) try: - if os.path.isfile(searchBannerFilename): - copyfile(searchBannerFilename, - accountDir + '/search_banner.png') - except BaseException: - pass + if os.path.isfile(search_banner_filename): + copyfile(search_banner_filename, + account_dir + '/search_banner.png') + except OSError: + print('EX: _set_theme_images unable to copy ' + + search_banner_filename) try: - if os.path.isfile(leftColImageFilename): - copyfile(leftColImageFilename, - accountDir + '/left_col_image.png') + if os.path.isfile(left_col_image_filename): + copyfile(left_col_image_filename, + account_dir + '/left_col_image.png') + elif os.path.isfile(account_dir + + '/left_col_image.png'): + try: + os.remove(account_dir + '/left_col_image.png') + except OSError: + print('EX: _set_theme_images unable to delete ' + + account_dir + '/left_col_image.png') + except OSError: + print('EX: _set_theme_images unable to copy ' + + left_col_image_filename) + + try: + if os.path.isfile(right_col_image_filename): + copyfile(right_col_image_filename, + account_dir + '/right_col_image.png') else: - if os.path.isfile(accountDir + - '/left_col_image.png'): - try: - os.remove(accountDir + '/left_col_image.png') - except BaseException: - pass - - except BaseException: - pass - - try: - if os.path.isfile(rightColImageFilename): - copyfile(rightColImageFilename, - accountDir + '/right_col_image.png') - else: - if os.path.isfile(accountDir + + if os.path.isfile(account_dir + '/right_col_image.png'): try: - os.remove(accountDir + '/right_col_image.png') - except BaseException: - pass - except BaseException: - pass + os.remove(account_dir + '/right_col_image.png') + except OSError: + print('EX: _set_theme_images ' + + 'unable to delete ' + + account_dir + '/right_col_image.png') + except OSError: + print('EX: _set_theme_images unable to copy ' + + right_col_image_filename) break -def setNewsAvatar(baseDir: str, name: str, - httpPrefix: str, - domain: str, domainFull: str) -> None: +def set_news_avatar(base_dir: str, name: str, + http_prefix: str, + domain: str, domain_full: str) -> None: """Sets the avatar for the news account """ nickname = 'news' - newFilename = baseDir + '/theme/' + name + '/icons/avatar_news.png' - if not os.path.isfile(newFilename): - newFilename = baseDir + '/theme/default/icons/avatar_news.png' - if not os.path.isfile(newFilename): + new_filename = base_dir + '/theme/' + name + '/icons/avatar_news.png' + if not os.path.isfile(new_filename): + new_filename = base_dir + '/theme/default/icons/avatar_news.png' + if not os.path.isfile(new_filename): return - avatarFilename = \ - httpPrefix + '://' + domainFull + '/users/' + nickname + '.png' - avatarFilename = avatarFilename.replace('/', '-') - filename = baseDir + '/cache/avatars/' + avatarFilename + avatar_filename = \ + local_actor_url(http_prefix, domain_full, nickname) + '.png' + avatar_filename = avatar_filename.replace('/', '-') + filename = base_dir + '/cache/avatars/' + avatar_filename if os.path.isfile(filename): try: os.remove(filename) - except BaseException: - pass - if os.path.isdir(baseDir + '/cache/avatars'): - copyfile(newFilename, filename) - accountDir = acctDir(baseDir, nickname, domain) - copyfile(newFilename, accountDir + '/avatar.png') + except OSError: + print('EX: set_news_avatar unable to delete ' + filename) + if os.path.isdir(base_dir + '/cache/avatars'): + copyfile(new_filename, filename) + account_dir = acct_dir(base_dir, nickname, domain) + copyfile(new_filename, account_dir + '/avatar.png') -def _setClearCacheFlag(baseDir: str) -> None: +def _set_clear_cache_flag(base_dir: str) -> None: """Sets a flag which can be used by an external system (eg. a script in a cron job) to clear the browser cache """ - if not os.path.isdir(baseDir + '/accounts'): + if not os.path.isdir(base_dir + '/accounts'): return - flagFilename = baseDir + '/accounts/.clear_cache' - with open(flagFilename, 'w+') as flagFile: - flagFile.write('\n') + flag_filename = base_dir + '/accounts/.clear_cache' + with open(flag_filename, 'w+', encoding='utf-8') as fp_flag: + fp_flag.write('\n') -def setTheme(baseDir: str, name: str, domain: str, - allowLocalNetworkAccess: bool, systemLanguage: str) -> bool: +def set_theme(base_dir: str, name: str, domain: str, + allow_local_network_access: bool, system_language: str, + dyslexic_font: bool, designer_reset: bool) -> bool: """Sets the theme with the given name as the current theme """ result = False - prevThemeName = getTheme(baseDir) - _removeTheme(baseDir) + prev_theme_name = get_theme(base_dir) - themes = getThemesList(baseDir) - for themeName in themes: - themeNameLower = themeName.lower() - if name == themeNameLower: - try: - globals()['setTheme' + themeName](baseDir, - allowLocalNetworkAccess) - except BaseException: - pass + # if the theme has changed then remove any custom settings + if prev_theme_name != name or designer_reset: + reset_theme_designer_settings(base_dir) - if prevThemeName: - if prevThemeName.lower() != themeNameLower: + _remove_theme(base_dir) + + # has the theme changed? + themes = get_themes_list(base_dir) + for theme_name in themes: + theme_name_lower = theme_name.lower() + if name == theme_name_lower: + if prev_theme_name: + if prev_theme_name.lower() != theme_name_lower or \ + designer_reset: # change the banner and profile image # to the default for the theme - _setThemeImages(baseDir, name) - _setThemeFonts(baseDir, name) + _set_theme_images(base_dir, name) + _set_theme_fonts(base_dir, name) result = True + break if not result: # default - _setThemeDefault(baseDir, allowLocalNetworkAccess) + _set_theme_default(base_dir, allow_local_network_access) result = True - variablesFile = baseDir + '/theme/' + name + '/theme.json' - if os.path.isfile(variablesFile): - _readVariablesFile(baseDir, name, variablesFile, - allowLocalNetworkAccess) + # read theme settings from a json file in the theme directory + variables_file = base_dir + '/theme/' + name + '/theme.json' + if os.path.isfile(variables_file): + _read_variables_file(base_dir, name, variables_file, + allow_local_network_access) - _setCustomFont(baseDir) + if dyslexic_font: + _set_dyslexic_font(base_dir) + else: + _set_custom_font(base_dir) # set the news avatar - newsAvatarThemeFilename = \ - baseDir + '/theme/' + name + '/icons/avatar_news.png' - if os.path.isdir(baseDir + '/accounts/news@' + domain): - if os.path.isfile(newsAvatarThemeFilename): - newsAvatarFilename = \ - baseDir + '/accounts/news@' + domain + '/avatar.png' - copyfile(newsAvatarThemeFilename, newsAvatarFilename) + news_avatar_theme_filename = \ + base_dir + '/theme/' + name + '/icons/avatar_news.png' + if os.path.isdir(base_dir + '/accounts/news@' + domain): + if os.path.isfile(news_avatar_theme_filename): + news_avatar_filename = \ + base_dir + '/accounts/news@' + domain + '/avatar.png' + copyfile(news_avatar_theme_filename, news_avatar_filename) - grayscaleFilename = baseDir + '/accounts/.grayscale' - if os.path.isfile(grayscaleFilename): - enableGrayscale(baseDir) + grayscale_filename = base_dir + '/accounts/.grayscale' + if os.path.isfile(grayscale_filename): + enable_grayscale(base_dir) else: - disableGrayscale(baseDir) + disable_grayscale(base_dir) - _copyThemeHelpFiles(baseDir, name, systemLanguage) - _setThemeInConfig(baseDir, name) - _setClearCacheFlag(baseDir) + _copy_theme_help_files(base_dir, name, system_language) + _set_theme_in_config(base_dir, name) + _set_clear_cache_flag(base_dir) return result -def updateDefaultThemesList(baseDir: str) -> None: +def update_default_themes_list(base_dir: str) -> None: """Recreates the list of default themes """ - themeNames = getThemesList(baseDir) - defaultThemesFilename = baseDir + '/defaultthemes.txt' - with open(defaultThemesFilename, 'w+') as defaultThemesFile: - for name in themeNames: - defaultThemesFile.write(name + '\n') + theme_names = get_themes_list(base_dir) + default_themes_filename = base_dir + '/defaultthemes.txt' + with open(default_themes_filename, 'w+', encoding='utf-8') as fp_def: + for name in theme_names: + fp_def.write(name + '\n') -def scanThemesForScripts(baseDir: str) -> bool: +def scan_themes_for_scripts(base_dir: str) -> bool: """Scans the theme directory for any svg files containing scripts """ - for subdir, dirs, files in os.walk(baseDir + '/theme'): - for f in files: - if not f.endswith('.svg'): + # allow recursive walk + for subdir, _, files in os.walk(base_dir + '/theme'): + for fname in files: + if not fname.endswith('.svg'): continue - svgFilename = os.path.join(subdir, f) + svg_filename = os.path.join(subdir, fname) content = '' - with open(svgFilename, 'r') as fp: - content = fp.read() - svgDangerous = dangerousSVG(content, False) - if svgDangerous: - print('svg file contains script: ' + svgFilename) + with open(svg_filename, 'r', encoding='utf-8') as fp_svg: + content = fp_svg.read() + svg_dangerous = dangerous_svg(content, False) + if svg_dangerous: + print('svg file contains script: ' + svg_filename) return True # deliberately no break - should resursively scan return False diff --git a/theme/blue/icons/avatar_default.png b/theme/blue/icons/avatar_default.png index f8cbb8f38..6515ba275 100644 Binary files a/theme/blue/icons/avatar_default.png and b/theme/blue/icons/avatar_default.png differ diff --git a/theme/blue/icons/bookmark.png b/theme/blue/icons/bookmark.png index 581db83d5..6dc818842 100644 Binary files a/theme/blue/icons/bookmark.png and b/theme/blue/icons/bookmark.png differ diff --git a/theme/blue/icons/calendar_notify.png b/theme/blue/icons/calendar_notify.png index ad4be5454..5171e3acc 100644 Binary files a/theme/blue/icons/calendar_notify.png and b/theme/blue/icons/calendar_notify.png differ diff --git a/theme/blue/icons/edit_notify.png b/theme/blue/icons/edit_notify.png index 4b7d3554a..55a80f176 100644 Binary files a/theme/blue/icons/edit_notify.png and b/theme/blue/icons/edit_notify.png differ diff --git a/theme/blue/icons/favicon.ico b/theme/blue/icons/favicon.ico index c7cb1bbbe..193c138a7 100644 Binary files a/theme/blue/icons/favicon.ico and b/theme/blue/icons/favicon.ico differ diff --git a/theme/blue/icons/ical.png b/theme/blue/icons/ical.png new file mode 100644 index 000000000..b8b17a1a0 Binary files /dev/null and b/theme/blue/icons/ical.png differ diff --git a/theme/blue/icons/like.png b/theme/blue/icons/like.png index 4246ba762..7484b15c5 100644 Binary files a/theme/blue/icons/like.png and b/theme/blue/icons/like.png differ diff --git a/theme/blue/icons/like_inactive.png b/theme/blue/icons/like_inactive.png index dbbe24f5f..1f42ead37 100644 Binary files a/theme/blue/icons/like_inactive.png and b/theme/blue/icons/like_inactive.png differ diff --git a/theme/blue/icons/mitm.png b/theme/blue/icons/mitm.png new file mode 100644 index 000000000..19bce88c3 Binary files /dev/null and b/theme/blue/icons/mitm.png differ diff --git a/theme/blue/icons/qrcode.png b/theme/blue/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/blue/icons/qrcode.png and b/theme/blue/icons/qrcode.png differ diff --git a/theme/blue/icons/reaction.png b/theme/blue/icons/reaction.png new file mode 100644 index 000000000..0279ca5df Binary files /dev/null and b/theme/blue/icons/reaction.png differ diff --git a/theme/blue/icons/repeat.png b/theme/blue/icons/repeat.png index 62bc3ed0c..6b55c30ad 100644 Binary files a/theme/blue/icons/repeat.png and b/theme/blue/icons/repeat.png differ diff --git a/theme/blue/icons/theme.png b/theme/blue/icons/theme.png new file mode 100644 index 000000000..582cca0e2 Binary files /dev/null and b/theme/blue/icons/theme.png differ diff --git a/theme/blue/icons/vcard.png b/theme/blue/icons/vcard.png new file mode 100644 index 000000000..1c06642db Binary files /dev/null and b/theme/blue/icons/vcard.png differ diff --git a/theme/blue/theme.json b/theme/blue/theme.json index 39bb7effd..8cbf8a735 100644 --- a/theme/blue/theme.json +++ b/theme/blue/theme.json @@ -1,4 +1,7 @@ { + "code-color": "white", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", "newswire-publish-icon": "True", "full-width-timeline-buttons": "False", "icons-as-buttons": "False", @@ -7,8 +10,11 @@ "banner-height": "20vh", "banner-height-mobile": "10vh", "newswire-date-color": "blue", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-header": "22px", "font-size-header-mobile": "32px", + "font-size-pageslist": "45px", "font-size": "45px", "font-size2": "45px", "font-size3": "45px", @@ -16,6 +22,8 @@ "font-size5": "29px", "gallery-font-size": "35px", "gallery-font-size-mobile": "55px", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "white", "main-bg-color": "#002365", "login-bg-color": "#002365", "welcome-bg-color": "#002365", @@ -34,5 +42,6 @@ "time-vertical-align": "-10px", "header-font": "'Domestic_Manners'", "*font-family": "'Domestic_Manners'", - "*src": "url('./fonts/Domestic_Manners.woff2') format('woff2')" + "*src": "url('./fonts/Domestic_Manners.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/debian/icons/bookmark.png b/theme/debian/icons/bookmark.png index 6580755af..dcbb24608 100644 Binary files a/theme/debian/icons/bookmark.png and b/theme/debian/icons/bookmark.png differ diff --git a/theme/debian/icons/calendar_notify.png b/theme/debian/icons/calendar_notify.png index 477fd0d2b..41e19f415 100644 Binary files a/theme/debian/icons/calendar_notify.png and b/theme/debian/icons/calendar_notify.png differ diff --git a/theme/debian/icons/edit_notify.png b/theme/debian/icons/edit_notify.png index 09f95e3cd..420a4217e 100644 Binary files a/theme/debian/icons/edit_notify.png and b/theme/debian/icons/edit_notify.png differ diff --git a/theme/debian/icons/favicon.ico b/theme/debian/icons/favicon.ico index c7cb1bbbe..193c138a7 100644 Binary files a/theme/debian/icons/favicon.ico and b/theme/debian/icons/favicon.ico differ diff --git a/theme/debian/icons/ical.png b/theme/debian/icons/ical.png new file mode 100644 index 000000000..b8b17a1a0 Binary files /dev/null and b/theme/debian/icons/ical.png differ diff --git a/theme/debian/icons/like.png b/theme/debian/icons/like.png index e536adf80..f2f2d83f0 100644 Binary files a/theme/debian/icons/like.png and b/theme/debian/icons/like.png differ diff --git a/theme/debian/icons/mitm.png b/theme/debian/icons/mitm.png new file mode 100644 index 000000000..19bce88c3 Binary files /dev/null and b/theme/debian/icons/mitm.png differ diff --git a/theme/debian/icons/qrcode.png b/theme/debian/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/debian/icons/qrcode.png and b/theme/debian/icons/qrcode.png differ diff --git a/theme/debian/icons/reaction.png b/theme/debian/icons/reaction.png new file mode 100644 index 000000000..0279ca5df Binary files /dev/null and b/theme/debian/icons/reaction.png differ diff --git a/theme/debian/icons/repeat.png b/theme/debian/icons/repeat.png index 974659cb1..495a9f9c9 100644 Binary files a/theme/debian/icons/repeat.png and b/theme/debian/icons/repeat.png differ diff --git a/theme/debian/icons/theme.png b/theme/debian/icons/theme.png new file mode 100644 index 000000000..582cca0e2 Binary files /dev/null and b/theme/debian/icons/theme.png differ diff --git a/theme/debian/icons/vcard.png b/theme/debian/icons/vcard.png new file mode 100644 index 000000000..1c06642db Binary files /dev/null and b/theme/debian/icons/vcard.png differ diff --git a/theme/debian/theme.json b/theme/debian/theme.json index 27cd7fd91..14d5b6c29 100644 --- a/theme/debian/theme.json +++ b/theme/debian/theme.json @@ -1,4 +1,13 @@ { + "code-color": "blue", + "diff-add": "#111", + "diff-remove": "#333", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#222", + "dropdown-bg-color": "white", + "dropdown-bg-color-hover": "lightgrey", + "dropdown-fg-color-hover": "#222", "today-circle": "#03a494", "options-main-link-color-hover": "white", "main-link-color-hover": "blue", @@ -30,7 +39,10 @@ "banner-height-mobile": "10vh", "hashtag-background-color": "grey", "focus-color": "grey", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "26px", "font-size2": "20px", "font-size3": "34px", @@ -54,6 +66,8 @@ "main-bg-color-report": "#e3dbf0", "main-header-color-roles": "#ebebf0", "cw-color": "#2d2c37", + "pageslist-color": "#111", + "pageslist-selected-color": "blue", "main-fg-color": "#2d2c37", "login-fg-color": "white", "welcome-fg-color": "white", @@ -91,5 +105,6 @@ "header-font": "'NimbusSanL'", "*font-family": "'NimbusSanL'", "*src": "url('./fonts/NimbusSanL.otf') format('opentype')", - "**src": "url('./fonts/NimbusSanL-italic.otf') format('opentype')" + "**src": "url('./fonts/NimbusSanL-italic.otf') format('opentype')", + "reply-icon-direction": "-1" } diff --git a/theme/default/icons/avatar_default.png b/theme/default/icons/avatar_default.png index f8cbb8f38..6515ba275 100644 Binary files a/theme/default/icons/avatar_default.png and b/theme/default/icons/avatar_default.png differ diff --git a/theme/default/icons/bookmark.png b/theme/default/icons/bookmark.png index 6497d0484..d4beeb84c 100644 Binary files a/theme/default/icons/bookmark.png and b/theme/default/icons/bookmark.png differ diff --git a/theme/default/icons/calendar_notify.png b/theme/default/icons/calendar_notify.png index 24f1ccc73..ca1f9d4ca 100644 Binary files a/theme/default/icons/calendar_notify.png and b/theme/default/icons/calendar_notify.png differ diff --git a/theme/default/icons/edit_notify.png b/theme/default/icons/edit_notify.png index 03eb4644a..2aad02cc8 100644 Binary files a/theme/default/icons/edit_notify.png and b/theme/default/icons/edit_notify.png differ diff --git a/theme/default/icons/favicon.ico b/theme/default/icons/favicon.ico index c7cb1bbbe..193c138a7 100644 Binary files a/theme/default/icons/favicon.ico and b/theme/default/icons/favicon.ico differ diff --git a/theme/default/icons/ical.png b/theme/default/icons/ical.png new file mode 100644 index 000000000..b8b17a1a0 Binary files /dev/null and b/theme/default/icons/ical.png differ diff --git a/theme/default/icons/like.png b/theme/default/icons/like.png index 330107c0d..dc1bf744a 100644 Binary files a/theme/default/icons/like.png and b/theme/default/icons/like.png differ diff --git a/theme/default/icons/mitm.png b/theme/default/icons/mitm.png new file mode 100644 index 000000000..19bce88c3 Binary files /dev/null and b/theme/default/icons/mitm.png differ diff --git a/theme/default/icons/qrcode.png b/theme/default/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/default/icons/qrcode.png and b/theme/default/icons/qrcode.png differ diff --git a/theme/default/icons/reaction.png b/theme/default/icons/reaction.png new file mode 100644 index 000000000..0279ca5df Binary files /dev/null and b/theme/default/icons/reaction.png differ diff --git a/theme/default/icons/repeat.png b/theme/default/icons/repeat.png index 4dc959fa9..392bc84d4 100644 Binary files a/theme/default/icons/repeat.png and b/theme/default/icons/repeat.png differ diff --git a/theme/default/icons/theme.png b/theme/default/icons/theme.png new file mode 100644 index 000000000..582cca0e2 Binary files /dev/null and b/theme/default/icons/theme.png differ diff --git a/theme/default/icons/vcard.png b/theme/default/icons/vcard.png new file mode 100644 index 000000000..1c06642db Binary files /dev/null and b/theme/default/icons/vcard.png differ diff --git a/theme/default/theme.json b/theme/default/theme.json index 62c1e5817..12a65a565 100644 --- a/theme/default/theme.json +++ b/theme/default/theme.json @@ -1,12 +1,267 @@ -{ - "post-separator-margin-top": "10px", - "post-separator-margin-bottom": "10px", +{ "newswire-publish-icon": "True", - "full-width-timeline-buttons": "False", + "full-width-timeline-buttons": "False", "icons-as-buttons": "False", "rss-icon-at-top": "True", "publish-button-at-top": "False", - "banner-height": "20vh", + "search-banner-height": "30vh", + "search-banner-height-mobile": "15vh", + "likes-names-margin": "2%", + "likes-names-size1": "30px", + "likes-names-size2": "40px", + "liker-names-margin": "2%", + "liker-names-vertical-spacing1": "50px", + "liker-names-vertical-spacing2": "100px", + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "avatar-rounding": "10%", + "timeline-icon-width": "50px", + "timeline-icon-width-mobile": "100px", + "timeline-icon-width-tiny": "50px", + "cw-style": "normal", + "cw-weight": "bold", + "column-right-fg-color-voted-on": "red", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", + "font-size-header": "18px", + "font-size-header-mobile": "32px", + "font-size-header-tiny": "16px", + "font-size-button-tiny": "13px", + "font-size-publish-button": "18px", + "font-size-newswire-tiny": "16px", + "font-size-dropdown-header": "40px", + "font-size-dropdown-header-tiny": "20px", + "font-size-mobile": "50px", + "font-size-tiny": "25px", + "font-size-pageslist": "32px", + "font-size-likes-mobile": "64px", + "font-size-likes-tiny": "16px", + "likes-margin-left-tiny": "10px", + "likes-margin-right-tiny": "10px", + "likes-margin-top-tiny": "-10px", + "likes-margin-left-mobile": "20px", + "likes-margin-right-mobile": "0px", + "likes-margin-top-mobile": "0px", + "font-size-pgp-key": "16px", + "font-size-pgp-key2": "18px", + "font-size-tox": "16px", + "font-size-tox2": "18px", + "font-size-emoji-reaction": "16px", + "font-size-emoji-reaction-mobile": "24px", + "font-size-emoji-reaction-tiny": "12px", + "follow-text-size1": "24px", + "follow-text-size2": "40px", + "follow-text-entry-width": "90%", + "time-color": "#aaa", + "time-vertical-align": "0%", + "time-vertical-align-mobile": "1.5%", + "time-vertical-align-tiny": "0.75%", + "publish-button-text": "#FFFFFF", + "button-margin": "5px", + "button-left-margin": "none", + "button-text": "#FFFFFF", + "button-selected-text": "#FFFFFF", + "publish-button-background": "#999", + "button-background": "#999", + "button-background-hover": "#777", + "button-text-hover": "white", + "button-selected": "#666", + "button-fg-highlighted": "#FFFFFF", + "button-deny": "darkred", + "button-width-chars": "10ch", + "button-height": "10px", + "button-height-padding-mobile": "20px", + "button-height-padding-tiny": "10px", + "button-height-padding": "10px", + "image-corners": "10%", + "gallery-border": "#ccc", + "gallery-hover": "#777", + "gallery-font-size": "22px", + "gallery-font-size-mobile": "35px", + "gallery-font-size-tiny": "17.5px", + "icons-side": "right", + "quote-right-margin": "0.1em", + "quote-font-weight": "normal", + "quote-font-size": "120%", + "quote-font-size-mobile": "120%", + "quote-font-size-tiny": "60%", + "line-spacing": "180%", + "line-spacing-newswire": "120%", + "column-left-width": "10vw", + "column-center-width": "80vw", + "column-right-width": "10vw", + "column-left-mobile-margin": "2%", + "column-left-tiny-margin": "1%", + "column-left-top-margin": "0", + "column-right-top-margin": "0", + "column-left-header-style": "uppercase", + "column-left-header-background": "#555", + "column-left-header-color": "#fff", + "column-left-header-size": "20px", + "column-left-header-size-mobile": "50px", + "column-left-header-size-tiny": "25px", + "column-left-border-width": "0", + "column-left-icons-margin": "0", + "column-right-border-width": "0", + "column-left-border-color": "black", + "column-left-icon-size": "2.1vw", + "column-left-icon-size-mobile": "10%", + "column-left-icon-size-tiny": "5%", + "column-left-image-width-mobile": "40vw", + "column-left-image-width-tiny": "20vw", + "column-right-image-width-mobile": "100vw", + "column-right-image-width-tiny": "50vw", + "column-right-icon-size": "2.1vw", + "column-right-icon-size-mobile": "10%", + "column-right-icon-size-tiny": "5%", + "newswire-voted-background-color": "black", + "login-button-fg-color": "black", + "button-event-corner-radius": "60px", + "button-event-fg-color": "white", + "hashtag-fg-color": "white", + "tab-border-width": "0px", + "tab-border-color": "grey", + "icon-brightness-change": "150%", + "container-button-padding": "20px", + "header-button-padding": "20px", + "container-padding": "2%", + "container-padding-bottom": "1%", + "container-padding-bottom-mobile": "0%", + "container-padding-bottom-tiny": "0%", + "vertical-between-posts": "10px", + "vertical-between-posts-header": "10px", + "containericons-horizontal-spacing": "1%", + "containericons-horizontal-spacing-mobile": "3%", + "containericons-horizontal-spacing-tiny": "1.5%", + "containericons-horizontal-offset": "-1%", + "containericons-vertical-align": "0.5%", + "containericons-vertical-align-mobile": "1%", + "containericons-vertical-align-tiny": "0.5%", + "likes-count-offset": "5px", + "publish-button-vertical-offset": "10px", + "publish-button-bottom-offset": "10px", "banner-height-mobile": "10vh", - "search-banner-height-mobile": "15vh" + "banner-height-tiny": "10vh", + "post-separator-background": "transparent", + "post-separator-width": "95%", + "separator-width-left": "95%", + "separator-width-right": "95%", + "post-separator-height": "1px", + "header-vertical-offset": "0", + "profile-background-height": "25vw", + "profile-text-align": "left", + "verticals-width": "0", + "italic-font-style": "italic", + "button-bottom-margin": "10px", + "rendering": "normal", + "voteresult-color": "#dddddd", + "voteresult-border-color": "#aaaaaa", + "voteresult-height": "32px", + "voteresult-height-mobile": "32px", + "voteresult-height-tiny": "16px", + "voteresult-width": "80%", + "voteresult-width-mobile": "80%", + "voteresult-width-tiny": "40%", + "vcard-icon-size": "32px", + "vcard-icon-size-mobile": "80px", + "vcard-icon-size-tiny": "80px", + "lines-color": "grey", + "place-color": "lightblue", + "event-color": "grey", + "event-public-color": "#282c37", + "today-foreground": "black", + "today-circle": "grey", + "event-background": "black", + "event-background-private": "#222", + "event-foreground": "white", + "title-text": "white", + "title-background": "grey", + "calendar-horizontal-padding": "0", + "calendar-cell-size": "1.5vw", + "calendar-cell-size-mobile": "1.5vw", + "calendar-cell-size-tiny": "1.5vw", + "font-size-calendar": "20px", + "font-size-calendar-mobile": "30px", + "font-size-calendar-tiny": "15px", + "font-size-calendar-header": "3rem", + "font-size-calendar-day": "1rem", + "font-size-calendar-cell": "2rem", + "font-size-calendar-cell-mobile": "4rem", + "font-size-calendar-cell-tiny": "2rem", + "calendar-header-font": "'Montserrat'", + "calendar-header-font-style": "italic", + "ical-icon-size": "32px", + "ical-icon-size-mobile": "80px", + "ical-icon-size-tiny": "80px", + "hashtag-vertical-spacing1": "50px", + "hashtag-vertical-spacing2": "100px", + "hashtag-vertical-spacing3": "100px", + "hashtag-vertical-spacing4": "150px", + "hashtag-size1": "30px", + "hashtag-size2": "40px", + "hashtag-margin": "2%", + "text-entry-foreground": "#ccc", + "text-entry-background": "#111", + "header-bg-color": "#282c37", + "post-bg-color": "#282c37", + "column-left-color": "#282c37", + "link-bg-color": "#282c37", + "dropdown-fg-color": "#dddddd", + "dropdown-bg-color": "#111", + "dropdown-bg-color-hover": "#333", + "dropdown-fg-color-hover": "#dddddd", + "main-bg-color": "#282c37", + "calendar-bg-color": "#282c37", + "main-bg-color-reply": "#212c37", + "main-bg-color-dm": "#222", + "main-bg-color-report": "#221c27", + "main-header-color-roles": "#282237", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "white", + "main-fg-color": "#dddddd", + "day-number": "#dddddd", + "day-number2": "#bbbbbb", + "cw-color": "#dddddd", + "column-left-fg-color": "#dddddd", + "column-right-fg-color": "yellow", + "main-link-color": "#999", + "main-link-color-hover": "#bbb", + "main-visited-color": "#888", + "border-color": "#505050", + "border-width": "2px", + "border-width-header": "2px", + "font-color-header": "#ccc", + "font-size-button-mobile": "34px", + "font-size-links": "18px", + "font-size-newswire": "18px", + "font-size-newswire-mobile": "38px", + "font-size": "30px", + "font-size2": "24px", + "font-size3": "38px", + "font-size4": "22px", + "font-size5": "20px", + "font-size-likes": "20px", + "button-highlighted": "green", + "button-selected-highlighted": "darkgreen", + "button-approve": "darkgreen", + "gallery-text-color": "#ccc", + "button-corner-radius": "15px", + "timeline-border-radius": "30px", + "timeline-posts-background-color": "#282c37", + "title-color": "#999", + "focus-color": "white", + "newswire-item-moderated-color": "white", + "newswire-date-moderated-color": "white", + "newswire-date-color": "white", + "login-button-color": "#2965", + "button-event-background-color": "green", + "hashtag-background-color": "black", + "banner-height": "15vh", + "post-separator-margin-top": "0", + "post-separator-margin-bottom": "0", + "header-font": "Arial, Helvetica, sans-serif", + "diff-add": "#FFFFFF", + "diff-remove": "#aaa", + "reply-icon-direction": "-1" } diff --git a/theme/hacker/icons/bookmark.png b/theme/hacker/icons/bookmark.png index d89691359..4a96f5e8e 100644 Binary files a/theme/hacker/icons/bookmark.png and b/theme/hacker/icons/bookmark.png differ diff --git a/theme/hacker/icons/calendar_notify.png b/theme/hacker/icons/calendar_notify.png index c1ca09ea8..e461a8c0d 100644 Binary files a/theme/hacker/icons/calendar_notify.png and b/theme/hacker/icons/calendar_notify.png differ diff --git a/theme/hacker/icons/edit_notify.png b/theme/hacker/icons/edit_notify.png index 232b91139..2aad02cc8 100644 Binary files a/theme/hacker/icons/edit_notify.png and b/theme/hacker/icons/edit_notify.png differ diff --git a/theme/hacker/icons/ical.png b/theme/hacker/icons/ical.png new file mode 100644 index 000000000..5226ffbae Binary files /dev/null and b/theme/hacker/icons/ical.png differ diff --git a/theme/hacker/icons/like.png b/theme/hacker/icons/like.png index eb1c50dd5..d30ede503 100644 Binary files a/theme/hacker/icons/like.png and b/theme/hacker/icons/like.png differ diff --git a/theme/hacker/icons/mitm.png b/theme/hacker/icons/mitm.png new file mode 100644 index 000000000..68f9b996b Binary files /dev/null and b/theme/hacker/icons/mitm.png differ diff --git a/theme/hacker/icons/qrcode.png b/theme/hacker/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/hacker/icons/qrcode.png and b/theme/hacker/icons/qrcode.png differ diff --git a/theme/hacker/icons/reaction.png b/theme/hacker/icons/reaction.png new file mode 100644 index 000000000..1e27dcb24 Binary files /dev/null and b/theme/hacker/icons/reaction.png differ diff --git a/theme/hacker/icons/repeat.png b/theme/hacker/icons/repeat.png index a0cb077a8..c84042717 100644 Binary files a/theme/hacker/icons/repeat.png and b/theme/hacker/icons/repeat.png differ diff --git a/theme/hacker/icons/theme.png b/theme/hacker/icons/theme.png new file mode 100644 index 000000000..56c199451 Binary files /dev/null and b/theme/hacker/icons/theme.png differ diff --git a/theme/hacker/icons/vcard.png b/theme/hacker/icons/vcard.png new file mode 100644 index 000000000..e2d479f73 Binary files /dev/null and b/theme/hacker/icons/vcard.png differ diff --git a/theme/hacker/theme.json b/theme/hacker/theme.json index 6bc48bcc5..545edb96f 100644 --- a/theme/hacker/theme.json +++ b/theme/hacker/theme.json @@ -1,5 +1,14 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#9ad791", + "dropdown-bg-color": "#222", + "dropdown-bg-color-hover": "#444", + "dropdown-fg-color-hover": "#9ad791", "column-left-header-background": "#035103", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-header": "12px", "font-size-header-mobile": "20px", "font-size-button-mobile": "20px", @@ -9,6 +18,7 @@ "font-size-newswire-mobile": "36px", "font-size-dropdown-header": "26px", "font-size-mobile": "40px", + "font-size-pageslist": "32px", "font-size": "26px", "font-size2": "16px", "font-size3": "36px", @@ -48,6 +58,8 @@ "main-bg-color-report": "#050202", "main-header-color-roles": "#1f192d", "cw-color": "#9ad791", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "white", "main-fg-color": "#9ad791", "login-fg-color": "#9ad791", "welcome-fg-color": "#9ad791", @@ -92,5 +104,6 @@ "time-color": "#9ad791", "place-color": "#9ad791", "event-color": "#9ad791", - "image-corners": "0%" + "image-corners": "0%", + "reply-icon-direction": "-1" } diff --git a/theme/henge/icons/bookmark.png b/theme/henge/icons/bookmark.png index c4fe4bcfc..af47a9eca 100644 Binary files a/theme/henge/icons/bookmark.png and b/theme/henge/icons/bookmark.png differ diff --git a/theme/henge/icons/calendar_notify.png b/theme/henge/icons/calendar_notify.png index b056a8a85..3a1dd0746 100644 Binary files a/theme/henge/icons/calendar_notify.png and b/theme/henge/icons/calendar_notify.png differ diff --git a/theme/henge/icons/edit_notify.png b/theme/henge/icons/edit_notify.png index 760f0afe8..2aad02cc8 100644 Binary files a/theme/henge/icons/edit_notify.png and b/theme/henge/icons/edit_notify.png differ diff --git a/theme/henge/icons/ical.png b/theme/henge/icons/ical.png new file mode 100644 index 000000000..7929edc79 Binary files /dev/null and b/theme/henge/icons/ical.png differ diff --git a/theme/henge/icons/like.png b/theme/henge/icons/like.png index d800d5a02..b7e480803 100644 Binary files a/theme/henge/icons/like.png and b/theme/henge/icons/like.png differ diff --git a/theme/henge/icons/mitm.png b/theme/henge/icons/mitm.png new file mode 100644 index 000000000..e915838cc Binary files /dev/null and b/theme/henge/icons/mitm.png differ diff --git a/theme/henge/icons/qrcode.png b/theme/henge/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/henge/icons/qrcode.png and b/theme/henge/icons/qrcode.png differ diff --git a/theme/henge/icons/reaction.png b/theme/henge/icons/reaction.png new file mode 100644 index 000000000..8fa200b93 Binary files /dev/null and b/theme/henge/icons/reaction.png differ diff --git a/theme/henge/icons/repeat.png b/theme/henge/icons/repeat.png index ee4932be2..8870029e2 100644 Binary files a/theme/henge/icons/repeat.png and b/theme/henge/icons/repeat.png differ diff --git a/theme/henge/icons/theme.png b/theme/henge/icons/theme.png new file mode 100644 index 000000000..11aeabe1f Binary files /dev/null and b/theme/henge/icons/theme.png differ diff --git a/theme/henge/icons/vcard.png b/theme/henge/icons/vcard.png new file mode 100644 index 000000000..f587975fb Binary files /dev/null and b/theme/henge/icons/vcard.png differ diff --git a/theme/henge/theme.json b/theme/henge/theme.json index ae2617326..e13ffd9ae 100644 --- a/theme/henge/theme.json +++ b/theme/henge/theme.json @@ -1,4 +1,11 @@ { + "code-color": "blue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "white", + "dropdown-bg-color": "#483335", + "dropdown-bg-color-hover": "#583335", + "dropdown-fg-color-hover": "white", "post-separator-margin-top": "10px", "post-separator-margin-bottom": "10px", "time-color": "grey", @@ -16,7 +23,10 @@ "banner-height": "25vh", "column-left-image-width-mobile": "40vw", "column-right-image-width-mobile": "40vw", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -37,6 +47,8 @@ "main-visited-color": "#e1c4bc", "options-main-visited-color": "#e1c4bc", "cw-color": "white", + "pageslist-color": "white", + "pageslist-selected-color": "yellow", "main-fg-color": "white", "options-fg-color": "white", "column-left-fg-color": "white", @@ -70,5 +82,6 @@ "quote-right-margin": "0.1em", "header-font": "'bgrove'", "*font-family": "'bgrove'", - "*src": "url('fonts/bgrove.woff2') format('woff2')" + "*src": "url('fonts/bgrove.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/indymediaclassic/icons/bookmark.png b/theme/indymediaclassic/icons/bookmark.png index b2d34ed44..53df2442e 100644 Binary files a/theme/indymediaclassic/icons/bookmark.png and b/theme/indymediaclassic/icons/bookmark.png differ diff --git a/theme/indymediaclassic/icons/calendar_notify.png b/theme/indymediaclassic/icons/calendar_notify.png index 641f92f70..d6188b6c7 100644 Binary files a/theme/indymediaclassic/icons/calendar_notify.png and b/theme/indymediaclassic/icons/calendar_notify.png differ diff --git a/theme/indymediaclassic/icons/edit_notify.png b/theme/indymediaclassic/icons/edit_notify.png index 5687aab1b..f44a294f3 100644 Binary files a/theme/indymediaclassic/icons/edit_notify.png and b/theme/indymediaclassic/icons/edit_notify.png differ diff --git a/theme/indymediaclassic/icons/ical.png b/theme/indymediaclassic/icons/ical.png new file mode 100644 index 000000000..1bdbd43c5 Binary files /dev/null and b/theme/indymediaclassic/icons/ical.png differ diff --git a/theme/indymediaclassic/icons/like.png b/theme/indymediaclassic/icons/like.png index 34782ddbf..904eab808 100644 Binary files a/theme/indymediaclassic/icons/like.png and b/theme/indymediaclassic/icons/like.png differ diff --git a/theme/indymediaclassic/icons/mitm.png b/theme/indymediaclassic/icons/mitm.png new file mode 100644 index 000000000..ccce2caae Binary files /dev/null and b/theme/indymediaclassic/icons/mitm.png differ diff --git a/theme/indymediaclassic/icons/qrcode.png b/theme/indymediaclassic/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/indymediaclassic/icons/qrcode.png and b/theme/indymediaclassic/icons/qrcode.png differ diff --git a/theme/indymediaclassic/icons/reaction.png b/theme/indymediaclassic/icons/reaction.png new file mode 100644 index 000000000..e048f9bf0 Binary files /dev/null and b/theme/indymediaclassic/icons/reaction.png differ diff --git a/theme/indymediaclassic/icons/repeat.png b/theme/indymediaclassic/icons/repeat.png index 70a8ba5fc..e30525261 100644 Binary files a/theme/indymediaclassic/icons/repeat.png and b/theme/indymediaclassic/icons/repeat.png differ diff --git a/theme/indymediaclassic/icons/theme.png b/theme/indymediaclassic/icons/theme.png new file mode 100644 index 000000000..cde1e634f Binary files /dev/null and b/theme/indymediaclassic/icons/theme.png differ diff --git a/theme/indymediaclassic/icons/vcard.png b/theme/indymediaclassic/icons/vcard.png new file mode 100644 index 000000000..689ccaa79 Binary files /dev/null and b/theme/indymediaclassic/icons/vcard.png differ diff --git a/theme/indymediaclassic/theme.json b/theme/indymediaclassic/theme.json index 60b20a44c..225e7b772 100644 --- a/theme/indymediaclassic/theme.json +++ b/theme/indymediaclassic/theme.json @@ -1,4 +1,11 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "white", + "dropdown-bg-color": "#222", + "dropdown-bg-color-hover": "#444", + "dropdown-fg-color-hover": "white", "newswire-publish-icon": "True", "full-width-timeline-buttons": "True", "icons-as-buttons": "False", @@ -20,7 +27,10 @@ "button-corner-radius": "5px", "timeline-border-radius": "5px", "focus-color": "blue", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -46,6 +56,8 @@ "main-visited-color": "#ffb900", "options-main-visited-color": "#ffb900", "cw-color": "white", + "pageslist-color": "white", + "pageslist-selected-color": "yellow", "main-fg-color": "white", "login-fg-color": "white", "welcome-fg-color": "white", @@ -85,5 +97,6 @@ "login-button-color": "red", "welcome-button-color": "red", "login-button-fg-color": "white", - "welcome-button-fg-color": "white" + "welcome-button-fg-color": "white", + "reply-icon-direction": "-1" } diff --git a/theme/indymediamodern/icons/bookmark.png b/theme/indymediamodern/icons/bookmark.png index d7d8e20f7..4c4793832 100644 Binary files a/theme/indymediamodern/icons/bookmark.png and b/theme/indymediamodern/icons/bookmark.png differ diff --git a/theme/indymediamodern/icons/calendar_notify.png b/theme/indymediamodern/icons/calendar_notify.png index c364c3109..3a3bf46d7 100644 Binary files a/theme/indymediamodern/icons/calendar_notify.png and b/theme/indymediamodern/icons/calendar_notify.png differ diff --git a/theme/indymediamodern/icons/edit_notify.png b/theme/indymediamodern/icons/edit_notify.png index c223a21db..612cdc4c2 100644 Binary files a/theme/indymediamodern/icons/edit_notify.png and b/theme/indymediamodern/icons/edit_notify.png differ diff --git a/theme/indymediamodern/icons/ical.png b/theme/indymediamodern/icons/ical.png new file mode 100644 index 000000000..93d2f809a Binary files /dev/null and b/theme/indymediamodern/icons/ical.png differ diff --git a/theme/indymediamodern/icons/like.png b/theme/indymediamodern/icons/like.png index f3f399753..14e9bbcc7 100644 Binary files a/theme/indymediamodern/icons/like.png and b/theme/indymediamodern/icons/like.png differ diff --git a/theme/indymediamodern/icons/mitm.png b/theme/indymediamodern/icons/mitm.png new file mode 100644 index 000000000..8c1a044f8 Binary files /dev/null and b/theme/indymediamodern/icons/mitm.png differ diff --git a/theme/indymediamodern/icons/qrcode.png b/theme/indymediamodern/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/indymediamodern/icons/qrcode.png and b/theme/indymediamodern/icons/qrcode.png differ diff --git a/theme/indymediamodern/icons/reaction.png b/theme/indymediamodern/icons/reaction.png new file mode 100644 index 000000000..2908ae393 Binary files /dev/null and b/theme/indymediamodern/icons/reaction.png differ diff --git a/theme/indymediamodern/icons/repeat.png b/theme/indymediamodern/icons/repeat.png index 3da4feb05..593aef393 100644 Binary files a/theme/indymediamodern/icons/repeat.png and b/theme/indymediamodern/icons/repeat.png differ diff --git a/theme/indymediamodern/icons/theme.png b/theme/indymediamodern/icons/theme.png new file mode 100644 index 000000000..dda60a3dd Binary files /dev/null and b/theme/indymediamodern/icons/theme.png differ diff --git a/theme/indymediamodern/icons/vcard.png b/theme/indymediamodern/icons/vcard.png new file mode 100644 index 000000000..070a0c3bd Binary files /dev/null and b/theme/indymediamodern/icons/vcard.png differ diff --git a/theme/indymediamodern/theme.json b/theme/indymediamodern/theme.json index d35527ca7..f5d7cf055 100644 --- a/theme/indymediamodern/theme.json +++ b/theme/indymediamodern/theme.json @@ -1,4 +1,13 @@ { + "code-color": "blue", + "diff-add": "#111", + "diff-remove": "#333", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "black", + "dropdown-bg-color": "#dedede", + "dropdown-bg-color-hover": "#ccc", + "dropdown-fg-color-hover": "black", "timeline-icon-width": "30px", "timeline-icon-width-mobile": "60px", "button-bottom-margin": "0", @@ -15,6 +24,8 @@ "follow-text-size2": "30px", "hashtag-size1": "20px", "hashtag-size2": "30px", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-calendar-header": "2rem", "font-size-calendar-cell": "2rem", "time-vertical-align": "1.1%", @@ -24,6 +35,7 @@ "header-button-padding": "0 0", "containericons-horizontal-spacing": "0%", "font-size-header": "14px", + "font-size-pageslist": "32px", "font-size": "22px", "font-size2": "16px", "font-size3": "30px", @@ -103,6 +115,8 @@ "main-bg-color-report": "white", "main-header-color-roles": "#ebebf0", "cw-color": "black", + "pageslist-color": "black", + "pageslist-selected-color": "blue", "main-fg-color": "black", "login-fg-color": "black", "welcome-fg-color": "black", @@ -141,5 +155,6 @@ "header-font": "'NimbusSanL'", "*font-family": "'NimbusSanL'", "*src": "url('./fonts/NimbusSanL.otf') format('opentype')", - "**src": "url('./fonts/NimbusSanL-italic.otf') format('opentype')" + "**src": "url('./fonts/NimbusSanL-italic.otf') format('opentype')", + "reply-icon-direction": "-1" } diff --git a/theme/lcd/icons/calendar_notify.png b/theme/lcd/icons/calendar_notify.png index 0d7d14bac..a70182992 100644 Binary files a/theme/lcd/icons/calendar_notify.png and b/theme/lcd/icons/calendar_notify.png differ diff --git a/theme/lcd/icons/edit_notify.png b/theme/lcd/icons/edit_notify.png index 22b8d3e5c..8a431fe62 100644 Binary files a/theme/lcd/icons/edit_notify.png and b/theme/lcd/icons/edit_notify.png differ diff --git a/theme/lcd/icons/ical.png b/theme/lcd/icons/ical.png new file mode 100644 index 000000000..43a4fa357 Binary files /dev/null and b/theme/lcd/icons/ical.png differ diff --git a/theme/lcd/icons/like.png b/theme/lcd/icons/like.png index ebfbc493d..6988ecefe 100644 Binary files a/theme/lcd/icons/like.png and b/theme/lcd/icons/like.png differ diff --git a/theme/lcd/icons/mitm.png b/theme/lcd/icons/mitm.png new file mode 100644 index 000000000..acecff810 Binary files /dev/null and b/theme/lcd/icons/mitm.png differ diff --git a/theme/lcd/icons/qrcode.png b/theme/lcd/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/lcd/icons/qrcode.png and b/theme/lcd/icons/qrcode.png differ diff --git a/theme/lcd/icons/reaction.png b/theme/lcd/icons/reaction.png new file mode 100644 index 000000000..a59bc187f Binary files /dev/null and b/theme/lcd/icons/reaction.png differ diff --git a/theme/lcd/icons/repeat.png b/theme/lcd/icons/repeat.png index d520e7a2d..17eac8ade 100644 Binary files a/theme/lcd/icons/repeat.png and b/theme/lcd/icons/repeat.png differ diff --git a/theme/lcd/icons/theme.png b/theme/lcd/icons/theme.png new file mode 100644 index 000000000..2fd9398c3 Binary files /dev/null and b/theme/lcd/icons/theme.png differ diff --git a/theme/lcd/icons/vcard.png b/theme/lcd/icons/vcard.png new file mode 100644 index 000000000..4d0bfa1c8 Binary files /dev/null and b/theme/lcd/icons/vcard.png differ diff --git a/theme/lcd/theme.json b/theme/lcd/theme.json index f65ecba80..a63827637 100644 --- a/theme/lcd/theme.json +++ b/theme/lcd/theme.json @@ -1,4 +1,11 @@ { + "code-color": "blue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#33390d", + "dropdown-bg-color": "#9fb42b", + "dropdown-bg-color-hover": "#33390d", + "dropdown-fg-color-hover": "#9fb42b", "newswire-publish-icon": "True", "full-width-timeline-buttons": "False", "icons-as-buttons": "False", @@ -24,6 +31,8 @@ "main-bg-color-dm": "#5fb42b", "main-header-color-roles": "#9fb42b", "cw-color": "#33390d", + "pageslist-color": "#111", + "pageslist-selected-color": "#333", "main-fg-color": "#33390d", "login-fg-color": "#33390d", "welcome-fg-color": "#33390d", @@ -60,8 +69,11 @@ "event-foreground": "white", "title-text": "white", "gallery-text-color": "#33390d", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-header": "22px", "font-size-header-mobile": "32px", + "font-size-pageslist": "45px", "font-size": "45px", "font-size2": "45px", "font-size3": "45px", @@ -83,5 +95,6 @@ "event-color": "#33390d", "header-font": "'LcdSolid'", "*font-family": "'LcdSolid'", - "*src": "url('./fonts/LcdSolid.woff2') format('woff2')" + "*src": "url('./fonts/LcdSolid.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/light/icons/bookmark.png b/theme/light/icons/bookmark.png index f3bb262aa..deff8f3d4 100644 Binary files a/theme/light/icons/bookmark.png and b/theme/light/icons/bookmark.png differ diff --git a/theme/light/icons/calendar_notify.png b/theme/light/icons/calendar_notify.png index 16bf5e79c..ee49926d2 100644 Binary files a/theme/light/icons/calendar_notify.png and b/theme/light/icons/calendar_notify.png differ diff --git a/theme/light/icons/edit_notify.png b/theme/light/icons/edit_notify.png index f991f2204..0032baee7 100644 Binary files a/theme/light/icons/edit_notify.png and b/theme/light/icons/edit_notify.png differ diff --git a/theme/light/icons/ical.png b/theme/light/icons/ical.png new file mode 100644 index 000000000..93d2f809a Binary files /dev/null and b/theme/light/icons/ical.png differ diff --git a/theme/light/icons/like.png b/theme/light/icons/like.png index d43305da8..49d07f91b 100644 Binary files a/theme/light/icons/like.png and b/theme/light/icons/like.png differ diff --git a/theme/light/icons/mitm.png b/theme/light/icons/mitm.png new file mode 100644 index 000000000..8c1a044f8 Binary files /dev/null and b/theme/light/icons/mitm.png differ diff --git a/theme/light/icons/qrcode.png b/theme/light/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/light/icons/qrcode.png and b/theme/light/icons/qrcode.png differ diff --git a/theme/light/icons/reaction.png b/theme/light/icons/reaction.png new file mode 100644 index 000000000..006cf9e7c Binary files /dev/null and b/theme/light/icons/reaction.png differ diff --git a/theme/light/icons/repeat.png b/theme/light/icons/repeat.png index 4d04917cb..498ac7666 100644 Binary files a/theme/light/icons/repeat.png and b/theme/light/icons/repeat.png differ diff --git a/theme/light/icons/theme.png b/theme/light/icons/theme.png new file mode 100644 index 000000000..d29e0b7b2 Binary files /dev/null and b/theme/light/icons/theme.png differ diff --git a/theme/light/icons/vcard.png b/theme/light/icons/vcard.png new file mode 100644 index 000000000..ca534b08f Binary files /dev/null and b/theme/light/icons/vcard.png differ diff --git a/theme/light/theme.json b/theme/light/theme.json index 3e4e10c1e..ab0f72185 100644 --- a/theme/light/theme.json +++ b/theme/light/theme.json @@ -1,4 +1,13 @@ { + "diff-add": "#111", + "diff-remove": "#333", + "code-color": "blue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#2d2c37", + "dropdown-bg-color": "#d6dbf0", + "dropdown-bg-color-hover": "#b6bbf0", + "dropdown-fg-color-hover": "#2d2c37", "avatar-rounding": "50%", "icon-brightness-change": "80%", "button-selected": "#999", @@ -19,10 +28,16 @@ "banner-height-mobile": "10vh", "hashtag-background-color": "lightblue", "focus-color": "grey", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", - "font-size": "32px", - "font-size2": "26px", - "font-size3": "40px", + "font-size-pageslist": "32px", + "font-size-links": "18px", + "font-size-newswire": "18px", + "font-size-newswire-mobile": "38px", + "font-size": "28px", + "font-size2": "24px", + "font-size3": "24px", "font-size4": "24px", "font-size5": "22px", "rgba(0, 0, 0, 0.5)": "rgba(0, 0, 0, 0.0)", @@ -40,6 +55,8 @@ "main-bg-color-report": "#dbe2ea", "main-header-color-roles": "#ebebf0", "cw-color": "#777", + "pageslist-color": "#111", + "pageslist-selected-color": "blue", "main-fg-color": "#2d2c37", "login-fg-color": "#2d2c37", "welcome-fg-color": "#2d2c37", @@ -75,7 +92,9 @@ "title-text": "#282c37", "title-background": "#ccc", "gallery-text-color": "black", - "header-font": "'ElectrumADFExp-Regular'", - "*font-family": "'ElectrumADFExp-Regular'", - "*src": "url('./fonts/ElectrumADFExp-Regular.otf') format('opentype')" + "header-font": "'Atkinson'", + "*font-family": "'Atkinson'", + "*src": "url('./fonts//Atkinson-Hyperlegible-Regular.woff2') format('woff2')", + "**src": "url('./fonts/Atkinson-Hyperlegible-Italic.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/night/icons/bookmark.png b/theme/night/icons/bookmark.png index 581db83d5..ae97ec394 100644 Binary files a/theme/night/icons/bookmark.png and b/theme/night/icons/bookmark.png differ diff --git a/theme/night/icons/calendar_notify.png b/theme/night/icons/calendar_notify.png index ad4be5454..db2c69d83 100644 Binary files a/theme/night/icons/calendar_notify.png and b/theme/night/icons/calendar_notify.png differ diff --git a/theme/night/icons/edit_notify.png b/theme/night/icons/edit_notify.png index 4b7d3554a..1167965ed 100644 Binary files a/theme/night/icons/edit_notify.png and b/theme/night/icons/edit_notify.png differ diff --git a/theme/night/icons/ical.png b/theme/night/icons/ical.png new file mode 100644 index 000000000..b8b17a1a0 Binary files /dev/null and b/theme/night/icons/ical.png differ diff --git a/theme/night/icons/like.png b/theme/night/icons/like.png index 4246ba762..9fba6ccdf 100644 Binary files a/theme/night/icons/like.png and b/theme/night/icons/like.png differ diff --git a/theme/night/icons/mitm.png b/theme/night/icons/mitm.png new file mode 100644 index 000000000..19bce88c3 Binary files /dev/null and b/theme/night/icons/mitm.png differ diff --git a/theme/night/icons/qrcode.png b/theme/night/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/night/icons/qrcode.png and b/theme/night/icons/qrcode.png differ diff --git a/theme/night/icons/reaction.png b/theme/night/icons/reaction.png new file mode 100644 index 000000000..0251435bb Binary files /dev/null and b/theme/night/icons/reaction.png differ diff --git a/theme/night/icons/repeat.png b/theme/night/icons/repeat.png index 62bc3ed0c..aee9e8dff 100644 Binary files a/theme/night/icons/repeat.png and b/theme/night/icons/repeat.png differ diff --git a/theme/night/icons/theme.png b/theme/night/icons/theme.png new file mode 100644 index 000000000..582cca0e2 Binary files /dev/null and b/theme/night/icons/theme.png differ diff --git a/theme/night/icons/vcard.png b/theme/night/icons/vcard.png new file mode 100644 index 000000000..1c06642db Binary files /dev/null and b/theme/night/icons/vcard.png differ diff --git a/theme/night/theme.json b/theme/night/theme.json index 4335d43ba..c47f613dd 100644 --- a/theme/night/theme.json +++ b/theme/night/theme.json @@ -1,4 +1,11 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#0481f5", + "dropdown-bg-color": "#0d0d10", + "dropdown-bg-color-hover": "#0b0b10", + "dropdown-fg-color-hover": "#0481f5", "avatar-rounding": "50%", "newswire-publish-icon": "True", "full-width-timeline-buttons": "False", @@ -15,7 +22,10 @@ "banner-height": "15vh", "banner-height-mobile": "10vh", "focus-color": "blue", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -36,6 +46,8 @@ "options-main-link-color": "#6481f5", "options-main-link-color-hover": "#d09338", "cw-color": "#0481f5", + "pageslist-color": "#bbbbbb", + "pageslist-selected-color": "lightblue", "main-fg-color": "#0481f5", "login-fg-color": "#0481f5", "welcome-fg-color": "#0481f5", @@ -68,5 +80,6 @@ "header-font": "'solidaric'", "*font-family": "'solidaric'", "*src": "url('./fonts/solidaric.woff2') format('woff2')", - "**src": "url('./fonts/solidaric-italic.woff2') format('woff2')" + "**src": "url('./fonts/solidaric-italic.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/pixel/icons/calendar_notify.png b/theme/pixel/icons/calendar_notify.png index db9bff38e..427333e66 100644 Binary files a/theme/pixel/icons/calendar_notify.png and b/theme/pixel/icons/calendar_notify.png differ diff --git a/theme/pixel/icons/edit_notify.png b/theme/pixel/icons/edit_notify.png index 551ca029c..283580e59 100644 Binary files a/theme/pixel/icons/edit_notify.png and b/theme/pixel/icons/edit_notify.png differ diff --git a/theme/pixel/icons/ical.png b/theme/pixel/icons/ical.png new file mode 100644 index 000000000..556b7b48d Binary files /dev/null and b/theme/pixel/icons/ical.png differ diff --git a/theme/pixel/icons/like.png b/theme/pixel/icons/like.png index c573008ee..f5b9cb509 100644 Binary files a/theme/pixel/icons/like.png and b/theme/pixel/icons/like.png differ diff --git a/theme/pixel/icons/mitm.png b/theme/pixel/icons/mitm.png new file mode 100644 index 000000000..0a594a408 Binary files /dev/null and b/theme/pixel/icons/mitm.png differ diff --git a/theme/pixel/icons/qrcode.png b/theme/pixel/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/pixel/icons/qrcode.png and b/theme/pixel/icons/qrcode.png differ diff --git a/theme/pixel/icons/reaction.png b/theme/pixel/icons/reaction.png new file mode 100644 index 000000000..937e4de2c Binary files /dev/null and b/theme/pixel/icons/reaction.png differ diff --git a/theme/pixel/icons/repeat.png b/theme/pixel/icons/repeat.png index 20e306ba8..b56895b4b 100644 Binary files a/theme/pixel/icons/repeat.png and b/theme/pixel/icons/repeat.png differ diff --git a/theme/pixel/icons/theme.png b/theme/pixel/icons/theme.png new file mode 100644 index 000000000..8a976ae6f Binary files /dev/null and b/theme/pixel/icons/theme.png differ diff --git a/theme/pixel/icons/vcard.png b/theme/pixel/icons/vcard.png new file mode 100644 index 000000000..216ea60ab Binary files /dev/null and b/theme/pixel/icons/vcard.png differ diff --git a/theme/pixel/theme.json b/theme/pixel/theme.json index 0aec8c372..1e656c4f7 100644 --- a/theme/pixel/theme.json +++ b/theme/pixel/theme.json @@ -1,8 +1,19 @@ { + "code-color": "blue", + "diff-add": "#111", + "diff-remove": "#333", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "black", + "dropdown-bg-color": "#aba0d4", + "dropdown-bg-color-hover": "#cba0d4", + "dropdown-fg-color-hover": "black", "title-color": "#111", "font-size-header": "18px", "font-size-header-mobile": "34px", "font-color-header": "#ccc", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "34px", "font-size-links": "18px", "font-size-publish-button": "18px", @@ -10,6 +21,7 @@ "font-size-newswire-mobile": "38px", "font-size-dropdown-header": "34px", "font-size-mobile": "34px", + "font-size-pageslist": "34px", "font-size": "34px", "font-size2": "34px", "font-size3": "38px", @@ -61,6 +73,8 @@ "column-left-header-background": "#5152a3", "column-left-header-color": "white", "column-left-fg-color": "black", + "pageslist-color": "black", + "pageslist-selected-color": "purple", "main-fg-color": "black", "dropdown-fg-color": "black", "dropdown-fg-color-hover": "#222", @@ -69,6 +83,7 @@ "post-separator-margin-top": "10px", "post-separator-margin-bottom": "10px", "newswire-publish-icon": "True", + "newswire-date-color": "#5152a3", "full-width-timeline-buttons": "False", "icons-as-buttons": "False", "rss-icon-at-top": "True", @@ -78,5 +93,6 @@ "search-banner-height-mobile": "15vh", "header-font": "'rainyhearts'", "*font-family": "'rainyhearts'", - "*src": "url('./fonts/rainyhearts.ttf') format('truetype')" + "*src": "url('./fonts/rainyhearts.ttf') format('truetype')", + "reply-icon-direction": "-1" } diff --git a/theme/purple/icons/bookmark.png b/theme/purple/icons/bookmark.png index 59b6f05cc..48d32b6a8 100644 Binary files a/theme/purple/icons/bookmark.png and b/theme/purple/icons/bookmark.png differ diff --git a/theme/purple/icons/calendar_notify.png b/theme/purple/icons/calendar_notify.png index 60d2f30ab..539d8c69b 100644 Binary files a/theme/purple/icons/calendar_notify.png and b/theme/purple/icons/calendar_notify.png differ diff --git a/theme/purple/icons/edit_notify.png b/theme/purple/icons/edit_notify.png index 19b53e58a..0fa5f13de 100644 Binary files a/theme/purple/icons/edit_notify.png and b/theme/purple/icons/edit_notify.png differ diff --git a/theme/purple/icons/ical.png b/theme/purple/icons/ical.png new file mode 100644 index 000000000..0c54320de Binary files /dev/null and b/theme/purple/icons/ical.png differ diff --git a/theme/purple/icons/like.png b/theme/purple/icons/like.png index 54e43712a..2906ed5e7 100644 Binary files a/theme/purple/icons/like.png and b/theme/purple/icons/like.png differ diff --git a/theme/purple/icons/mitm.png b/theme/purple/icons/mitm.png new file mode 100644 index 000000000..aadad00a1 Binary files /dev/null and b/theme/purple/icons/mitm.png differ diff --git a/theme/purple/icons/qrcode.png b/theme/purple/icons/qrcode.png index 933a2671c..6d9e03129 100644 Binary files a/theme/purple/icons/qrcode.png and b/theme/purple/icons/qrcode.png differ diff --git a/theme/purple/icons/reaction.png b/theme/purple/icons/reaction.png new file mode 100644 index 000000000..da610dd09 Binary files /dev/null and b/theme/purple/icons/reaction.png differ diff --git a/theme/purple/icons/repeat.png b/theme/purple/icons/repeat.png index c33a0a3b9..7a81ccccf 100644 Binary files a/theme/purple/icons/repeat.png and b/theme/purple/icons/repeat.png differ diff --git a/theme/purple/icons/theme.png b/theme/purple/icons/theme.png new file mode 100644 index 000000000..29edd3cd5 Binary files /dev/null and b/theme/purple/icons/theme.png differ diff --git a/theme/purple/icons/vcard.png b/theme/purple/icons/vcard.png new file mode 100644 index 000000000..5db426e3b Binary files /dev/null and b/theme/purple/icons/vcard.png differ diff --git a/theme/purple/theme.json b/theme/purple/theme.json index e1f14ff94..6d59ef18e 100644 --- a/theme/purple/theme.json +++ b/theme/purple/theme.json @@ -1,4 +1,11 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#f98bb0", + "dropdown-bg-color": "#2f152d", + "dropdown-bg-color-hover": "#3f152d", + "dropdown-fg-color-hover": "#f98bb0", "column-left-top-margin": "0.4cm", "column-right-top-margin": "0.8cm", "time-vertical-align": "0%", @@ -13,7 +20,13 @@ "publish-button-at-top": "False", "search-banner-height": "25vh", "search-banner-height-mobile": "10vh", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", + "font-size-links": "18px", + "font-size-newswire": "18px", + "font-size-newswire-mobile": "38px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -32,6 +45,8 @@ "main-bg-color-report": "#12152d", "main-header-color-roles": "#1f192d", "cw-color": "#f98bb0", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "white", "main-fg-color": "#f98bb0", "login-fg-color": "#f98bb0", "welcome-fg-color": "#f98bb0", @@ -62,8 +77,8 @@ "day-number2": "lightgrey", "today-foreground": "white", "today-circle": "red", - "event-background": "#444", - "event-background-private": "#888", + "event-background": "#333", + "event-background-private": "#111", "event-foreground": "white", "title-text": "white", "title-background": "#ff42a0", @@ -71,7 +86,9 @@ "time-color": "#f98bb0", "place-color": "#f98bb0", "event-color": "#f98bb0", - "header-font": "'CheGuevaraTextSans-Regular'", - "*font-family": "'CheGuevaraTextSans-Regular'", - "*src": "url('./fonts/CheGuevaraTextSans-Regular.woff2') format('woff2')" + "header-font": "'Atkinson'", + "*font-family": "'Atkinson'", + "*src": "url('./fonts//Atkinson-Hyperlegible-Regular.woff2') format('woff2')", + "**src": "url('./fonts/Atkinson-Hyperlegible-Italic.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/rc3/icons/bookmark.png b/theme/rc3/icons/bookmark.png index 1df20b17b..f364f9d9b 100644 Binary files a/theme/rc3/icons/bookmark.png and b/theme/rc3/icons/bookmark.png differ diff --git a/theme/rc3/icons/calendar_notify.png b/theme/rc3/icons/calendar_notify.png index 01a74849d..2f7188389 100644 Binary files a/theme/rc3/icons/calendar_notify.png and b/theme/rc3/icons/calendar_notify.png differ diff --git a/theme/rc3/icons/edit_notify.png b/theme/rc3/icons/edit_notify.png index 3004a5706..bd32d6761 100644 Binary files a/theme/rc3/icons/edit_notify.png and b/theme/rc3/icons/edit_notify.png differ diff --git a/theme/rc3/icons/ical.png b/theme/rc3/icons/ical.png new file mode 100644 index 000000000..2269e7d74 Binary files /dev/null and b/theme/rc3/icons/ical.png differ diff --git a/theme/rc3/icons/like.png b/theme/rc3/icons/like.png index 981f63f15..6e04790ff 100644 Binary files a/theme/rc3/icons/like.png and b/theme/rc3/icons/like.png differ diff --git a/theme/rc3/icons/links.png b/theme/rc3/icons/links.png index ea303bbd4..1ba71b702 100644 Binary files a/theme/rc3/icons/links.png and b/theme/rc3/icons/links.png differ diff --git a/theme/rc3/icons/mitm.png b/theme/rc3/icons/mitm.png new file mode 100644 index 000000000..aea21612d Binary files /dev/null and b/theme/rc3/icons/mitm.png differ diff --git a/theme/rc3/icons/qrcode.png b/theme/rc3/icons/qrcode.png index 933a2671c..a59fe9a03 100644 Binary files a/theme/rc3/icons/qrcode.png and b/theme/rc3/icons/qrcode.png differ diff --git a/theme/rc3/icons/reaction.png b/theme/rc3/icons/reaction.png new file mode 100644 index 000000000..b5ccf3a91 Binary files /dev/null and b/theme/rc3/icons/reaction.png differ diff --git a/theme/rc3/icons/repeat.png b/theme/rc3/icons/repeat.png index 89f75efa1..886fbfa61 100644 Binary files a/theme/rc3/icons/repeat.png and b/theme/rc3/icons/repeat.png differ diff --git a/theme/rc3/icons/theme.png b/theme/rc3/icons/theme.png new file mode 100644 index 000000000..b85a3f0c0 Binary files /dev/null and b/theme/rc3/icons/theme.png differ diff --git a/theme/rc3/icons/vcard.png b/theme/rc3/icons/vcard.png new file mode 100644 index 000000000..d50d4971a Binary files /dev/null and b/theme/rc3/icons/vcard.png differ diff --git a/theme/rc3/theme.json b/theme/rc3/theme.json index 14083636b..d346c49ef 100644 --- a/theme/rc3/theme.json +++ b/theme/rc3/theme.json @@ -1,4 +1,11 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "white", + "dropdown-bg-color": "#002a3a", + "dropdown-bg-color-hover": "#025d84", + "dropdown-fg-color-hover": "white", "post-separator-margin-top": "10px", "post-separator-margin-bottom": "10px", "calendar-header-font-style": "normal", @@ -32,11 +39,14 @@ "banner-height": "15vh", "banner-height-mobile": "10vh", "focus-color": "blue", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-header": "14px", "font-size-header-mobile": "24px", "font-size-button-mobile": "24px", "font-size-links": "12px", "font-size-newswire": "12px", + "font-size-pageslist": "32px", "font-size": "22px", "font-size2": "16px", "font-size3": "30px", @@ -58,6 +68,8 @@ "options-main-link-color": "#05b9ec", "options-main-link-color-hover": "#46eed5", "cw-color": "white", + "pageslist-color": "white", + "pageslist-selected-color": "#dddddd", "main-fg-color": "white", "login-fg-color": "white", "welcome-fg-color": "white", @@ -93,5 +105,6 @@ "*font-family": "'Montserrat-Regular'", "*src": "url('./fonts/Montserrat-Regular.ttf') format('truetype')", "**font-family": "'Orbitron'", - "**src": "url('./fonts/Orbitron.ttf') format('truetype')" + "**src": "url('./fonts/Orbitron.ttf') format('truetype')", + "reply-icon-direction": "-1" } diff --git a/theme/solidaric/icons/bookmark.png b/theme/solidaric/icons/bookmark.png index c26d0af72..910422e2a 100644 Binary files a/theme/solidaric/icons/bookmark.png and b/theme/solidaric/icons/bookmark.png differ diff --git a/theme/solidaric/icons/calendar_notify.png b/theme/solidaric/icons/calendar_notify.png index 1f0b12012..a6a4da0d1 100644 Binary files a/theme/solidaric/icons/calendar_notify.png and b/theme/solidaric/icons/calendar_notify.png differ diff --git a/theme/solidaric/icons/edit_notify.png b/theme/solidaric/icons/edit_notify.png index ba8ef51af..2c057f64a 100644 Binary files a/theme/solidaric/icons/edit_notify.png and b/theme/solidaric/icons/edit_notify.png differ diff --git a/theme/solidaric/icons/ical.png b/theme/solidaric/icons/ical.png new file mode 100644 index 000000000..5e60ea341 Binary files /dev/null and b/theme/solidaric/icons/ical.png differ diff --git a/theme/solidaric/icons/like.png b/theme/solidaric/icons/like.png index 843103d05..78525180a 100644 Binary files a/theme/solidaric/icons/like.png and b/theme/solidaric/icons/like.png differ diff --git a/theme/solidaric/icons/mitm.png b/theme/solidaric/icons/mitm.png new file mode 100644 index 000000000..176f6b522 Binary files /dev/null and b/theme/solidaric/icons/mitm.png differ diff --git a/theme/solidaric/icons/qrcode.png b/theme/solidaric/icons/qrcode.png index 933a2671c..561ad14ff 100644 Binary files a/theme/solidaric/icons/qrcode.png and b/theme/solidaric/icons/qrcode.png differ diff --git a/theme/solidaric/icons/reaction.png b/theme/solidaric/icons/reaction.png new file mode 100644 index 000000000..6c844c031 Binary files /dev/null and b/theme/solidaric/icons/reaction.png differ diff --git a/theme/solidaric/icons/repeat.png b/theme/solidaric/icons/repeat.png index e2daf8082..af07aa7a4 100644 Binary files a/theme/solidaric/icons/repeat.png and b/theme/solidaric/icons/repeat.png differ diff --git a/theme/solidaric/icons/scope_share.png b/theme/solidaric/icons/scope_share.png index 616ceb42b..bf2bb8f53 100644 Binary files a/theme/solidaric/icons/scope_share.png and b/theme/solidaric/icons/scope_share.png differ diff --git a/theme/solidaric/icons/theme.png b/theme/solidaric/icons/theme.png new file mode 100644 index 000000000..5e067f572 Binary files /dev/null and b/theme/solidaric/icons/theme.png differ diff --git a/theme/solidaric/icons/vcard.png b/theme/solidaric/icons/vcard.png new file mode 100644 index 000000000..e51daf988 Binary files /dev/null and b/theme/solidaric/icons/vcard.png differ diff --git a/theme/solidaric/theme.json b/theme/solidaric/theme.json index 6c28047de..da76aaf24 100644 --- a/theme/solidaric/theme.json +++ b/theme/solidaric/theme.json @@ -1,4 +1,11 @@ { + "code-color": "blue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#2d2c37", + "dropdown-bg-color": "#ddd", + "dropdown-bg-color-hover": "#ccc", + "dropdown-fg-color-hover": "#2d2c37", "button-fg-highlighted": "#eeeeee", "button-selected-text": "#eeeeee", "button-text": "#eeeeee", @@ -26,7 +33,10 @@ "button-selected-highlighted": "darkred", "newswire-date-color": "grey", "focus-color": "grey", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -47,6 +57,8 @@ "main-bg-color-report": "#eeeeee", "main-header-color-roles": "#ebebf0", "cw-color": "#2d2c37", + "pageslist-color": "#111", + "pageslist-selected-color": "#444", "main-fg-color": "#2d2c37", "login-fg-color": "#2d2c37", "welcome-fg-color": "#2d2c37", @@ -87,5 +99,6 @@ "header-font": "'solidaric'", "*font-family": "'solidaric'", "*src": "url('./fonts/solidaric.woff2') format('woff2')", - "**src": "url('./fonts/solidaric-italic.woff2') format('woff2')" + "**src": "url('./fonts/solidaric-italic.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/starlight/icons/bookmark.png b/theme/starlight/icons/bookmark.png index b78e63dec..c2d3b60a0 100644 Binary files a/theme/starlight/icons/bookmark.png and b/theme/starlight/icons/bookmark.png differ diff --git a/theme/starlight/icons/calendar_notify.png b/theme/starlight/icons/calendar_notify.png index 66191625e..62087a730 100644 Binary files a/theme/starlight/icons/calendar_notify.png and b/theme/starlight/icons/calendar_notify.png differ diff --git a/theme/starlight/icons/edit_notify.png b/theme/starlight/icons/edit_notify.png index 1faf4aed9..af90c82a4 100644 Binary files a/theme/starlight/icons/edit_notify.png and b/theme/starlight/icons/edit_notify.png differ diff --git a/theme/starlight/icons/ical.png b/theme/starlight/icons/ical.png new file mode 100644 index 000000000..3336820f8 Binary files /dev/null and b/theme/starlight/icons/ical.png differ diff --git a/theme/starlight/icons/like.png b/theme/starlight/icons/like.png index 1286cf422..2d3e10ff9 100644 Binary files a/theme/starlight/icons/like.png and b/theme/starlight/icons/like.png differ diff --git a/theme/starlight/icons/mitm.png b/theme/starlight/icons/mitm.png new file mode 100644 index 000000000..94c8a28fc Binary files /dev/null and b/theme/starlight/icons/mitm.png differ diff --git a/theme/starlight/icons/qrcode.png b/theme/starlight/icons/qrcode.png index 933a2671c..d9bbd5b89 100644 Binary files a/theme/starlight/icons/qrcode.png and b/theme/starlight/icons/qrcode.png differ diff --git a/theme/starlight/icons/reaction.png b/theme/starlight/icons/reaction.png new file mode 100644 index 000000000..d72516699 Binary files /dev/null and b/theme/starlight/icons/reaction.png differ diff --git a/theme/starlight/icons/repeat.png b/theme/starlight/icons/repeat.png index b95814fef..f3c465189 100644 Binary files a/theme/starlight/icons/repeat.png and b/theme/starlight/icons/repeat.png differ diff --git a/theme/starlight/icons/repeat_inactive.png b/theme/starlight/icons/repeat_inactive.png index 71bee69b4..9854e44a8 100644 Binary files a/theme/starlight/icons/repeat_inactive.png and b/theme/starlight/icons/repeat_inactive.png differ diff --git a/theme/starlight/icons/theme.png b/theme/starlight/icons/theme.png new file mode 100644 index 000000000..16ec012ce Binary files /dev/null and b/theme/starlight/icons/theme.png differ diff --git a/theme/starlight/icons/vcard.png b/theme/starlight/icons/vcard.png new file mode 100644 index 000000000..8fda1ad28 Binary files /dev/null and b/theme/starlight/icons/vcard.png differ diff --git a/theme/starlight/theme.json b/theme/starlight/theme.json index 2d8734b38..a34d208b3 100644 --- a/theme/starlight/theme.json +++ b/theme/starlight/theme.json @@ -1,4 +1,11 @@ { + "code-color": "lightblue", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#ffc4bc", + "dropdown-bg-color": "#1f0d10", + "dropdown-bg-color-hover": "#222", + "dropdown-fg-color-hover": "#ffc4bc", "post-separator-margin-top": "10px", "post-separator-margin-bottom": "10px", "newswire-publish-icon": "True", @@ -11,7 +18,10 @@ "column-left-image-width-mobile": "40vw", "line-spacing-newswire": "120%", "focus-color": "darkred", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px", "font-size-button-mobile": "26px", + "font-size-pageslist": "32px", "font-size": "32px", "font-size2": "26px", "font-size3": "40px", @@ -35,6 +45,8 @@ "main-visited-color": "#e1c4bc", "options-main-visited-color": "#e1c4bc", "cw-color": "#ffc4bc", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "red", "main-fg-color": "#ffc4bc", "login-fg-color": "#ffc4bc", "welcome-fg-color": "#ffc4bc", @@ -74,5 +86,6 @@ "quote-right-margin": "0.1em", "header-font": "'bgrove'", "*font-family": "'bgrove'", - "*src": "url('fonts/bgrove.woff2') format('woff2')" + "*src": "url('fonts/bgrove.woff2') format('woff2')", + "reply-icon-direction": "-1" } diff --git a/theme/zen/icons/bookmark.png b/theme/zen/icons/bookmark.png index b925adb55..5a180adfe 100644 Binary files a/theme/zen/icons/bookmark.png and b/theme/zen/icons/bookmark.png differ diff --git a/theme/zen/icons/calendar_notify.png b/theme/zen/icons/calendar_notify.png index edf76fe06..bafde1bea 100644 Binary files a/theme/zen/icons/calendar_notify.png and b/theme/zen/icons/calendar_notify.png differ diff --git a/theme/zen/icons/edit_notify.png b/theme/zen/icons/edit_notify.png index 0058e1824..92247136c 100644 Binary files a/theme/zen/icons/edit_notify.png and b/theme/zen/icons/edit_notify.png differ diff --git a/theme/zen/icons/ical.png b/theme/zen/icons/ical.png new file mode 100644 index 000000000..62828fe10 Binary files /dev/null and b/theme/zen/icons/ical.png differ diff --git a/theme/zen/icons/like.png b/theme/zen/icons/like.png index 7eb61c6ab..7493c5a8f 100644 Binary files a/theme/zen/icons/like.png and b/theme/zen/icons/like.png differ diff --git a/theme/zen/icons/mitm.png b/theme/zen/icons/mitm.png new file mode 100644 index 000000000..c04ad28c7 Binary files /dev/null and b/theme/zen/icons/mitm.png differ diff --git a/theme/zen/icons/qrcode.png b/theme/zen/icons/qrcode.png index 933a2671c..71c2450c3 100644 Binary files a/theme/zen/icons/qrcode.png and b/theme/zen/icons/qrcode.png differ diff --git a/theme/zen/icons/reaction.png b/theme/zen/icons/reaction.png new file mode 100644 index 000000000..09a4d0f3b Binary files /dev/null and b/theme/zen/icons/reaction.png differ diff --git a/theme/zen/icons/repeat.png b/theme/zen/icons/repeat.png index 572fc079e..6069e2ebd 100644 Binary files a/theme/zen/icons/repeat.png and b/theme/zen/icons/repeat.png differ diff --git a/theme/zen/icons/showhide.png b/theme/zen/icons/showhide.png index 143701e29..ed36fd797 100644 Binary files a/theme/zen/icons/showhide.png and b/theme/zen/icons/showhide.png differ diff --git a/theme/zen/icons/theme.png b/theme/zen/icons/theme.png new file mode 100644 index 000000000..042af0260 Binary files /dev/null and b/theme/zen/icons/theme.png differ diff --git a/theme/zen/icons/vcard.png b/theme/zen/icons/vcard.png new file mode 100644 index 000000000..10ae466cf Binary files /dev/null and b/theme/zen/icons/vcard.png differ diff --git a/theme/zen/theme.json b/theme/zen/theme.json index 24892fcb7..62c1b5e0c 100644 --- a/theme/zen/theme.json +++ b/theme/zen/theme.json @@ -1,7 +1,39 @@ { + "code-color": "blue", + "font-size-header": "18px", + "font-size-header-mobile": "32px", + "font-size-header-tiny": "16px", + "font-size-button-tiny": "13px", + "font-size-publish-button": "18px", + "font-size-newswire-tiny": "16px", + "font-size-dropdown-header": "40px", + "font-size-dropdown-header-tiny": "20px", + "font-size-mobile": "50px", + "font-size-tiny": "25px", + "font-size-pageslist": "32px", + "font-size-likes-mobile": "64px", + "font-size-likes-tiny": "16px", + "font-size-pgp-key": "16px", + "font-size-pgp-key2": "18px", + "font-size-tox": "16px", + "font-size-tox2": "18px", + "font-size-emoji-reaction": "16px", + "font-size-emoji-reaction-mobile": "24px", + "font-size-emoji-reaction-tiny": "12px", + "likes-margin-left-mobile": "20px", + "likes-margin-right-mobile": "0px", + "likes-margin-top-mobile": "0px", + "pwa-theme-color": "apple-mobile-web-app-status-bar-style", + "pwa-theme-background-color": "black-translucent", + "dropdown-fg-color": "#d5c7b7", + "dropdown-bg-color": "#4c4e41", + "dropdown-bg-color-hover": "#3c4e41", + "dropdown-fg-color-hover": "#d5c7b7", "avatar-rounding": "50%", "dropdown-bg-color-hover": "#463b35", "cw-color": "#d5c7b7", + "pageslist-color": "#dddddd", + "pageslist-selected-color": "yellow", "main-fg-color": "#d5c7b7", "column-left-fg-color": "#d5c7b7", "button-text": "#d5c7b7", @@ -53,5 +85,8 @@ "dropdown-bg-color": "#504e41", "header-font": "'Barlow-Regular'", "*font-family": "'Barlow-Regular'", - "*src": "url('fonts/Barlow-Regular.woff2') format('woff2')" + "*src": "url('fonts/Barlow-Regular.woff2') format('woff2')", + "reply-icon-direction": "-1", + "font-page-dash-tiny": "16px", + "font-page-dash-mobile": "32px" } diff --git a/threads.py b/threads.py index f56f1f519..0f26d8acb 100644 --- a/threads.py +++ b/threads.py @@ -1,7 +1,7 @@ __filename__ = "threads.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -11,12 +11,13 @@ import threading import sys import time import datetime +from socket import error as SocketError -class threadWithTrace(threading.Thread): +class thread_with_trace(threading.Thread): def __init__(self, *args, **keywords): - self.startTime = datetime.datetime.utcnow() - self.isStarted = False + self.start_time = datetime.datetime.utcnow() + self.is_started = False tries = 0 while tries < 3: try: @@ -24,8 +25,8 @@ class threadWithTrace(threading.Thread): threading.Thread.__init__(self, *self._args, **self._keywords) self.killed = False break - except Exception as e: - print('ERROR: threads.py/__init__ failed - ' + str(e)) + except Exception as ex: + print('ERROR: threads.py/__init__ failed - ' + str(ex)) time.sleep(1) tries += 1 @@ -37,104 +38,115 @@ class threadWithTrace(threading.Thread): self.run = self.__run threading.Thread.start(self) break - except Exception as e: - print('ERROR: threads.py/start failed - ' + str(e)) + except Exception as ex: + print('ERROR: threads.py/start failed - ' + str(ex)) time.sleep(1) tries += 1 # note that this is set True even if all tries failed - self.isStarted = True + self.is_started = True def __run(self): sys.settrace(self.globaltrace) + if not callable(self.__run_backup): + print('ERROR: threads.py/__run ' + + str(self.__run_backup) + 'is not callable') + return try: self.__run_backup() self.run = self.__run_backup - except Exception as e: - print('ERROR: threads.py/__run failed - ' + str(e)) - pass + except Exception as ex: + print('ERROR: threads.py/__run failed - ' + str(ex)) def globaltrace(self, frame, event, arg): + """Trace the thread + """ if event == 'call': return self.localtrace - else: - return None + return None def localtrace(self, frame, event, arg): + """Trace the thread + """ if self.killed: if event == 'line': raise SystemExit() return self.localtrace def kill(self): + """Kill the thread + """ self.killed = True - def clone(self, fn): - return threadWithTrace(target=fn, - args=self._args, - daemon=True) + def clone(self, func): + """Create a clone + """ + print('THREAD: clone') + return thread_with_trace(target=func, + args=self._args, + daemon=True) -def removeDormantThreads(baseDir: str, threadsList: [], debug: bool, - timeoutMins: int = 30) -> None: +def remove_dormant_threads(base_dir: str, threads_list: [], debug: bool, + timeout_mins: int) -> None: """Removes threads whose execution has completed """ - if len(threadsList) == 0: + if len(threads_list) == 0: return - timeoutSecs = int(timeoutMins * 60) - dormantThreads = [] - currTime = datetime.datetime.utcnow() + timeout_secs = int(timeout_mins * 60) + dormant_threads = [] + curr_time = datetime.datetime.utcnow() changed = False # which threads are dormant? - noOfActiveThreads = 0 - for th in threadsList: - removeThread = False + no_of_active_threads = 0 + for thrd in threads_list: + remove_thread = False - if th.isStarted: - if not th.is_alive(): - if (currTime - th.startTime).total_seconds() > 10: + if thrd.is_started: + if not thrd.is_alive(): + if (curr_time - thrd.start_time).total_seconds() > 10: if debug: print('DEBUG: ' + 'thread is not alive ten seconds after start') - removeThread = True + remove_thread = True # timeout for started threads - if (currTime - th.startTime).total_seconds() > timeoutSecs: + if (curr_time - thrd.start_time).total_seconds() > timeout_secs: if debug: print('DEBUG: started thread timed out') - removeThread = True + remove_thread = True else: # timeout for threads which havn't been started - if (currTime - th.startTime).total_seconds() > timeoutSecs: + if (curr_time - thrd.start_time).total_seconds() > timeout_secs: if debug: print('DEBUG: unstarted thread timed out') - removeThread = True + remove_thread = True - if removeThread: - dormantThreads.append(th) + if remove_thread: + dormant_threads.append(thrd) else: - noOfActiveThreads += 1 + no_of_active_threads += 1 if debug: - print('DEBUG: ' + str(noOfActiveThreads) + - ' active threads out of ' + str(len(threadsList))) + print('DEBUG: ' + str(no_of_active_threads) + + ' active threads out of ' + str(len(threads_list))) # remove the dormant threads - dormantCtr = 0 - for th in dormantThreads: + dormant_ctr = 0 + for thrd in dormant_threads: if debug: - print('DEBUG: Removing dormant thread ' + str(dormantCtr)) - dormantCtr += 1 - threadsList.remove(th) - th.kill() + print('DEBUG: Removing dormant thread ' + str(dormant_ctr)) + dormant_ctr += 1 + threads_list.remove(thrd) + thrd.kill() changed = True # start scheduled threads - if len(threadsList) < 10: + if len(threads_list) < 10: ctr = 0 - for th in threadsList: - if not th.isStarted: + for thrd in threads_list: + if not thrd.is_started: print('Starting new send thread ' + str(ctr)) - th.start() + thrd.start() changed = True break ctr += 1 @@ -143,11 +155,31 @@ def removeDormantThreads(baseDir: str, threadsList: [], debug: bool, return if debug: - sendLogFilename = baseDir + '/send.csv' + send_log_filename = base_dir + '/send.csv' try: - with open(sendLogFilename, 'a+') as logFile: - logFile.write(currTime.strftime("%Y-%m-%dT%H:%M:%SZ") + - ',' + str(noOfActiveThreads) + - ',' + str(len(threadsList)) + '\n') - except BaseException: - pass + with open(send_log_filename, 'a+', encoding='utf-8') as fp_log: + fp_log.write(curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + ',' + str(no_of_active_threads) + + ',' + str(len(threads_list)) + '\n') + except OSError: + print('EX: remove_dormant_threads unable to write ' + + send_log_filename) + + +def begin_thread(thread, calling_function: str) -> bool: + """Start a thread + """ + try: + if not thread.is_alive(): + thread.start() + except SocketError as ex: + print('WARN: socket error while starting ' + + 'thread. ' + calling_function + ' ' + str(ex)) + return False + except ValueError as ex: + print('WARN: value error while starting ' + + 'thread. ' + calling_function + ' ' + str(ex)) + return False + except BaseException: + pass + return True diff --git a/tox.py b/tox.py index 4268991b4..682f389a2 100644 --- a/tox.py +++ b/tox.py @@ -1,100 +1,126 @@ __filename__ = "tox.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" -def getToxAddress(actorJson: {}) -> str: +from utils import get_attachment_property_value + + +def get_tox_address(actor_json: {}) -> str: """Returns tox address for the given actor """ - if not actorJson.get('attachment'): + if not actor_json.get('attachment'): return '' - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue['name'].lower().startswith('tox'): + if not name_value.lower().startswith('tox'): continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue.get('value'): + prop_value_name, _ = \ + get_attachment_property_value(property_value) + if not prop_value_name: continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - propertyValue['value'] = propertyValue['value'].strip() - if len(propertyValue['value']) != 76: + property_value[prop_value_name] = \ + property_value[prop_value_name].strip() + if len(property_value[prop_value_name]) != 76: continue - if propertyValue['value'].upper() != propertyValue['value']: + if property_value[prop_value_name].upper() != \ + property_value[prop_value_name]: continue - if '"' in propertyValue['value']: + if '"' in property_value[prop_value_name]: continue - if ' ' in propertyValue['value']: + if ' ' in property_value[prop_value_name]: continue - if ',' in propertyValue['value']: + if ',' in property_value[prop_value_name]: continue - if '.' in propertyValue['value']: + if '.' in property_value[prop_value_name]: continue - return propertyValue['value'] + return property_value[prop_value_name] return '' -def setToxAddress(actorJson: {}, toxAddress: str) -> None: +def set_tox_address(actor_json: {}, tox_address: str) -> None: """Sets an tox address for the given actor """ - notToxAddress = False + not_tox_address = False - if len(toxAddress) != 76: - notToxAddress = True - if toxAddress.upper() != toxAddress: - notToxAddress = True - if '"' in toxAddress: - notToxAddress = True - if ' ' in toxAddress: - notToxAddress = True - if '.' in toxAddress: - notToxAddress = True - if ',' in toxAddress: - notToxAddress = True - if '<' in toxAddress: - notToxAddress = True + if len(tox_address) != 76: + not_tox_address = True + if tox_address.upper() != tox_address: + not_tox_address = True + if '"' in tox_address: + not_tox_address = True + if ' ' in tox_address: + not_tox_address = True + if '.' in tox_address: + not_tox_address = True + if ',' in tox_address: + not_tox_address = True + if '<' in tox_address: + not_tox_address = True - if not actorJson.get('attachment'): - actorJson['attachment'] = [] + if not actor_json.get('attachment'): + actor_json['attachment'] = [] # remove any existing value - propertyFound = None - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + property_found = None + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue['name'].lower().startswith('tox'): + if not name_value.lower().startswith('tox'): continue - propertyFound = propertyValue + property_found = property_value break - if propertyFound: - actorJson['attachment'].remove(propertyFound) - if notToxAddress: + if property_found: + actor_json['attachment'].remove(property_found) + if not_tox_address: return - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue['name'].lower().startswith('tox'): + if not name_value.lower().startswith('tox'): continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - propertyValue['value'] = toxAddress + prop_value_name, _ = \ + get_attachment_property_value(property_value) + if not prop_value_name: + continue + property_value[prop_value_name] = tox_address return - newToxAddress = { + new_tox_address = { "name": "Tox", "type": "PropertyValue", - "value": toxAddress + "value": tox_address } - actorJson['attachment'].append(newToxAddress) + actor_json['attachment'].append(new_tox_address) diff --git a/translations/ar.json b/translations/ar.json index 027f0a02f..5b94f68fd 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -412,6 +412,7 @@ "menuInbox": "صندوق الوارد", "menuSearch": "البحث / المتتالية", "menuNewPost": "منشور جديد", + "menuNewBlog": "مشاركة مدونة جديدة", "menuCalendar": "تقويم", "menuDM": "رسالة مباشرة", "menuReplies": "الردود", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "عرّف عن نفسك وحدد التاريخ والوقت اللذين ترغب في الإقامة فيهما", "Members": "أعضاء", "Join": "انضم", - "Leave": "يترك" + "Leave": "يترك", + "System Monitor": "مراقب النظام", + "Add content warnings for the following sites": "أضف تحذيرات المحتوى للمواقع التالية", + "Known Web Crawlers": "برامج زحف الويب المعروفة", + "Add to the calendar": "أضف إلى التقويم", + "Content License": "ترخيص المحتوى", + "Reaction by": "رد فعل", + "Notify on emoji reactions": "يخطر على ردود الفعل الرموز التعبيرية", + "Select reaction": "حدد رد الفعل", + "Don't show the Reaction button": "لا تظهر زر رد الفعل", + "New feed URL": "موجز جديد URL", + "New link title and URL": "عنوان الارتباط الجديد وعنوان URL", + "Theme Designer": "مصمم المظهر", + "Reset": "إعادة ضبط", + "Encryption Keys": "مفاتيح التشفير", + "Filtered words within bio": "كلمات مفلترة داخل السيرة الذاتية", + "Write your news report": "اكتب تقريرك الإخباري", + "Dyslexic font": "الخط المعسر القراءة", + "Leave a comment": "اترك تعليقا", + "View comments": "تعليقات عرض", + "Multi Status": "متعدد الحالات", + "Lots of things": "أشياء كثيرة", + "Created": "مخلوق", + "It is done": "تم", + "Time Zone": "وحدة زمنية", + "Show who liked this post": "أظهر من أحب هذا المنشور", + "Show who repeated this post": "أظهر من كرر هذا المنصب", + "Repeated by": "يتكرر بواسطة", + "Register": "يسجل", + "Web Bots Allowed": "مسموح روبوتات الويب", + "Known Search Bots": "روبوتات بحث الويب المعروفة", + "mitm": "يمكن قراءة الرسالة أو تعديلها من قبل طرف ثالث", + "Bold reading": "قراءة جريئة", + "SHOW EDITS": "عرض التعديلات", + "Attach an image, video or audio file": "إرفاق صورة أو فيديو أو ملف صوتي", + "Set a place and time": "حدد المكان والزمان", + "Describe your attachment": "صِف مرفقك", + "Language used": "اللغة المستخدمة", + "lang_ar": "عربي", + "lang_bn": "البنغالية", + "lang_cy": "تهرب من دفع الرهان", + "lang_en": "إنجليزي", + "lang_fr": "فرنسي", + "lang_hi": "الهندية", + "lang_ja": "اليابانية", + "lang_ku": "كردي", + "lang_pl": "تلميع", + "lang_ru": "الروسية", + "lang_uk": "الأوكرانية", + "lang_ca": "الكاتالونية", + "lang_de": "ألمانية", + "lang_es": "الأسبانية", + "lang_ga": "ايرلندية", + "lang_it": "إيطالي", + "lang_ko": "الكورية", + "lang_oc": "الأوكسيتانية", + "lang_pt": "البرتغالية", + "lang_sw": "السواحيلية", + "lang_tr": "اللغة التركية", + "lang_zh": "صينى", + "lang_nl": "هولندي", + "lang_el": "اليونانية", + "lang_yi": "اليديشية", + "Common emoji": "الرموز التعبيرية الشائعة", + "Copy and paste into your text": "نسخ ولصق في النص الخاص بك", + "shrug": "هز كتفيه", + "DM warning": "لا يتم تشفير الرسائل المباشرة من طرف إلى طرف. لا تشارك أي معلومات حساسة للغاية هنا.", + "Transcript": "نص", + "Color contrast is too low": "تباين الألوان منخفض جدًا", + "View Larger Map": "عرض خريطة أكبر", + "Start Time": "وقت البدء", + "End Time": "وقت النهاية", + "Switch to calendar view": "قم بالتبديل إلى عرض التقويم", + "Save": "يحفظ", + "Switch to moderation view": "قم بالتبديل إلى عرض الاعتدال", + "Minimize attached images": "تصغير الصور المرفقة", + "SHOW MEDIA": "عرض الوسائط", + "ActivityPub Specification": "مواصفات ActivityPub", + "Dogwhistle words": "كلمات Dogwhistle", + "Content warnings will be added for the following": "ستتم إضافة تحذيرات المحتوى لما يلي", + "nowplaying": "الان العب", + "NowPlaying": "الان العب", + "Import and Export": "استيراد وتصدير", + "Import Follows": "يتبع الاستيراد", + "Post expiry period in days": "فترة ما بعد انتهاء الصلاحية بالأيام", + "Keep DMs during post expiry": "احتفظ بالرسائل الخاصّة أثناء انتهاء صلاحية النشر", + "Notifications": "إشعارات", + "ntfy URL": "ntfy URL", + "ntfy topic": "موضوع ntfy", + "Last hour": "الساعة الأخيرة", + "Last 3 hours": "آخر 3 ساعات", + "Last 6 hours": "آخر 6 ساعات", + "Last 12 hours": "آخر 12 ساعة", + "Last day": "بالأمس", + "Last 2 days": "آخر يومين", + "Last week": "الأسبوع الماضي", + "Last 2 weeks": "آخر أسبوعين", + "Last month": "الشهر الماضي", + "Last 6 months": "آخر 6 أشهر", + "Last year": "العام الماضي", + "Unauthorized": "غير مصرح", + "No login credentials were posted": "لم يتم نشر بيانات اعتماد تسجيل الدخول", + "Credentials are too long": "أوراق الاعتماد طويلة جدًا", + "Site DevOps": "DevOps الموقع", + "A list of devops nicknames. One per line.": "قائمة بأسماء المطورين. واحد في كل سطر.", + "devops": "devops", + "Reject spam accounts": "رفض حسابات البريد العشوائي" } diff --git a/translations/bn.json b/translations/bn.json new file mode 100644 index 000000000..bdd4de2eb --- /dev/null +++ b/translations/bn.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "আরো দেখুন", + "Your browser does not support the video tag.": "আপনার ব্রাউজার ভিডিও ট্যাগ সমর্থন করে না।", + "Your browser does not support the audio tag.": "আপনার ব্রাউজার অডিও ট্যাগ সমর্থন করে না।", + "Show profile": "প্রোফাইল দেখান", + "Show options for this person": "এই ব্যক্তির জন্য বিকল্প দেখান", + "Repeat this post": "পুনরাবৃত্তি করুন", + "Undo the repeat": "পুনরাবৃত্তি পূর্বাবস্থায় ফেরান", + "Like this post": "লাইক", + "Undo the like": "অপছন্দ", + "Delete this post": "মুছে ফেলা", + "Delete this event": "মুছে ফেলা", + "Reply to this post": "উত্তর দিন", + "Write your post text below.": "নতুন পোস্ট", + "Write your reply to": "আপনার উত্তর লিখুন", + "this post": "এই পোস্ট", + "Write your report below.": "নীচে আপনার রিপোর্ট লিখুন.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "এই বার্তাটি শুধুমাত্র মডারেটরদের কাছে যায়, এমনকি এটি অন্যান্য ফেডিভার্স ঠিকানা উল্লেখ করলেও।", + "Also see": "এছাড়াও দেখুন", + "Terms of Service": "সেবা পাবার শর্ত", + "Enter the details for your shared item below.": "নীচে আপনার ভাগ করা আইটেম জন্য বিবরণ লিখুন.", + "Subject or Content Warning (optional)": "বিষয় বা বিষয়বস্তু সতর্কতা (ঐচ্ছিক)", + "Write something": "কিছু লিখুন", + "Name of the shared item": "ভাগ করা আইটেমের নাম", + "Description of the item being shared": "শেয়ার করা আইটেমের বর্ণনা", + "Type of shared item. eg. hat": "ভাগ করা আইটেমের প্রকার। যেমন টুপি", + "Category of shared item. eg. clothing": "ভাগ করা আইটেম বিভাগ. যেমন পোশাক", + "Duration of listing in days": "দিনের মধ্যে তালিকার সময়কাল", + "City or location of the shared item": "শেয়ার করা আইটেমের শহর বা অবস্থান", + "Describe a shared item": "একটি ভাগ করা আইটেম বর্ণনা করুন", + "Public": "পাবলিক", + "Visible to anyone": "যে কারো কাছে দৃশ্যমান", + "Unlisted": "তালিকাভুক্ত নয়", + "Not on public timeline": "পাবলিক টাইমলাইনে নয়", + "Followers": "অনুগামী", + "Only to followers": "শুধুমাত্র অনুসারীদের জন্য", + "DM": "সরাসরি বারত্তা", + "Only to mentioned people": "শুধুমাত্র উল্লেখিত ব্যক্তিদের জন্য", + "Report": "রিপোর্ট", + "Send to moderators": "মডারেটরদের কাছে পাঠান", + "Search for emoji": "ইমোজি অনুসন্ধান করুন", + "Cancel": "✘", + "Submit": "জমা দিন", + "Image description": "ছবির বর্ণনা", + "Item image": "আইটেম ইমেজ", + "Type": "টাইপ", + "Category": "শ্রেণী", + "Location": "অবস্থান", + "Login": "প্রবেশ করুন", + "Edit": "সম্পাদনা করুন", + "Switch to timeline view": "টাইমলাইন ভিউ", + "Approve": "অনুমোদন করুন", + "Deny": "অস্বীকার করুন", + "Posts": "পোস্ট", + "Following": "অনুসরণ করছে", + "Followers": "অনুগামী", + "Roles": "ভূমিকা", + "Skills": "দক্ষতা", + "Shares": "শেয়ার", + "Block": "ব্লক", + "Unfollow": "আনফলো", + "Your browser does not support the audio element.": "আপনার ব্রাউজার অডিও উপাদান সমর্থন করে না.", + "Your browser does not support the video element.": "আপনার ব্রাউজার ভিডিও উপাদান সমর্থন করে না.", + "Create a new post": "নতুন পোস্ট", + "Create a new DM": "একটি নতুন সরাসরি বার্তা তৈরি করুন", + "Switch to profile view": "প্রোফাইল ভিউ", + "Inbox": "ইনবক্স", + "Sent": "পাঠানো হয়েছে", + "Search and follow": "অনুসন্ধান/অনুসরণ করুন", + "Refresh": "রিফ্রেশ", + "Nickname or URL. Block using *@domain or nickname@domain": "ডাকনাম বা URL. *@domain বা nickname@domain ব্যবহার করে ব্লক করুন", + "Remove the above item": "উপরের আইটেমটি সরান", + "Remove": "অপসারণ", + "Suspend the above account nickname": "উপরের অ্যাকাউন্টের ডাকনামটি স্থগিত করুন", + "Suspend": "সাসপেন্ড", + "Remove a suspension for an account nickname": "একটি অ্যাকাউন্ট ডাকনামের জন্য একটি সাসপেনশন সরান৷", + "Unsuspend": "সাসপেন্ড", + "Block an account on another instance": "অন্য উদাহরণে একটি অ্যাকাউন্ট ব্লক করুন", + "Unblock": "আনব্লক করুন", + "Unblock an account on another instance": "অন্য উদাহরণে একটি অ্যাকাউন্ট আনব্লক করুন", + "Information about current blocks/suspensions": "বর্তমান ব্লক/সাসপেনশন সম্পর্কে তথ্য", + "Info": "তথ্য", + "Remove": "অপসারণ", + "Yes": "হ্যাঁ", + "No": "না", + "Delete this post?": "এই পোস্টটি মুছবেন?", + "Follow": "অনুসরণ করুন", + "Stop following": "অনুসরণ করা বন্ধ করুন", + "Options for": "জন্য বিকল্প", + "View": "দেখুন", + "Stop blocking": "ব্লক করা বন্ধ করুন", + "Enter an emoji name to search for": "অনুসন্ধান করতে একটি ইমোজি নাম লিখুন", + "Search screen text": "একটি ঠিকানা লিখুন, শেয়ার করা আইটেম, -সংরক্ষণ করুন, 'ইতিহাস, #হ্যাশট্যাগ, *স্কিল, .ওয়ান্টেড বা : ইমোজি: অনুসন্ধান করতে", + "Go Back": "◀", + "Moderation Information": "সংযম তথ্য", + "Suspended accounts": "স্থগিত অ্যাকাউন্ট", + "These are currently suspended": "এগুলো বর্তমানে স্থগিত রয়েছে", + "Blocked accounts and hashtags": "ব্লক করা অ্যাকাউন্ট এবং হ্যাশট্যাগ", + "These are globally blocked for all accounts on this instance": "এই উদাহরণে সমস্ত অ্যাকাউন্টের জন্য এগুলি বিশ্বব্যাপী অবরুদ্ধ", + "Any blocks or suspensions made by moderators will be shown here.": "মডারেটরদের দ্বারা তৈরি যেকোনো ব্লক বা সাসপেনশন এখানে দেখানো হবে।", + "Welcome. Please enter your login details below.": "স্বাগত. নিচে আপনার লগইন বিবরণ এখানে ক্লিক করুন।", + "Welcome. Please login or register a new account.": "স্বাগত. লগইন করুন বা একটি নতুন অ্যাকাউন্ট নিবন্ধন করুন.", + "Please enter some credentials": "অনুগ্রহ করে কিছু শংসাপত্র লিখুন", + "You will become the admin of this site.": "আপনি এই সাইটের অ্যাডমিন হয়ে উঠবেন।", + "Terms of Service": "সেবা পাবার শর্ত", + "About this Instance": "এই উদাহরণ সম্পর্কে", + "Nickname": "ডাকনাম", + "Enter Nickname": "ডাক নাম প্রবেশ করান", + "Password": "পাসওয়ার্ড", + "Enter Password": "সর্বনিম্ন 8 অক্ষর", + "Profile for": "জন্য প্রোফাইল", + "The files attached below should be no larger than 10MB in total uploaded at once.": "নীচে সংযুক্ত ফাইলগুলি একবারে আপলোড করা মোট 10MB এর বেশি হওয়া উচিত নয়৷", + "Avatar image": "অবতার ছবি", + "Background image": "ব্যাকগ্রাউন্ড ইমেজ, যা আপনার অবতারের পিছনে প্রদর্শিত হয়", + "Timeline banner image": "টাইমলাইন ব্যানার ছবি", + "Approve follower requests": "অনুসরণকারীদের অনুরোধ অনুমোদন", + "This is a bot account": "এটি একটি বট অ্যাকাউন্ট", + "Filtered words": "ফিল্টার করা শব্দ", + "One per line": "প্রতি লাইনে একটি", + "Blocked accounts": "ব্লক করা অ্যাকাউন্ট", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "ব্লক করা অ্যাকাউন্ট, প্রতি লাইনে একটি, nickname@domain বা *@blockeddomain আকারে", + "Federation list": "ফেডারেশন তালিকা", + "Federate only with a defined set of instances. One domain name per line.": "শুধুমাত্র দৃষ্টান্তের একটি সংজ্ঞায়িত সেট দিয়ে ফেডারেট করুন। প্রতি লাইনে একটি ডোমেইন নাম।", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "আপনি যদি সংস্থাগুলির মধ্যে অংশগ্রহণ করতে চান তবে আপনি আপনার কাছে থাকা কিছু দক্ষতা এবং আনুমানিক দক্ষতার স্তর নির্দেশ করতে পারেন। এটি সংগঠকদের দক্ষতার উপযুক্ত সমন্বয়ে দল গঠন করতে সাহায্য করে।", + "A list of moderator nicknames. One per line.": "মডারেটরের ডাকনামের একটি তালিকা। প্রতি লাইনে একটি।", + "Moderators": "মডারেটর", + "List of moderator nicknames": "মডারেটরের ডাকনামের তালিকা", + "Your bio": "আপনার জীবনী", + "Skill": "দক্ষতা", + "Copy the text then paste it into your post": "লেখাটি কপি করে আপনার পোস্টে পেস্ট করুন", + "Emoji Search": "ইমোজি অনুসন্ধান", + "No results": "কোন ফলাফল নেই", + "Skills search": "দক্ষতা অনুসন্ধান", + "Shared Items Search": "ভাগ করা আইটেম অনুসন্ধান", + "Contact": "যোগাযোগ", + "Shared Item": "ভাগ করা আইটেম", + "Mod": "পরিমিত", + "Approve follow requests": "অনুসরণ অনুরোধ অনুমোদন", + "Page down": "পৃষ্ঠা নিচে নামানো", + "Page up": "উপরের পাতা", + "Vote": "ভোট", + "Replies": "উত্তর", + "Media": "মিডিয়া", + "This is a group account": "এটি একটি গ্রুপ অ্যাকাউন্ট", + "Date": "তারিখ", + "Time": "সময়", + "Location": "অবস্থান", + "Calendar": "ক্যালেন্ডার", + "Sun": "রবিবার", + "Mon": "সোমবার", + "Tue": "মঙ্গলবার", + "Wed": "বুধবার", + "Thu": "বৃহস্পতিবার", + "Fri": "শুক্রবার", + "Sat": "শনিবার", + "January": "জানুয়ারি", + "February": "ফেব্রুয়ারি", + "March": "মার্চ", + "April": "এপ্রিল", + "May": "মে", + "June": "জুন", + "July": "জুলাই", + "August": "আগস্ট", + "September": "সেপ্টেম্বর", + "October": "অক্টোবর", + "November": "নভেম্বর", + "December": "ডিসেম্বর", + "Only people I follow can send me DMs": "শুধুমাত্র আমি যারা অনুসরণ করি তারা আমাকে সরাসরি বার্তা পাঠাতে পারে", + "Logout": "প্রস্থান", + "Danger Zone": "বিপদজনক এলাকা", + "Deactivate this account": "এই অ্যাকাউন্ট নিষ্ক্রিয় করুন", + "Snooze": "তন্দ্রা", + "Unsnooze": "স্নুজ আনুন", + "Donations link": "অনুদান লিঙ্ক", + "Donate": "দান করুন", + "Change Password": "পাসওয়ার্ড পরিবর্তন করুন", + "Confirm Password": "পাসওয়ার্ড নিশ্চিত করুন", + "Instance Title": "উদাহরণ শিরোনাম", + "Instance Short Description": "উদাহরণ সংক্ষিপ্ত বিবরণ", + "Instance Description": "উদাহরণ বিবরণ", + "Instance Logo": "ইনস্ট্যান্স লোগো", + "Bookmark this post": "বুকমার্ক", + "Undo the bookmark": "বুকমার্ক আনবুক করুন", + "Bookmarks": "সংরক্ষিত", + "Theme": "থিম", + "Default": "ডিফল্ট", + "Light": "আলো", + "Purple": "বেগুনি", + "Hacker": "হ্যাকার", + "HighVis": "হাই ভিস", + "Question": "প্রশ্ন", + "Enter your question": "আপনার প্রশ্ন লিখুন", + "Enter the choices for your question below.": "নীচে আপনার প্রশ্নের জন্য পছন্দ লিখুন.", + "Ask a question": "প্রশ্ন জিজ্ঞাসা কর", + "Possible answers": "সম্ভাব্য উত্তর", + "replying to": "এর জবাব", + "replying to themselves": "নিজেদের জবাব দিচ্ছে", + "announces": "ঘোষণা করে", + "Previous month": "পূর্ববর্তী মাস", + "Next month": "পরের মাসে", + "Get the source code": "সোর্স কোড পান", + "This is a media instance": "এটি একটি মিডিয়া উদাহরণ", + "Mute this post": "নিঃশব্দ", + "Undo mute": "মিউট পূর্বাবস্থায় ফেরান", + "XMPP": "এক্সএমপিপি", + "Matrix": "ম্যাট্রিক্স", + "Email": "ইমেইল", + "PGP": "পিজিপি কী", + "PGP Fingerprint": "পিজিপি ফিঙ্গারপ্রিন্ট", + "This is a scheduled post.": "এটি একটি নির্ধারিত পোস্ট।", + "Remove scheduled posts": "নির্ধারিত পোস্টগুলি সরান", + "Remove Twitter posts": "টুইটার পোস্টগুলি সরান", + "Sensitive": "সংবেদনশীল", + "Word Replacements": "শব্দ প্রতিস্থাপন", + "Happening Today": "আজ", + "Happening Tomorrow": "কাল", + "Happening This Week": "শীঘ্রই", + "Blog": "ব্লগ", + "Blogs": "ব্লগ", + "Title": "শিরোনাম", + "About the author": "লেখক সম্পর্কে", + "Edit blog post": "ব্লগ পোস্ট সম্পাদনা করুন", + "Publicly visible post": "সর্বজনীনভাবে দৃশ্যমান পোস্ট", + "Your Posts": "আপনার পোস্ট", + "Git Projects": "গিট প্রকল্প", + "List of project names that you wish to receive git patches for": "প্রকল্পের নামের তালিকা যেগুলির জন্য আপনি গিট প্যাচ পেতে চান", + "Show/Hide Buttons": "দেখান/লুকান", + "Custom Font": "কাস্টম ফন্ট", + "Remove the custom font": "কাস্টম ফন্ট সরান", + "Lcd": "এলসিডি", + "Blue": "নীল", + "Zen": "জেন", + "Night": "রাত্রি", + "Starlight": "স্টারলাইট", + "Search banner image": "অনুসন্ধান ব্যানার চিত্র", + "Henge": "হেঙ্গে", + "QR Code": "QR কোড", + "Reminder": "অনুস্মারক", + "Scheduled note to yourself": "নিজেকে নির্ধারিত নোট", + "Replying to": "উত্তর দিচ্ছে", + "Send to": "পাঠানো", + "Show a list of addresses to send to": "পাঠাতে ঠিকানার একটি তালিকা দেখান", + "Petname": "ডাক নাম", + "Ok": "ঠিক আছে", + "This is nothing less than an utter triumph": "এটি একটি সম্পূর্ণ বিজয়ের চেয়ে কম কিছু নয়", + "Not Found": "পাওয়া যায়নি", + "These are not the droids you are looking for": "এগুলি আপনি যে ড্রয়েডগুলি খুঁজছেন তা নয়৷", + "Not changed": "পরিবর্তন নেই", + "The contents of your local cache are up to date": "আপনার স্থানীয় ক্যাশে বিষয়বস্তু আপ টু ডেট", + "Bad Request": "খারাপ অনুরোধ", + "Better luck next time": "পরবর্তিতে আরো ভাল ভাগ্য হোক", + "Unavailable": "অনুপলব্ধ", + "The server is busy. Please try again later": "সার্ভার ব্যস্ত. অনুগ্রহ করে একটু পরে আবার চেষ্টা করুন", + "Receive calendar events from this account": "এই অ্যাকাউন্ট থেকে ক্যালেন্ডার ইভেন্টগুলি পান৷", + "Grayscale": "গ্রেস্কেল", + "Liked by": "দ্বারা পছন্দ হয়েছে", + "Solidaric": "সলিডারিক", + "YouTube Replacement Domain": "YouTube প্রতিস্থাপন ডোমেন", + "Notes": "মন্তব্য", + "Allow replies.": "উত্তরের অনুমতি দিন।", + "Event": "ঘটনা", + "Event name": "অনুষ্ঠানের নাম", + "Events": "ঘটনা", + "Create an event": "একটি ইভেন্ট তৈরি করুন", + "Describe the event": "ঘটনার বর্ণনা দাও", + "Start Date": "শুরুর তারিখ", + "End Date": "শেষ তারিখ", + "Categories": "ক্যাটাগরি", + "This is a private event.": "এটি একটি ব্যক্তিগত অনুষ্ঠান।", + "Allow anonymous participation.": "বেনামী অংশগ্রহণের অনুমতি দিন.", + "Anyone can join": "যে কেউ যোগ দিতে পারেন", + "Apply to join": "যোগদানের জন্য আবেদন করুন", + "Invitation only": "শুধুমাত্র আমন্ত্রণ", + "Joining": "যোগদান", + "Status of the event": "অনুষ্ঠানের অবস্থা", + "Tentative": "সম্ভাব্য", + "Confirmed": "নিশ্চিত করা হয়েছে", + "Cancelled": "বাতিল", + "Event banner image description": "ইভেন্ট ব্যানার ছবির বিবরণ", + "Banner image": "ব্যানার ইমেজ", + "Maximum attendees": "সর্বাধিক উপস্থিতি", + "Ticket URL": "টিকিট URL", + "Create a new event": "একটি নতুন ইভেন্ট তৈরি করুন", + "Moderation policy or code of conduct": "সংযম নীতি বা আচরণবিধি", + "Edit event": "ইভেন্ট সম্পাদনা করুন", + "Notify when posts are liked": "পোস্ট লাইক হলে জানিয়ে দিন", + "Don't show the Like button": "লাইক বাটন দেখাবেন না", + "Autogenerated Hashtags": "স্বয়ংক্রিয়ভাবে তৈরি হ্যাশট্যাগ", + "Autogenerated Content Warnings": "স্বয়ংক্রিয় তৈরি বিষয়বস্তু সতর্কতা", + "Indymedia": "ইন্ডিমিডিয়া", + "Indymediaclassic": "ইন্ডিমিডিয়া ক্লাসিক", + "Indymediamodern": "ইন্ডিমিডিয়া আধুনিক", + "Hashtag Blocked": "হ্যাশট্যাগ অবরুদ্ধ", + "This is a blogging instance": "এটি একটি ব্লগিং উদাহরণ", + "Edit Links": "লিঙ্কগুলি সম্পাদনা করুন", + "One link per line. Description followed by the link.": "প্রতি লাইনে একটি লিঙ্ক। লিঙ্ক অনুসরণ করে বর্ণনা. শিরোনাম # দিয়ে শুরু হওয়া উচিত", + "Left column image": "বাম কলামের ছবি", + "Right column image": "ডান কলাম ইমেজ", + "RSS feed for this site": "এই সাইটের জন্য RSS ফিড", + "Edit newswire": "নিউজওয়্যার সম্পাদনা করুন", + "Add RSS feed links below.": "নীচে RSS ফিড লিঙ্ক. শুরুতে বা শেষে একটি * যোগ করুন যে একটি ফিড সংযত হওয়া উচিত। যুক্ত কর একটি ! শুরুতে বা শেষে নির্দেশ করে যে ফিডের বিষয়বস্তু মিরর করা উচিত।", + "Newswire RSS Feed": "নিউজওয়্যার আরএসএস ফিড", + "Nicknames whose blog entries appear on the newswire.": "ডাকনাম যাদের ব্লগ এন্ট্রি নিউজওয়্যারে প্রদর্শিত হয়।", + "Posts to be approved": "পোস্ট অনুমোদন করতে হবে", + "Discuss": "আলোচনা করা", + "Moderator Discussion": "মডারেটর আলোচনা", + "Vote": "ভোট", + "Remove Vote": "ভোট সরান", + "This is a news instance": "এটি একটি খবরের উদাহরণ", + "News": "খবর", + "Read more...": "আরও পড়ুন...", + "Edit News Post": "সংবাদ পোস্ট সম্পাদনা করুন", + "A list of editor nicknames. One per line.": "সম্পাদকের ডাকনামের একটি তালিকা। প্রতি লাইনে একটি।", + "Site Editors": "সাইট সম্পাদক", + "Allow news posts": "সংবাদ পোস্টের অনুমতি দিন", + "Publish": "প্রকাশ করুন", + "Publish a news article": "একটি সংবাদ নিবন্ধ প্রকাশ করুন", + "News tagging rules": "নিউজ ট্যাগ করার নিয়ম", + "See instructions": "নির্দেশাবলী দেখুন", + "Search": "অনুসন্ধান করুন", + "Newswire": "নিউজওয়্যার", + "Links": "লিঙ্ক", + "Post": "পোস্ট", + "User": "ব্যবহারকারী", + "Features" : "বৈশিষ্ট্য", + "Article": "প্রবন্ধ", + "Create an article": "একটি নিবন্ধ তৈরি করুন", + "Settings": "সেটিংস", + "Citations": "উদ্ধৃতি", + "Choose newswire items referenced in your article": "আপনার নিবন্ধে উল্লেখ করা নিউজওয়্যার আইটেম চয়ন করুন", + "RSS feed for your blog": "আপনার ব্লগের জন্য RSS ফিড", + "Create a new shared item": "একটি নতুন ভাগ করা আইটেম তৈরি করুন", + "Rc3": "Rc3", + "Hashtag origins": "হ্যাশট্যাগের উৎপত্তি", + "admin": "অ্যাডমিন", + "moderator": "মডারেটর", + "editor": "সম্পাদক", + "delegator": "প্রতিনিধি", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "RSS ফিড যোগ করতে সম্পাদনা আইকন নির্বাচন করুন", + "Select the edit icon to add web links": "ওয়েব লিঙ্ক যোগ করতে সম্পাদনা আইকন নির্বাচন করুন", + "Hashtag Categories RSS Feed": "হ্যাশট্যাগ বিভাগ RSS ফিড", + "Ask about a shared item.": "একটি ভাগ করা আইটেম সম্পর্কে জিজ্ঞাসা করুন.", + "Account Information": "হিসাবের তথ্য", + "This account interacts with the following instances": "এই অ্যাকাউন্টটি নিম্নলিখিত উদাহরণগুলির সাথে ইন্টারঅ্যাক্ট করে৷", + "News posts are moderated": "সংবাদ পোস্ট মডারেট করা হয়", + "Filter": "ছাঁকনি", + "Filter out words": "শব্দ ফিল্টার আউট", + "Unfilter": "আনফিল্টার", + "Unfilter words": "আনফিল্টার শব্দ", + "Show Accounts": "অ্যাকাউন্ট দেখান", + "Peertube Instances": "Peertube দৃষ্টান্ত", + "Show video previews for the following Peertube sites.": "নিম্নলিখিত Peertube সাইটগুলির জন্য ভিডিও পূর্বরূপ দেখান।", + "Follows you": "তোমাকে অনুসরন করে", + "Verify all signatures": "সমস্ত স্বাক্ষর যাচাই করুন", + "Blocked followers": "অবরুদ্ধ ফলোয়ার", + "Blocked following": "অনুসরণ ব্লক করা হয়েছে", + "Receives posts from the following accounts": "নিম্নলিখিত অ্যাকাউন্ট থেকে পোস্ট গ্রহণ", + "Sends out posts to the following accounts": "নিম্নলিখিত অ্যাকাউন্টে পোস্ট পাঠায়", + "Word frequencies": "শব্দ ফ্রিকোয়েন্সি", + "New account": "নতুন হিসাব", + "Moved to new account address": "নতুন অ্যাকাউন্ট ঠিকানায় সরানো হয়েছে৷", + "Yet another Epicyon Instance": "এখনও আরেকটি Epicyon উদাহরণ", + "Other accounts": "অন্যান্য ফেডিভার্স অ্যাকাউন্ট", + "Pin this post to your profile.": "এই পোস্টটি আপনার প্রোফাইলে পিন করুন।", + "Administered by": "দ্বারা পরিচালিত", + "Version": "সংস্করণ", + "Skip to timeline": "টাইমলাইনে এড়িয়ে যান", + "Skip to Newswire": "নিউজওয়্যারে এড়িয়ে যান", + "Skip to Links": "লিঙ্ক এড়িয়ে যান", + "Publish a blog article": "একটি ব্লগ নিবন্ধ প্রকাশ করুন", + "Featured writer": "আলোচিত লেখক", + "Broch mode": "ব্রোচ মোড", + "Pixel": "পিক্সেল", + "DM bounce": "বার্তা শুধুমাত্র অনুসরণ করা অ্যাকাউন্ট থেকে গ্রহণ করা হয়", + "Next": "পরবর্তী", + "Preview": "পূর্বরূপ", + "Linked": "ওয়েব লিঙ্ক করা হয়েছে", + "hashtag": "হ্যাশট্যাগ", + "smile": "হাসি", + "wink": "পলক", + "mentioning": "উল্লেখ", + "sad face": "গোমরা মুখ", + "thinking emoji": "চিন্তার ইমোজি", + "laughing": "হাস্যময়", + "gender": "লিঙ্গ", + "He/Him": "সে/তাকে", + "She/Her": "সে/তার", + "girl": "মেয়ে", + "boy": "ছেলে", + "pronoun": "সর্বনাম", + "Type of instance": "উদাহরণের ধরন", + "Security": "নিরাপত্তা", + "Enabling broch mode": "ব্রোচ মোড সক্রিয় করা আক্রমণের বিরুদ্ধে একটি অস্থায়ী দুর্গ প্রদান করে। শুধুমাত্র ইতিমধ্যে পরিচিত উদাহরণ দ্বারা পোস্ট গ্রহণ করা হবে. যদি বন্ধ না করা হয়, এটি এক সপ্তাহ পরে শেষ হয়ে যায়।", + "Instance Settings": "ইনস্ট্যান্স সেটিংস", + "Video Settings": "ভিডিও সেটিংস", + "Filtering and Blocking": "ফিল্টারিং এবং ব্লকিং", + "Role Assignment": "ভূমিকা অ্যাসাইনমেন্ট", + "Contact Details": "যোগাযোগের ঠিকানা", + "Background Images": "পটভূমি ছবি", + "heart": "হৃদয়", + "counselor": "পরামর্শদাতা", + "Counselors": "পরামর্শদাতা", + "shocked": "বিস্মিত", + "Encrypted": "এনক্রিপ্ট করা", + "Direct Message permitted instances": "সরাসরি বার্তা অনুমোদিত উদাহরণ", + "Direct messages are always allowed from these instances.": "এই দৃষ্টান্ত থেকে সরাসরি বার্তা সবসময় অনুমোদিত হয়.", + "Key Shortcuts": "কী শর্টকাট", + "menuTimeline": "টাইমলাইন ভিউ", + "menuEdit": "সম্পাদনা করুন", + "menuProfile": "প্রোফাইল ভিউ", + "menuInbox": "ইনবক্স", + "menuSearch": "অনুসন্ধান/অনুসরণ করুন", + "menuNewPost": "নতুন পোস্ট", + "menuNewBlog": "নতুন ব্লগ", + "menuCalendar": "ক্যালেন্ডার", + "menuDM": "সরাসরি বার্তা", + "menuReplies": "উত্তর", + "menuOutbox": "পাঠানো হয়েছে", + "menuBookmarks": "বুকমার্ক", + "menuShares": "ভাগ করা আইটেম", + "menuBlogs": "ব্লগ", + "menuNewswire": "নিউজওয়্যার", + "menuLinks": "লিঙ্ক", + "menuModeration": "সংযম", + "menuFollowing": "অনুসরণ করছে", + "menuFollowers": "অনুগামী", + "menuRoles": "ভূমিকা", + "menuSkills": "দক্ষতা", + "menuLogout": "প্রস্থান", + "menuKeys": "কী শর্টকাট", + "submitButton": "জমা বাটন", + "menuMedia": "মিডিয়া", + "followButton": "ফলো/আনফলো বোতাম", + "blockButton": "ব্লক বোতাম", + "infoButton": "তথ্য বোতাম", + "snoozeButton": "তন্দ্রা বোতাম", + "reportButton": "রিপোর্ট বোতাম", + "viewButton": "দেখুন বোতাম", + "enterPetname": "পেটের নাম লিখুন", + "enterNotes": "নোট লিখুন", + "These access keys may be used": "এই অ্যাক্সেস কীগুলি সাধারণত ALT + SHIFT + কী বা ALT + কী দিয়ে ব্যবহার করা যেতে পারে", + "Show numbers of accounts within instance metadata": "উদাহরণ মেটাডেটার মধ্যে অ্যাকাউন্টের সংখ্যা দেখান", + "Show version number within instance metadata": "উদাহরণ মেটাডেটার মধ্যে সংস্করণ নম্বর দেখান", + "Joined": "যোগদান করেছেন", + "City for spoofed GPS image metadata": "স্পুফড জিপিএস ইমেজ মেটাডেটার জন্য শহর", + "Occupation": "পেশা", + "Artists": "শিল্পী", + "Graphic Design": "গ্রাফিক ডিজাইন", + "Import Theme": "থিম আমদানি করুন", + "Export Theme": "থিম রপ্তানি করুন", + "Custom post submit button text": "কাস্টম পোস্ট জমা বোতাম পাঠ্য", + "Blocked User Agents": "ব্লকড ইউজার এজেন্ট", + "Notify me when this account posts": "এই অ্যাকাউন্ট পোস্ট যখন আমাকে অবহিত", + "Languages": "ভাষা", + "Translated": "অনূদিত", + "Quantity": "পরিমাণ", + "food": "খাদ্য", + "Price": "দাম", + "Currency": "মুদ্রা", + "List of domains which can access the shared items catalog": "ডোমেনের তালিকা যা শেয়ার করা আইটেম ক্যাটালগ অ্যাক্সেস করতে পারে", + "Shares Catalog": "শেয়ার ক্যাটালগ", + "tool": "টুল", + "clothes": "বস্ত্র", + "medical": "চিকিৎসা", + "Wanted": "চেয়েছিলেন", + "Describe something wanted": "কিছু চেয়েছিলেন বর্ণনা করুন", + "Enter the details for your wanted item below.": "নীচে আপনার কাঙ্ক্ষিত আইটেম জন্য বিশদ লিখুন.", + "Name of the wanted item": "কাঙ্ক্ষিত আইটেমের নাম", + "Description of the item wanted": "চাই আইটেম বর্ণনা", + "Type of wanted item. eg. hat": "চাই আইটেম প্রকার. যেমন টুপি", + "Category of wanted item. eg. clothes": "চাই আইটেম বিভাগ. যেমন বস্ত্র", + "City or location of the wanted item": "কাঙ্ক্ষিত আইটেমের শহর বা অবস্থান", + "Maximum Price": "সর্বোচ্চ মূল্য", + "Create a new wanted item": "একটি নতুন চাই আইটেম তৈরি করুন", + "Wanted Items Search": "চাই আইটেম অনুসন্ধান", + "Website": "ওয়েবসাইট", + "Low Bandwidth": "দুর্বল ইন্টারনেট সংযোগ", + "accommodation": "বাসস্থান", + "Forbidden": "নিষিদ্ধ", + "You're not allowed": "আপনাকে অনুমতি দেওয়া হচ্ছে না", + "Hours after posting during which replies are allowed": "পোস্ট করার কয়েক ঘণ্টা পর উত্তর দেওয়া যাবে", + "Twitter": "টুইটার", + "Twitter Replacement Domain": "টুইটার প্রতিস্থাপন ডোমেন", + "Buy": "কেনা", + "Request to stay": "থাকার অনুরোধ রইল", + "Profile": "প্রোফাইল", + "Introduce yourself and specify the date and time when you wish to stay": "আপনার পরিচয় দিন এবং আপনি কখন থাকতে চান সেই তারিখ ও সময় উল্লেখ করুন", + "Members": "সদস্যরা", + "Join": "যোগদান করুন", + "Leave": "ছেড়ে দিন", + "System Monitor": "সিস্টেম মনিটর", + "Add content warnings for the following sites": "নিম্নলিখিত সাইটের জন্য বিষয়বস্তু সতর্কতা যোগ করুন", + "Known Web Crawlers": "পরিচিত ওয়েব ক্রলার", + "Add to the calendar": "ক্যালেন্ডারে যোগ করুন", + "Content License": "বিষয়বস্তুর লাইসেন্স", + "Reaction by": "দ্বারা প্রতিক্রিয়া", + "Notify on emoji reactions": "ইমোজি প্রতিক্রিয়া সম্পর্কে অবহিত করুন", + "Select reaction": "প্রতিক্রিয়া", + "Don't show the Reaction button": "প্রতিক্রিয়া বোতামটি দেখাবেন না", + "New feed URL": "নতুন ফিড URL", + "New link title and URL": "নতুন লিঙ্ক শিরোনাম এবং URL", + "Theme Designer": "থিম ডিজাইনার", + "Reset": "রিসেট", + "Encryption Keys": "এনক্রিপশন কী", + "Filtered words within bio": "জীবনী মধ্যে ফিল্টার করা শব্দ", + "Write your news report": "আপনার সংবাদ প্রতিবেদন লিখুন", + "Dyslexic font": "ডিসলেক্সিক ফন্ট", + "Leave a comment": "মতামত দিন", + "View comments": "মন্তব্য দেখুন", + "Multi Status": "মাল্টি স্ট্যাটাস", + "Lots of things": "অনেক কিছু", + "Created": "তৈরি হয়েছে", + "It is done": "এটা করা হয়", + "Time Zone": "সময় অঞ্চল", + "Show who liked this post": "কে এই পোস্ট পছন্দ করেছে দেখান", + "Show who repeated this post": "কে এই পোস্টটি পুনরাবৃত্তি করেছে তা দেখান", + "Repeated by": "দ্বারা পুনরাবৃত্তি", + "Register": "নিবন্ধন", + "Web Bots Allowed": "ওয়েব অনুসন্ধান বট অনুমোদিত", + "Known Search Bots": "পরিচিত ওয়েব অনুসন্ধান বট", + "mitm": "বার্তাটি তৃতীয় পক্ষের দ্বারা পড়া বা সংশোধন করা যেতে পারে", + "Bold reading": "সাহসী পড়া", + "SHOW EDITS": "সম্পাদনাগুলি দেখান৷", + "Attach an image, video or audio file": "একটি ছবি, ভিডিও বা অডিও ফাইল সংযুক্ত করুন", + "Set a place and time": "একটি স্থান এবং সময় সেট করুন", + "Describe your attachment": "আপনার সংযুক্তি বর্ণনা করুন", + "Language used": "ভাষা ব্যবহার করা হয়েছে", + "lang_ar": "আরবি", + "lang_bn": "বাংলা", + "lang_cy": "ওয়েলশ", + "lang_en": "ইংরেজি", + "lang_fr": "ফরাসি", + "lang_hi": "হিন্দি", + "lang_ja": "জাপানিজ", + "lang_ku": "কুর্দি", + "lang_pl": "পোলিশ", + "lang_ru": "রাশিয়ান", + "lang_uk": "ইউক্রেনীয়", + "lang_ca": "কাতালান", + "lang_de": "জার্মান", + "lang_es": "স্পেনীয়", + "lang_ga": "আইরিশ", + "lang_it": "ইতালীয়", + "lang_ko": "কোরিয়ান", + "lang_oc": "অক্সিটান", + "lang_pt": "পর্তুগীজ", + "lang_sw": "সোয়াহিলি", + "lang_tr": "তুর্কি", + "lang_zh": "চাইনিজ", + "lang_nl": "ডাচ", + "lang_el": "গ্রীক", + "lang_yi": "য়িদ্দিশ", + "Common emoji": "সাধারণ ইমোজি", + "Copy and paste into your text": "আপনার টেক্সট কপি এবং পেস্ট করুন", + "shrug": "ঝাঁকান", + "DM warning": "সরাসরি বার্তাগুলি এন্ড-টু-এন্ড এনক্রিপ্ট করা হয় না। এখানে কোনো অতি সংবেদনশীল তথ্য শেয়ার করবেন না।", + "Transcript": "প্রতিলিপি", + "Color contrast is too low": "রঙের বৈসাদৃশ্য খুব কম", + "View Larger Map": "বড় মানচিত্র দেখুন", + "Start Time": "সময় শুরু", + "End Time": "শেষ সময়", + "Switch to calendar view": "ক্যালেন্ডার ভিউতে স্যুইচ করুন", + "Save": "সংরক্ষণ", + "Switch to moderation view": "সংযম দৃশ্যে স্যুইচ করুন", + "Minimize attached images": "সংযুক্ত ছবি ছোট করুন", + "SHOW MEDIA": "মিডিয়া দেখান", + "ActivityPub Specification": "ActivityPub স্পেসিফিকেশন", + "Dogwhistle words": "কুকুরের হুইসেল শব্দ", + "Content warnings will be added for the following": "নিম্নলিখিত জন্য বিষয়বস্তু সতর্কতা যোগ করা হবে", + "nowplaying": "এখন চলছে", + "NowPlaying": "এখন চলছে", + "Import and Export": "আমদানি এবং রপ্তানি", + "Import Follows": "আমদানি অনুসরণ করে", + "Post expiry period in days": "দিনের মধ্যে মেয়াদ শেষ হওয়ার পরে", + "Keep DMs during post expiry": "পোস্টের মেয়াদ শেষ হওয়ার সময় সরাসরি বার্তা রাখুন", + "Notifications": "বিজ্ঞপ্তি", + "ntfy URL": "ntfy ইউআরএল", + "ntfy topic": "ntfy বিষয়", + "Last hour": "শেষ ঘন্টা", + "Last 3 hours": "শেষ ৩ ঘন্টা", + "Last 6 hours": "শেষ ৬ ঘণ্টা", + "Last 12 hours": "শেষ 12 ঘন্টা", + "Last day": "শেষ দিন", + "Last 2 days": "গত ২ দিন", + "Last week": "গত সপ্তাহে", + "Last 2 weeks": "গত ২ সপ্তাহ", + "Last month": "গত মাসে", + "Last 6 months": "গত ৬ মাস", + "Last year": "গত বছর", + "Unauthorized": "অননুমোদিত", + "No login credentials were posted": "কোনো লগইন শংসাপত্র পোস্ট করা হয়নি", + "Credentials are too long": "শংসাপত্রগুলি খুব দীর্ঘ৷", + "Site DevOps": "সাইট DevOps", + "A list of devops nicknames. One per line.": "ডেভপস ডাকনামের একটি তালিকা। প্রতি লাইনে একটি।", + "devops": "devops", + "Reject spam accounts": "স্প্যাম অ্যাকাউন্ট প্রত্যাখ্যান করুন" +} diff --git a/translations/ca.json b/translations/ca.json index 7e3920654..1384007cc 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -412,6 +412,7 @@ "menuInbox": "Capa inferior", "menuSearch": "Cerca / Segueix", "menuNewPost": "Nou missatge", + "menuNewBlog": "Nova entrada al blog", "menuCalendar": "Calendari", "menuDM": "Missatges directes", "menuReplies": "Resum", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Presenteu-vos i especifiqueu la data i l’hora en què voleu romandre", "Members": "Membres", "Join": "Uneix-te", - "Leave": "Marxa" + "Leave": "Marxa", + "System Monitor": "Monitor del sistema", + "Add content warnings for the following sites": "Afegiu advertiments de contingut per als llocs següents", + "Known Web Crawlers": "Exploradors web coneguts", + "Add to the calendar": "Afegeix al calendari", + "Content License": "Llicència de contingut", + "Reaction by": "Reacció de", + "Notify on emoji reactions": "Notificar sobre les reaccions dels emojis", + "Select reaction": "Seleccioneu la reacció", + "Don't show the Reaction button": "No mostris el botó de reacció", + "New feed URL": "URL de feed nou", + "New link title and URL": "Títol i URL de l'enllaç nous", + "Theme Designer": "Dissenyador temàtic", + "Reset": "Restableix", + "Encryption Keys": "Claus de xifratge", + "Filtered words within bio": "Paraules filtrades dins de la biografia", + "Write your news report": "Escriu la teva notícia", + "Dyslexic font": "Tipus de lletra dislèxic", + "Leave a comment": "Deixa un comentari", + "View comments": "Veure comentaris", + "Multi Status": "Estat múltiple", + "Lots of things": "Moltes coses", + "Created": "Creat", + "It is done": "Esta fet", + "Time Zone": "Fus horari", + "Show who liked this post": "Mostra a qui li agrada aquesta publicació", + "Show who repeated this post": "Mostra qui ha repetit aquesta publicació", + "Repeated by": "Repetit per", + "Register": "Registra't", + "Web Bots Allowed": "Bots web permesos", + "Known Search Bots": "Bots de cerca web coneguts", + "mitm": "El missatge podria haver estat llegit o modificat per un tercer", + "Bold reading": "Lectura atrevida", + "SHOW EDITS": "MOSTRA EDICIONS", + "Attach an image, video or audio file": "Adjunteu un fitxer d'imatge, vídeo o àudio", + "Set a place and time": "Estableix un lloc i una hora", + "Describe your attachment": "Descriu el teu adjunt", + "Language used": "Llengua utilitzada", + "lang_ar": "àrab", + "lang_bn": "Bengalí", + "lang_cy": "Gal·lès", + "lang_en": "Anglès", + "lang_fr": "Francès", + "lang_hi": "Hindi", + "lang_ja": "Japonès", + "lang_ku": "Kurd", + "lang_pl": "Polonès", + "lang_ru": "Rus", + "lang_uk": "Ucraïnès", + "lang_ca": "Català", + "lang_de": "Alemany", + "lang_es": "Espanyol", + "lang_ga": "Irlandès", + "lang_it": "Italià", + "lang_ko": "Coreà", + "lang_oc": "Occità", + "lang_pt": "Portuguès", + "lang_sw": "Suahili", + "lang_tr": "Turc", + "lang_zh": "Xinès", + "lang_nl": "Holandès", + "lang_el": "Grec", + "lang_yi": "Yiddish", + "Common emoji": "Emoji comú", + "Copy and paste into your text": "Copia i enganxa al teu text", + "shrug": "arronsar les espatlles", + "DM warning": "Els missatges directes no estan xifrats d'extrem a extrem. No compartiu cap informació molt sensible aquí.", + "Transcript": "Transcripció", + "Color contrast is too low": "El contrast de color és massa baix", + "View Larger Map": "Veure mapa més gran", + "Start Time": "L'hora d'inici", + "End Time": "Temps esgotat", + "Switch to calendar view": "Canvia a la vista del calendari", + "Save": "Desa", + "Switch to moderation view": "Canvia a la visualització de moderació", + "Minimize attached images": "Minimitzar les imatges adjuntes", + "SHOW MEDIA": "MOSTRA ELS MITJANS", + "ActivityPub Specification": "Especificació d'ActivityPub", + "Dogwhistle words": "Paraules de xiulet", + "Content warnings will be added for the following": "S'afegiran advertències de contingut per al següent", + "nowplaying": "arajugant", + "NowPlaying": "AraJugant", + "Import and Export": "Importació i Exportació", + "Import Follows": "Segueix la importació", + "Post expiry period in days": "Període posterior a la caducitat en dies", + "Keep DMs during post expiry": "Conserveu els missatges directes durant la caducitat posterior", + "Notifications": "Notificacions", + "ntfy URL": "URL ntfy", + "ntfy topic": "tema ntfy", + "Last hour": "Última hora", + "Last 3 hours": "Últimes 3 hores", + "Last 6 hours": "Últimes 6 hores", + "Last 12 hours": "Últimes 12 hores", + "Last day": "L'últim dia", + "Last 2 days": "2 darrers dies", + "Last week": "La setmana passada", + "Last 2 weeks": "Últimes 2 setmanes", + "Last month": "El mes passat", + "Last 6 months": "Últims 6 mesos", + "Last year": "L'any passat", + "Unauthorized": "No autoritzat", + "No login credentials were posted": "No s'ha publicat cap credencial d'inici de sessió", + "Credentials are too long": "Les credencials són massa llargues", + "Site DevOps": "Lloc DevOps", + "A list of devops nicknames. One per line.": "Una llista de sobrenoms de devops. Un per línia.", + "devops": "devops", + "Reject spam accounts": "Rebutja els comptes de correu brossa" } diff --git a/translations/cy.json b/translations/cy.json index 337eea82a..1f7245ab7 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -412,6 +412,7 @@ "menuInbox": "Mewnflwch", "menuSearch": "Chwilio / Dilyn", "menuNewPost": "Swydd newydd", + "menuNewBlog": "Post blog newydd", "menuCalendar": "Galendr", "menuDM": "Negeseuon Uniongyrchol", "menuReplies": "Atebion", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Cyflwynwch eich hun a nodwch y dyddiad a'r amser pan fyddwch yn dymuno aros", "Members": "Aelodau", "Join": "Ymunwch", - "Leave": "Gadewch" + "Leave": "Gadewch", + "System Monitor": "Monitor System", + "Add content warnings for the following sites": "Ychwanegwch rybuddion cynnwys ar gyfer y gwefannau canlynol", + "Known Web Crawlers": "Crawlers Gwe Hysbys", + "Add to the calendar": "Ychwanegwch at y calendr", + "Content License": "Trwydded Cynnwys", + "Reaction by": "Ymateb gan", + "Notify on emoji reactions": "Hysbysu ar ymatebion emoji", + "Select reaction": "Dewiswch adwaith", + "Don't show the Reaction button": "Peidiwch â dangos y botwm Adwaith", + "New feed URL": "URL porthiant newydd", + "New link title and URL": "Teitl dolen ac URL newydd", + "Theme Designer": "Dylunydd Thema", + "Reset": "Ail gychwyn", + "Encryption Keys": "Allweddi Amgryptio", + "Filtered words within bio": "Geiriau wedi'u hidlo o fewn cofiant", + "Write your news report": "Ysgrifennwch eich adroddiad newyddion", + "Dyslexic font": "Ffont dyslecsig", + "Leave a comment": "Gadael sylw", + "View comments": "Gweld sylwadau", + "Multi Status": "Statws Aml", + "Lots of things": "Llawer o pethau", + "Created": "Wedi creu", + "It is done": "Mae'n cael ei wneud", + "Time Zone": "Parth Amser", + "Show who liked this post": "Dangoswch pwy oedd yn hoffi'r post hwn", + "Show who repeated this post": "Dangoswch pwy ailadroddodd y post hwn", + "Repeated by": "Ailadrodd gan", + "Register": "Cofrestrwch", + "Web Bots Allowed": "Web Bots a Ganiateir", + "Known Search Bots": "Bots Chwilio Gwe Hysbys", + "mitm": "Gallai'r neges fod wedi cael ei darllen neu ei haddasu gan drydydd parti", + "Bold reading": "Darllen beiddgar", + "SHOW EDITS": "GOLYGIADAU SIOE", + "Attach an image, video or audio file": "Atodwch ddelwedd, fideo neu ffeil sain", + "Set a place and time": "Gosodwch le ac amser", + "Describe your attachment": "Disgrifiwch eich atodiad", + "Language used": "Iaith a ddefnyddir", + "lang_ar": "Arabeg", + "lang_bn": "Bengali", + "lang_cy": "Cymraeg", + "lang_en": "Saesneg", + "lang_fr": "Ffrangeg", + "lang_hi": "Hindi", + "lang_ja": "Japaneaidd", + "lang_ku": "Cwrdaidd", + "lang_pl": "Pwyleg", + "lang_ru": "Rwsieg", + "lang_uk": "Wcrain", + "lang_ca": "Catalaneg", + "lang_de": "Almaeneg", + "lang_es": "Sbaeneg", + "lang_ga": "Gwyddelod", + "lang_it": "Eidaleg", + "lang_ko": "Corëeg", + "lang_oc": "Ocsitaneg", + "lang_pt": "Portiwgaleg", + "lang_sw": "Swahili", + "lang_tr": "Twrceg", + "lang_zh": "Tseiniaidd", + "lang_nl": "Iseldireg", + "lang_el": "Groeg", + "lang_yi": "Iddeweg", + "Common emoji": "Emoji cyffredin", + "Copy and paste into your text": "Copïwch a gludwch i'ch testun", + "shrug": "shrug", + "DM warning": "Nid yw negeseuon uniongyrchol wedi'u hamgryptio o'r dechrau i'r diwedd. Peidiwch â rhannu unrhyw wybodaeth hynod sensitif yma.", + "Transcript": "Trawsgrifiad", + "Color contrast is too low": "Mae cyferbyniad lliw yn rhy isel", + "View Larger Map": "Gweld Map Mwy", + "Start Time": "Amser Dechrau", + "End Time": "Amser Gorffen", + "Switch to calendar view": "Newid i wedd calendr", + "Save": "Arbed", + "Switch to moderation view": "Newid i wedd safoni", + "Minimize attached images": "Lleihau delweddau sydd ynghlwm", + "SHOW MEDIA": "DANGOS CYFRYNGAU", + "ActivityPub Specification": "Manyleb GweithgareddPub", + "Dogwhistle words": "Geiriau chwibanogl", + "Content warnings will be added for the following": "Bydd rhybuddion cynnwys yn cael eu hychwanegu ar gyfer y canlynol", + "nowplaying": "nawrynchwarae", + "NowPlaying": "NawrYnChwarae", + "Import and Export": "Mewnforio ac Allforio", + "Import Follows": "Mewnforio Dilyn", + "Post expiry period in days": "Cyfnod ar ôl dod i ben mewn dyddiau", + "Keep DMs during post expiry": "Cadwch Negeseuon Uniongyrchol pan ddaw'r post i ben", + "Notifications": "Hysbysiadau", + "ntfy URL": "ntfy URL", + "ntfy topic": "pwnc ntfy", + "Last hour": "Awr olaf", + "Last 3 hours": "3 awr diwethaf", + "Last 6 hours": "6 awr diwethaf", + "Last 12 hours": "12 awr diwethaf", + "Last day": "Diwrnod olaf", + "Last 2 days": "2 ddiwrnod diwethaf", + "Last week": "Wythnos diwethaf", + "Last 2 weeks": "2 wythnos diwethaf", + "Last month": "Mis diwethaf", + "Last 6 months": "6 mis diwethaf", + "Last year": "Blwyddyn diwethaf", + "Unauthorized": "Anawdurdodedig", + "No login credentials were posted": "Ni bostiwyd unrhyw fanylion mewngofnodi", + "Credentials are too long": "Mae manylion yn rhy hir", + "Site DevOps": "Gwefan DevOps", + "A list of devops nicknames. One per line.": "Mae rhestr o devops llysenwau. Un i bob llinell.", + "devops": "devops", + "Reject spam accounts": "Gwrthod cyfrifon sbam" } diff --git a/translations/de.json b/translations/de.json index 9abc39dc1..3e9b6f24b 100644 --- a/translations/de.json +++ b/translations/de.json @@ -412,6 +412,7 @@ "menuInbox": "Inbox", "menuSearch": "Suche / Folgen", "menuNewPost": "Neuer Beitrag", + "menuNewBlog": "Neuer Blogbeitrag", "menuCalendar": "Kalender", "menuDM": "Direkte Nachrichten", "menuReplies": "Antworten", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Stellen Sie sich vor und geben Sie Datum und Uhrzeit Ihres Aufenthalts an", "Members": "Mitglieder", "Join": "Verbinden", - "Leave": "Verlassen" + "Leave": "Verlassen", + "System Monitor": "Systemmonitor", + "Add content warnings for the following sites": "Inhaltswarnungen für die folgenden Websites hinzufügen", + "Known Web Crawlers": "Bekannte Web-Crawler", + "Add to the calendar": "Zum Kalender hinzufügen", + "Content License": "Inhaltslizenz", + "Reaction by": "Reaktion von", + "Notify on emoji reactions": "Bei Emoji-Reaktionen benachrichtigen", + "Select reaction": "Reaktion auswählen", + "Don't show the Reaction button": "Reaktionstaste nicht anzeigen", + "New feed URL": "Neue Feed-URL", + "New link title and URL": "Neuer Linktitel und URL", + "Theme Designer": "Themendesigner", + "Reset": "Zurücksetzen", + "Encryption Keys": "Verschlüsselungsschlüssel", + "Filtered words within bio": "Gefilterte Wörter in der Biografie", + "Write your news report": "Schreiben Sie Ihren Nachrichtenbericht", + "Dyslexic font": "Schriftart für Legastheniker", + "Leave a comment": "Hinterlasse einen Kommentar", + "View comments": "Kommentare ansehen", + "Multi Status": "Multi-Status", + "Lots of things": "Viele Dinge", + "Created": "Erstellt", + "It is done": "Es ist vollbracht", + "Time Zone": "Zeitzone", + "Show who liked this post": "Zeigen, wem dieser Beitrag gefallen hat", + "Show who repeated this post": "Zeigen Sie, wer diesen Beitrag wiederholt hat", + "Repeated by": "Wiederholt von", + "Register": "Registrieren", + "Web Bots Allowed": "Webbots erlaubt", + "Known Search Bots": "Bekannte Bots für die Websuche", + "mitm": "Die Nachricht könnte von einem Dritten gelesen oder geändert worden sein", + "Bold reading": "Mutige Lektüre", + "SHOW EDITS": "BEARBEITUNGEN ZEIGEN", + "Attach an image, video or audio file": "Hängen Sie eine Bild-, Video- oder Audiodatei an", + "Set a place and time": "Legen Sie einen Ort und eine Zeit fest", + "Describe your attachment": "Beschreiben Sie Ihren Anhang", + "Language used": "Sprache verwendet", + "lang_ar": "Arabisch", + "lang_bn": "Bengali", + "lang_cy": "Walisisch", + "lang_en": "Englisch", + "lang_fr": "Französisch", + "lang_hi": "Hindi", + "lang_ja": "Japanisch", + "lang_ku": "Kurdisch", + "lang_pl": "Polieren", + "lang_ru": "Russisch", + "lang_uk": "Ukrainisch", + "lang_ca": "Katalanisch", + "lang_de": "Deutsch", + "lang_es": "Spanisch", + "lang_ga": "Irisch", + "lang_it": "Italienisch", + "lang_ko": "Koreanisch", + "lang_oc": "Okzitanisch", + "lang_pt": "Portugiesisch", + "lang_sw": "Suaheli", + "lang_tr": "Türkisch", + "lang_zh": "Chinesisch", + "lang_nl": "Niederländisch", + "lang_el": "Griechisch", + "lang_yi": "Jiddisch", + "Common emoji": "Gewöhnliches Emoji", + "Copy and paste into your text": "Kopieren und in Ihren Text einfügen", + "shrug": "zucken", + "DM warning": "Direktnachrichten sind nicht Ende-zu-Ende verschlüsselt. Geben Sie hier keine hochsensiblen Informationen weiter.", + "Transcript": "Abschrift", + "Color contrast is too low": "Der Farbkontrast ist zu gering", + "View Larger Map": "größere Karte ansehen", + "Start Time": "Startzeit", + "End Time": "Endzeit", + "Switch to calendar view": "Zur Kalenderansicht wechseln", + "Save": "Speichern", + "Switch to moderation view": "Wechseln Sie zur Moderationsansicht", + "Minimize attached images": "Angehängte Bilder minimieren", + "SHOW MEDIA": "MEDIEN ZEIGEN", + "ActivityPub Specification": "ActivityPub-Spezifikation", + "Dogwhistle words": "Hundepfeife Worte", + "Content warnings will be added for the following": "Inhaltswarnungen werden für Folgendes hinzugefügt", + "nowplaying": "läuftgerade", + "NowPlaying": "LäuftGerade", + "Import and Export": "Import und Export", + "Import Follows": "Import folgt", + "Post expiry period in days": "Nachablaufzeitraum in Tagen", + "Keep DMs during post expiry": "Bewahren Sie Direktnachrichten während des Ablaufs auf", + "Notifications": "Benachrichtigungen", + "ntfy URL": "ntfy-URL", + "ntfy topic": "ntfy-Thema", + "Last hour": "Letzte Stunde", + "Last 3 hours": "Die letzten 3 Stunden", + "Last 6 hours": "Die letzten 6 Stunden", + "Last 12 hours": "Die letzten 12 Stunden", + "Last day": "Letzter Tag", + "Last 2 days": "Die letzten 2 Tage", + "Last week": "Letzte Woche", + "Last 2 weeks": "Letzte 2 Wochen", + "Last month": "Im vergangenen Monat", + "Last 6 months": "Letzte 6 Monate", + "Last year": "Vergangenes Jahr", + "Unauthorized": "Unbefugt", + "No login credentials were posted": "Es wurden keine Zugangsdaten gepostet", + "Credentials are too long": "Anmeldeinformationen sind zu lang", + "Site DevOps": "Site-DevOps", + "A list of devops nicknames. One per line.": "Eine Liste von Entwickler-Spitznamen. Eine pro Zeile.", + "devops": "devops", + "Reject spam accounts": "Gwrthod cyfrifon sbam" } diff --git a/translations/el.json b/translations/el.json new file mode 100644 index 000000000..809695903 --- /dev/null +++ b/translations/el.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "ΔΕΙΤΕ ΠΕΡΙΣΣΟΤΕΡΑ", + "Your browser does not support the video tag.": "Το πρόγραμμα περιήγησής σας δεν υποστηρίζει την ετικέτα βίντεο.", + "Your browser does not support the audio tag.": "Το πρόγραμμα περιήγησής σας δεν υποστηρίζει την ετικέτα ήχου.", + "Show profile": "Εμφάνιση προφίλ", + "Show options for this person": "Εμφάνιση επιλογών για αυτό το άτομο", + "Repeat this post": "Επαναλαμβάνω", + "Undo the repeat": "Αναίρεση της επανάληψης", + "Like this post": "Αρέσει", + "Undo the like": "Διαφορετικός", + "Delete this post": "Διαγράφω", + "Delete this event": "Διαγράφω", + "Reply to this post": "Απάντηση", + "Write your post text below.": "Νέα ανάρτηση", + "Write your reply to": "Γράψτε την απάντησή σας σε", + "this post": "αυτή η ανάρτηση", + "Write your report below.": "Γράψτε την αναφορά σας παρακάτω.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Αυτό το μήνυμα πηγαίνει μόνο σε επόπτες, ακόμα κι αν αναφέρει άλλες διαφορετικές διευθύνσεις.", + "Also see": "Δείτε επίσης", + "Terms of Service": "Όροι χρήσης", + "Enter the details for your shared item below.": "Εισαγάγετε τα στοιχεία για το κοινόχρηστο στοιχείο σας παρακάτω.", + "Subject or Content Warning (optional)": "Προειδοποίηση θέματος ή περιεχομένου (προαιρετικό)", + "Write something": "Γράψε κάτι", + "Name of the shared item": "Όνομα του κοινόχρηστου στοιχείου", + "Description of the item being shared": "Περιγραφή του στοιχείου που κοινοποιείται", + "Type of shared item. eg. hat": "Τύπος κοινόχρηστου στοιχείου. π.χ. καπέλο", + "Category of shared item. eg. clothing": "Κατηγορία κοινόχρηστου στοιχείου. π.χ. είδη ένδυσης", + "Duration of listing in days": "Διάρκεια καταχώρισης σε ημέρες", + "City or location of the shared item": "Πόλη ή τοποθεσία του κοινόχρηστου στοιχείου", + "Describe a shared item": "Περιγράψτε ένα κοινόχρηστο στοιχείο", + "Public": "Δημόσιο", + "Visible to anyone": "Ορατό σε οποιονδήποτε", + "Unlisted": "Ακαταχώριστος", + "Not on public timeline": "Όχι σε δημόσιο χρονοδιάγραμμα", + "Followers": "Οπαδοί", + "Only to followers": "Μόνο σε οπαδούς", + "DM": "Αμεσο μήνυμα", + "Only to mentioned people": "Μόνο στα αναφερόμενα άτομα", + "Report": "Κανω ΑΝΑΦΟΡΑ", + "Send to moderators": "Αποστολή στους συντονιστές", + "Search for emoji": "Αναζήτηση για emoji", + "Cancel": "✘", + "Submit": "υποβάλλουν", + "Image description": "περιγραφή εικόνας", + "Item image": "Εικόνα στοιχείου", + "Type": "Τύπος", + "Category": "Κατηγορία", + "Location": "Τοποθεσία", + "Login": "Σύνδεση", + "Edit": "Επεξεργασία", + "Switch to timeline view": "Προβολή χρονολογίου", + "Approve": "Εγκρίνω", + "Deny": "Αρνούμαι", + "Posts": "Αναρτήσεις", + "Following": "ΕΠΟΜΕΝΟ", + "Followers": "Οπαδοί", + "Roles": "Ρόλοι", + "Skills": "Δεξιότητες", + "Shares": "Μερίδια", + "Block": "ΟΙΚΟΔΟΜΙΚΟ ΤΕΤΡΑΓΩΝΟ", + "Unfollow": "Κατάργηση παρακολούθησης", + "Your browser does not support the audio element.": "Ο περιηγητής σας δεν υποστηρίζει το στοιχείο ήχου.", + "Your browser does not support the video element.": "Το πρόγραμμα περιήγησής σας δεν υποστηρίζει το στοιχείο βίντεο.", + "Create a new post": "Νέα ανάρτηση", + "Create a new DM": "Δημιουργήστε ένα νέο άμεσο μήνυμα", + "Switch to profile view": "Προβολή προφίλ", + "Inbox": "Inbox", + "Sent": "Απεσταλμένα", + "Search and follow": "Αναζήτηση/ακολουθήστε", + "Refresh": "Φρεσκάρω", + "Nickname or URL. Block using *@domain or nickname@domain": "Ψευδώνυμο ή διεύθυνση URL. Αποκλεισμός χρησιμοποιώντας *@domain ή nickname@domain", + "Remove the above item": "Αφαιρέστε το παραπάνω στοιχείο", + "Remove": "Αφαιρώ", + "Suspend the above account nickname": "Ανέστειλε το παραπάνω ψευδώνυμο λογαριασμού", + "Suspend": "Αναστέλλω", + "Remove a suspension for an account nickname": "Καταργήστε μια αναστολή για ένα ψευδώνυμο λογαριασμού", + "Unsuspend": "Κατάργηση αναστολής", + "Block an account on another instance": "Αποκλεισμός λογαριασμού σε άλλη περίπτωση", + "Unblock": "Ξεβουλώνω", + "Unblock an account on another instance": "Ξεμπλοκάρετε έναν λογαριασμό σε άλλη περίπτωση", + "Information about current blocks/suspensions": "Πληροφορίες για τρέχοντα μπλοκ/αναστολές", + "Info": "Πληροφορίες", + "Remove": "Αφαιρώ", + "Yes": "Ναί", + "No": "Οχι", + "Delete this post?": "Διαγραφή αυτής της ανάρτησης;", + "Follow": "Ακολουθηστε", + "Stop following": "Σταματήστε να ακολουθείτε", + "Options for": "Επιλογές για", + "View": "Θέα", + "Stop blocking": "Σταματήστε το μπλοκάρισμα", + "Enter an emoji name to search for": "Εισαγάγετε ένα όνομα emoji για αναζήτηση", + "Search screen text": "Εισαγάγετε μια διεύθυνση, κοινόχρηστο στοιχείο, -σώσει, 'ιστορία, #hashtag, *επιδεξιότητα, .καταζητούμενος ή :emoji: για αναζήτηση", + "Go Back": "◀", + "Moderation Information": "Πληροφορίες Συντονισμού", + "Suspended accounts": "Λογαριασμοί σε αναστολή", + "These are currently suspended": "Επί του παρόντος έχουν ανασταλεί", + "Blocked accounts and hashtags": "Αποκλεισμένοι λογαριασμοί και hashtags", + "These are globally blocked for all accounts on this instance": "Αυτά είναι παγκοσμίως αποκλεισμένα για όλους τους λογαριασμούς σε αυτήν την περίπτωση", + "Any blocks or suspensions made by moderators will be shown here.": "Τυχόν αποκλεισμοί ή αναστολές που έγιναν από επόπτες θα εμφανίζονται εδώ.", + "Welcome. Please enter your login details below.": "Καλως ΗΡΘΑΤΕ. Εισαγάγετε τα στοιχεία σύνδεσής σας παρακάτω.", + "Welcome. Please login or register a new account.": "Καλως ΗΡΘΑΤΕ. Παρακαλούμε συνδεθείτε ή εγγραφείτε νέο λογαριασμό.", + "Please enter some credentials": "Εισαγάγετε ορισμένα διαπιστευτήρια", + "You will become the admin of this site.": "Θα γίνετε ο διαχειριστής αυτού του ιστότοπου.", + "Terms of Service": "Όροι χρήσης", + "About this Instance": "Σχετικά με αυτήν την Περίπτωση", + "Nickname": "Παρατσούκλι", + "Enter Nickname": "Εισαγάγετε ψευδώνυμο", + "Password": "Κωδικός πρόσβασης", + "Enter Password": "Τουλάχιστον 8 χαρακτήρες", + "Profile for": "Προφίλ για", + "The files attached below should be no larger than 10MB in total uploaded at once.": "Τα αρχεία που επισυνάπτονται παρακάτω δεν πρέπει να είναι μεγαλύτερα από 10 MB συνολικά που έχουν μεταφορτωθεί ταυτόχρονα.", + "Avatar image": "Εικόνα avatar", + "Background image": "Εικόνα φόντου, που εμφανίζεται πίσω από το avatar σας", + "Timeline banner image": "Εικόνα banner γραμμής χρόνου", + "Approve follower requests": "Έγκριση αιτημάτων ακολούθων", + "This is a bot account": "Αυτός είναι ένας λογαριασμός bot", + "Filtered words": "Φιλτραρισμένες λέξεις", + "One per line": "Ένα ανά γραμμή", + "Blocked accounts": "Αποκλεισμένοι λογαριασμοί", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Αποκλεισμένοι λογαριασμοί, ένας ανά γραμμή, με τη μορφή nickname@domain ή *@blockeddomain", + "Federation list": "Λίστα ομοσπονδίας", + "Federate only with a defined set of instances. One domain name per line.": "Συνένωση μόνο με ένα καθορισμένο σύνολο παρουσιών. Ένα όνομα τομέα ανά γραμμή.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Εάν θέλετε να συμμετάσχετε σε οργανισμούς, τότε μπορείτε να υποδείξετε κάποιες δεξιότητες που έχετε και κατά προσέγγιση επίπεδα επάρκειας. Αυτό βοηθά τους διοργανωτές να δημιουργήσουν ομάδες με κατάλληλο συνδυασμό δεξιοτήτων.", + "A list of moderator nicknames. One per line.": "Μια λίστα με ψευδώνυμα συντονιστών. Ένα ανά γραμμή.", + "Moderators": "Συντονιστές", + "List of moderator nicknames": "Λίστα με ψευδώνυμα συντονιστή", + "Your bio": "Το βιογραφικό σου", + "Skill": "Επιδεξιότητα", + "Copy the text then paste it into your post": "Αντιγράψτε το κείμενο και μετά επικολλήστε το στην ανάρτησή σας", + "Emoji Search": "Αναζήτηση Emoji", + "No results": "Χωρίς αποτέλεσμα", + "Skills search": "Αναζήτηση δεξιοτήτων", + "Shared Items Search": "Αναζήτηση κοινόχρηστων αντικειμένων", + "Contact": "Επικοινωνία", + "Shared Item": "Κοινόχρηστο στοιχείο", + "Mod": "Μέτριος", + "Approve follow requests": "Έγκριση αιτημάτων παρακολούθησης", + "Page down": "Σελίδα κάτω", + "Page up": "Σελίδα προς τα πάνω", + "Vote": "Ψήφος", + "Replies": "Απαντήσεις", + "Media": "Μεσο ΜΑΖΙΚΗΣ ΕΝΗΜΕΡΩΣΗΣ", + "This is a group account": "Αυτός είναι ένας ομαδικός λογαριασμός", + "Date": "Ημερομηνία", + "Time": "χρόνος", + "Location": "Τοποθεσία", + "Calendar": "Ημερολόγιο", + "Sun": "Κυρ", + "Mon": "Δευ", + "Tue": "Τρί", + "Wed": "Τετ", + "Thu": "Πέμ", + "Fri": "Παρ", + "Sat": "Σάβ", + "January": "Ιανουάριος", + "February": "Φεβρουάριος", + "March": "Μάρτιος", + "April": "Απρίλιος", + "May": "Ενδέχεται", + "June": "Ιούνιος", + "July": "Ιούλιος", + "August": "Αύγουστος", + "September": "Σεπτέμβριος", + "October": "Οκτώβριος", + "November": "Νοέμβριος", + "December": "Δεκέμβριος", + "Only people I follow can send me DMs": "Μόνο άτομα που ακολουθώ μπορούν να μου στείλουν άμεσα μηνύματα", + "Logout": "Αποσύνδεση", + "Danger Zone": "Επικίνδυνη ζώνη", + "Deactivate this account": "Απενεργοποιήστε αυτόν τον λογαριασμό", + "Snooze": "Υπνάκος", + "Unsnooze": "Κατάργηση αναβολής", + "Donations link": "Σύνδεσμος δωρεών", + "Donate": "Προσφέρω", + "Change Password": "Άλλαξε κωδικό", + "Confirm Password": "Επιβεβαίωση Κωδικού", + "Instance Title": "Τίτλος παραδείγματος", + "Instance Short Description": "Σύντομη περιγραφή παράδειγμα", + "Instance Description": "Περιγραφή παραδείγματος", + "Instance Logo": "Λογότυπο για παράδειγμα", + "Bookmark this post": "Σελιδοδείκτης", + "Undo the bookmark": "Κατάργηση σελιδοδείκτη", + "Bookmarks": "Αποθηκεύτηκε", + "Theme": "Θέμα", + "Default": "Προκαθορισμένο", + "Light": "Φως", + "Purple": "Μωβ", + "Hacker": "Χάκερ", + "HighVis": "Υψηλή ορατότητα", + "Question": "Ερώτηση", + "Enter your question": "Εισαγάγετε την ερώτησή σας", + "Enter the choices for your question below.": "Εισαγάγετε τις επιλογές για την ερώτησή σας παρακάτω.", + "Ask a question": "Κάνε μια ερώτηση", + "Possible answers": "Πιθανές απαντήσεις", + "replying to": "απαντώντας σε", + "replying to themselves": "απαντώντας στον εαυτό τους", + "announces": "ανακοινώνει", + "Previous month": "Προηγούμενος μήνας", + "Next month": "Τον επόμενο μήνα", + "Get the source code": "Λάβετε τον πηγαίο κώδικα", + "This is a media instance": "Αυτό είναι ένα παράδειγμα μέσων ενημέρωσης", + "Mute this post": "Βουβός", + "Undo mute": "Αναίρεση σίγασης", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "ΗΛΕΚΤΡΟΝΙΚΗ ΔΙΕΥΘΥΝΣΗ", + "PGP": "Κλειδί PGP", + "PGP Fingerprint": "Δακτυλικό αποτύπωμα PGP", + "This is a scheduled post.": "Αυτή είναι μια προγραμματισμένη ανάρτηση.", + "Remove scheduled posts": "Κατάργηση προγραμματισμένων αναρτήσεων", + "Remove Twitter posts": "Καταργήστε τις αναρτήσεις στο Twitter", + "Sensitive": "Ευαίσθητος", + "Word Replacements": "Αντικαταστάσεις λέξεων", + "Happening Today": "Σήμερα", + "Happening Tomorrow": "Αύριο", + "Happening This Week": "Σύντομα", + "Blog": "Ιστολόγιο", + "Blogs": "Blogs", + "Title": "Τίτλος", + "About the author": "Σχετικά με τον Συγγραφέα", + "Edit blog post": "Επεξεργασία ανάρτησης ιστολογίου", + "Publicly visible post": "Δημόσια ορατή ανάρτηση", + "Your Posts": "Οι αναρτήσεις σας", + "Git Projects": "Έργα Git", + "List of project names that you wish to receive git patches for": "Λίστα ονομάτων έργων για τα οποία θέλετε να λαμβάνετε ενημερώσεις κώδικα git", + "Show/Hide Buttons": "Εμφάνιση απόκρυψη", + "Custom Font": "Προσαρμοσμένη γραμματοσειρά", + "Remove the custom font": "Αφαιρέστε την προσαρμοσμένη γραμματοσειρά", + "Lcd": "οθόνη υγρού κρυστάλλου", + "Blue": "Μπλε", + "Zen": "Ζεν", + "Night": "Νύχτα", + "Starlight": "Αστροφεγγιά", + "Search banner image": "Αναζήτηση εικόνας banner", + "Henge": "Henge", + "QR Code": "Κωδικός QR", + "Reminder": "Υπενθύμιση", + "Scheduled note to yourself": "Προγραμματισμένη σημείωση για τον εαυτό σας", + "Replying to": "Απαντώντας σε", + "Send to": "Στέλνω σε", + "Show a list of addresses to send to": "Εμφάνιση λίστας διευθύνσεων προς αποστολή", + "Petname": "Ονομα κατοικιδίου", + "Ok": "Εντάξει", + "This is nothing less than an utter triumph": "Αυτό δεν είναι τίποτα λιγότερο από έναν απόλυτο θρίαμβο", + "Not Found": "Δεν βρέθηκε", + "These are not the droids you are looking for": "Αυτά δεν είναι τα droid που ψάχνετε", + "Not changed": "Δεν άλλαξε", + "The contents of your local cache are up to date": "Τα περιεχόμενα της τοπικής σας προσωρινής μνήμης είναι ενημερωμένα", + "Bad Request": "Κακό αίτημα", + "Better luck next time": "Καλύτερη τύχη την επόμενη φορά", + "Unavailable": "Μη διαθέσιμο", + "The server is busy. Please try again later": "Ο διακομιστής είναι απασχολημένος. Παρακαλώ δοκιμάστε ξανά αργότερα", + "Receive calendar events from this account": "Λάβετε συμβάντα ημερολογίου από αυτόν τον λογαριασμό", + "Grayscale": "Κλίμακα του γκρι", + "Liked by": "Αρέσει από", + "Solidaric": "Αλληλεγγύη", + "YouTube Replacement Domain": "Τομέας αντικατάστασης YouTube", + "Notes": "Σημειώσεις", + "Allow replies.": "Επιτρέπονται οι απαντήσεις.", + "Event": "Εκδήλωση", + "Event name": "Όνομα εκδήλωσης", + "Events": "Εκδηλώσεις", + "Create an event": "Δημιουργήστε μια εκδήλωση", + "Describe the event": "Περιγράψτε το γεγονός", + "Start Date": "Ημερομηνία έναρξης", + "End Date": "Ημερομηνία λήξης", + "Categories": "Κατηγορίες", + "This is a private event.": "Αυτή είναι μια ιδιωτική εκδήλωση.", + "Allow anonymous participation.": "Να επιτρέπεται η ανώνυμη συμμετοχή.", + "Anyone can join": "Οποιοσδήποτε μπορεί να συμμετέχει", + "Apply to join": "Κάντε αίτηση για συμμετοχή", + "Invitation only": "Μόνο πρόσκληση", + "Joining": "Συμμετοχή", + "Status of the event": "Κατάσταση της εκδήλωσης", + "Tentative": "Δοκιμαστικός", + "Confirmed": "Επιβεβαιωμένος", + "Cancelled": "Ακυρώθηκε", + "Event banner image description": "Περιγραφή εικόνας banner εκδήλωσης", + "Banner image": "Εικόνα πανό", + "Maximum attendees": "Μέγιστος αριθμός συμμετεχόντων", + "Ticket URL": "URL εισιτηρίου", + "Create a new event": "Δημιουργήστε ένα νέο συμβάν", + "Moderation policy or code of conduct": "Πολιτική μετριοπάθειας ή κώδικας συμπεριφοράς", + "Edit event": "Επεξεργασία συμβάντος", + "Notify when posts are liked": "Ειδοποίηση όταν αρέσουν οι αναρτήσεις", + "Don't show the Like button": "Να μην εμφανίζεται το κουμπί \"Μου αρέσει\".", + "Autogenerated Hashtags": "Hashtags που δημιουργούνται αυτόματα", + "Autogenerated Content Warnings": "Προειδοποιήσεις αυτοδημιουργημένου περιεχομένου", + "Indymedia": "Indymedia", + "Indymediaclassic": "Indymedia Classic", + "Indymediamodern": "Indymedia Modern", + "Hashtag Blocked": "Το Hashtag έχει αποκλειστεί", + "This is a blogging instance": "Αυτό είναι ένα παράδειγμα blogging", + "Edit Links": "Επεξεργασία συνδέσμων", + "One link per line. Description followed by the link.": "Ένας σύνδεσμος ανά γραμμή. Περιγραφή ακολουθούμενη από τον σύνδεσμο. Οι τίτλοι πρέπει να ξεκινούν με #", + "Left column image": "Εικόνα αριστερής στήλης", + "Right column image": "Εικόνα δεξιάς στήλης", + "RSS feed for this site": "Ροή RSS για αυτόν τον ιστότοπο", + "Edit newswire": "Επεξεργασία ειδήσεων", + "Add RSS feed links below.": "Οι σύνδεσμοι τροφοδοσίας RSS παρακάτω. Προσθέστε ένα * στην αρχή ή στο τέλος για να υποδείξετε ότι μια ροή πρέπει να εποπτεύεται. Πρόσθεσε ένα ! στην αρχή ή στο τέλος για να υποδείξετε ότι το περιεχόμενο της ροής πρέπει να αντικατοπτρίζεται.", + "Newswire RSS Feed": "Newswire RSS Feed", + "Nicknames whose blog entries appear on the newswire.": "Ψευδώνυμα των οποίων οι καταχωρήσεις ιστολογίου εμφανίζονται στο newswire.", + "Posts to be approved": "Δημοσιεύσεις προς έγκριση", + "Discuss": "Συζητώ", + "Moderator Discussion": "Συζήτηση συντονιστή", + "Vote": "Ψήφος", + "Remove Vote": "Κατάργηση ψήφου", + "This is a news instance": "Αυτό είναι ένα παράδειγμα ειδήσεων", + "News": "Νέα", + "Read more...": "Διαβάστε περισσότερα...", + "Edit News Post": "Επεξεργασία ανάρτησης ειδήσεων", + "A list of editor nicknames. One per line.": "Μια λίστα με ψευδώνυμα συντάκτη. Ένα ανά γραμμή.", + "Site Editors": "Συντάκτες ιστότοπου", + "Allow news posts": "Επιτρέπονται οι αναρτήσεις ειδήσεων", + "Publish": "Δημοσιεύω", + "Publish a news article": "Δημοσιεύστε ένα άρθρο ειδήσεων", + "News tagging rules": "Κανόνες προσθήκης ετικετών ειδήσεων", + "See instructions": "Δείτε οδηγίες", + "Search": "Αναζήτηση", + "Newswire": "Newswire", + "Links": "Συνδέσεις", + "Post": "Θέση", + "User": "Χρήστης", + "Features" : "Χαρακτηριστικά", + "Article": "Αρθρο", + "Create an article": "Δημιουργήστε ένα άρθρο", + "Settings": "Ρυθμίσεις", + "Citations": "Αναφορές", + "Choose newswire items referenced in your article": "Επιλέξτε είδη ειδήσεων που αναφέρονται στο άρθρο σας", + "RSS feed for your blog": "Ροή RSS για το ιστολόγιό σας", + "Create a new shared item": "Δημιουργήστε ένα νέο κοινόχρηστο στοιχείο", + "Rc3": "Rc3", + "Hashtag origins": "Προέλευση hashtag", + "admin": "διαχειριστής", + "moderator": "μεσολαβητής", + "editor": "συντάκτης", + "delegator": "αντιπρόσωπος", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "Επιλέξτε το εικονίδιο επεξεργασίας για να προσθέσετε ροές RSS", + "Select the edit icon to add web links": "Επιλέξτε το εικονίδιο επεξεργασίας για να προσθέσετε συνδέσμους ιστού", + "Hashtag Categories RSS Feed": "Ροή RSS Κατηγορίες Hashtag", + "Ask about a shared item.": "Ρωτήστε για ένα κοινόχρηστο στοιχείο.", + "Account Information": "Πληροφορίες λογαριασμού", + "This account interacts with the following instances": "Αυτός ο λογαριασμός αλληλεπιδρά με τις ακόλουθες περιπτώσεις", + "News posts are moderated": "Οι αναρτήσεις ειδήσεων εποπτεύονται", + "Filter": "Φίλτρο", + "Filter out words": "Φιλτράρετε λέξεις", + "Unfilter": "Ξεφιλτράρισμα", + "Unfilter words": "Ξεφιλτράρισμα λέξεων", + "Show Accounts": "Εμφάνιση λογαριασμών", + "Peertube Instances": "Περιπτώσεις Peertube", + "Show video previews for the following Peertube sites.": "Εμφάνιση προεπισκοπήσεων βίντεο για τους παρακάτω ιστότοπους της Peertube.", + "Follows you": "Σε ακολουθει", + "Verify all signatures": "Επαληθεύστε όλες τις υπογραφές", + "Blocked followers": "Αποκλεισμένοι ακόλουθοι", + "Blocked following": "Αποκλείστηκε η παρακολούθηση", + "Receives posts from the following accounts": "Λαμβάνει δημοσιεύσεις από τους παρακάτω λογαριασμούς", + "Sends out posts to the following accounts": "Στέλνει αναρτήσεις στους παρακάτω λογαριασμούς", + "Word frequencies": "Συχνότητες λέξεων", + "New account": "Νέος λογαριασμός", + "Moved to new account address": "Μεταφέρθηκε σε νέα διεύθυνση λογαριασμού", + "Yet another Epicyon Instance": "Ακόμα ένα παράδειγμα Epicyon", + "Other accounts": "Άλλοι ομοσπονδιακοί λογαριασμοί", + "Pin this post to your profile.": "Καρφιτσώστε αυτήν την ανάρτηση στο προφίλ σας.", + "Administered by": "Διαχειρίζεται από", + "Version": "Εκδοχή", + "Skip to timeline": "Μετάβαση στο χρονοδιάγραμμα", + "Skip to Newswire": "Μετάβαση στο Newswire", + "Skip to Links": "Μετάβαση στους Συνδέσμους", + "Publish a blog article": "Δημοσιεύστε ένα άρθρο ιστολογίου", + "Featured writer": "Επιλεγμένος συγγραφέας", + "Broch mode": "Λειτουργία μπροσού", + "Pixel": "Εικονοκύτταρο", + "DM bounce": "Τα μηνύματα γίνονται δεκτά μόνο από λογαριασμούς που παρακολουθείτε", + "Next": "Επόμενο", + "Preview": "Προεπισκόπηση", + "Linked": "Διαδικτυακός σύνδεσμος", + "hashtag": "hash-tag", + "smile": "χαμόγελο", + "wink": "κλείσιμο ματιού", + "mentioning": "αναφέροντας", + "sad face": "λυπημένο πρόσωπο", + "thinking emoji": "emoji σκέψης", + "laughing": "γέλιο", + "gender": "γένος", + "He/Him": "Αυτός αυτόν", + "She/Her": "Αυτή/Αυτή", + "girl": "κορίτσι", + "boy": "αγόρι", + "pronoun": "αντωνυμία", + "Type of instance": "Τύπος περίπτωσης", + "Security": "Ασφάλεια", + "Enabling broch mode": "Η ενεργοποίηση της λειτουργίας μπροσού παρέχει μια προσωρινή ενίσχυση κατά της επίθεσης. Θα γίνονται δεκτές μόνο αναρτήσεις από ήδη γνωστές περιπτώσεις. Εάν δεν είναι απενεργοποιημένο, παρέρχεται μετά από μια εβδομάδα.", + "Instance Settings": "Ρυθμίσεις παρουσίας", + "Video Settings": "Ρυθμίσεις βίντεο", + "Filtering and Blocking": "Φιλτράρισμα και αποκλεισμός", + "Role Assignment": "Ανάθεση Ρόλων", + "Contact Details": "Στοιχεία επικοινωνίας", + "Background Images": "Εικόνες φόντου", + "heart": "καρδιά", + "counselor": "σύμβουλος", + "Counselors": "Σύμβουλοι", + "shocked": "σοκαρισμένος", + "Encrypted": "Κρυπτογραφημένο", + "Direct Message permitted instances": "Επιτρεπόμενες περιπτώσεις Απευθείας Μήνυμα", + "Direct messages are always allowed from these instances.": "Τα άμεσα μηνύματα επιτρέπονται πάντα από αυτές τις περιπτώσεις.", + "Key Shortcuts": "Συντομεύσεις πλήκτρων", + "menuTimeline": "Προβολή χρονολογίου", + "menuEdit": "Επεξεργασία", + "menuProfile": "Προβολή προφίλ", + "menuInbox": "Inbox", + "menuSearch": "Αναζήτηση/ακολουθήστε", + "menuNewPost": "Νέα ανάρτηση", + "menuNewBlog": "Νέα ανάρτηση ιστολογίου", + "menuCalendar": "Ημερολόγιο", + "menuDM": "Αμεσα μηνύματα", + "menuReplies": "Απαντήσεις", + "menuOutbox": "Απεσταλμένα", + "menuBookmarks": "Σελιδοδείκτες", + "menuShares": "Κοινόχρηστα στοιχεία", + "menuBlogs": "Blogs", + "menuNewswire": "Newswire", + "menuLinks": "Συνδέσεις", + "menuModeration": "Μετριοπάθεια", + "menuFollowing": "ΕΠΟΜΕΝΟ", + "menuFollowers": "Οπαδοί", + "menuRoles": "Ρόλοι", + "menuSkills": "Δεξιότητες", + "menuLogout": "Αποσύνδεση", + "menuKeys": "Συντομεύσεις πλήκτρων", + "submitButton": "Κουμπί υποβολής", + "menuMedia": "Μεσο ΜΑΖΙΚΗΣ ΕΝΗΜΕΡΩΣΗΣ", + "followButton": "Κουμπί παρακολούθησης/κατάργησης παρακολούθησης", + "blockButton": "Κουμπί αποκλεισμού", + "infoButton": "Κουμπί πληροφοριών", + "snoozeButton": "Κουμπί αναβολής", + "reportButton": "Κουμπί αναφοράς", + "viewButton": "Κουμπί προβολής", + "enterPetname": "Εισαγάγετε το όνομα κατοικίδιου ζώου", + "enterNotes": "Εισαγάγετε σημειώσεις", + "These access keys may be used": "Αυτά τα πλήκτρα πρόσβασης μπορούν να χρησιμοποιηθούν, συνήθως με ALT + SHIFT + πλήκτρο ή ALT + πλήκτρο", + "Show numbers of accounts within instance metadata": "Εμφάνιση αριθμών λογαριασμών σε μεταδεδομένα παρουσίας", + "Show version number within instance metadata": "Εμφάνιση του αριθμού έκδοσης στα μεταδεδομένα παρουσίας", + "Joined": "Εντάχθηκαν", + "City for spoofed GPS image metadata": "Πόλη για πλαστά μεταδεδομένα εικόνας GPS", + "Occupation": "Κατοχή", + "Artists": "Καλλιτέχνες", + "Graphic Design": "Γραφικό σχέδιο", + "Import Theme": "Εισαγωγή θέματος", + "Export Theme": "Εξαγωγή θέματος", + "Custom post submit button text": "Προσαρμοσμένο κείμενο κουμπιού υποβολής ανάρτησης", + "Blocked User Agents": "Αποκλεισμένοι πράκτορες χρήστη", + "Notify me when this account posts": "Να ειδοποιούμαι όταν αυτός ο λογαριασμός δημοσιεύει", + "Languages": "Γλώσσες", + "Translated": "Μεταφρασμένο", + "Quantity": "Ποσότητα", + "food": "τροφή", + "Price": "Τιμή", + "Currency": "Νόμισμα", + "List of domains which can access the shared items catalog": "Λίστα τομέων που μπορούν να έχουν πρόσβαση στον κατάλογο κοινόχρηστων στοιχείων", + "Shares Catalog": "Κατάλογος μετοχών", + "tool": "εργαλείο", + "clothes": "ρούχα", + "medical": "ιατρικός", + "Wanted": "Καταζητούμενος", + "Describe something wanted": "Περιγράψτε κάτι που θέλετε", + "Enter the details for your wanted item below.": "Εισαγάγετε τα στοιχεία για το αντικείμενο που επιθυμείτε παρακάτω.", + "Name of the wanted item": "Όνομα του ζητούμενου προϊόντος", + "Description of the item wanted": "Περιγραφή του ζητούμενου είδους", + "Type of wanted item. eg. hat": "Τύπος καταζητούμενου αντικειμένου. π.χ. καπέλο", + "Category of wanted item. eg. clothes": "Κατηγορία καταζητούμενου αντικειμένου. π.χ. ρούχα", + "City or location of the wanted item": "Πόλη ή τοποθεσία του ζητούμενου προϊόντος", + "Maximum Price": "Μέγιστη Τιμή", + "Create a new wanted item": "Δημιουργήστε ένα νέο αντικείμενο που αναζητάτε", + "Wanted Items Search": "Αναζήτηση Αναζητούμενων Αντικειμένων", + "Website": "Δικτυακός τόπος", + "Low Bandwidth": "Χαμηλό εύρος ζώνης", + "accommodation": "κατάλυμα", + "Forbidden": "Απαγορευμένος", + "You're not allowed": "Δεν επιτρέπεται", + "Hours after posting during which replies are allowed": "Ώρες μετά την ανάρτηση κατά τις οποίες επιτρέπονται οι απαντήσεις", + "Twitter": "Κελάδημα", + "Twitter Replacement Domain": "Τομέας αντικατάστασης Twitter", + "Buy": "Αγορά", + "Request to stay": "Αίτημα παραμονής", + "Profile": "Προφίλ", + "Introduce yourself and specify the date and time when you wish to stay": "Συστηθείτε και προσδιορίστε την ημερομηνία και την ώρα που επιθυμείτε να μείνετε", + "Members": "Μέλη", + "Join": "Συμμετοχή", + "Leave": "Αδεια", + "System Monitor": "Παρακολούθηση συστήματος", + "Add content warnings for the following sites": "Προσθέστε προειδοποιήσεις περιεχομένου για τους παρακάτω ιστότοπους", + "Known Web Crawlers": "Γνωστοί ανιχνευτές Ιστού", + "Add to the calendar": "Προσθήκη στο ημερολόγιο", + "Content License": "Άδεια περιεχομένου", + "Reaction by": "Αντίδραση από", + "Notify on emoji reactions": "Ειδοποίηση για αντιδράσεις emoji", + "Select reaction": "Αντίδραση", + "Don't show the Reaction button": "Να μην εμφανίζεται το κουμπί Αντίδραση", + "New feed URL": "Νέα διεύθυνση URL ροής", + "New link title and URL": "Νέος τίτλος και διεύθυνση URL συνδέσμου", + "Theme Designer": "Σχεδιαστής θεμάτων", + "Reset": "Επαναφορά", + "Encryption Keys": "Κλειδιά κρυπτογράφησης", + "Filtered words within bio": "Φιλτραρισμένες λέξεις μέσα στη βιογραφία", + "Write your news report": "Γράψτε την αναφορά σας", + "Dyslexic font": "Δυσλεξική γραμματοσειρά", + "Leave a comment": "Αφήστε ένα σχόλιο", + "View comments": "Προβολή σχολίων", + "Multi Status": "Πολλαπλή κατάσταση", + "Lots of things": "Πολλά πράγματα", + "Created": "Δημιουργήθηκε", + "It is done": "Εχει γίνει", + "Time Zone": "Ζώνη ώρας", + "Show who liked this post": "Δείξτε σε ποιον άρεσε αυτή η ανάρτηση", + "Show who repeated this post": "Δείξτε ποιος επανέλαβε αυτήν την ανάρτηση", + "Repeated by": "Επαναλαμβάνεται από", + "Register": "Κανω ΕΓΓΡΑΦΗ", + "Web Bots Allowed": "Επιτρέπονται τα ρομπότ αναζήτησης Ιστού", + "Known Search Bots": "Γνωστά ρομπότ αναζήτησης Ιστού", + "mitm": "Το μήνυμα θα μπορούσε να έχει διαβαστεί ή τροποποιηθεί από τρίτο μέρος", + "Bold reading": "Τολμηρή ανάγνωση", + "SHOW EDITS": "ΕΜΦΑΝΙΣΗ ΕΠΕΞΕΡΓΑΣΙΩΝ", + "Attach an image, video or audio file": "Επισυνάψτε ένα αρχείο εικόνας, βίντεο ή ήχου", + "Set a place and time": "Ορίστε τόπο και χρόνο", + "Describe your attachment": "Περιγράψτε το συνημμένο σας", + "Language used": "Γλώσσα που χρησιμοποιείται", + "lang_ar": "αραβικός", + "lang_bn": "Μπενγκάλι", + "lang_cy": "Δεν πληρώνω τα οφειλόμενα", + "lang_en": "Αγγλικά", + "lang_fr": "γαλλική γλώσσα", + "lang_hi": "Χίντι", + "lang_ja": "Ιαπωνικά", + "lang_ku": "κουρδικά", + "lang_pl": "Στίλβωση", + "lang_ru": "Ρωσική", + "lang_uk": "Ουκρανός", + "lang_ca": "καταλανικά", + "lang_de": "Γερμανός", + "lang_es": "Ισπανικά", + "lang_ga": "ιρλανδικός", + "lang_it": "ιταλικός", + "lang_ko": "κορεάτης", + "lang_oc": "Οξιτανός", + "lang_pt": "Πορτογαλικά", + "lang_sw": "Σουαχίλι", + "lang_tr": "τούρκικος", + "lang_zh": "κινέζικα", + "lang_nl": "Ολλανδός", + "lang_el": "Ελληνικά", + "lang_yi": "γερμανοεβραϊκή διάλεκτος", + "Common emoji": "Κοινά emoji", + "Copy and paste into your text": "Αντιγράψτε και επικολλήστε στο κείμενό σας", + "shrug": "σήκωμα των ώμων", + "DM warning": "Τα άμεσα μηνύματα δεν είναι κρυπτογραφημένα από άκρο σε άκρο. Μην μοιράζεστε καμία εξαιρετικά ευαίσθητη πληροφορία εδώ.", + "Transcript": "Αντίγραφο", + "Color contrast is too low": "Η χρωματική αντίθεση είναι πολύ χαμηλή", + "View Larger Map": "Δείτε Μεγαλύτερο Χάρτη", + "Start Time": "Ωρα έναρξης", + "End Time": "Τέλος χρόνου", + "Switch to calendar view": "Μετάβαση σε προβολή ημερολογίου", + "Save": "Αποθηκεύσετε", + "Switch to moderation view": "Μετάβαση σε προβολή εποπτείας", + "Minimize attached images": "Ελαχιστοποιήστε τις συνημμένες εικόνες", + "SHOW MEDIA": "ΔΕΙΤΕ ΜΕΣΑ", + "ActivityPub Specification": "Προδιαγραφές ActivityPub", + "Dogwhistle words": "Σφυρίχτρα λέξεις", + "Content warnings will be added for the following": "Θα προστεθούν προειδοποιήσεις περιεχομένου για τα ακόλουθα", + "nowplaying": "τώραπαίζει", + "NowPlaying": "ΤώραΠαίζει", + "Import and Export": "Εισάγω και εξάγω", + "Import Follows": "Ακολουθεί εισαγωγή", + "Post expiry period in days": "Η περίοδος μετά τη λήξη σε ημέρες", + "Keep DMs during post expiry": "Διατηρήστε τα άμεσα μηνύματα κατά τη λήξη της ανάρτησης", + "Notifications": "Ειδοποιήσεις", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy θέμα", + "Last hour": "Τελευταία ώρα", + "Last 3 hours": "Τελευταίες 3 ώρες", + "Last 6 hours": "Τελευταίες 6 ώρες", + "Last 12 hours": "Τελευταίες 12 ώρες", + "Last day": "Τελευταία μέρα", + "Last 2 days": "Τελευταίες 2 μέρες", + "Last week": "Την προηγούμενη εβδομάδα", + "Last 2 weeks": "Τελευταίες 2 εβδομάδες", + "Last month": "Τον προηγούμενο μήνα", + "Last 6 months": "Τελευταίοι 6 μήνες", + "Last year": "Πέρυσι", + "Unauthorized": "Ανεξουσιοδότητος", + "No login credentials were posted": "Δεν δημοσιεύτηκαν διαπιστευτήρια σύνδεσης", + "Credentials are too long": "Τα διαπιστευτήρια είναι πολύ μεγάλα", + "Site DevOps": "DevOps ιστότοπου", + "A list of devops nicknames. One per line.": "Μια λίστα με ψευδώνυμα devops. Ένα ανά γραμμή.", + "devops": "devops", + "Reject spam accounts": "Gwrthod cyfrifon sbam" +} diff --git a/translations/en.json b/translations/en.json index eec7f8cc3..95bf94265 100644 --- a/translations/en.json +++ b/translations/en.json @@ -179,7 +179,7 @@ "Instance Short Description": "Instance Short Description", "Instance Description": "Instance Description", "Instance Logo": "Instance Logo", - "Bookmark this post": "Save this for later viewing", + "Bookmark this post": "Bookmark", "Undo the bookmark": "Unbookmark", "Bookmarks": "Saved", "Theme": "Theme", @@ -412,6 +412,7 @@ "menuInbox": "Inbox", "menuSearch": "Search/follow", "menuNewPost": "New post", + "menuNewBlog": "New blog", "menuCalendar": "Calendar", "menuDM": "Direct Messages", "menuReplies": "Replies", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Introduce yourself and specify the date and time when you wish to stay", "Members": "Members", "Join": "Join", - "Leave": "Leave" + "Leave": "Leave", + "System Monitor": "System Monitor", + "Add content warnings for the following sites": "Add content warnings for the following sites", + "Known Web Crawlers": "Known Web Crawlers", + "Add to the calendar": "Add to the calendar", + "Content License": "Content License", + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Notify on emoji reactions", + "Select reaction": "Reaction", + "Don't show the Reaction button": "Don't show the Reaction button", + "New feed URL": "New feed URL", + "New link title and URL": "New link title and URL", + "Theme Designer": "Theme Designer", + "Reset": "Reset", + "Encryption Keys": "Encryption Keys", + "Filtered words within bio": "Filtered words within bio", + "Write your news report": "Write your news report", + "Dyslexic font": "Dyslexic font", + "Leave a comment": "Leave a comment", + "View comments": "View comments", + "Multi Status": "Multi Status", + "Lots of things": "Lots of things", + "Created": "Created", + "It is done": "It is done", + "Time Zone": "Time Zone", + "Show who liked this post": "Show who liked this post", + "Show who repeated this post": "Show who repeated this post", + "Repeated by": "Repeated by", + "Register": "Register", + "Web Bots Allowed": "Web Search Bots Allowed", + "Known Search Bots": "Known Web Search Bots", + "mitm": "Message could have been read or modified by a third party", + "Bold reading": "Bold reading", + "SHOW EDITS": "SHOW EDITS", + "Attach an image, video or audio file": "Attach an image, video or audio file", + "Set a place and time": "Set a place and time", + "Describe your attachment": "Describe your attachment", + "Language used": "Language used", + "lang_ar": "Arabic", + "lang_bn": "Bengali", + "lang_cy": "Welsh", + "lang_en": "English", + "lang_fr": "French", + "lang_hi": "Hindi", + "lang_ja": "Japanese", + "lang_ku": "Kurdish", + "lang_pl": "Polish", + "lang_ru": "Russian", + "lang_uk": "Ukrainian", + "lang_ca": "Catalan", + "lang_de": "German", + "lang_es": "Spanish", + "lang_ga": "Irish", + "lang_it": "Italian", + "lang_ko": "Korean", + "lang_oc": "Occitan", + "lang_pt": "Portuguese", + "lang_sw": "Swahili", + "lang_tr": "Turkish", + "lang_zh": "Chinese", + "lang_nl": "Dutch", + "lang_el": "Greek", + "lang_yi": "Yiddish", + "Common emoji": "Common emoji", + "Copy and paste into your text": "Copy and paste into your text", + "shrug": "shrug", + "DM warning": "Direct messages are not end-to-end encrypted. Do not share any highly sensitive information here.", + "Transcript": "Transcript", + "Color contrast is too low": "Color contrast is too low", + "View Larger Map": "View Larger Map", + "Start Time": "Start Time", + "End Time": "End Time", + "Switch to calendar view": "Switch to calendar view", + "Save": "Save", + "Switch to moderation view": "Switch to moderation view", + "Minimize attached images": "Minimize attached images", + "SHOW MEDIA": "SHOW MEDIA", + "ActivityPub Specification": "ActivityPub Specification", + "Dogwhistle words": "Dogwhistle words", + "Content warnings will be added for the following": "Content warnings will be added for the following", + "nowplaying": "nowplaying", + "NowPlaying": "NowPlaying", + "Import and Export": "Import and Export", + "Import Follows": "Import Follows", + "Post expiry period in days": "Post expiry period in days", + "Keep DMs during post expiry": "Keep DMs during post expiry", + "Notifications": "Notifications", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy topic", + "Last hour": "Last hour", + "Last 3 hours": "Last 3 hours", + "Last 6 hours": "Last 6 hours", + "Last 12 hours": "Last 12 hours", + "Last day": "Last day", + "Last 2 days": "Last 2 days", + "Last week": "Last week", + "Last 2 weeks": "Last 2 weeks", + "Last month": "Last month", + "Last 6 months": "Last 6 months", + "Last year": "Last year", + "Unauthorized": "Unauthorized", + "No login credentials were posted": "No login credentials were posted", + "Credentials are too long": "Credentials are too long", + "Site DevOps": "Site DevOps", + "A list of devops nicknames. One per line.": "A list of devops nicknames. One per line.", + "devops": "devops", + "Reject spam accounts": "Reject spam accounts" } diff --git a/translations/es.json b/translations/es.json index 5576bd971..bb1a16ffe 100644 --- a/translations/es.json +++ b/translations/es.json @@ -6,16 +6,16 @@ "Show options for this person": "Mostrar opciones para esta persona", "Repeat this post": "Repite esta publicación", "Undo the repeat": "Deshacer la repetición", - "Like this post": "Como esta publicación", + "Like this post": "Me gusta esta publicación", "Undo the like": "Deshacer el me gusta", "Delete this post": "Borra esta publicación", "Delete this event": "Eliminar este evento", "Reply to this post": "Responder a esta publicación", - "Write your post text below.": "Nueva publicación", + "Write your post text below.": "Escribe el texto de la publicación abajo", "Write your reply to": "Escribe tu respuesta a", "this post": "esta publicación", - "Write your report below.": "Escribe tu informe a continuación.", - "This message only goes to moderators, even if it mentions other fediverse addresses.": "Este mensaje solo va a los moderadores, incluso si menciona otras direcciones de fediverse.", + "Write your report below.": "Escribe tu reporte a continuación.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Este mensaje solo va a los moderadores, incluso si menciona otras direcciones del fediverso.", "Also see": "Ver también", "Terms of Service": "Términos de servicio", "Enter the details for your shared item below.": "Ingrese los detalles de su artículo compartido a continuación.", @@ -30,13 +30,13 @@ "Describe a shared item": "Describir un elemento compartido.", "Public": "Pública", "Visible to anyone": "Visible para cualquiera", - "Unlisted": "No estante en la lista", + "Unlisted": "Oculto", "Not on public timeline": "No en la línea de tiempo pública", - "Followers": "Seguidoras", + "Followers": "Seguidores", "Only to followers": "Solo para seguidores", "DM": "MD", "Only to mentioned people": "Solo para personas mencionadas", - "Report": "Informe", + "Report": "Reporte", "Send to moderators": "Enviar a moderadores", "Search for emoji": "Buscar emoji", "Cancel": "Cancelar", @@ -53,10 +53,10 @@ "Deny": "Negar", "Posts": "Publicaciones", "Following": "Siguiendo", - "Followers": "Seguidoras", + "Followers": "Seguidores", "Roles": "Roles", "Skills": "Habilidades", - "Shares": "Comparte", + "Shares": "Items compartidos", "Block": "Bloquear", "Unfollow": "Dejar de seguir", "Your browser does not support the audio element.": "Su navegador no es compatible con el elemento de audio.", @@ -64,8 +64,8 @@ "Create a new post": "Crea una nueva publicación", "Create a new DM": "Crear un nuevo mensaje directo", "Switch to profile view": "Cambiar a la vista de perfil", - "Inbox": "Entrada", - "Sent": "Enviada", + "Inbox": "Bandeja de entrada", + "Sent": "Enviados", "Search and follow": "Busca y sigue", "Refresh": "Refrescar", "Nickname or URL. Block using *@domain or nickname@domain": "Apodo o URL. Bloquear usando *@dominio o apodo@dominio", @@ -74,16 +74,16 @@ "Suspend the above account nickname": "Suspender el apodo de la cuenta anterior", "Suspend": "Suspender", "Remove a suspension for an account nickname": "Eliminar una suspensión para un apodo de cuenta", - "Unsuspend": "Unsopendido", + "Unsuspend": "Des-suspender", "Block an account on another instance": "Bloquear una cuenta en otra instancia", - "Unblock": "Desatascar", + "Unblock": "Desbloquear", "Unblock an account on another instance": "Desbloquee una cuenta en otra instancia", - "Information about current blocks/suspensions": "Información sobre bloques / suspensiones actuales", + "Information about current blocks/suspensions": "Información sobre bloqueos / suspensiones actuales", "Info": "Info", - "Remove": "Retirar", + "Remove": "Remover", "Yes": "Sí", "No": "No", - "Delete this post?": "¿Borra esta publicación?", + "Delete this post?": "¿Borrar esta publicación?", "Follow": "Seguir", "Stop following": "Dejar de seguir", "Options for": "Opciones para", @@ -91,12 +91,12 @@ "Stop blocking": "Dejar de bloquear", "Enter an emoji name to search for": "Ingrese un nombre de emoji para buscar", "Search screen text": "Ingrese una dirección, elemento compartido, -guardar, 'historial, #hashtag, *habilidad, .buscada o :emoji: para buscar", - "Go Back": "◀", + "Go Back": "Retroceder", "Moderation Information": "Información de moderación", "Suspended accounts": "Cuentas suspendidas", - "These are currently suspended": "Actualmente están suspendidos", - "Blocked accounts and hashtags": "Cuentas bloqueadas y hashtags", - "These are globally blocked for all accounts on this instance": "Estos están bloqueados globalmente para todas las cuentas en esta instancia", + "These are currently suspended": "Estas cuentas están actualmente suspendidas", + "Blocked accounts and hashtags": "Cuentas y hashtags bloqueados", + "These are globally blocked for all accounts on this instance": "Estas cuentas están bloqueadas globalmente para todas las cuentas en esta instancia", "Any blocks or suspensions made by moderators will be shown here.": "Aquí se mostrarán todos los bloqueos o suspensiones realizados por los moderadores.", "Welcome. Please enter your login details below.": "Bienvenido. Ingrese sus datos de inicio de sesión a continuación.", "Welcome. Please login or register a new account.": "Bienvenido. Inicie sesión o registre una nueva cuenta.", @@ -107,9 +107,9 @@ "Nickname": "Apodo", "Enter Nickname": "Ingrese el apodo", "Password": "Contraseña", - "Enter Password": "Mínimo 8 caracteres", + "Enter Password": "Ingrese su contraseña (Mínimo 8 caracteres)", "Profile for": "Perfil para", - "The files attached below should be no larger than 10MB in total uploaded at once.": "Los archivos adjuntos a continuación no deben tener más de 10 MB en total cargados a la vez.", + "The files attached below should be no larger than 10MB in total uploaded at once.": "Los archivos adjuntos a continuación no deben pesar más de 10 MB en total cargados a la vez.", "Avatar image": "Imagen de avatar", "Background image": "Imagen de fondo", "Timeline banner image": "Imagen de banner de línea de tiempo", @@ -119,12 +119,12 @@ "One per line": "Una por línea", "Blocked accounts": "Cuentas bloqueadas", "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Cuentas bloqueadas, una por línea, en la forma apodo@dominio o *@dominiodbloqueado", - "Federation list": "Lista de la Federación", + "Federation list": "Lista de Federación", "Federate only with a defined set of instances. One domain name per line.": "Federe solo con un conjunto definido de instancias. Un nombre de dominio por línea.", "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Si desea participar dentro de las organizaciones, puede indicar algunas habilidades que tiene y niveles de competencia aproximados. Esto ayuda a los organizadores a construir equipos con una combinación adecuada de habilidades.", - "A list of moderator nicknames. One per line.": "Una lista de apodos de moderador. Una por línea.", + "A list of moderator nicknames. One per line.": "Una lista de usuarios de moderador. Una por línea.", "Moderators": "Moderadoras", - "List of moderator nicknames": "Lista de apodos de moderador", + "List of moderator nicknames": "Lista de usuarios moderadores", "Your bio": "Tu biografía", "Skill": "Habilidad", "Copy the text then paste it into your post": "Copia el texto y pégalo en tu publicación", @@ -133,26 +133,26 @@ "Skills search": "Búsqueda de habilidades", "Shared Items Search": "Búsqueda de elementos compartidos", "Contact": "Contacto", - "Shared Item": "Compartido", - "Mod": "Mod", - "Approve follow requests": "Aprobar seguir solicitudes", + "Shared Item": "Item compartido", + "Mod": "Moderación", + "Approve follow requests": "Aprobar solicitudes de seguimiento", "Page down": "Página abajo", "Page up": "Página arriba", "Vote": "Votar", - "Replies": "Resp", + "Replies": "Respuestas", "Media": "Medios", "This is a group account": "Esta es una cuenta grupal", "Date": "Fecha", "Time": "Hora", "Location": "Ubicación", "Calendar": "Calendario", - "Sun": "Sun", - "Mon": "Mon", - "Tue": "Tue", - "Wed": "Wed", - "Thu": "Thu", - "Fri": "Fri", - "Sat": "Sat", + "Sun": "Domingo", + "Mon": "Lunes", + "Tue": "Martes", + "Wed": "Miércoles", + "Thu": "Jueves", + "Fri": "Viernes", + "Sat": "Sábado", "January": "Enero", "February": "Febrero", "March": "Marzo", @@ -166,22 +166,22 @@ "November": "Noviembre", "December": "Diciembre", "Only people I follow can send me DMs": "Solo las personas que sigo pueden enviarme mensajes directos", - "Logout": "Cerrar", + "Logout": "Cerrar sesión", "Danger Zone": "Zona peligrosa", "Deactivate this account": "Desactivar esta cuenta", - "Snooze": "dormitar", - "Unsnooze": "Despierta", + "Snooze": "Dormitar", + "Unsnooze": "Despertar", "Donations link": "Enlace de donaciones", "Donate": "Donar", - "Change Password": "Cambia la contraseña", + "Change Password": "Cambiar la contraseña", "Confirm Password": "Confirmar contraseña", "Instance Title": "Título de instancia", - "Instance Short Description": "Descripción breve de instancia", - "Instance Description": "Descripción de instancia", - "Instance Logo": "Logotipo de instancia", - "Bookmark this post": "Marcar esta publicación", - "Undo the bookmark": "Deshacer el marcador", - "Bookmarks": "Ahorra", + "Instance Short Description": "Descripción breve de la instancia", + "Instance Description": "Descripción de la instancia", + "Instance Logo": "Logotipo de la instancia", + "Bookmark this post": "Guardar esta publicación", + "Undo the bookmark": "Deshacer el guardado", + "Bookmarks": "Publicaciones guardadas", "Theme": "Tema", "Default": "Defecto", "Light": "Ligera", @@ -194,7 +194,7 @@ "Ask a question": "Haz una pregunta", "Possible answers": "Respuestas posibles", "replying to": "respondiendo a", - "replying to themselves": "respondiéndose a sí mismos", + "replying to themselves": "respondiéndose a sí mismo", "announces": "anuncia", "Previous month": "Mes anterior", "Next month": "Próximo mes", @@ -212,19 +212,19 @@ "Remove Twitter posts": "Eliminar publicaciones de Twitter", "Sensitive": "Sensible", "Word Replacements": "Reemplazos de palabras", - "Happening Today": "Hoy", - "Happening Tomorrow": "Mañana", - "Happening This Week": "Pronto", + "Happening Today": "Ocurriendo hoy", + "Happening Tomorrow": "Ocurriendo mañana", + "Happening This Week": "Ocurre pronto", "Blog": "Blog", "Blogs": "Blogs", "Title": "Título", - "About the author": "Sobre la autora", + "About the author": "Sobre el autor", "Edit blog post": "Editar publicación de blog", "Publicly visible post": "Publicación públicamente visible", "Your Posts": "Tus publicaciones", "Git Projects": "Proyectos Git", "List of project names that you wish to receive git patches for": "Lista de nombres de proyectos para los que desea recibir parches git", - "Show/Hide Buttons": "Botones Mostrar / Ocultar", + "Show/Hide Buttons": "Mostrar / Ocultar Botones", "Custom Font": "Fuente personalizada", "Remove the custom font": "Eliminar la fuente personalizada", "Lcd": "LCD", @@ -236,11 +236,11 @@ "Henge": "Henge", "QR Code": "Código QR", "Reminder": "Recordatorio", - "Scheduled note to yourself": "Nota programada para ti", + "Scheduled note to yourself": "Programar nota para ti", "Replying to": "Respondiendo a", "Send to": "Enviar a", "Show a list of addresses to send to": "Mostrar una lista de direcciones para enviar", - "Petname": "Nombre de mascota", + "Petname": "Apodo", "Ok": "Okay", "This is nothing less than an utter triumph": "Esto es nada menos que un triunfo absoluto", "Not Found": "No encontrada", @@ -253,7 +253,7 @@ "The server is busy. Please try again later": "El servidor esta ocupado. Por favor, inténtelo de nuevo más tarde", "Receive calendar events from this account": "Recibe eventos de calendario de esta cuenta", "Grayscale": "Escala de grises", - "Liked by": "Apreciado por", + "Liked by": "Le gusta a", "Solidaric": "Solidaridad", "YouTube Replacement Domain": "Dominio de reemplazo de YouTube", "Notes": "Notas", @@ -264,18 +264,18 @@ "Create an event": "Crea un evento", "Describe the event": "Describe el evento", "Start Date": "Fecha de inicio", - "End Date": "Fecha final", + "End Date": "Fecha de finalización", "Categories": "Categorías", "This is a private event.": "Este es un evento privado.", "Allow anonymous participation.": "Permitir la participación anónima.", "Anyone can join": "Cualquiera puede unirse", - "Apply to join": "Aplica para unirte", + "Apply to join": "Postular para unirte", "Invitation only": "Sólo con Invitación", - "Joining": "Unión", + "Joining": "Uniéndose", "Status of the event": "Estado del evento", - "Tentative": "Tentativa", - "Confirmed": "Confirmada", - "Cancelled": "Cancelada", + "Tentative": "Tentativo", + "Confirmed": "Confirmado", + "Cancelled": "Cancelado", "Event banner image description": "Descripción de la imagen del banner del evento", "Banner image": "Imagen de banner", "Maximum attendees": "Asistentes máximos", @@ -290,7 +290,7 @@ "Indymedia": "Indymedia", "Indymediaclassic": "Indymedia Classic", "Indymediamodern": "Indymedia Modern", - "Hashtag Blocked": "Hashtag bloqueada", + "Hashtag Blocked": "Hashtag bloqueado", "This is a blogging instance": "Esta es una instancia de blogs", "Edit Links": "Editar enlaces", "One link per line. Description followed by the link.": "Un enlace por línea. Descripción seguida del enlace.", @@ -300,7 +300,7 @@ "Edit newswire": "Editar newswire", "Add RSS feed links below.": "Agregue los enlaces de fuentes RSS a continuación.", "Newswire RSS Feed": "Canal RSS de Newswire", - "Nicknames whose blog entries appear on the newswire.": "Apodos cuyas entradas de blog aparecen en el newswire.", + "Nicknames whose blog entries appear on the newswire.": "Usuarios cuyas entradas de blog aparecen en el newswire.", "Posts to be approved": "Publicaciones a aprobar", "Discuss": "Discutir", "Moderator Discussion": "Discusión del moderador", @@ -308,7 +308,7 @@ "Remove Vote": "Eliminar voto", "This is a news instance": "Esta es una instancia de noticias", "News": "Noticias", - "Read more...": "Lee mas...", + "Read more...": "Leer mas...", "Edit News Post": "Editar publicación de noticias", "A list of editor nicknames. One per line.": "Una lista de apodos de los editores. Uno por línea.", "Site Editors": "Editores del sitio", @@ -321,10 +321,10 @@ "Newswire": "Newswire", "Links": "Enlaces", "Post": "Enviar", - "User": "Usuaria", + "User": "Usuario", "Features" : "Caracteristicas", "Article": "Artículo", - "Create an article": "Crea un articulo", + "Create an article": "Crea un artículo", "Settings": "Configuraciones", "Citations": "Citas", "Choose newswire items referenced in your article": "Elija elementos de Newswire a los que se hace referencia en su artículo", @@ -332,7 +332,7 @@ "Create a new shared item": "Crea un nuevo elemento compartido", "Rc3": "Rc3", "Hashtag origins": "Orígenes del hashtag", - "admin": "administración", + "admin": "administrador", "moderator": "moderador", "editor": "editor", "delegator": "delegador", @@ -353,7 +353,7 @@ "Show video previews for the following Peertube sites.": "Muestre vistas previas de video para los siguientes sitios de Peertube.", "Follows you": "Te sigue", "Verify all signatures": "Verificar todas las firmas", - "Blocked followers": "Seguidores bloqueadas", + "Blocked followers": "Seguidores bloqueados", "Blocked following": "Seguimiento bloqueado", "Receives posts from the following accounts": "Recibe publicaciones de las siguientes cuentas", "Sends out posts to the following accounts": "Envía publicaciones a las siguientes cuentas", @@ -376,7 +376,7 @@ "Next": "Próxima", "Preview": "Avance", "Linked": "enlace web", - "hashtag": "hash-tag", + "hashtag": "hashtag", "smile": "sonreír", "wink": "guiño", "mentioning": "mencionar", @@ -403,8 +403,8 @@ "Counselors": "Consejeras", "shocked": "conmocionada", "Encrypted": "Cifrada", - "Direct Message permitted instances": "Mensaje directo permitido instancias", - "Direct messages are always allowed from these instances.": "Los mensajes directos siempre están permitidos de estas instancias.", + "Direct Message permitted instances": "Instancias que tienen permitidas los mensajes directos", + "Direct messages are always allowed from these instances.": "Los mensajes directos siempre están permitidos desde estas instancias.", "Key Shortcuts": "Atajos clave", "menuTimeline": "Vista de la línea de tiempo", "menuEdit": "Editar", @@ -412,10 +412,11 @@ "menuInbox": "Bandeja de entrada", "menuSearch": "Búsqueda / Seguir", "menuNewPost": "Nueva publicación", + "menuNewBlog": "Nueva entrada de blog", "menuCalendar": "Calendario", "menuDM": "Mensajes directos", "menuReplies": "Respuestas", - "menuOutbox": "Enviada", + "menuOutbox": "Bandeja de Salida", "menuBookmarks": "Marcadores", "menuShares": "Artículos compartidos", "menuBlogs": "Blogs", @@ -423,20 +424,20 @@ "menuLinks": "Enlaces web", "menuModeration": "Moderación", "menuFollowing": "Siguiente", - "menuFollowers": "De seguidores", + "menuFollowers": "Seguidores", "menuRoles": "Roles", "menuSkills": "Habilidades", "menuLogout": "Cerrar sesión", "menuKeys": "Atajos clave", "submitButton": "Botón de enviar", "menuMedia": "Medios de comunicación", - "followButton": "Botón de seguimiento / dejo", + "followButton": "Botón de seguimiento", "blockButton": "Botón de bloqueo", "infoButton": "Botón de información", "snoozeButton": "El botón de dormitar", - "reportButton": "Botón de informe", + "reportButton": "Botón de reporte", "viewButton": "Botón de vista", - "enterPetname": "Entrar en nombre de pettname", + "enterPetname": "Ingresar apodo cariñoso", "enterNotes": "Ingresar notas", "These access keys may be used": "Se pueden usar estas teclas de acceso, típicamente con teclas ALT + MAYÚS + teclas o ALT +", "Show numbers of accounts within instance metadata": "Muestra el número de cuentas dentro de los metadatos de la instancia.", @@ -448,11 +449,11 @@ "Graphic Design": "Diseño gráfico", "Import Theme": "Tema de importación", "Export Theme": "Tema de exportación", - "Custom post submit button text": "POST POST PERSONALIZADO Botón Texto", + "Custom post submit button text": "Texto personalizado del botón de envío", "Blocked User Agents": "Agentes de usuario bloqueados", "Notify me when this account posts": "Notifíqueme cuando se publique esta cuenta", "Languages": "Idiomas", - "Translated": "Traducida", + "Translated": "Traducido", "Quantity": "Cantidad", "food": "comida", "Price": "Precio", @@ -461,9 +462,9 @@ "Shares Catalog": "Catálogo de acciones", "tool": "herramienta", "clothes": "ropa", - "medical": "médica", - "Wanted": "Buscada", - "Describe something wanted": "Describe algo quería", + "medical": "Cosas médicas", + "Wanted": "Buscado", + "Describe something wanted": "Describa lo que quiere", "Enter the details for your wanted item below.": "Ingrese los detalles de su artículo deseado a continuación.", "Name of the wanted item": "Nombre del artículo buscado", "Description of the item wanted": "Descripción del artículo deseado", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Preséntese y especifique la fecha y hora en que desea quedarse", "Members": "Miembros", "Join": "Entrar", - "Leave": "Dejar" + "Leave": "Dejar", + "System Monitor": "Monitor del sistema", + "Add content warnings for the following sites": "Agregue advertencias de contenido para los siguientes sitios", + "Known Web Crawlers": "Rastreadores web conocidos", + "Add to the calendar": "Agregar al calendario", + "Content License": "Licencia de contenido", + "Reaction by": "Reacción de", + "Notify on emoji reactions": "Notificar sobre reacciones emoji", + "Select reaction": "Seleccionar reacción", + "Don't show the Reaction button": "No mostrar el botón de reacción", + "New feed URL": "URL de nuevo feed", + "New link title and URL": "Nuevo título de enlace y URL", + "Theme Designer": "Diseñadora de temas", + "Reset": "Reiniciar", + "Encryption Keys": "Claves de cifrado", + "Filtered words within bio": "Palabras filtradas dentro de la biografía", + "Write your news report": "Escribe tu informe de noticias", + "Dyslexic font": "Fuente disléxica", + "Leave a comment": "Dejar un comentario", + "View comments": "Ver comentarios", + "Multi Status": "Estado múltiple", + "Lots of things": "Muchas cosas", + "Created": "Creado", + "It is done": "Está hecho", + "Time Zone": "Zona horaria", + "Show who liked this post": "Mostrar a quién le gustó esta publicación", + "Show who repeated this post": "Mostrar quién repitió esta publicación", + "Repeated by": "Repetido por", + "Register": "Registrarse", + "Web Bots Allowed": "Bots web permitidos", + "Known Search Bots": "Bots de búsqueda web conocidos", + "mitm": "El mensaje podría haber sido leído o modificado por un tercero", + "Bold reading": "Lectura en negrita", + "SHOW EDITS": "MOSTRAR EDICIONES", + "Attach an image, video or audio file": "Adjuntar un archivo de imagen, video o audio", + "Set a place and time": "Establece un lugar y una hora", + "Describe your attachment": "Describa su apego", + "Language used": "Idioma utilizado", + "lang_ar": "Arábica", + "lang_bn": "Bengalí", + "lang_cy": "Galesa", + "lang_en": "Inglesa", + "lang_fr": "Francesa", + "lang_hi": "Hindi", + "lang_ja": "Japonesa", + "lang_ku": "Kurda", + "lang_pl": "Polaca", + "lang_ru": "Rusa", + "lang_uk": "Ucrania", + "lang_ca": "Catalana", + "lang_de": "Alemana", + "lang_es": "Española", + "lang_ga": "Irlandesa", + "lang_it": "Italiana", + "lang_ko": "Coreana", + "lang_oc": "Occitano", + "lang_pt": "Portuguesa", + "lang_sw": "Swahili", + "lang_tr": "Turca", + "lang_zh": "China", + "lang_nl": "Holandesa", + "lang_el": "Griega", + "lang_yi": "Yídish", + "Common emoji": "Emojis comunes", + "Copy and paste into your text": "Copia y pega en tu texto", + "shrug": "encogimiento de hombros", + "DM warning": "Los mensajes directos no están cifrados de extremo a extremo. No comparta ninguna información altamente confidencial aquí.", + "Transcript": "Transcripción", + "Color contrast is too low": "El contraste de color es demasiado bajo", + "View Larger Map": "Ver mapa más grande", + "Start Time": "Hora de inicio", + "End Time": "Hora de finalización", + "Switch to calendar view": "Cambiar a vista de calendario", + "Save": "Guardar", + "Switch to moderation view": "Cambiar a la vista de moderación", + "Minimize attached images": "Minimizar imágenes adjuntas", + "SHOW MEDIA": "MOSTRAR MEDIOS", + "ActivityPub Specification": "Especificación de ActivityPub", + "Dogwhistle words": "Palabras de silbato para perros", + "Content warnings will be added for the following": "Se agregarán advertencias de contenido para lo siguiente", + "nowplaying": "reproduciendoahora", + "NowPlaying": "ReproduciendoAhora", + "Import and Export": "Importar y exportar", + "Import Follows": "Importar seguimientos", + "Post expiry period in days": "Período de vencimiento posterior en días", + "Keep DMs during post expiry": "Conservar los mensajes directos durante el vencimiento de la publicación", + "Notifications": "Notificaciones", + "ntfy URL": "URL ntfy", + "ntfy topic": "tema ntfy", + "Last hour": "Ultima hora", + "Last 3 hours": "últimas 3 horas", + "Last 6 hours": "últimas 6 horas", + "Last 12 hours": "últimas 12 horas", + "Last day": "Último día", + "Last 2 days": "últimos 2 días", + "Last week": "La semana pasada", + "Last 2 weeks": "últimas 2 semanas", + "Last month": "El mes pasado", + "Last 6 months": "últimos 6 meses", + "Last year": "El año pasado", + "Unauthorized": "No autorizado", + "No login credentials were posted": "No se publicaron credenciales de inicio de sesión", + "Credentials are too long": "Las credenciales son demasiado largas", + "Site DevOps": "DevOps del sitio", + "A list of devops nicknames. One per line.": "Una lista de apodos de devops. Uno por línea.", + "devops": "devops", + "Reject spam accounts": "Rechazar cuentas de spam" } diff --git a/translations/fr.json b/translations/fr.json index a0b498f4f..80924412a 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -412,6 +412,7 @@ "menuInbox": "Boîte de réception", "menuSearch": "Rechercher / suivre", "menuNewPost": "Nouveau poste", + "menuNewBlog": "Nouvel article de blog", "menuCalendar": "Calendrier", "menuDM": "Messages directs", "menuReplies": "réponses", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Présentez-vous et précisez la date et l'heure auxquelles vous souhaitez rester", "Members": "Membres", "Join": "Rejoindre", - "Leave": "Laisser" + "Leave": "Laisser", + "System Monitor": "Moniteur système", + "Add content warnings for the following sites": "Ajouter des avertissements de contenu pour les sites suivants", + "Known Web Crawlers": "Crawlers Web connus", + "Add to the calendar": "Ajouter au calendrier", + "Content License": "Licence de contenu", + "Reaction by": "Réaction par", + "Notify on emoji reactions": "Avertir sur les réactions emoji", + "Select reaction": "Sélectionnez la réaction", + "Don't show the Reaction button": "Ne pas afficher le bouton Réaction", + "New feed URL": "Nouvelle URL de flux", + "New link title and URL": "Nouveau titre et URL du lien", + "Theme Designer": "Concepteur de thème", + "Reset": "Réinitialiser", + "Encryption Keys": "Clés de cryptage", + "Filtered words within bio": "Mots filtrés dans la biographie", + "Write your news report": "Rédigez votre reportage", + "Dyslexic font": "Police dyslexique", + "Leave a comment": "Laissez un commentaire", + "View comments": "Voir les commentaires", + "Multi Status": "Statut multiple", + "Lots of things": "Beaucoup de choses", + "Created": "Créé", + "It is done": "C'est fait", + "Time Zone": "Fuseau horaire", + "Show who liked this post": "Montrer qui a aimé ce post", + "Show who repeated this post": "Montrer qui a répété ce post", + "Repeated by": "Répété par", + "Register": "S'inscrire", + "Web Bots Allowed": "Robots Web autorisés", + "Known Search Bots": "Robots de recherche Web connus", + "mitm": "Le message a pu être lu ou modifié par un tiers", + "Bold reading": "Lecture audacieuse", + "SHOW EDITS": "AFFICHER LES MODIFICATIONS", + "Attach an image, video or audio file": "Joindre une image, une vidéo ou un fichier audio", + "Set a place and time": "Fixez un lieu et une heure", + "Describe your attachment": "Décrivez votre pièce jointe", + "Language used": "Langue utilisée", + "lang_ar": "Arabe", + "lang_bn": "Bengali", + "lang_cy": "Gallois", + "lang_en": "Anglaise", + "lang_fr": "Français", + "lang_hi": "Hindi", + "lang_ja": "Japonaise", + "lang_ku": "Kurde", + "lang_pl": "Polonais", + "lang_ru": "Russe", + "lang_uk": "Ukrainienne", + "lang_ca": "Catalane", + "lang_de": "Allemande", + "lang_es": "Espagnole", + "lang_ga": "Irlandaise", + "lang_it": "Italienne", + "lang_ko": "Coréenne", + "lang_oc": "Occitan", + "lang_pt": "Portugais", + "lang_sw": "Swahili", + "lang_tr": "Turque", + "lang_zh": "Chinoise", + "lang_nl": "Néerlandaise", + "lang_el": "Grecque", + "lang_yi": "Yiddish", + "Common emoji": "Émoji commun", + "Copy and paste into your text": "Copiez et collez dans votre texte", + "shrug": "hausser les épaules", + "DM warning": "Les messages directs ne sont pas chiffrés de bout en bout. Ne partagez aucune information hautement sensible ici.", + "Transcript": "Transcription", + "Color contrast is too low": "Le contraste des couleurs est trop faible", + "View Larger Map": "Agrandir le plan", + "Start Time": "Heure de début", + "End Time": "Heure de fin", + "Switch to calendar view": "Basculer vers la vue calendrier", + "Save": "Sauvegarder", + "Switch to moderation view": "Passer en mode modération", + "Minimize attached images": "Réduire les images jointes", + "SHOW MEDIA": "AFFICHER LES MÉDIAS", + "ActivityPub Specification": "Spécification ActivityPub", + "Dogwhistle words": "Mots de sifflet de chien", + "Content warnings will be added for the following": "Des avertissements de contenu seront ajoutés pour les éléments suivants", + "nowplaying": "lectureencours", + "NowPlaying": "LectureEnCours", + "Import and Export": "Importer et exporter", + "Import Follows": "Importer suit", + "Post expiry period in days": "Délai après expiration en jours", + "Keep DMs during post expiry": "Conserver les messages directs après l'expiration", + "Notifications": "Avis", + "ntfy URL": "URL ntfy", + "ntfy topic": "sujet ntfy", + "Last hour": "Dernière heure", + "Last 3 hours": "3 dernières heures", + "Last 6 hours": "6 dernières heures", + "Last 12 hours": "12 dernières heures", + "Last day": "Dernier jour", + "Last 2 days": "2 derniers jours", + "Last week": "La semaine dernière", + "Last 2 weeks": "2 dernières semaines", + "Last month": "Le mois dernier", + "Last 6 months": "6 derniers mois", + "Last year": "L'année dernière", + "Unauthorized": "Non autorisé", + "No login credentials were posted": "Aucun identifiant de connexion n'a été posté", + "Credentials are too long": "Les identifiants sont trop longs", + "Site DevOps": "DevOps du site", + "A list of devops nicknames. One per line.": "Une liste de surnoms de devops. Un par ligne.", + "devops": "devops", + "Reject spam accounts": "Rejeter les comptes de spam" } diff --git a/translations/ga.json b/translations/ga.json index 0ca375921..6109f82a2 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -412,6 +412,7 @@ "menuInbox": "Bosca isteach", "menuSearch": "Cuardaigh / Lean", "menuNewPost": "Post nua", + "menuNewBlog": "Blagphost nua", "menuCalendar": "Caileandar", "menuDM": "Teachtaireachtaí díreacha", "menuReplies": "Freagraí", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Cuir tú féin in aithne agus sonraigh an dáta agus an t-am ar mhaith leat fanacht", "Members": "Baill", "Join": "Bí páirteach", - "Leave": "Fág" + "Leave": "Fág", + "System Monitor": "Monatóir Córais", + "Add content warnings for the following sites": "Cuir rabhaidh ábhair leis na suíomhanna seo a leanas", + "Known Web Crawlers": "Crawlers Gréasáin Aitheanta", + "Add to the calendar": "Cuir leis an bhféilire", + "Content License": "Ceadúnas Ábhar", + "Reaction by": "Imoibriú le", + "Notify on emoji reactions": "Fógra a thabhairt faoi imoibrithe emoji", + "Select reaction": "Roghnaigh imoibriú", + "Don't show the Reaction button": "Ná taispeáin an cnaipe Imoibriú", + "New feed URL": "URL beathaithe nua", + "New link title and URL": "Teideal nasc nua agus URL", + "Theme Designer": "Dearthóir Téama", + "Reset": "Athshocraigh", + "Encryption Keys": "Eochracha Criptithe", + "Filtered words within bio": "Focail scagtha laistigh den bheathaisnéis", + "Write your news report": "Scríobh do thuairisc nuachta", + "Dyslexic font": "Cló disléicseach", + "Leave a comment": "Fág trácht", + "View comments": "Féach ar thuairimí", + "Multi Status": "Stádas Il", + "Lots of things": "A lán rudaí", + "Created": "Cruthaithe", + "It is done": "Déantar é", + "Time Zone": "Crios Ama", + "Show who liked this post": "Taispeáin cé a thaitin an postáil seo", + "Show who repeated this post": "Taispeáin cé a rinne an postáil seo arís", + "Repeated by": "Arís agus arís eile ag", + "Register": "Clár", + "Web Bots Allowed": "Róbónna Gréasáin Ceadaithe", + "Known Search Bots": "Róbónna Cuardach Gréasáin Aitheanta", + "mitm": "D'fhéadfadh tríú páirtí an teachtaireacht a léamh nó a mhodhnú", + "Bold reading": "Léamh trom", + "SHOW EDITS": "EAGARTHÓIRÍ TAISPEÁINT", + "Attach an image, video or audio file": "Ceangail íomhá, físeán nó comhad fuaime", + "Set a place and time": "Socraigh áit agus am", + "Describe your attachment": "Déan cur síos ar do cheangaltán", + "Language used": "Teanga a úsáidtear", + "lang_ar": "Araibis", + "lang_bn": "Beangáilis", + "lang_cy": "Breatnais", + "lang_en": "Béarla", + "lang_fr": "Fraincis", + "lang_hi": "Hiondúis", + "lang_ja": "Seapánach", + "lang_ku": "Coirdis", + "lang_pl": "Polainnis", + "lang_ru": "Rúisis", + "lang_uk": "Úcráinis", + "lang_ca": "Catalóinis", + "lang_de": "Gearmáinis", + "lang_es": "Spainnis", + "lang_ga": "Gaeilge", + "lang_it": "Iodálach", + "lang_ko": "Cóiréis", + "lang_oc": "Béarla", + "lang_pt": "Portaingéilis", + "lang_sw": "Swahili", + "lang_tr": "Tuircis", + "lang_zh": "Síneach", + "lang_nl": "Ollainnis", + "lang_el": "Gréigis", + "lang_yi": "Giúdais", + "Common emoji": "Emoji coitianta", + "Copy and paste into your text": "Cóipeáil agus greamaigh isteach i do théacs", + "shrug": "shrug", + "DM warning": "Níl teachtaireachtaí díreacha criptithe ó cheann go ceann. Ná roinn aon fhaisnéis an-íogair anseo.", + "Transcript": "Athscríbhinn", + "Color contrast is too low": "Tá codarsnacht dath ró-íseal", + "View Larger Map": "Féach ar Léarscáil Níos Mó", + "Start Time": "Am Tosaigh", + "End Time": "Am Deiridh", + "Switch to calendar view": "Athraigh go hamharc féilire", + "Save": "Sábháil", + "Switch to moderation view": "Athraigh go dtí an t-amharc modhnóireachta", + "Minimize attached images": "Íoslaghdaigh íomhánna ceangailte", + "SHOW MEDIA": "Taispeáin MEÁIN", + "ActivityPub Specification": "Sonraíocht ActivityPub", + "Dogwhistle words": "Focail feadóg mhadra", + "Content warnings will be added for the following": "Cuirfear rabhaidh ábhair leis maidir leis na nithe seo a leanas", + "nowplaying": "anoisagimirt", + "NowPlaying": "AnoisAgImirt", + "Import and Export": "Iompórtáil agus Easpórtáil", + "Import Follows": "Leanann Iompórtáil", + "Post expiry period in days": "Tréimhse iar-éagtha i laethanta", + "Keep DMs during post expiry": "Coinnigh Teachtaireachtaí Díreacha nuair a rachaidh postáil in éag", + "Notifications": "Fógraí", + "ntfy URL": "ntfy URL", + "ntfy topic": "topaic ntfy", + "Last hour": "Uair dheireanach", + "Last 3 hours": "3 uair an chloig caite", + "Last 6 hours": "6 uair an chloig caite", + "Last 12 hours": "12 uair an chloig caite", + "Last day": "Lá deirneach", + "Last 2 days": "2 lá seo caite", + "Last week": "An tseachtain seo caite", + "Last 2 weeks": "2 sheachtain anuas", + "Last month": "An mhí seo caite", + "Last 6 months": "6 mhí anuas", + "Last year": "Anuraidh", + "Unauthorized": "Neamhúdaraithe", + "No login credentials were posted": "Níor postáladh aon dintiúir logáil isteach", + "Credentials are too long": "Tá dintiúir ró-fhada", + "Site DevOps": "Suíomh DevOps", + "A list of devops nicknames. One per line.": "Tá liosta devops leasainmneacha. Ceann in aghaidh an líne.", + "devops": "devops", + "Reject spam accounts": "Diúltaigh cuntais turscair" } diff --git a/translations/hi.json b/translations/hi.json index 6b9af71af..6c6bf7d86 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -412,6 +412,7 @@ "menuInbox": "इनबॉक्स", "menuSearch": "खोज / अनुसरण करें", "menuNewPost": "नई पोस्ट", + "menuNewBlog": "नया ब्लॉग पोस्ट", "menuCalendar": "पंचांग", "menuDM": "सीधे संदेश", "menuReplies": "जवाब", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "अपना परिचय दें और वह तारीख और समय निर्दिष्ट करें जब आप रुकना चाहते हैं", "Members": "सदस्यों", "Join": "शामिल हों", - "Leave": "छोड़ना" + "Leave": "छोड़ना", + "System Monitor": "सिस्टम मॉनिटर", + "Add content warnings for the following sites": "निम्नलिखित साइटों के लिए सामग्री चेतावनियाँ जोड़ें", + "Known Web Crawlers": "ज्ञात वेब क्रॉलर", + "Add to the calendar": "कैलेंडर में जोड़ें", + "Content License": "सामग्री लाइसेंस", + "Reaction by": "द्वारा प्रतिक्रिया", + "Notify on emoji reactions": "इमोजी प्रतिक्रियाओं पर सूचित करें", + "Select reaction": "प्रतिक्रिया का चयन करें", + "Don't show the Reaction button": "प्रतिक्रिया बटन न दिखाएं", + "New feed URL": "नया फ़ीड URL", + "New link title and URL": "नया लिंक शीर्षक और URL", + "Theme Designer": "थीम डिजाइनर", + "Reset": "रीसेट", + "Encryption Keys": "एन्क्रिप्शन कुंजी", + "Filtered words within bio": "जीवनी के भीतर फ़िल्टर किए गए शब्द", + "Write your news report": "अपनी समाचार रिपोर्ट लिखें", + "Dyslexic font": "डिस्लेक्सिक फ़ॉन्ट", + "Leave a comment": "एक टिप्पणी छोड़ें", + "View comments": "टिप्पणियाँ देखें", + "Multi Status": "बहु स्थिति", + "Lots of things": "बहुत सी बातें", + "Created": "बनाया था", + "It is done": "हो गया है", + "Time Zone": "समय क्षेत्र", + "Show who liked this post": "दिखाएँ कि इस पोस्ट को किसने पसंद किया", + "Show who repeated this post": "दिखाएं कि इस पोस्ट को किसने दोहराया", + "Repeated by": "द्वारा दोहराया गया", + "Register": "रजिस्टर करें", + "Web Bots Allowed": "वेब बॉट्स की अनुमति है", + "Known Search Bots": "ज्ञात वेब खोज बॉट्स", + "mitm": "संदेश किसी तीसरे पक्ष द्वारा पढ़ा या संशोधित किया जा सकता था", + "Bold reading": "बोल्ड रीडिंग", + "SHOW EDITS": "संपादन दिखाएं", + "Attach an image, video or audio file": "एक छवि, वीडियो या ऑडियो फ़ाइल संलग्न करें", + "Set a place and time": "एक जगह और समय निर्धारित करें", + "Describe your attachment": "अपने अनुलग्नक का वर्णन करें", + "Language used": "इस्तेमाल की जाने वाली भाषा", + "lang_ar": "अरबी", + "lang_bn": "बंगाली", + "lang_cy": "वेल्शो", + "lang_en": "अंग्रेज़ी", + "lang_fr": "फ्रेंच", + "lang_hi": "हिन्दी", + "lang_ja": "जापानी", + "lang_ku": "कुर्द", + "lang_pl": "पोलिश", + "lang_ru": "रूसी", + "lang_uk": "यूक्रेनी", + "lang_ca": "कातालान", + "lang_de": "जर्मन", + "lang_es": "स्पैनिश", + "lang_ga": "आयरिश", + "lang_it": "इतालवी", + "lang_ko": "कोरियाई", + "lang_oc": "ओसीटान", + "lang_pt": "पुर्तगाली", + "lang_sw": "Swahili", + "lang_tr": "तुर्की", + "lang_zh": "चीनी", + "lang_nl": "डच", + "lang_el": "यूनानी", + "lang_yi": "यहूदी", + "Common emoji": "आम इमोजी", + "Copy and paste into your text": "अपने टेक्स्ट में कॉपी और पेस्ट करें", + "shrug": "कंधे उचकाने की क्रिया", + "DM warning": "डायरेक्ट मैसेज एंड-टू-एंड एन्क्रिप्टेड नहीं होते हैं। यहां कोई अति संवेदनशील जानकारी साझा न करें।", + "Transcript": "प्रतिलिपि", + "Color contrast is too low": "रंग कंट्रास्ट बहुत कम है", + "View Larger Map": "बड़ा नक्शा देखें", + "Start Time": "समय शुरू", + "End Time": "अंत समय", + "Switch to calendar view": "कैलेंडर दृश्य पर स्विच करें", + "Save": "बचाना", + "Switch to moderation view": "मॉडरेशन दृश्य पर स्विच करें", + "Minimize attached images": "संलग्न छवियों को छोटा करें", + "SHOW MEDIA": "मीडिया दिखाएं", + "ActivityPub Specification": "गतिविधिपब विशिष्टता", + "Dogwhistle words": "कुत्ते की सीटी शब्द", + "Content warnings will be added for the following": "निम्नलिखित के लिए सामग्री चेतावनियां जोड़ दी जाएंगी", + "nowplaying": "अब खेल रहे हैं", + "NowPlaying": "अब खेल रहे हैं", + "Import and Export": "आयात और निर्यात", + "Import Follows": "आयात का अनुसरण करता है", + "Post expiry period in days": "दिनों में समाप्ति अवधि पोस्ट करें", + "Keep DMs during post expiry": "समाप्ति के बाद सीधे संदेश रखें", + "Notifications": "सूचनाएं", + "ntfy URL": "एनटीएफई यूआरएल", + "ntfy topic": "एनटीएफई विषय", + "Last hour": "अंतिम घंटा", + "Last 3 hours": "पिछले 3 घंटे", + "Last 6 hours": "पिछले 6 घंटे", + "Last 12 hours": "पिछले 12 घंटे", + "Last day": "आखरी दिन", + "Last 2 days": "पिछले 2 दिन", + "Last week": "पिछले सप्ताह", + "Last 2 weeks": "पिछले 2 सप्ताह", + "Last month": "पिछले महीने", + "Last 6 months": "पिछले 6 महीने", + "Last year": "पिछले साल", + "Unauthorized": "अनधिकृत", + "No login credentials were posted": "कोई लॉगिन क्रेडेंशियल पोस्ट नहीं किया गया था", + "Credentials are too long": "क्रेडेंशियल बहुत लंबे हैं", + "Site DevOps": "साइट देवऑप्स", + "A list of devops nicknames. One per line.": "देवोप्स उपनामों की एक सूची। प्रति पंक्ति एक।", + "devops": "devops", + "Reject spam accounts": "स्पैम खातों को अस्वीकार करें" } diff --git a/translations/it.json b/translations/it.json index 19c143f5f..aa501515f 100644 --- a/translations/it.json +++ b/translations/it.json @@ -411,7 +411,8 @@ "menuProfile": "Visualizzazione del profilo", "menuInbox": "Posta in arrivo", "menuSearch": "Cerca / Segui", - "menuNewPost": "Nuovo post.", + "menuNewPost": "Nuovo post", + "menuNewBlog": "Nuovo articolo sul blog", "menuCalendar": "Calendario", "menuDM": "Messaggi diretti", "menuReplies": "Risposte", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Presentati e specifica la data e l'ora in cui desideri soggiornare", "Members": "Membri", "Join": "Aderire", - "Leave": "Lasciare" + "Leave": "Lasciare", + "System Monitor": "Monitor di sistema", + "Add content warnings for the following sites": "Aggiungi avvisi sui contenuti per i seguenti siti", + "Known Web Crawlers": "Crawler Web conosciuti", + "Add to the calendar": "Aggiungi al calendario", + "Content License": "Licenza sui contenuti", + "Reaction by": "Reazione di", + "Notify on emoji reactions": "Notifica sulle reazioni emoji", + "Select reaction": "Seleziona reazione", + "Don't show the Reaction button": "Non mostrare il pulsante Reazione", + "New feed URL": "Nuovo URL del feed", + "New link title and URL": "Nuovo titolo e URL del collegamento", + "Theme Designer": "Progettista di temi", + "Reset": "Ripristina", + "Encryption Keys": "Chiavi di crittografia", + "Filtered words within bio": "Parole filtrate all'interno della biografia", + "Write your news report": "Scrivi il tuo reportage", + "Dyslexic font": "Carattere dislessico", + "Leave a comment": "Lascia un commento", + "View comments": "Visualizza commenti", + "Multi Status": "Stato multiplo", + "Lots of things": "Un sacco di cose", + "Created": "Creata", + "It is done": "È fatta", + "Time Zone": "Fuso orario", + "Show who liked this post": "Mostra a chi è piaciuto questo post", + "Show who repeated this post": "Mostra chi ha ripetuto questo post", + "Repeated by": "Ripetuto da", + "Register": "Registrati", + "Web Bots Allowed": "Web bot consentiti", + "Known Search Bots": "Bot di ricerca Web noti", + "mitm": "Il messaggio potrebbe essere stato letto o modificato da terzi", + "Bold reading": "Lettura audace", + "SHOW EDITS": "MOSTRA MODIFICHE", + "Attach an image, video or audio file": "Allega un file immagine, video o audio", + "Set a place and time": "Stabilisci un luogo e un'ora", + "Describe your attachment": "Descrivi il tuo allegato", + "Language used": "Linguaggio utilizzato", + "lang_ar": "Araba", + "lang_bn": "Bengalese", + "lang_cy": "Gallese", + "lang_en": "Inglese", + "lang_fr": "Francese", + "lang_hi": "Hindi", + "lang_ja": "Giapponese", + "lang_ku": "Curda", + "lang_pl": "Polacca", + "lang_ru": "Russa", + "lang_uk": "Ucraina", + "lang_ca": "Catalana", + "lang_de": "Tedesca", + "lang_es": "Spagnola", + "lang_ga": "Irlandesi", + "lang_it": "Italiana", + "lang_ko": "Coreana", + "lang_oc": "Occitano", + "lang_pt": "Portoghese", + "lang_sw": "Swahili", + "lang_tr": "Turca", + "lang_zh": "Cinese", + "lang_nl": "Olandese", + "lang_el": "Greca", + "lang_yi": "Yiddish", + "Common emoji": "Emoji comuni", + "Copy and paste into your text": "Copia e incolla nel tuo testo", + "shrug": "scrollare le spalle", + "DM warning": "I messaggi diretti non sono crittografati end-to-end. Non condividere qui alcuna informazione altamente sensibile.", + "Transcript": "Trascrizione", + "Color contrast is too low": "Il contrasto del colore è troppo basso", + "View Larger Map": "Visualizza mappa più grande", + "Start Time": "Ora di inizio", + "End Time": "Tempo scaduto", + "Switch to calendar view": "Passa alla visualizzazione del calendario", + "Save": "Salva", + "Switch to moderation view": "Passa alla visualizzazione moderazione", + "Minimize attached images": "Riduci al minimo le immagini allegate", + "SHOW MEDIA": "MOSTRA MEDIA", + "ActivityPub Specification": "Specifica ActivityPub", + "Dogwhistle words": "Parole da fischietto", + "Content warnings will be added for the following": "Verranno aggiunti avvisi sui contenuti per quanto segue", + "nowplaying": "ora giocando", + "NowPlaying": "OraGiocando", + "Import and Export": "Importazione e esportazione", + "Import Follows": "Importa segue", + "Post expiry period in days": "Scadenza post in giorni", + "Keep DMs during post expiry": "Conserva i messaggi diretti durante la scadenza successiva", + "Notifications": "Notifiche", + "ntfy URL": "ntfy URL", + "ntfy topic": "argomento ntfy", + "Last hour": "Ultima ora", + "Last 3 hours": "Ultime 3 ore", + "Last 6 hours": "Ultime 6 ore", + "Last 12 hours": "Ultime 12 ore", + "Last day": "Ultimo giorno", + "Last 2 days": "Ultimi 2 giorni", + "Last week": "La settimana scorsa", + "Last 2 weeks": "Ultime 2 settimane", + "Last month": "Lo scorso mese", + "Last 6 months": "Ultimi 6 mesi", + "Last year": "L'anno scorso", + "Unauthorized": "Non autorizzato", + "No login credentials were posted": "Non sono state pubblicate credenziali di accesso", + "Credentials are too long": "Le credenziali sono troppo lunghe", + "Site DevOps": "Sito DevOps", + "A list of devops nicknames. One per line.": "Un elenco di soprannomi devops. Uno per riga.", + "devops": "devops", + "Reject spam accounts": "Rifiuta gli account spam" } diff --git a/translations/ja.json b/translations/ja.json index 18a6d237c..7ad0ff780 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -412,6 +412,7 @@ "menuInbox": "受信箱", "menuSearch": "検索/フォロー", "menuNewPost": "新しい投稿", + "menuNewBlog": "新しいブログ投稿", "menuCalendar": "カレンダー", "menuDM": "ダイレクトメッセージ", "menuReplies": "返信", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "自己紹介をし、滞在したい日時を指定してください", "Members": "メンバー", "Join": "加入", - "Leave": "離れる" + "Leave": "離れる", + "System Monitor": "システムモニター", + "Add content warnings for the following sites": "次のサイトのコンテンツ警告を追加します", + "Known Web Crawlers": "既知のWebクローラー", + "Add to the calendar": "カレンダーに追加", + "Content License": "コンテンツライセンス", + "Reaction by": "による反応", + "Notify on emoji reactions": "絵文字の反応を通知する", + "Select reaction": "反応を選択", + "Don't show the Reaction button": "反応ボタンを表示しない", + "New feed URL": "新しいフィードURL", + "New link title and URL": "新しいリンクのタイトルとURL", + "Theme Designer": "テーマデザイナー", + "Reset": "リセット", + "Encryption Keys": "暗号化キー", + "Filtered words within bio": "伝記内のフィルタリングされた単語", + "Write your news report": "ニュースレポートを書く", + "Dyslexic font": "失読症フォント", + "Leave a comment": "コメントを残す", + "View comments": "コメントを見る", + "Multi Status": "マルチステータス", + "Lots of things": "多くの物", + "Created": "作成した", + "It is done": "されております", + "Time Zone": "タイムゾーン", + "Show who liked this post": "この投稿を高く評価した人を表示する", + "Show who repeated this post": "この投稿を繰り返した人を表示する", + "Repeated by": "によって繰り返される", + "Register": "登録", + "Web Bots Allowed": "許可されたWebボット", + "Known Search Bots": "既知のWeb検索ボット", + "mitm": "メッセージが第三者によって読み取られたり変更されたりした可能性があります", + "Bold reading": "大胆な読書", + "SHOW EDITS": "編集を表示", + "Attach an image, video or audio file": "画像、ビデオ、またはオーディオファイルを添付します", + "Set a place and time": "場所と時間を設定する", + "Describe your attachment": "愛着を説明してください", + "Language used": "使用言語", + "lang_ar": "アラビア語", + "lang_bn": "ベンガル語", + "lang_cy": "ウェールズ", + "lang_en": "英語", + "lang_fr": "フランス語", + "lang_hi": "ヒンディー語", + "lang_ja": "日本", + "lang_ku": "クルド", + "lang_pl": "研磨", + "lang_ru": "ロシア", + "lang_uk": "ウクライナ語", + "lang_ca": "カタロニア語", + "lang_de": "ドイツ人", + "lang_es": "スペイン語", + "lang_ga": "アイルランド人", + "lang_it": "イタリア語", + "lang_ko": "韓国語", + "lang_oc": "オック語", + "lang_pt": "ポルトガル語", + "lang_sw": "スワヒリ語", + "lang_tr": "トルコ語", + "lang_zh": "中国語", + "lang_nl": "オランダの", + "lang_el": "ギリシャ語", + "lang_yi": "イディッシュ語", + "Common emoji": "一般的な絵文字", + "Copy and paste into your text": "コピーしてテキストに貼り付けます", + "shrug": "肩をすくめる", + "DM warning": "ダイレクトメッセージはエンドツーエンドで暗号化されません。 ここでは機密性の高い情報を共有しないでください。", + "Transcript": "トランスクリプト", + "Color contrast is too low": "色のコントラストが低すぎる", + "View Larger Map": "大きな地図を見る", + "Start Time": "始まる時間", + "End Time": "終了時間", + "Switch to calendar view": "カレンダービューに切り替えます", + "Save": "保存", + "Switch to moderation view": "モデレートビューに切り替えます", + "Minimize attached images": "添付画像を最小限に抑える", + "SHOW MEDIA": "メディアを表示", + "ActivityPub Specification": "ActivityPubの仕様", + "Dogwhistle words": "犬笛の言葉", + "Content warnings will be added for the following": "以下のコンテンツ警告が追加されます", + "nowplaying": "再生中", + "NowPlaying": "再生中", + "Import and Export": "インポートとエクスポート", + "Import Follows": "インポートフォロー", + "Post expiry period in days": "投稿の有効期限 (日数)", + "Keep DMs during post expiry": "投稿の有効期限が切れるまでダイレクト メッセージを保持する", + "Notifications": "通知", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy トピック", + "Last hour": "最後の時間", + "Last 3 hours": "過去 3 時間", + "Last 6 hours": "過去 6 時間", + "Last 12 hours": "過去 12 時間", + "Last day": "最終日", + "Last 2 days": "過去 2 日間", + "Last week": "先週", + "Last 2 weeks": "過去 2 週間", + "Last month": "先月", + "Last 6 months": "過去 6 か月", + "Last year": "去年", + "Unauthorized": "無許可", + "No login credentials were posted": "ログイン認証情報が投稿されていません", + "Credentials are too long": "資格情報が長すぎます", + "Site DevOps": "サイト DevOps", + "A list of devops nicknames. One per line.": "DevOps ニックネームのリスト。 1 行に 1 つ。", + "devops": "devops", + "Reject spam accounts": "スパムアカウントを拒否" } diff --git a/translations/ko.json b/translations/ko.json new file mode 100644 index 000000000..a287d2f72 --- /dev/null +++ b/translations/ko.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "더 보기", + "Your browser does not support the video tag.": "이 브라우저는 비디오 태그를 지원하지 않습니다.", + "Your browser does not support the audio tag.": "이 브라우저는 오디오 태그를 지원하지 않습니다.", + "Show profile": "프로필 표시", + "Show options for this person": "이 사람에 대한 옵션 표시", + "Repeat this post": "이 포스트 반복", + "Undo the repeat": "반복 실행 취소", + "Like this post": "이 포스트 좋아요", + "Undo the like": "좋아요 취소", + "Delete this post": "이 포스트 삭제", + "Delete this event": "이 이벤트 삭제", + "Reply to this post": "이 포스트에 답장", + "Write your post text below.": "새로운 게시물", + "Write your reply to": "답장을 작성하는 포스트", + "this post": "이 포스트", + "Write your report below.": "아래에 신고를 작성해주세요.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "이 메시지는 다른 연합우주 주소를 언급하더라도 중재자에게만 전달되어요.", + "Also see": "이것도 참고하세요", + "Terms of Service": "서비스 약관", + "Enter the details for your shared item below.": "아래에 공유 항목에 대한 세부 정보를 입력해주세요.", + "Subject or Content Warning (optional)": "제목 또는 내용 경고 (선택)", + "Write something": "무언가 쓰기", + "Name of the shared item": "공유 항목의 이름", + "Description of the item being shared": "공유 중인 항목에 대한 설명", + "Type of shared item. eg. hat": "공유 항목의 유형이에요. 예시: 모자", + "Category of shared item. eg. clothing": "공유 항목의 범주에요. 예시: 의류", + "Duration of listing in days": "상장 기간(일)", + "City or location of the shared item": "공유 항목의 도시 또는 위치", + "Describe a shared item": "공유 항목 설명", + "Public": "공개", + "Visible to anyone": "누구나 볼 수 있음", + "Unlisted": "공개 타임라인에 비표시", + "Not on public timeline": "공개 타임라인에 없음", + "Followers": "팔로워만", + "Only to followers": "팔로워에게만", + "DM": "DM", + "Only to mentioned people": "멘션한 사람들에게만", + "Report": "신고", + "Send to moderators": "중재자에게 보내기", + "Search for emoji": "이모지 검색", + "Cancel": "취소", + "Submit": "제출", + "Image description": "이미지 설명", + "Item image": "아이템 이미지", + "Type": "유형", + "Category": "범주", + "Location": "위치", + "Login": "로그인", + "Edit": "편집", + "Switch to timeline view": "타임라인 보기", + "Approve": "승인", + "Deny": "거부", + "Posts": "포스트", + "Following": "팔로잉", + "Followers": "팔로워", + "Roles": "역할", + "Skills": "기술", + "Shares": "공유", + "Block": "차단", + "Unfollow": "팔로우 해제", + "Your browser does not support the audio element.": "이 브라우저는 오디오 요소를 지원하지 않습니다.", + "Your browser does not support the video element.": "이 브라우저는 비디오 요소를 지원하지 않습니다.", + "Create a new post": "새 포스트 보내기", + "Create a new DM": "새 DM 보내기", + "Switch to profile view": "프로필 보기", + "Inbox": "받음", + "Sent": "보냄", + "Search and follow": "검색하고 팔로우", + "Refresh": "새로고침", + "Nickname or URL. Block using *@domain or nickname@domain": "닉네임 또는 URL. *@도메인 또는 닉네임@도메인을 사용해 차단", + "Remove the above item": "위 항목 삭제", + "Remove": "삭제", + "Suspend the above account nickname": "위 계정 닉네임 정지", + "Suspend": "정지", + "Remove a suspension for an account nickname": "계정 닉네임에 대한 정지 삭제", + "Unsuspend": "정지 해제", + "Block an account on another instance": "다른 인스턴스에서 계정 차단", + "Unblock": "차단 해제", + "Unblock an account on another instance": "다른 인스턴스에서 계정 차단 해제", + "Information about current blocks/suspensions": "현재 차단/정지에 대한 정보", + "Info": "정보", + "Remove": "삭제", + "Yes": "네", + "No": "아니요", + "Delete this post?": "이 포스트를 삭제할까요?", + "Follow": "팔로우", + "Stop following": "팔로우 해제", + "Options for": "옵션", + "View": "보기", + "Stop blocking": "차단 해제", + "Enter an emoji name to search for": "검색할 이모지 이름을 입력해주세요", + "Search screen text": "검색할 주소, 공유 항목, -저장, '기록, #해시태그, *기술, .원하는 것 또는 :이모지:", + "Go Back": "◀️", + "Moderation Information": "중재 정보", + "Suspended accounts": "정지된 계정", + "These are currently suspended": "현재 일시 중단되었습니다.", + "Blocked accounts and hashtags": "차단된 계정 및 해시태그", + "These are globally blocked for all accounts on this instance": "이 인스턴스의 모든 계정에 대해 전역적으로 차단됩니다.", + "Any blocks or suspensions made by moderators will be shown here.": "중재자가 만든 모든 차단 또는 정지가 여기에 표시됩니다.", + "Welcome. Please enter your login details below.": "반가워요. 아래에 로그인 정보를 입력하세요.", + "Welcome. Please login or register a new account.": "반가워요. 로그인하거나 새 계정을 등록하세요.", + "Please enter some credentials": "몇 가지 자격 증명을 입력해주세요", + "You will become the admin of this site.": "이 사이트의 관리자가 됩니다.", + "Terms of Service": "서비스 약관", + "About this Instance": "이 인스턴스 정보", + "Nickname": "닉네임", + "Enter Nickname": "닉네임 입력", + "Password": "비밀번호", + "Enter Password": "비밀번호 입력", + "Profile for": "프로필", + "The files attached below should be no larger than 10MB in total uploaded at once.": "아래 첨부파일은 한번에 업로드되는 총 용량이 10MB 이하여야 합니다.", + "Avatar image": "아바타 이미지", + "Background image": "아바타 뒤에 나타나는 배경 이미지", + "Timeline banner image": "타임라인 배너 이미지", + "Approve follower requests": "팔로우 요청 승인", + "This is a bot account": "봇 계정입니다", + "Filtered words": "필터링된 단어", + "One per line": "한 줄에 하나씩", + "Blocked accounts": "차단된 계정", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "차단된 계정(닉네임@도메인 또는 *@blockeddomain 형식)", + "Federation list": "연합 목록", + "Federate only with a defined set of instances. One domain name per line.": "정의된 인스턴스와만 연합해요. 한 줄에 하나의 도메인 이름.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "조직 내에서 참여하기를 원하는 경우 귀하가 가지고 있는 몇 가지 기술과 대략적인 숙련도 수준을 표시할 수 있습니다. 이것은 조직자가 적절한 기술 조합으로 팀을 구성하는 데 도움이 됩니다.", + "A list of moderator nicknames. One per line.": "중재자 닉네임 목록입니다. 한 줄에 하나씩.", + "Moderators": "중재자", + "List of moderator nicknames": "중재자 닉네임 목록", + "Your bio": "내 소개", + "Skill": "기술", + "Copy the text then paste it into your post": "텍스트를 복사한 다음 포스트에 붙여넣으세요", + "Emoji Search": "이모티콘 검색", + "No results": "결과 없음", + "Skills search": "기술 검색", + "Shared Items Search": "공유 항목 검색", + "Contact": "연락", + "Shared Item": "공유 항목", + "Mod": "관리", + "Approve follow requests": "팔로우 요청 승인", + "Page down": "페이지 아래로", + "Page up": "페이지 위로", + "Vote": "투표", + "Replies": "답장", + "Media": "미디어", + "This is a group account": "그룹 계정입니다", + "Date": "날짜", + "Time": "시간", + "Location": "위치", + "Calendar": "달력", + "Sun": "일요일", + "Mon": "월요일", + "Tue": "화요일", + "Wed": "수요일", + "Thu": "목요일", + "Fri": "금요일", + "Sat": "토요일", + "January": "1월", + "February": "2월", + "March": "3월", + "April": "4월", + "May": "5월", + "June": "6월", + "July": "7월", + "August": "8월", + "September": "9월", + "October": "10월", + "November": "11월", + "December": "12월", + "Only people I follow can send me DMs": "내가 팔로우하는 사람들만 나에게 쪽지를 보낼 수 있습니다.", + "Logout": "로그 아웃", + "Danger Zone": "위험 지역", + "Deactivate this account": "이 계정 비활성화", + "Snooze": "음소거", + "Unsnooze": "음소거 해제", + "Donations link": "기부 링크", + "Donate": "기부", + "Change Password": "비밀번호 변경", + "Confirm Password": "비밀번호 확인", + "Instance Title": "인스턴스 제목", + "Instance Short Description": "인스턴스 간략한 설명", + "Instance Description": "인스턴스 설명", + "Instance Logo": "인스턴스 로고", + "Bookmark this post": "이 포스트 북마크", + "Undo the bookmark": "북마크 해제", + "Bookmarks": "북마크", + "Theme": "주제", + "Default": "기본", + "Light": "밝은", + "Purple": "보라색", + "Hacker": "해커", + "HighVis": "높은 시인성", + "Question": "질문", + "Enter your question": "질문을 입력하세요", + "Enter the choices for your question below.": "아래에 질문에 대한 선택 사항을 입력해주세요.", + "Ask a question": "질문하기", + "Possible answers": "가능한 답변", + "replying to": "답장", + "replying to themselves": "스스로에게 답장", + "announces": "발표하다", + "Previous month": "지난달", + "Next month": "다음 달", + "Get the source code": "소스 코드 가져오기", + "This is a media instance": "미디어 인스턴스입니다", + "Mute this post": "이 포스트 음소거", + "Undo mute": "음소거 취소", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "이메일", + "PGP": "PGP 키", + "PGP Fingerprint": "PGP 지문", + "This is a scheduled post.": "예정된 포스팅입니다.", + "Remove scheduled posts": "예약된 포스트 삭제", + "Remove Twitter posts": "트위터 포스트 삭제", + "Sensitive": "민감한", + "Word Replacements": "단어 대체", + "Happening Today": "오늘", + "Happening Tomorrow": "내일", + "Happening This Week": "곧", + "Blog": "블로그", + "Blogs": "블로그", + "Title": "제목", + "About the author": "저자 소개", + "Edit blog post": "블로그 포스트 수정", + "Publicly visible post": "공개적으로 볼 수 있는 포스트", + "Your Posts": "내 포스트", + "Git Projects": "Git 프로젝트", + "List of project names that you wish to receive git patches for": "git 패치를 받고 싶은 프로젝트 이름 목록", + "Show/Hide Buttons": "표시/숨기기", + "Custom Font": "커스텀 폰트", + "Remove the custom font": "커스텀 폰트 삭제", + "Lcd": "LCD", + "Blue": "푸른", + "Zen": "선", + "Night": "밤", + "Starlight": "별빛", + "Search banner image": "배너 이미지 검색", + "Henge": "헨지", + "QR Code": "QR 코드", + "Reminder": "리마인더", + "Scheduled note to yourself": "나에 대한 예정된 메모", + "Replying to": "답장", + "Send to": "보내기", + "Show a list of addresses to send to": "보낼 주소 목록 표시", + "Petname": "애칭", + "Ok": "확인", + "This is nothing less than an utter triumph": "이것은 완전한 승리에 지나지 않는다.", + "Not Found": "찾을 수 없음", + "These are not the droids you are looking for": "이것은 당신이 찾고 있는 드로이드가 아닙니다", + "Not changed": "변경되지 않음", + "The contents of your local cache are up to date": "로컬 캐시의 내용이 최신 상태입니다.", + "Bad Request": "잘못된 요청", + "Better luck next time": "다음 기회에", + "Unavailable": "없는", + "The server is busy. Please try again later": "서버가 사용 중입니다. 나중에 다시 시도 해주십시오", + "Receive calendar events from this account": "이 계정에서 캘린더 이벤트 수신", + "Grayscale": "그레이스케일", + "Liked by": "좋아하는 사람", + "Solidaric": "연대", + "YouTube Replacement Domain": "YouTube 대체 도메인", + "Notes": "메모", + "Allow replies.": "답장을 허용합니다.", + "Event": "이벤트", + "Event name": "이벤트 이름", + "Events": "이벤트", + "Create an event": "이벤트 만들기", + "Describe the event": "이벤트 설명", + "Start Date": "시작일", + "End Date": "종료일", + "Categories": "카테고리", + "This is a private event.": "비공개 이벤트입니다.", + "Allow anonymous participation.": "익명 참여를 허용합니다.", + "Anyone can join": "누구나 가입 가능", + "Apply to join": "가입 신청", + "Invitation only": "초대만 가능", + "Joining": "합류", + "Status of the event": "이벤트 현황", + "Tentative": "잠정적인", + "Confirmed": "확인됨", + "Cancelled": "취소 된", + "Event banner image description": "이벤트 배너 이미지 설명", + "Banner image": "배너 이미지", + "Maximum attendees": "최대 참석자", + "Ticket URL": "티켓 URL", + "Create a new event": "새 이벤트 만들기", + "Moderation policy or code of conduct": "중재 정책 또는 행동 강령", + "Edit event": "이벤트 수정", + "Notify when posts are liked": "포스트가 좋아요 표시되면 알림", + "Don't show the Like button": "좋아요 버튼을 표시하지 않음", + "Autogenerated Hashtags": "자동 생성된 해시태그", + "Autogenerated Content Warnings": "자동 생성된 콘텐츠 경고", + "Indymedia": "인디미디어", + "Indymediaclassic": "인디미디어 클래식", + "Indymediamodern": "인디미디어 모던", + "Hashtag Blocked": "해시태그 차단됨", + "This is a blogging instance": "블로깅 인스턴스입니다", + "Edit Links": "링크 편집", + "One link per line. Description followed by the link.": "한 줄에 하나의 링크. 설명 뒤에 링크가 표시됩니다. 제목은 #으로 시작해야 합니다.", + "Left column image": "왼쪽 열 이미지", + "Right column image": "오른쪽 열 이미지", + "RSS feed for this site": "이 사이트의 RSS 피드", + "Edit newswire": "뉴스와이어 편집", + "Add RSS feed links below.": "RSS 피드 링크는 아래에 있습니다. 시작 또는 끝에 *를 추가하여 피드를 검토해야 함을 나타냅니다. 시작 또는 끝에 !를 추가하여 피드 콘텐츠를 미러링해야 함을 나타냅니다.", + "Newswire RSS Feed": "뉴스와이어 RSS 피드", + "Nicknames whose blog entries appear on the newswire.": "뉴스와이어에 블로그 항목이 나타나는 별명.", + "Posts to be approved": "승인될 포스트", + "Discuss": "논의하다", + "Moderator Discussion": "중재자 토론", + "Vote": "투표", + "Remove Vote": "투표 삭제", + "This is a news instance": "이것은 뉴스 인스턴스입니다", + "News": "소식", + "Read more...": "더 읽기...", + "Edit News Post": "뉴스 포스트 편집", + "A list of editor nicknames. One per line.": "편집자 닉네임 목록입니다. 한 줄에 하나씩.", + "Site Editors": "사이트 편집자", + "Allow news posts": "뉴스 포스트 허용", + "Publish": "게시", + "Publish a news article": "뉴스 기사 게시", + "News tagging rules": "뉴스 태그 규칙", + "See instructions": "지침 보기", + "Search": "검색", + "Newswire": "뉴스와이어", + "Links": "연결", + "Post": "게시하다", + "User": "사용자", + "Features" : "특징", + "Article": "기사", + "Create an article": "기사 만들기", + "Settings": "설정", + "Citations": "인용", + "Choose newswire items referenced in your article": "기사에서 참조된 뉴스와이어 항목 선택", + "RSS feed for your blog": "블로그의 RSS 피드", + "Create a new shared item": "새 공유 항목 보내기", + "Rc3": "Rc3", + "Hashtag origins": "해시태그 출처", + "admin": "관리자", + "moderator": "중재자", + "editor": "편집자", + "delegator": "위임자", + "Debian": "데비안", + "Select the edit icon to add RSS feeds": "RSS 피드를 추가하려면 편집 아이콘을 선택하세요.", + "Select the edit icon to add web links": "웹 링크를 추가하려면 편집 아이콘을 선택하십시오.", + "Hashtag Categories RSS Feed": "해시태그 카테고리 RSS 피드", + "Ask about a shared item.": "공유 항목에 대해 질문합니다.", + "Account Information": "계정 정보", + "This account interacts with the following instances": "이 계정은 다음 인스턴스와 상호 작용합니다.", + "News posts are moderated": "뉴스 포스트가 검토됨", + "Filter": "필터", + "Filter out words": "단어 필터링", + "Unfilter": "필터링 해제", + "Unfilter words": "단어 필터링 해제", + "Show Accounts": "계정 표시", + "Peertube Instances": "피어튜브 인스턴스", + "Show video previews for the following Peertube sites.": "다음 Peertube 사이트에 대한 비디오 미리보기를 표시합니다.", + "Follows you": "나를 팔로우함", + "Verify all signatures": "모든 서명 확인", + "Blocked followers": "차단된 팔로워", + "Blocked following": "차단된 팔로우", + "Receives posts from the following accounts": "다음 계정에서 포스트 수신", + "Sends out posts to the following accounts": "다음 계정으로 포스트를 보냅니다.", + "Word frequencies": "단어 빈도", + "New account": "새 계정", + "Moved to new account address": "새 계정 주소로 이전됨", + "Yet another Epicyon Instance": "또 다른 에픽욘 인스턴스", + "Other accounts": "기타 페디버스 계정", + "Pin this post to your profile.": "이 게시물을 프로필에 고정하세요.", + "Administered by": "에 의해 관리", + "Version": "버전", + "Skip to timeline": "타임라인으로 건너뛰기", + "Skip to Newswire": "뉴스와이어로 건너뛰기", + "Skip to Links": "링크로 건너뛰기", + "Publish a blog article": "블로그 기사 게시", + "Featured writer": "추천 작가", + "Broch mode": "브로치 모드", + "Pixel": "픽셀", + "DM bounce": "메시지는 팔로우된 계정에서만 허용됩니다.", + "Next": "다음", + "Preview": "미리보기", + "Linked": "웹 연결됨", + "hashtag": "해시태그", + "smile": "웃다", + "wink": "눈짓", + "mentioning": "언급", + "sad face": "슬픈 얼굴", + "thinking emoji": "생각 이모티콘", + "laughing": "웃음", + "gender": "성별", + "He/Him": "그/그", + "She/Her": "그녀/그녀", + "girl": "소녀", + "boy": "소년", + "pronoun": "대명사", + "Type of instance": "인스턴스 유형", + "Security": "보안", + "Enabling broch mode": "브로치 모드를 활성화하면 공격에 대한 일시적인 강화가 제공됩니다. 이미 알려진 인스턴스의 포스트만 수락됩니다. 끄지 않으면 일주일 후에 경과됩니다.", + "Instance Settings": "인스턴스 설정", + "Video Settings": "비디오 설정", + "Filtering and Blocking": "필터링 및 차단", + "Role Assignment": "역할 할당", + "Contact Details": "연락처 세부 정보", + "Background Images": "배경 이미지", + "heart": "마음", + "counselor": "참사관", + "Counselors": "상담원", + "shocked": "충격", + "Encrypted": "암호화됨", + "Direct Message permitted instances": "쪽지 허용 인스턴스", + "Direct messages are always allowed from these instances.": "이러한 인스턴스에서 보내는 쪽지는 항상 허용됩니다.", + "Key Shortcuts": "주요 단축키", + "menuTimeline": "타임라인 보기", + "menuEdit": "편집하다", + "menuProfile": "프로필 보기", + "menuInbox": "받은 편지함", + "menuSearch": "받은 편지함", + "menuNewPost": "새로운 포스트", + "menuNewBlog": "새 블로그 게시물", + "menuCalendar": "달력", + "menuDM": "쪽지", + "menuReplies": "답장", + "menuOutbox": "보냄", + "menuBookmarks": "책갈피", + "menuShares": "공유 항목", + "menuBlogs": "블로그", + "menuNewswire": "뉴스와이어", + "menuLinks": "연결", + "menuModeration": "절도", + "menuFollowing": "팔로잉", + "menuFollowers": "팔로워", + "menuRoles": "역할", + "menuSkills": "기술", + "menuLogout": "로그 아웃", + "menuKeys": "주요 단축키", + "submitButton": "제출 버튼", + "menuMedia": "미디어", + "followButton": "팔로우/언팔로우 버튼", + "blockButton": "차단 버튼", + "infoButton": "정보 버튼", + "snoozeButton": "음소거 버튼", + "reportButton": "신고 버튼", + "viewButton": "보기 버튼", + "enterPetname": "애칭 입력", + "enterNotes": "메모 입력", + "These access keys may be used": "이러한 액세스 키는 일반적으로 ALT + SHIFT + 키 또는 ALT + 키와 함께 사용할 수 있습니다.", + "Show numbers of accounts within instance metadata": "인스턴스 메타데이터 내 계정 수 표시", + "Show version number within instance metadata": "인스턴스 메타데이터 내 버전 번호 표시", + "Joined": "가입", + "City for spoofed GPS image metadata": "스푸핑된 이미지 GPS 메타데이터 도시", + "Occupation": "직업", + "Artists": "아티스트", + "Graphic Design": "그래픽 디자인", + "Import Theme": "테마 가져오기", + "Export Theme": "테마 내보내기", + "Custom post submit button text": "커스텀 포스트 제출 버튼 텍스트", + "Blocked User Agents": "차단된 사용자 에이전트", + "Notify me when this account posts": "이 계정이 게시되면 알림", + "Languages": "언어", + "Translated": "번역", + "Quantity": "수량", + "food": "음식", + "Price": "가격", + "Currency": "통화", + "List of domains which can access the shared items catalog": "공유 항목 카탈로그에 액세스할 수 있는 도메인 목록", + "Shares Catalog": "공유 항목 카탈로그", + "tool": "도구", + "clothes": "옷", + "medical": "의료", + "Wanted": "구함", + "Describe something wanted": "원하는 것을 설명", + "Enter the details for your wanted item below.": "아래에 원하는 항목에 대한 세부 정보를 입력하십시오.", + "Name of the wanted item": "원하는 아이템의 이름", + "Description of the item wanted": "원하는 아이템 설명", + "Type of wanted item. eg. hat": "원하는 아이템의 종류. 예를 들어 모자", + "Category of wanted item. eg. clothes": "원하는 항목의 범주입니다. 예를 들어 옷", + "City or location of the wanted item": "원하는 품목의 도시 또는 위치", + "Maximum Price": "최대 가격", + "Create a new wanted item": "원하는 새 항목 만들기", + "Wanted Items Search": "수배품 검색", + "Website": "웹사이트", + "Low Bandwidth": "낮은 대역폭", + "accommodation": "숙소", + "Forbidden": "금지됨", + "You're not allowed": "당신은 허용되지 않습니다", + "Hours after posting during which replies are allowed": "올린 후 답장이 허용되는 시간", + "Twitter": "트위터", + "Twitter Replacement Domain": "트위터 대체 도메인", + "Buy": "구입", + "Request to stay": "숙박 요청", + "Profile": "프로필", + "Introduce yourself and specify the date and time when you wish to stay": "자신을 소개하고 머물고 싶은 날짜와 시간을 지정하십시오.", + "Members": "회원", + "Join": "참여하기", + "Leave": "떠나기", + "System Monitor": "시스템 모니터", + "Add content warnings for the following sites": "다음 사이트에 대한 콘텐츠 경고 추가", + "Known Web Crawlers": "알려진 웹 크롤러", + "Add to the calendar": "캘린더에 추가", + "Content License": "콘텐츠 라이선스", + "Reaction by": "반응한 사람", + "Notify on emoji reactions": "이모지 반응 알림", + "Select reaction": "반응 선택", + "Don't show the Reaction button": "반응 버튼을 표시하지 않음", + "New feed URL": "새 피드 URL", + "New link title and URL": "새 링크 제목 및 URL", + "Theme Designer": "테마 디자이너", + "Reset": "초기화", + "Encryption Keys": "암호화 키", + "Filtered words within bio": "바이오 내 필터링된 단어", + "Write your news report": "뉴스 보고서 작성", + "Dyslexic font": "난독증 글꼴", + "Leave a comment": "코멘트를 남겨주세요", + "View comments": "댓글 보기", + "Multi Status": "다중 상태", + "Lots of things": "많은 것들", + "Created": "만들어짐", + "It is done": "완료했어요", + "Time Zone": "시간대", + "Show who liked this post": "이 포스트를 좋아한 사람 표시", + "Show who repeated this post": "이 포스트를 반복한 사람 표시", + "Repeated by": "반복한 사람", + "Register": "등록", + "Web Bots Allowed": "웹 봇 허용", + "Known Search Bots": "알려진 웹 검색 봇", + "mitm": "제3자가 메시지를 읽거나 수정했을 수 있습니다.", + "Bold reading": "굵은 글씨", + "SHOW EDITS": "수정사항 보기", + "Attach an image, video or audio file": "이미지, 비디오 또는 오디오 파일 첨부", + "Set a place and time": "장소와 시간을 정하다", + "Describe your attachment": "첨부 파일 설명", + "Language used": "사용 언어", + "lang_ar": "아라비아어", + "lang_bn": "벵골어", + "lang_cy": "웨일스어", + "lang_en": "영어", + "lang_fr": "프랑스어", + "lang_hi": "힌디어", + "lang_ja": "일본어", + "lang_ku": "쿠르드어", + "lang_pl": "폴란드어", + "lang_ru": "러시아어", + "lang_uk": "우크라이나어", + "lang_ca": "카탈루니아어", + "lang_de": "독일어", + "lang_es": "스페인어", + "lang_ga": "아일랜드어", + "lang_it": "이탈리아어", + "lang_ko": "한국어", + "lang_oc": "옥시탄어", + "lang_pt": "포르투갈어", + "lang_sw": "스와힐리어", + "lang_tr": "터키어", + "lang_zh": "중국어", + "lang_nl": "네덜란드어", + "lang_el": "그리스어", + "lang_yi": "이디시어", + "Common emoji": "일반적인 이모티콘", + "Copy and paste into your text": "텍스트에 복사하여 붙여넣기", + "shrug": "어깨를 으쓱하다", + "DM warning": "다이렉트 메시지는 종단 간 암호화되지 않습니다. 여기에 매우 민감한 정보를 공유하지 마십시오.", + "Transcript": "성적 증명서", + "Color contrast is too low": "색상 대비가 너무 낮습니다.", + "View Larger Map": "큰 지도 보기", + "Start Time": "시작 시간", + "End Time": "종료 시간", + "Switch to calendar view": "캘린더 보기로 전환", + "Save": "저장", + "Switch to moderation view": "관리자 보기로 전환", + "Minimize attached images": "첨부된 이미지 최소화", + "SHOW MEDIA": "미디어 표시", + "ActivityPub Specification": "ActivityPub 사양", + "Dogwhistle words": "개 휘파람 단어", + "Content warnings will be added for the following": "다음에 대한 콘텐츠 경고가 추가됩니다.", + "nowplaying": "지금 재생", + "NowPlaying": "지금 재생", + "Import and Export": "가져오기 및 내보내기", + "Import Follows": "팔로잉 목록 가져오기", + "Post expiry period in days": "포스트 만료 기간(일)", + "Keep DMs during post expiry": "만료 후 DM 보관", + "Notifications": "알림", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy 주제", + "Last hour": "지난 시간", + "Last 3 hours": "지난 3시간", + "Last 6 hours": "지난 6시간", + "Last 12 hours": "지난 12시간", + "Last day": "마지막 날", + "Last 2 days": "지난 2일", + "Last week": "지난주", + "Last 2 weeks": "지난 2주", + "Last month": "지난 달", + "Last 6 months": "지난 6개월", + "Last year": "작년", + "Unauthorized": "무단", + "No login credentials were posted": "게시된 로그인 자격 증명이 없습니다.", + "Credentials are too long": "자격 증명이 너무 깁니다.", + "Site DevOps": "사이트 DevOps", + "A list of devops nicknames. One per line.": "데브옵스 닉네임 목록입니다. 한 줄에 하나씩.", + "devops": "devops", + "Reject spam accounts": "스팸 계정 거부" +} diff --git a/translations/ku.json b/translations/ku.json index e6f6acb02..4e915e40e 100644 --- a/translations/ku.json +++ b/translations/ku.json @@ -412,6 +412,7 @@ "menuInbox": "Inbott", "menuSearch": "Lêgerîn / bişopîne", "menuNewPost": "Peyama nû", + "menuNewBlog": "Posta blogê ya nû", "menuCalendar": "Salname", "menuDM": "Peyamên rasterast", "menuReplies": "Bersiv", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Xwe bidin nasîn û roj û dema ku hûn dixwazin bimînin bimînin diyar bikin", "Members": "Endam", "Join": "Bihevgirêdan", - "Leave": "Terikandin" + "Leave": "Terikandin", + "System Monitor": "System Monitor", + "Add content warnings for the following sites": "Ji bo malperên jêrîn hişyariyên naverokê zêde bikin", + "Known Web Crawlers": "Crawlerên Webê yên naskirî", + "Add to the calendar": "Di salnameyê de zêde bike", + "Content License": "Naverok License de", + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Li ser reaksiyonên emoji agahdar bikin", + "Select reaction": "Reaksiyonê hilbijêrin", + "Don't show the Reaction button": "Bişkoka Reaksiyonê nîşan nede", + "New feed URL": "URL-ya feed nû", + "New link title and URL": "Sernav û URL-ya girêdana nû", + "Theme Designer": "Theme Designer", + "Reset": "Reset", + "Encryption Keys": "Bişkojkên Şîfrekirinê", + "Filtered words within bio": "Peyvên fîlterkirî di hundurê biyografiyê de", + "Write your news report": "Rapora xwe ya nûçeyan binivîsin", + "Dyslexic font": "Font Dyslexic", + "Leave a comment": "Bihêle şîroveyek", + "View comments": "Binêre şîroveyan", + "Multi Status": "Multi Status", + "Lots of things": "Gelek tişt", + "Created": "Afirandin", + "It is done": "Tê kirin", + "Time Zone": "Qada demê", + "Show who liked this post": "Nîşan bide kê ev post eciband", + "Show who repeated this post": "Nîşan bide kê ev post dubare kiriye", + "Repeated by": "Ji hêla dubare kirin", + "Register": "Fêhrist", + "Web Bots Allowed": "Web Bots Destûrdar in", + "Known Search Bots": "Botên Lêgerîna Webê yên naskirî", + "mitm": "Peyam dikaribû ji hêla aliyek sêyemîn ve were xwendin an guhertin", + "Bold reading": "Xwendina qelew", + "SHOW EDITS": "GERÎŞTAN NÎŞAN DE", + "Attach an image, video or audio file": "Wêneyek, vîdyoyek an pelê deng veke", + "Set a place and time": "Cih û dem destnîşan bikin", + "Describe your attachment": "Girêdana xwe diyar bike", + "Language used": "Zimanê bikaranîn", + "lang_ar": "Erebî", + "lang_bn": "Bengalî", + "lang_cy": "Galerkî", + "lang_en": "Îngilîzî", + "lang_fr": "Fransî", + "lang_hi": "Hindî", + "lang_ja": "Japonî", + "lang_ku": "Kurdî", + "lang_pl": "Polandî", + "lang_ru": "Rûsî", + "lang_uk": "Ûkraynî", + "lang_ca": "Katalanî", + "lang_de": "Almanî", + "lang_es": "Îspanyolî", + "lang_ga": "Irlandî", + "lang_it": "Îtalî", + "lang_ko": "Koreyî", + "lang_oc": "Occitan", + "lang_pt": "Portekizî", + "lang_sw": "Swahîlîyî", + "lang_tr": "Tirkî", + "lang_zh": "Çînî", + "lang_nl": "Holandî", + "lang_el": "Yewnanî", + "lang_yi": "Yîddîşî", + "Common emoji": "Emojiyên hevpar", + "Copy and paste into your text": "Di nivîsa xwe de kopî bikin û bixin", + "shrug": "şuştin", + "DM warning": "Peyamên rasterast bi dawî-bi-dawî ne şîfrekirî ne. Li vir agahdariya pir hesas parve nekin.", + "Transcript": "Transcript", + "Color contrast is too low": "Berevajî reng pir kêm e", + "View Larger Map": "Nexşeya Mezin bibînin", + "Start Time": "Demjimêra Destpêkê", + "End Time": "Dema Dawî", + "Switch to calendar view": "Biguherîne bo dîtina salnameyê", + "Save": "Rizgarkirin", + "Switch to moderation view": "Biguherîne bo dîtina moderatoriyê", + "Minimize attached images": "Wêneyên pêvekirî kêm bikin", + "SHOW MEDIA": "MEDYA NÎŞAN DE", + "ActivityPub Specification": "Specification ActivityPub", + "Dogwhistle words": "Peyvên kûçikê", + "Content warnings will be added for the following": "Hişyariyên naverokê dê ji bo jêrîn werin zêdekirin", + "nowplaying": "nihadilîze", + "NowPlaying": "NihaDilîze", + "Import and Export": "Import û Export", + "Import Follows": "Import Follows", + "Post expiry period in days": "Demjimêra qedandinê di çend rojan de", + "Keep DMs during post expiry": "Di dema qedandina postê de Peyamên Rasterast biparêzin", + "Notifications": "Notifications", + "ntfy URL": "ntfy URL", + "ntfy topic": "mijara ntfy", + "Last hour": "Saeta dawî", + "Last 3 hours": "3 saetên dawî", + "Last 6 hours": "6 saetên dawî", + "Last 12 hours": "12 saetên dawî", + "Last day": "Roja dawî", + "Last 2 days": "2 rojên dawî", + "Last week": "Hefteya çûyî", + "Last 2 weeks": "2 hefteyên dawî", + "Last month": "meha borî", + "Last 6 months": "6 mehên dawî", + "Last year": "Sala borî", + "Unauthorized": "Bêmaf", + "No login credentials were posted": "Tu pêbaweriyên têketinê nehatin şandin", + "Credentials are too long": "Bawernameyên pir dirêj in", + "Site DevOps": "Malpera DevOps", + "A list of devops nicknames. One per line.": "Lîsteya navên devops. Her rêzek yek.", + "devops": "devops", + "Reject spam accounts": "Hesabên spam red bikin" } diff --git a/translations/nl.json b/translations/nl.json new file mode 100644 index 000000000..dc6b7eb64 --- /dev/null +++ b/translations/nl.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "LAAT MEER ZIEN", + "Your browser does not support the video tag.": "Uw browser ondersteunt de videotag niet.", + "Your browser does not support the audio tag.": "Uw browser ondersteunt de audiotag niet.", + "Show profile": "Toon profiel", + "Show options for this person": "Opties voor deze persoon weergeven", + "Repeat this post": "Herhalen", + "Undo the repeat": "Herhaling ongedaan maken", + "Like this post": "Leuk vinden", + "Undo the like": "in tegenstelling tot", + "Delete this post": "Verwijderen", + "Delete this event": "Verwijderen", + "Reply to this post": "Antwoord", + "Write your post text below.": "Nieuw bericht", + "Write your reply to": "Schrijf je antwoord op", + "this post": "deze post", + "Write your report below.": "Schrijf je verslag hieronder.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Dit bericht gaat alleen naar moderators, zelfs als het andere fediverse adressen vermeldt.", + "Also see": "Zie ook", + "Terms of Service": "Servicevoorwaarden", + "Enter the details for your shared item below.": "Voer hieronder de details van uw gedeelde item in.", + "Subject or Content Warning (optional)": "Onderwerp- of inhoudswaarschuwing (optioneel)", + "Write something": "Schrijf iets", + "Name of the shared item": "Naam van het gedeelde item", + "Description of the item being shared": "Beschrijving van het item dat wordt gedeeld", + "Type of shared item. eg. hat": "Type gedeeld item. bijv. hoed", + "Category of shared item. eg. clothing": "Categorie van gedeeld item. bijv. kleding", + "Duration of listing in days": "Duur van de aanbieding in dagen", + "City or location of the shared item": "Stad of locatie van het gedeelde item", + "Describe a shared item": "Een gedeeld item beschrijven", + "Public": "Openbaar", + "Visible to anyone": "Zichtbaar voor iedereen", + "Unlisted": "niet vermeld", + "Not on public timeline": "Niet op openbare tijdlijn", + "Followers": "Volgers", + "Only to followers": "Alleen voor volgers", + "DM": "DB", + "Only to mentioned people": "Alleen voor genoemde personen", + "Report": "Verslag doen van", + "Send to moderators": "Stuur naar moderators", + "Search for emoji": "Zoeken naar emoji", + "Cancel": "✘", + "Submit": "Indienen", + "Image description": "Afbeeldingsomschrijving", + "Item image": "Afbeelding van het item", + "Type": "Type", + "Category": "Categorie", + "Location": "Plaats", + "Login": "Log in", + "Edit": "Bewerk", + "Switch to timeline view": "Tijdlijnweergave", + "Approve": "Goedkeuren", + "Deny": "Ontkennen", + "Posts": "Berichten", + "Following": "In aansluiting op", + "Followers": "Volgers", + "Roles": "Rollen", + "Skills": "Vaardigheden", + "Shares": "Aandelen", + "Block": "Blok", + "Unfollow": "Niet meer volgen", + "Your browser does not support the audio element.": "Uw browser ondersteunt het audio-element niet.", + "Your browser does not support the video element.": "Uw browser ondersteunt het video-element niet.", + "Create a new post": "Nieuw bericht", + "Create a new DM": "Een nieuw privébericht maken", + "Switch to profile view": "Profielweergave", + "Inbox": "Postvak IN", + "Sent": "Verzonden", + "Search and follow": "Zoeken/volgen", + "Refresh": "Vernieuwen", + "Nickname or URL. Block using *@domain or nickname@domain": "Bijnaam of URL. Blokkeren met *@domein of bijnaam@domein", + "Remove the above item": "Verwijder het bovenstaande item", + "Remove": "Verwijderen", + "Suspend the above account nickname": "De bijnaam van het bovenstaande account opschorten", + "Suspend": "Opschorten", + "Remove a suspension for an account nickname": "Een schorsing voor een accountbijnaam verwijderen", + "Unsuspend": "Opschorten", + "Block an account on another instance": "Een account op een andere instantie blokkeren", + "Unblock": "Deblokkeren", + "Unblock an account on another instance": "Deblokkeer een account op een andere instantie", + "Information about current blocks/suspensions": "Informatie over huidige blokkades/schorsingen", + "Info": "Info", + "Remove": "Verwijderen", + "Yes": "Ja", + "No": "Nee", + "Delete this post?": "Verwijder deze post?", + "Follow": "Volgen", + "Stop following": "Stop met volgen", + "Options for": "Opties voor", + "View": "Weergave", + "Stop blocking": "Stop met blokkeren", + "Enter an emoji name to search for": "Voer een emoji-naam in om naar te zoeken", + "Search screen text": "Voer een adres, gedeeld item, -opslaan, 'geschiedenis, #hashtag, *vaardigheid, .gezocht of :emoji: in om te zoeken naar", + "Go Back": "◀", + "Moderation Information": "Moderatie-informatie", + "Suspended accounts": "Geschorste accounts", + "These are currently suspended": "Deze zijn momenteel opgeschort", + "Blocked accounts and hashtags": "Geblokkeerde accounts en hashtags", + "These are globally blocked for all accounts on this instance": "Deze zijn wereldwijd geblokkeerd voor alle accounts op deze instantie", + "Any blocks or suspensions made by moderators will be shown here.": "Alle blokkades of opschortingen die door moderators zijn gemaakt, worden hier weergegeven.", + "Welcome. Please enter your login details below.": "Welkom. Vul hieronder uw inloggegevens in.", + "Welcome. Please login or register a new account.": "Welkom. Log in of registreer een nieuw account.", + "Please enter some credentials": "Voer a.u.b. enkele inloggegevens in", + "You will become the admin of this site.": "U wordt de beheerder van deze site.", + "Terms of Service": "Servicevoorwaarden", + "About this Instance": "Over deze instantie", + "Nickname": "Bijnaam", + "Enter Nickname": "Voer bijnaam in", + "Password": "Wachtwoord", + "Enter Password": "Minimaal 8 tekens", + "Profile for": "Profiel voor", + "The files attached below should be no larger than 10MB in total uploaded at once.": "De onderstaande bestanden mogen in totaal niet groter zijn dan 10 MB in één keer geüpload.", + "Avatar image": "Avatar afbeelding", + "Background image": "Achtergrondafbeelding, die achter je avatar verschijnt", + "Timeline banner image": "Tijdlijnbannerafbeelding", + "Approve follower requests": "Keur verzoeken van volgers goed", + "This is a bot account": "Dit is een bot-account", + "Filtered words": "Gefilterde woorden", + "One per line": "Een per regel", + "Blocked accounts": "Geblokkeerde accounts", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Geblokkeerde accounts, één per regel, in de vorm bijnaam@domein of *@blockeddomain", + "Federation list": "Federatie lijst", + "Federate only with a defined set of instances. One domain name per line.": "Alleen federeren met een gedefinieerde set instanties. Eén domeinnaam per regel.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Als je binnen organisaties wilt participeren, kun je een aantal vaardigheden aangeven die je hebt en vaardigheidsniveaus bij benadering. Dit helpt organisatoren om teams samen te stellen met de juiste combinatie van vaardigheden.", + "A list of moderator nicknames. One per line.": "Een lijst met moderatornamen. Een per regel.", + "Moderators": "Moderatoren", + "List of moderator nicknames": "Lijst met bijnamen van moderatoren", + "Your bio": "Je biografie", + "Skill": "Vaardigheid", + "Copy the text then paste it into your post": "Kopieer de tekst en plak deze in je bericht", + "Emoji Search": "Emoji zoeken", + "No results": "Geen resultaten", + "Skills search": "Vaardigheden zoeken", + "Shared Items Search": "Gedeelde items zoeken", + "Contact": "Contact", + "Shared Item": "Gedeeld item", + "Mod": "Gematigd", + "Approve follow requests": "Volgverzoeken goedkeuren", + "Page down": "Pagina omlaag", + "Page up": "Pagina omhoog", + "Vote": "Stemmen", + "Replies": "Antwoorden", + "Media": "Media", + "This is a group account": "Dit is een groepsaccount", + "Date": "Datum", + "Time": "Tijd", + "Location": "Plaats", + "Calendar": "Kalender", + "Sun": "Zon", + "Mon": "Maa", + "Tue": "Din", + "Wed": "Woe", + "Thu": "Don", + "Fri": "Vri", + "Sat": "Zat", + "January": "Januari", + "February": "Februari", + "March": "Maart", + "April": "April", + "May": "Kunnen", + "June": "Juni", + "July": "Juli", + "August": "Augustus", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "December", + "Only people I follow can send me DMs": "Alleen mensen die ik volg, kunnen mij privéberichten sturen", + "Logout": "Uitloggen", + "Danger Zone": "Gevarenzone", + "Deactivate this account": "Deactiveer dit account", + "Snooze": "Snooze", + "Unsnooze": "Snoozen", + "Donations link": "Donaties link", + "Donate": "Doneren", + "Change Password": "Wachtwoord wijzigen", + "Confirm Password": "Bevestig wachtwoord", + "Instance Title": "Instantietitel", + "Instance Short Description": "Instantie Korte beschrijving", + "Instance Description": "Instantiebeschrijving", + "Instance Logo": "Instantielogo", + "Bookmark this post": "Bladwijzer", + "Undo the bookmark": "Bladwijzer opheffen", + "Bookmarks": "Opgeslagen", + "Theme": "Thema", + "Default": "Standaard", + "Light": "Licht", + "Purple": "Purper", + "Hacker": "Hacker", + "HighVis": "Hallo Vis", + "Question": "Vraag", + "Enter your question": "Vul je vraag in", + "Enter the choices for your question below.": "Vul hieronder de keuzes voor uw vraag in.", + "Ask a question": "Een vraag stellen", + "Possible answers": "Mogelijke antwoorden", + "replying to": "reageren op", + "replying to themselves": "zichzelf beantwoorden", + "announces": "kondigt aan", + "Previous month": "Vorige maand", + "Next month": "Volgende maand", + "Get the source code": "De broncode ophalen", + "This is a media instance": "Dit is een media-instantie", + "Mute this post": "Stom", + "Undo mute": "Dempen ongedaan maken", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "E-mail", + "PGP": "PGP-sleutel", + "PGP Fingerprint": "PGP-vingerafdruk", + "This is a scheduled post.": "Dit is een geplande post.", + "Remove scheduled posts": "Geplande berichten verwijderen", + "Remove Twitter posts": "Twitter-berichten verwijderen", + "Sensitive": "Gevoelig", + "Word Replacements": "Woordvervangingen", + "Happening Today": "Vandaag", + "Happening Tomorrow": "Morgen", + "Happening This Week": "Spoedig", + "Blog": "Blog", + "Blogs": "Blogs", + "Title": "Titel", + "About the author": "Over de auteur", + "Edit blog post": "Blogpost bewerken", + "Publicly visible post": "Openbaar zichtbaar bericht", + "Your Posts": "Jouw palen", + "Git Projects": "Git-projecten", + "List of project names that you wish to receive git patches for": "Lijst met projectnamen waarvoor u Git-patches wilt ontvangen", + "Show/Hide Buttons": "Laten zien verbergen", + "Custom Font": "Aangepast lettertype", + "Remove the custom font": "Het aangepaste lettertype verwijderen", + "Lcd": "LCD", + "Blue": "Blauw", + "Zen": "Zen", + "Night": "Nacht", + "Starlight": "Sterrenlicht", + "Search banner image": "Zoek bannerafbeelding", + "Henge": "Henge", + "QR Code": "QR code", + "Reminder": "Herinnering", + "Scheduled note to yourself": "Geplande notitie voor jezelf", + "Replying to": "Reageren op", + "Send to": "Verzenden naar", + "Show a list of addresses to send to": "Toon een lijst met adressen om naar te verzenden", + "Petname": "Naam van huisdier", + "Ok": "OK", + "This is nothing less than an utter triumph": "Dit is niets minder dan een totale triomf", + "Not Found": "Niet gevonden", + "These are not the droids you are looking for": "Dit zijn niet de droids die je zoekt", + "Not changed": "Niet veranderd", + "The contents of your local cache are up to date": "De inhoud van uw lokale cache is up-to-date", + "Bad Request": "Foutief verzoek", + "Better luck next time": "Volgende keer beter", + "Unavailable": "Niet beschikbaar", + "The server is busy. Please try again later": "De server is bezet. Probeer het later opnieuw", + "Receive calendar events from this account": "Agenda-afspraken van dit account ontvangen", + "Grayscale": "Grijswaarden", + "Liked by": "Interessant gevonden door", + "Solidaric": "Solidarisch", + "YouTube Replacement Domain": "YouTube vervangend domein", + "Notes": "Opmerkingen", + "Allow replies.": "Reacties toestaan.", + "Event": "Evenement", + "Event name": "Evenement naam", + "Events": "Evenementen", + "Create an event": "Maak een evenement", + "Describe the event": "Beschrijf het evenement", + "Start Date": "Startdatum", + "End Date": "Einddatum", + "Categories": "Categorieën", + "This is a private event.": "Dit is een besloten evenement.", + "Allow anonymous participation.": "Anonieme deelname toestaan.", + "Anyone can join": "Iedereen kan meedoen", + "Apply to join": "Aanmelden om lid te worden", + "Invitation only": "Alleen op uitnodiging", + "Joining": "Meedoen", + "Status of the event": "Status van het evenement", + "Tentative": "Voorlopig", + "Confirmed": "Bevestigd", + "Cancelled": "Geannuleerd", + "Event banner image description": "Beschrijving afbeelding evenementbanner", + "Banner image": "Bannerafbeelding", + "Maximum attendees": "Maximum aantal deelnemers", + "Ticket URL": "Ticket-URL", + "Create a new event": "Een nieuw evenement maken", + "Moderation policy or code of conduct": "Moderatiebeleid of gedragscode", + "Edit event": "Evenement bewerken", + "Notify when posts are liked": "Melden wanneer berichten geliked worden", + "Don't show the Like button": "Laat de Like-knop niet zien", + "Autogenerated Hashtags": "Automatisch gegenereerde hashtags", + "Autogenerated Content Warnings": "Automatisch gegenereerde inhoudswaarschuwingen", + "Indymedia": "Indymedia", + "Indymediaclassic": "Indymedia Klassiek", + "Indymediamodern": "Indymedia Modern", + "Hashtag Blocked": "Hashtag geblokkeerd", + "This is a blogging instance": "Dit is een blog-instantie", + "Edit Links": "Koppelingen bewerken", + "One link per line. Description followed by the link.": "Eén link per regel. Beschrijving gevolgd door de link. Titels moeten beginnen met #", + "Left column image": "Afbeelding linkerkolom", + "Right column image": "Afbeelding rechterkolom", + "RSS feed for this site": "RSS-feed voor deze site", + "Edit newswire": "Bewerk nieuwsdraad", + "Add RSS feed links below.": "RSS-feedlinks hieronder. Voeg een * toe aan het begin of einde om aan te geven dat een feed gemodereerd moet worden. Voeg een ... toe ! aan het begin of einde om aan te geven dat de feedinhoud moet worden gespiegeld.", + "Newswire RSS Feed": "Newswire RSS-feed", + "Nicknames whose blog entries appear on the newswire.": "Bijnamen waarvan de blogberichten op de nieuwsdraad verschijnen.", + "Posts to be approved": "Goed te keuren berichten", + "Discuss": "Bespreken", + "Moderator Discussion": "Discussie moderator", + "Vote": "Stemmen", + "Remove Vote": "Stem verwijderen", + "This is a news instance": "Dit is een nieuwsinstantie", + "News": "Nieuws", + "Read more...": "Lees verder...", + "Edit News Post": "Nieuwsbericht bewerken", + "A list of editor nicknames. One per line.": "Een lijst met bijnamen van editors. Een per regel.", + "Site Editors": "Site-editors", + "Allow news posts": "Nieuwsberichten toestaan", + "Publish": "Publiceren", + "Publish a news article": "Een nieuwsartikel publiceren", + "News tagging rules": "Regels voor het taggen van nieuws", + "See instructions": "Zie instructies", + "Search": "Zoekopdracht", + "Newswire": "Nieuwsdraad", + "Links": "Links", + "Post": "Na", + "User": "Gebruiker", + "Features" : "Functies", + "Article": "Artikel", + "Create an article": "Een artikel maken", + "Settings": "Instellingen", + "Citations": "Citaten", + "Choose newswire items referenced in your article": "Kies newswire-items waarnaar in uw artikel wordt verwezen", + "RSS feed for your blog": "RSS-feed voor je blog", + "Create a new shared item": "Een nieuw gedeeld item maken", + "Rc3": "Rc3", + "Hashtag origins": "Hashtag-oorsprong", + "admin": "beheerder", + "moderator": "moderator", + "editor": "editor", + "delegator": "afgevaardigde", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "Selecteer het bewerkingspictogram om RSS-feeds toe te voegen", + "Select the edit icon to add web links": "Selecteer het bewerkingspictogram om weblinks toe te voegen", + "Hashtag Categories RSS Feed": "Hashtag-categorieën RSS-feed", + "Ask about a shared item.": "Vraag naar een gedeeld item.", + "Account Information": "Account Informatie", + "This account interacts with the following instances": "Dit account heeft interactie met de volgende instanties", + "News posts are moderated": "Nieuwsberichten worden gemodereerd", + "Filter": "Filter", + "Filter out words": "Woorden uitfilteren", + "Unfilter": "Filter ongedaan maken", + "Unfilter words": "Woorden uitfilteren", + "Show Accounts": "Accounts tonen", + "Peertube Instances": "Peertube-instanties", + "Show video previews for the following Peertube sites.": "Toon videovoorbeelden voor de volgende Peertube-sites.", + "Follows you": "Volgt jou", + "Verify all signatures": "Controleer alle handtekeningen", + "Blocked followers": "Geblokkeerde volgers", + "Blocked following": "Geblokkeerd volgen", + "Receives posts from the following accounts": "Ontvangt berichten van de volgende accounts", + "Sends out posts to the following accounts": "Verstuurt berichten naar de volgende accounts", + "Word frequencies": "Woord frequenties", + "New account": "Nieuw account", + "Moved to new account address": "Verplaatst naar nieuw accountadres", + "Yet another Epicyon Instance": "Nog een andere Epicyon-instantie", + "Other accounts": "Andere fediverse rekeningen", + "Pin this post to your profile.": "Pin dit bericht op je profiel.", + "Administered by": "Beheerd door", + "Version": "Versie", + "Skip to timeline": "Spring naar tijdlijn", + "Skip to Newswire": "Ga naar Newswire", + "Skip to Links": "Ga naar links", + "Publish a blog article": "Een blogartikel publiceren", + "Featured writer": "Aanbevolen schrijver", + "Broch mode": "Broch-modus", + "Pixel": "Pixel", + "DM bounce": "Berichten worden alleen geaccepteerd van gevolgde accounts", + "Next": "Volgende", + "Preview": "Voorbeeld", + "Linked": "Weblinked", + "hashtag": "hashtag", + "smile": "glimlach", + "wink": "knipoog", + "mentioning": "vermelden", + "sad face": "verdrietig gezicht", + "thinking emoji": "denkende emoji", + "laughing": "lachend", + "gender": "geslacht", + "He/Him": "hij/hem", + "She/Her": "zij/haar", + "girl": "meisje", + "boy": "jongen", + "pronoun": "voornaamwoord", + "Type of instance": "Type instantie", + "Security": "Beveiliging", + "Enabling broch mode": "Het inschakelen van de broch-modus biedt een tijdelijke versterking tegen aanvallen. Alleen berichten van reeds bekende instanties worden geaccepteerd. Als het niet is uitgeschakeld, verstrijkt het na een week.", + "Instance Settings": "Instantie-instellingen", + "Video Settings": "Beeldinstellingen", + "Filtering and Blocking": "Filteren en blokkeren", + "Role Assignment": "Roltoewijzing", + "Contact Details": "Contact details", + "Background Images": "Achtergrondafbeeldingen", + "heart": "hart", + "counselor": "raadgever", + "Counselors": "Adviseurs", + "shocked": "geschokt", + "Encrypted": "Versleuteld", + "Direct Message permitted instances": "Instant Message toegestane instanties", + "Direct messages are always allowed from these instances.": "Directe berichten zijn altijd toegestaan vanuit deze instanties.", + "Key Shortcuts": "Sneltoetsen", + "menuTimeline": "Tijdlijnweergave", + "menuEdit": "Bewerk", + "menuProfile": "Profielweergave", + "menuInbox": "Postvak IN", + "menuSearch": "Zoeken/volgen", + "menuNewPost": "Nieuw bericht", + "menuNewBlog": "Nieuwe blogpost", + "menuCalendar": "Kalender", + "menuDM": "Directe berichten", + "menuReplies": "Antwoorden", + "menuOutbox": "Verzonden", + "menuBookmarks": "Bladwijzers", + "menuShares": "Gedeelde items", + "menuBlogs": "Blogs", + "menuNewswire": "Nieuwsdraad", + "menuLinks": "Links", + "menuModeration": "Met mate", + "menuFollowing": "In aansluiting op", + "menuFollowers": "Volgers", + "menuRoles": "Rollen", + "menuSkills": "Vaardigheden", + "menuLogout": "Uitloggen", + "menuKeys": "Sneltoetsen", + "submitButton": "Verzendknop", + "menuMedia": "Media", + "followButton": "Volg/niet meer volgen knop", + "blockButton": "Blokkeer knop", + "infoButton": "Info-knop", + "snoozeButton": "Snooze knop", + "reportButton": "Meldknop", + "viewButton": "Bekijk knop", + "enterPetname": "Voer huisdiernaam in", + "enterNotes": "Notities invoeren", + "These access keys may be used": "Deze toegangstoetsen kunnen worden gebruikt, meestal met ALT + SHIFT + toets of ALT + toets", + "Show numbers of accounts within instance metadata": "Aantal accounts binnen instantiemetadata weergeven", + "Show version number within instance metadata": "Toon versienummer binnen instantie metadata", + "Joined": "Lid geworden", + "City for spoofed GPS image metadata": "Stad voor vervalste metagegevens van GPS-afbeeldingen", + "Occupation": "Bezigheid", + "Artists": "Artiesten", + "Graphic Design": "Grafisch ontwerp", + "Import Theme": "Thema importeren", + "Export Theme": "Thema exporteren", + "Custom post submit button text": "Tekst op de knop voor het verzenden van een bericht", + "Blocked User Agents": "Geblokkeerde gebruikersagenten", + "Notify me when this account posts": "Laat me weten wanneer dit account berichten plaatst", + "Languages": "Talen", + "Translated": "Vertaald", + "Quantity": "Aantal stuks", + "food": "voedsel", + "Price": "Prijs", + "Currency": "Munteenheid", + "List of domains which can access the shared items catalog": "Lijst met domeinen die toegang hebben tot de catalogus met gedeelde items", + "Shares Catalog": "Catalogus deelt", + "tool": "hulpmiddel", + "clothes": "kleren", + "medical": "medisch", + "Wanted": "Gezocht", + "Describe something wanted": "Beschrijf iets wat gewenst is", + "Enter the details for your wanted item below.": "Vul hieronder de gegevens van je gewenste item in.", + "Name of the wanted item": "Naam van het gewenste item", + "Description of the item wanted": "Beschrijving van het gewenste item", + "Type of wanted item. eg. hat": "Soort gezocht item. bijv. hoed", + "Category of wanted item. eg. clothes": "Categorie van het gezochte item. bijv. kleren", + "City or location of the wanted item": "Stad of locatie van het gewenste item", + "Maximum Price": "Maximale prijs", + "Create a new wanted item": "Maak een nieuw gezocht item aan", + "Wanted Items Search": "Zoeken naar gezochte items", + "Website": "Website", + "Low Bandwidth": "Lage bandbreedte", + "accommodation": "accommodatie", + "Forbidden": "Verboden", + "You're not allowed": "Je mag niet", + "Hours after posting during which replies are allowed": "Uren na het posten waarin antwoorden is toegestaan", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter vervangend domein", + "Buy": "Kopen", + "Request to stay": "Verzoek om te blijven", + "Profile": "Profiel", + "Introduce yourself and specify the date and time when you wish to stay": "Stel jezelf voor en geef de datum en tijd aan waarop je wilt blijven", + "Members": "Leden", + "Join": "Meedoen", + "Leave": "Vertrekken", + "System Monitor": "Systeemmonitor", + "Add content warnings for the following sites": "Inhoudswaarschuwingen toevoegen voor de volgende sites", + "Known Web Crawlers": "Bekende webcrawlers", + "Add to the calendar": "Voeg toe aan de kalender", + "Content License": "Inhoudslicentie", + "Reaction by": "Reactie door", + "Notify on emoji reactions": "Melden bij emoji-reacties", + "Select reaction": "Reactie", + "Don't show the Reaction button": "Laat de reactieknop niet zien", + "New feed URL": "Nieuwe feed-URL", + "New link title and URL": "Nieuwe linktitel en URL", + "Theme Designer": "Thema Ontwerper", + "Reset": "Resetten", + "Encryption Keys": "Coderingssleutels", + "Filtered words within bio": "Gefilterde woorden in biografie", + "Write your news report": "Schrijf je nieuwsbericht", + "Dyslexic font": "Dyslectisch lettertype", + "Leave a comment": "Laat een reactie achter", + "View comments": "Bekijk de reacties", + "Multi Status": "Meerdere status", + "Lots of things": "Veel dingen", + "Created": "Gemaakt", + "It is done": "Het is gebeurd", + "Time Zone": "Tijdzone", + "Show who liked this post": "Laat zien wie dit bericht leuk vond", + "Show who repeated this post": "Laat zien wie dit bericht heeft herhaald", + "Repeated by": "Herhaald door", + "Register": "Register", + "Web Bots Allowed": "Bots voor zoeken op internet toegestaan", + "Known Search Bots": "Bekende webzoekbots", + "mitm": "Bericht kan zijn gelezen of gewijzigd door een derde partij", + "Bold reading": "Vet lezen", + "SHOW EDITS": "TOON BEWERKINGEN", + "Attach an image, video or audio file": "Voeg een afbeelding, video of audiobestand toe", + "Set a place and time": "Stel een plaats en tijd in", + "Describe your attachment": "Beschrijf uw bijlage", + "Language used": "Gebruikte taal", + "lang_ar": "Arabisch", + "lang_bn": "Bengaals", + "lang_cy": "Welsh", + "lang_en": "Engels", + "lang_fr": "Frans", + "lang_hi": "Hindi", + "lang_ja": "Japans", + "lang_ku": "Koerdisch", + "lang_pl": "Pools", + "lang_ru": "Russisch", + "lang_uk": "Oekraïens", + "lang_ca": "Catalaans", + "lang_de": "Duits", + "lang_es": "Spaans", + "lang_ga": "Iers", + "lang_it": "Italiaans", + "lang_ko": "Koreaans", + "lang_oc": "Occitaans", + "lang_pt": "Portugees", + "lang_sw": "Swahili", + "lang_tr": "Turks", + "lang_zh": "Chinese", + "lang_nl": "Nederlands", + "lang_el": "Ελληνικά", + "lang_yi": "Jiddisch", + "Common emoji": "Gemeenschappelijke emoji", + "Copy and paste into your text": "Kopieer en plak in je tekst", + "shrug": "schouderophalend", + "DM warning": "Directe berichten zijn niet end-to-end versleuteld. Deel hier geen zeer gevoelige informatie.", + "Transcript": "Vertaling", + "Color contrast is too low": "Kleurcontrast is te laag", + "View Larger Map": "zie grotere kaart", + "Start Time": "Starttijd", + "End Time": "Eindtijd", + "Switch to calendar view": "Overschakelen naar kalenderweergave", + "Save": "Opslaan", + "Switch to moderation view": "Overschakelen naar moderatieweergave", + "Minimize attached images": "Bijgevoegde afbeeldingen minimaliseren", + "SHOW MEDIA": "TOON MEDIA", + "ActivityPub Specification": "ActivityPub-specificatie", + "Dogwhistle words": "Hondenfluitwoorden", + "Content warnings will be added for the following": "Er worden inhoudswaarschuwingen toegevoegd voor het volgende:", + "nowplaying": "nuaanhetspelen", + "NowPlaying": "NuAanHetSpelen", + "Import and Export": "Importeren en exporteren", + "Import Follows": "Volgt importeren", + "Post expiry period in days": "Na afloopperiode in dagen", + "Keep DMs during post expiry": "Directe berichten bewaren tijdens de vervaldatum", + "Notifications": "Meldingen", + "ntfy URL": "ntfy-URL", + "ntfy topic": "ntfy onderwerp", + "Last hour": "Laatste uur", + "Last 3 hours": "Laatste 3 uur", + "Last 6 hours": "Laatste 6 uur", + "Last 12 hours": "Laatste 12 uur", + "Last day": "Laatste dag", + "Last 2 days": "Laatste 2 dagen", + "Last week": "Vorige week", + "Last 2 weeks": "Afgelopen 2 weken", + "Last month": "Vorige maand", + "Last 6 months": "Afgelopen 6 maanden", + "Last year": "Afgelopen jaar", + "Unauthorized": "Ongeautoriseerd", + "No login credentials were posted": "Er zijn geen inloggegevens gepost", + "Credentials are too long": "Inloggegevens zijn te lang", + "Site DevOps": "Site DevOps", + "A list of devops nicknames. One per line.": "Een lijst met devops-bijnamen. Een per regel.", + "devops": "devops", + "Reject spam accounts": "Spamaccounts afwijzen" +} diff --git a/translations/oc.json b/translations/oc.json index e895eba22..25bfd8613 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -175,7 +175,7 @@ "Instance Short Description": "Instance Short Description", "Instance Description": "Instance Description", "Instance Logo": "Instance Logo", - "Bookmark this post": "Save this for later viewing", + "Bookmark this post": "Bookmark", "Undo the bookmark": "Undo the bookmark", "Bookmarks": "Saved", "Theme": "Theme", @@ -408,6 +408,7 @@ "menuInbox": "Inbox", "menuSearch": "Search/follow", "menuNewPost": "New post", + "menuNewBlog": "New blog", "menuCalendar": "Calendar", "menuDM": "Direct Messages", "menuReplies": "Replies", @@ -483,5 +484,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Introduce yourself and specify the date and time when you wish to stay", "Members": "Members", "Join": "Join", - "Leave": "Leave" + "Leave": "Leave", + "System Monitor": "System Monitor", + "Add content warnings for the following sites": "Add content warnings for the following sites", + "Known Web Crawlers": "Known Web Crawlers", + "Add to the calendar": "Add to the calendar", + "Content License": "Content License", + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Notify on emoji reactions", + "Select reaction": "Reaction", + "Don't show the Reaction button": "Don't show the Reaction button", + "New feed URL": "New feed URL", + "New link title and URL": "New link title and URL", + "Theme Designer": "Theme Designer", + "Reset": "Reset", + "Encryption Keys": "Encryption Keys", + "Filtered words within bio": "Filtered words within bio", + "Write your news report": "Write your news report", + "Dyslexic font": "Dyslexic font", + "Leave a comment": "Leave a comment", + "View comments": "View comments", + "Multi Status": "Multi Status", + "Lots of things": "Lots of things", + "Created": "Created", + "It is done": "It is done", + "Time Zone": "Time Zone", + "Show who liked this post": "Show who liked this post", + "Show who repeated this post": "Show who repeated this post", + "Repeated by": "Repeated by", + "Register": "Register", + "Web Bots Allowed": "Web Search Bots Allowed", + "Known Search Bots": "Known Web Search Bots", + "mitm": "Message could have been read or modified by a third party", + "Bold reading": "Bold reading", + "SHOW EDITS": "SHOW EDITS", + "Attach an image, video or audio file": "Attach an image, video or audio file", + "Set a place and time": "Set a place and time", + "Describe your attachment": "Describe your attachment", + "Language used": "Language used", + "lang_ar": "Arabic", + "lang_bn": "Bengali", + "lang_cy": "Welsh", + "lang_en": "English", + "lang_fr": "French", + "lang_hi": "Hindi", + "lang_ja": "Japanese", + "lang_ku": "Kurdish", + "lang_pl": "Polish", + "lang_ru": "Russian", + "lang_uk": "Ukrainian", + "lang_ca": "Catalan", + "lang_de": "German", + "lang_es": "Spanish", + "lang_ga": "Irish", + "lang_it": "Italian", + "lang_ko": "Korean", + "lang_oc": "Occitan", + "lang_pt": "Portuguese", + "lang_sw": "Swahili", + "lang_tr": "Turkish", + "lang_zh": "Chinese", + "lang_nl": "Dutch", + "lang_el": "Greek", + "lang_yi": "Yiddish", + "Common emoji": "Common emoji", + "Copy and paste into your text": "Copy and paste into your text", + "shrug": "shrug", + "DM warning": "Direct messages are not end-to-end encrypted. Do not share any highly sensitive information here.", + "Transcript": "Transcript", + "Color contrast is too low": "Color contrast is too low", + "View Larger Map": "View Larger Map", + "Start Time": "Start Time", + "End Time": "End Time", + "Switch to calendar view": "Switch to calendar view", + "Save": "Save", + "Switch to moderation view": "Switch to moderation view", + "Minimize attached images": "Minimize attached images", + "SHOW MEDIA": "SHOW MEDIA", + "ActivityPub Specification": "ActivityPub Specification", + "Dogwhistle words": "Dogwhistle words", + "Content warnings will be added for the following": "Content warnings will be added for the following", + "nowplaying": "nowplaying", + "NowPlaying": "NowPlaying", + "Import and Export": "Import and Export", + "Import Follows": "Import Follows", + "Post expiry period in days": "Post expiry period in days", + "Keep DMs during post expiry": "Keep DMs during post expiry", + "Notifications": "Notifications", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy topic", + "Last hour": "Last hour", + "Last 3 hours": "Last 3 hours", + "Last 6 hours": "Last 6 hours", + "Last 12 hours": "Last 12 hours", + "Last day": "Last day", + "Last 2 days": "Last 2 days", + "Last week": "Last week", + "Last 2 weeks": "Last 2 weeks", + "Last month": "Last month", + "Last 6 months": "Last 6 months", + "Last year": "Last year", + "Unauthorized": "Unauthorized", + "No login credentials were posted": "No login credentials were posted", + "Credentials are too long": "Credentials are too long", + "Site DevOps": "Site DevOps", + "A list of devops nicknames. One per line.": "A list of devops nicknames. One per line.", + "devops": "devops", + "Reject spam accounts": "Reject spam accounts" } diff --git a/translations/pl.json b/translations/pl.json new file mode 100644 index 000000000..077724aad --- /dev/null +++ b/translations/pl.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "POKAŻ WIĘCEJ", + "Your browser does not support the video tag.": "Twoja przeglądarka nie obsługuje tagu wideo.", + "Your browser does not support the audio tag.": "Twoja przeglądarka nie obsługuje znacznika audio.", + "Show profile": "Pokaż profil", + "Show options for this person": "Pokaż opcje dla tej osoby", + "Repeat this post": "Powtarzać", + "Undo the repeat": "Cofnij powtórzenie", + "Like this post": "Tak jak", + "Undo the like": "w odróżnieniu", + "Delete this post": "Usunąć", + "Delete this event": "Usunąć", + "Reply to this post": "Odpowiedź", + "Write your post text below.": "Nowy post", + "Write your reply to": "Napisz swoją odpowiedź do", + "this post": "ten post", + "Write your report below.": "Napisz swój raport poniżej.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Ta wiadomość trafia tylko do moderatorów, nawet jeśli wspomina o innych zbieżnych adresach.", + "Also see": "Zobacz także", + "Terms of Service": "Warunki usługi", + "Enter the details for your shared item below.": "Wprowadź poniżej szczegóły swojego udostępnionego elementu.", + "Subject or Content Warning (optional)": "Ostrzeżenie o temacie lub treści (opcjonalnie)", + "Write something": "Napisz coś", + "Name of the shared item": "Nazwa udostępnionego elementu", + "Description of the item being shared": "Opis udostępnianego elementu", + "Type of shared item. eg. hat": "Typ udostępnionego elementu. np. kapelusz", + "Category of shared item. eg. clothing": "Kategoria udostępnionego elementu. np. odzież", + "Duration of listing in days": "Czas trwania aukcji w dniach", + "City or location of the shared item": "Miasto lub lokalizacja udostępnianego elementu", + "Describe a shared item": "Opisz udostępniony element", + "Public": "Publiczny", + "Visible to anyone": "Widoczne dla każdego", + "Unlisted": "Nie katalogowany", + "Not on public timeline": "Nie na publicznej osi czasu", + "Followers": "Obserwujący", + "Only to followers": "Tylko dla obserwujących", + "DM": "WD", + "Only to mentioned people": "Tylko do wspomnianych osób", + "Report": "Raport", + "Send to moderators": "Wyślij do moderatorów", + "Search for emoji": "Wyszukaj emotikony", + "Cancel": "✘", + "Submit": "Składać", + "Image description": "Opis obrazu", + "Item image": "Obraz przedmiotu", + "Type": "Rodzaj", + "Category": "Kategoria", + "Location": "Lokalizacja", + "Login": "Zaloguj sie", + "Edit": "Edytować", + "Switch to timeline view": "Widok osi czasu", + "Approve": "Zatwierdzić", + "Deny": "Zaprzeczyć", + "Posts": "Posty", + "Following": "Następny", + "Followers": "Obserwujący", + "Roles": "Role", + "Skills": "Umiejętności", + "Shares": "Akcje", + "Block": "Blok", + "Unfollow": "Przestań obserwować", + "Your browser does not support the audio element.": "Twoja przeglądarka nie obsługuje elementu audio.", + "Your browser does not support the video element.": "Twoja przeglądarka nie obsługuje elementu wideo.", + "Create a new post": "Nowy post", + "Create a new DM": "Utwórz nową wiadomość bezpośrednią", + "Switch to profile view": "Widok profilu", + "Inbox": "W pudełku", + "Sent": "Wysłano", + "Search and follow": "Szukaj/obserwuj", + "Refresh": "Odświeżać", + "Nickname or URL. Block using *@domain or nickname@domain": "Pseudonim lub adres URL. Blokuj przy użyciu *@domena lub pseudonim@domena", + "Remove the above item": "Usuń powyższy element", + "Remove": "Usunąć", + "Suspend the above account nickname": "Zawieś powyższy pseudonim konta", + "Suspend": "Zawieszać", + "Remove a suspension for an account nickname": "Usuń zawieszenie dla pseudonimu konta", + "Unsuspend": "Anuluj zawieszenie", + "Block an account on another instance": "Zablokuj konto w innej instancji", + "Unblock": "Odblokować", + "Unblock an account on another instance": "Odblokuj konto w innej instancji", + "Information about current blocks/suspensions": "Informacje o aktualnych blokadach/zawieszeniach", + "Info": "Informacje", + "Remove": "Usunąć", + "Yes": "TAk", + "No": "Nie", + "Delete this post?": "Usuń ten post?", + "Follow": "Śledzić", + "Stop following": "Przestań podążać", + "Options for": "Opcje dla", + "View": "Pogląd", + "Stop blocking": "Przestań blokować", + "Enter an emoji name to search for": "Wpisz nazwę emoji do wyszukania", + "Search screen text": "Wpisz adres, udostępniony element, -save, 'historię, #hashtag, *umiejętność, .wanted lub :emoji:, aby wyszukać", + "Go Back": "◀", + "Moderation Information": "Informacje o moderacji", + "Suspended accounts": "Zawieszone Konta", + "These are currently suspended": "Są one obecnie zawieszone", + "Blocked accounts and hashtags": "Zablokowane konta i hashtagi", + "These are globally blocked for all accounts on this instance": "Są one globalnie zablokowane dla wszystkich kont w tej instancji", + "Any blocks or suspensions made by moderators will be shown here.": "Tutaj zostaną pokazane wszelkie bloki lub zawieszenia wykonane przez moderatorów.", + "Welcome. Please enter your login details below.": "Witamy. Wprowadź poniżej swoje dane logowania.", + "Welcome. Please login or register a new account.": "Witamy. Zaloguj się lub zarejestruj nowe konto.", + "Please enter some credentials": "Proszę podać dane uwierzytelniające", + "You will become the admin of this site.": "Zostaniesz administratorem tej strony.", + "Terms of Service": "Warunki usługi", + "About this Instance": "O tej instancji", + "Nickname": "Przezwisko", + "Enter Nickname": "Wpisz pseudonim", + "Password": "Hasło", + "Enter Password": "Minimum 8 znaków", + "Profile for": "Profil dla", + "The files attached below should be no larger than 10MB in total uploaded at once.": "Załączone poniżej pliki nie powinny być większe niż 10 MB przesyłane jednorazowo.", + "Avatar image": "Obraz awatara", + "Background image": "Obraz tła, który pojawia się za twoim awatarem", + "Timeline banner image": "Obraz banera osi czasu", + "Approve follower requests": "Zatwierdź prośby obserwujących", + "This is a bot account": "To jest konto bota", + "Filtered words": "Filtrowane słowa", + "One per line": "Jeden na linię", + "Blocked accounts": "Zablokowane konta", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Zablokowane konta, po jednym w wierszu, w postaci pseudonim@domena lub *@zablokowanadomena", + "Federation list": "Lista federacyjna", + "Federate only with a defined set of instances. One domain name per line.": "Federuj tylko ze zdefiniowanym zestawem instancji. Jedna nazwa domeny w wierszu.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Jeśli chcesz uczestniczyć w organizacjach, możesz wskazać niektóre umiejętności, które posiadasz i przybliżony poziom biegłości. Pomaga to organizatorom w budowaniu zespołów z odpowiednią kombinacją umiejętności.", + "A list of moderator nicknames. One per line.": "Lista pseudonimów moderatorów. Jeden na linię.", + "Moderators": "Moderatorzy", + "List of moderator nicknames": "Lista pseudonimów moderatora", + "Your bio": "Twoja biografia", + "Skill": "Umiejętność", + "Copy the text then paste it into your post": "Skopiuj tekst, a następnie wklej go do swojego posta", + "Emoji Search": "Wyszukiwanie emotikonów", + "No results": "Brak wyników", + "Skills search": "Wyszukiwanie umiejętności", + "Shared Items Search": "Wyszukiwanie elementów udostępnionych", + "Contact": "Kontakt", + "Shared Item": "Udostępniony element", + "Mod": "Umi", + "Approve follow requests": "Zatwierdź prośby o śledzenie", + "Page down": "Strona w dół", + "Page up": "Strona w górę", + "Vote": "Głosować", + "Replies": "Odpowiedzi", + "Media": "Głoska bezdźwięczna", + "This is a group account": "To jest konto grupowe", + "Date": "Data", + "Time": "Czas", + "Location": "Lokalizacja", + "Calendar": "Kalendarz", + "Sun": "Nie", + "Mon": "Pon", + "Tue": "Wto", + "Wed": "Śro", + "Thu": "Czw", + "Fri": "Pią", + "Sat": "Sob", + "January": "Styczeń", + "February": "Luty", + "March": "Marsz", + "April": "Kwiecień", + "May": "Może", + "June": "Czerwiec", + "July": "Lipiec", + "August": "Sierpień", + "September": "Wrzesień", + "October": "Październik", + "November": "Listopad", + "December": "Grudzień", + "Only people I follow can send me DMs": "Tylko osoby, które obserwuję, mogą wysyłać mi bezpośrednie wiadomości", + "Logout": "Wyloguj", + "Danger Zone": "Strefa niebezpieczeństwa", + "Deactivate this account": "Dezaktywuj to konto", + "Snooze": "Drzemka", + "Unsnooze": "Odłóż", + "Donations link": "Link do darowizn", + "Donate": "Podarować", + "Change Password": "Zmień hasło", + "Confirm Password": "Potwierdź hasło", + "Instance Title": "Tytuł instancji", + "Instance Short Description": "Krótki opis instancji", + "Instance Description": "Opis instancji", + "Instance Logo": "Logo instancji", + "Bookmark this post": "Zapisz to do późniejszego przeglądania", + "Undo the bookmark": "Usuń zakładkę", + "Bookmarks": "Zapisane", + "Theme": "Temat", + "Default": "Domyślna", + "Light": "Lekki", + "Purple": "Purpurowy", + "Hacker": "Haker", + "HighVis": "Cześć Vis", + "Question": "Pytanie", + "Enter your question": "Wpisz swoje pytanie", + "Enter the choices for your question below.": "Wprowadź poniżej opcje dotyczące swojego pytania.", + "Ask a question": "Zadać pytanie", + "Possible answers": "Możliwe odpowiedzi", + "replying to": "odpowiadając na", + "replying to themselves": "odpowiadając sobie", + "announces": "ogłasza", + "Previous month": "Poprzedni miesiac", + "Next month": "W następnym miesiącu", + "Get the source code": "Pobierz kod źródłowy", + "This is a media instance": "To jest instancja medialna", + "Mute this post": "Niemy", + "Undo mute": "Cofnij wyciszenie", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "E-mail", + "PGP": "Klucz PGP", + "PGP Fingerprint": "Odcisk palca PGP", + "This is a scheduled post.": "To jest zaplanowany post.", + "Remove scheduled posts": "Usuń zaplanowane posty", + "Remove Twitter posts": "Usuń posty na Twitterze", + "Sensitive": "Wrażliwy", + "Word Replacements": "Zamienniki słów", + "Happening Today": "Dziś", + "Happening Tomorrow": "Jutro", + "Happening This Week": "Już wkrótce", + "Blog": "Blog", + "Blogs": "Blogs", + "Title": "Tytuł", + "About the author": "O autorze", + "Edit blog post": "Edytuj post na blogu", + "Publicly visible post": "Publicznie widoczny post", + "Your Posts": "Twoje posty", + "Git Projects": "Projekty Gita", + "List of project names that you wish to receive git patches for": "Lista nazw projektów, dla których chcesz otrzymywać łatki git", + "Show/Hide Buttons": "Pokaż ukryj", + "Custom Font": "Czcionka niestandardowa", + "Remove the custom font": "Usuń niestandardową czcionkę", + "Lcd": "LCD", + "Blue": "Niebieski", + "Zen": "Zen", + "Night": "Noc", + "Starlight": "Gwiezdny", + "Search banner image": "Wyszukaj obraz banera", + "Henge": "Henge", + "QR Code": "Kod QR", + "Reminder": "Przypomnienie", + "Scheduled note to yourself": "Zaplanowana notatka dla siebie", + "Replying to": "Odpowiadam na", + "Send to": "Odpowiadam na", + "Show a list of addresses to send to": "Pokaż listę adresów do wysłania", + "Petname": "Nazwa zwierzaka", + "Ok": "Dobrze", + "This is nothing less than an utter triumph": "To nic innego jak całkowity triumf", + "Not Found": "Nie znaleziono", + "These are not the droids you are looking for": "To nie są droidy, których szukasz", + "Not changed": "Nie zmieniony", + "The contents of your local cache are up to date": "Zawartość Twojej lokalnej pamięci podręcznej jest aktualna", + "Bad Request": "Zła prośba", + "Better luck next time": "Więcej szczęścia następnym razem", + "Unavailable": "Niedostępne", + "The server is busy. Please try again later": "Serwer jest zajęty. Spróbuj ponownie później", + "Receive calendar events from this account": "Otrzymuj wydarzenia z kalendarza z tego konta", + "Grayscale": "Skala szarości", + "Liked by": "Polubione przez", + "Solidaric": "Solidarność", + "YouTube Replacement Domain": "Domena zastępcza YouTube", + "Notes": "Uwagi", + "Allow replies.": "Zezwalaj na odpowiedzi.", + "Event": "Wydarzenie", + "Event name": "Nazwa wydarzenia", + "Events": "Wydarzenia", + "Create an event": "Utwórz wydarzenie", + "Describe the event": "Opisz wydarzenie", + "Start Date": "Data rozpoczęcia", + "End Date": "Data zakonczenia", + "Categories": "Kategorie", + "This is a private event.": "To jest prywatne wydarzenie.", + "Allow anonymous participation.": "Zezwól na udział anonimowy.", + "Anyone can join": "Każdy może dołączyć", + "Apply to join": "Złóż wniosek o przyłączenie", + "Invitation only": "Tylko z zaproszeniem", + "Joining": "Łączący", + "Status of the event": "Status wydarzenia", + "Tentative": "Niepewny", + "Confirmed": "Potwierdzony", + "Cancelled": "Odwołany", + "Event banner image description": "Opis obrazu banera wydarzenia", + "Banner image": "Obraz banera", + "Maximum attendees": "Maksymalna liczba uczestników", + "Ticket URL": "Adres URL biletu", + "Create a new event": "Utwórz nowe wydarzenie", + "Moderation policy or code of conduct": "Polityka moderacji lub kodeks postępowania", + "Edit event": "Edytuj wydarzenie", + "Notify when posts are liked": "Powiadamiaj o polubieniach postów", + "Don't show the Like button": "Nie pokazuj przycisku Lubię to", + "Autogenerated Hashtags": "Hashtagi generowane automatycznie", + "Autogenerated Content Warnings": "Ostrzeżenia dotyczące treści generowanych automatycznie", + "Indymedia": "Indymedia", + "Indymediaclassic": "Klasyka indymedia", + "Indymediamodern": "Indymedia Nowoczesne", + "Hashtag Blocked": "Hashtag zablokowany", + "This is a blogging instance": "To jest instancja blogowa", + "Edit Links": "Edytuj linki", + "One link per line. Description followed by the link.": "Jedno łącze na linię. Opis, po którym następuje link. Tytuły powinny zaczynać się od #", + "Left column image": "Obraz w lewej kolumnie", + "Right column image": "Obraz prawej kolumny", + "RSS feed for this site": "Kanał RSS dla tej witryny", + "Edit newswire": "Edytuj newswire", + "Add RSS feed links below.": "Linki do kanałów RSS poniżej. Dodaj * na początku lub na końcu, aby wskazać, że kanał powinien być moderowany. Dodać ! na początku lub na końcu, aby wskazać, że treść kanału powinna być dublowana.", + "Newswire RSS Feed": "Kanał RSS Newswire", + "Nicknames whose blog entries appear on the newswire.": "Pseudonimy, których wpisy na blogu pojawiają się w newswire.", + "Posts to be approved": "Posty do zatwierdzenia", + "Discuss": "Omówić", + "Moderator Discussion": "Dyskusja moderatora", + "Vote": "Głosować", + "Remove Vote": "Usuń głos", + "This is a news instance": "To jest instancja wiadomości", + "News": "Aktualności", + "Read more...": "Czytaj więcej...", + "Edit News Post": "Edytuj post z wiadomościami", + "A list of editor nicknames. One per line.": "Lista pseudonimów redaktorów. Jeden na linię.", + "Site Editors": "Redaktorzy witryny", + "Allow news posts": "Zezwalaj na publikacje wiadomości", + "Publish": "Publikować", + "Publish a news article": "Opublikuj artykuł z wiadomościami", + "News tagging rules": "Zasady tagowania wiadomości", + "See instructions": "Zobacz instrukcje", + "Search": "Szukaj", + "Newswire": "Newswire", + "Links": "Spinki do mankietów", + "Post": "Poczta", + "User": "Użytkownik", + "Features" : "Cechy", + "Article": "Artykuł", + "Create an article": "Utwórz artykuł", + "Settings": "Ustawienia", + "Citations": "Cytaty", + "Choose newswire items referenced in your article": "Wybierz artykuły newswire, do których odwołuje się Twój artykuł", + "RSS feed for your blog": "Kanał RSS dla Twojego bloga", + "Create a new shared item": "Utwórz nowy udostępniony element", + "Rc3": "Rc3", + "Hashtag origins": "Początki hashtagów", + "admin": "Admin", + "moderator": "moderator", + "editor": "redaktor", + "delegator": "delegujący", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "Wybierz ikonę edycji, aby dodać kanały RSS", + "Select the edit icon to add web links": "Wybierz ikonę edycji, aby dodać linki do stron internetowych", + "Hashtag Categories RSS Feed": "Hashtag Kategorie Kanał RSS", + "Ask about a shared item.": "Zapytaj o udostępniony element.", + "Account Information": "informacje o koncie", + "This account interacts with the following instances": "To konto współdziała z następującymi instancjami", + "News posts are moderated": "Posty z wiadomościami są moderowane", + "Filter": "Filtr", + "Filter out words": "Odfiltruj słowa", + "Unfilter": "Odfiltruj", + "Unfilter words": "Odfiltruj słowa", + "Show Accounts": "Pokaż konta", + "Peertube Instances": "Instancje Peertube", + "Show video previews for the following Peertube sites.": "Pokaż podgląd wideo dla następujących witryn Peertube.", + "Follows you": "Śledzi cię", + "Verify all signatures": "Zweryfikuj wszystkie podpisy", + "Blocked followers": "Zablokowani obserwatorzy", + "Blocked following": "Zablokowano obserwowanie", + "Receives posts from the following accounts": "Otrzymuje posty z następujących kont", + "Sends out posts to the following accounts": "Wysyła posty na następujące konta", + "Word frequencies": "Częstotliwości słów", + "New account": "Nowe konto", + "Moved to new account address": "Przeniesiono na nowy adres konta", + "Yet another Epicyon Instance": "Kolejna instancja Epicyon", + "Other accounts": "Inne konta federacyjne", + "Pin this post to your profile.": "Przypnij ten post do swojego profilu.", + "Administered by": "Administrowane przez", + "Version": "Wersja", + "Skip to timeline": "Przejdź do osi czasu", + "Skip to Newswire": "Przejdź do Newswire", + "Skip to Links": "Przejdź do linków", + "Publish a blog article": "Opublikuj artykuł na blogu", + "Featured writer": "Wyróżniony pisarz", + "Broch mode": "Tryb broszki", + "Pixel": "Piksel", + "DM bounce": "Wiadomości są przyjmowane tylko z obserwowanych kont", + "Next": "Następny", + "Preview": "Zapowiedź", + "Linked": "Połączony z siecią", + "hashtag": "hash-tag", + "smile": "uśmiechać się", + "wink": "Puść oczko", + "mentioning": "wspominając", + "sad face": "smutna mina", + "thinking emoji": "myślący emoji", + "laughing": "śmiać się", + "gender": "Płeć", + "He/Him": "On/On", + "She/Her": "Ona jej", + "girl": "dziewczyna", + "boy": "chłopiec", + "pronoun": "zaimek", + "Type of instance": "Rodzaj instancji", + "Security": "Bezpieczeństwo", + "Enabling broch mode": "Włączenie trybu broch zapewnia tymczasową ochronę przed atakiem. Akceptowane będą tylko posty już znanych instancji. Jeśli nie jest wyłączony, upłynie po tygodniu.", + "Instance Settings": "Ustawienia instancji", + "Video Settings": "Ustawienia wideo", + "Filtering and Blocking": "Filtrowanie i blokowanie", + "Role Assignment": "Przypisanie roli", + "Contact Details": "Szczegóły kontaktu", + "Background Images": "Obrazy tła", + "heart": "serce", + "counselor": "doradca", + "Counselors": "Doradcy", + "shocked": "wstrząśnięty", + "Encrypted": "Zaszyfrowane", + "Direct Message permitted instances": "Dozwolone przypadki bezpośredniej wiadomości", + "Direct messages are always allowed from these instances.": "Bezpośrednie wiadomości są zawsze dozwolone z tych instancji.", + "Key Shortcuts": "Skróty klawiszowe", + "menuTimeline": "Widok osi czasu", + "menuEdit": "Edytować", + "menuProfile": "Widok profilu", + "menuInbox": "W pudełku", + "menuSearch": "Szukaj/obserwuj", + "menuNewPost": "Nowy post", + "menuNewBlog": "Nowy wpis na blogu", + "menuCalendar": "Kalendarz", + "menuDM": "Bezpośrednie wiadomości", + "menuReplies": "Odpowiedzi", + "menuOutbox": "Wysłano", + "menuBookmarks": "Zakładki", + "menuShares": "Udostępnione elementy", + "menuBlogs": "Blogs", + "menuNewswire": "Newswire", + "menuLinks": "Spinki do mankietów", + "menuModeration": "Umiar", + "menuFollowing": "Następny", + "menuFollowers": "Obserwujący", + "menuRoles": "Role", + "menuSkills": "Umiejętności", + "menuLogout": "Wyloguj", + "menuKeys": "Skróty klawiszowe", + "submitButton": "Przycisk Prześlij", + "menuMedia": "Głoska bezdźwięczna", + "followButton": "Przycisk Śledź/Przestań obserwować", + "blockButton": "Przycisk blokowania", + "infoButton": "Przycisk informacji", + "snoozeButton": "Przycisk drzemki", + "reportButton": "Przycisk Zgłoś", + "viewButton": "Przycisk Widok", + "enterPetname": "Wpisz imię zwierzaka", + "enterNotes": "Wprowadź notatki", + "These access keys may be used": "Te klawisze dostępu mogą być używane, zwykle z klawiszem ALT + SHIFT + lub ALT + klawisz", + "Show numbers of accounts within instance metadata": "Pokaż numery kont w metadanych instancji", + "Show version number within instance metadata": "Pokaż numer wersji w metadanych instancji", + "Joined": "Dołączył", + "City for spoofed GPS image metadata": "Miasto dla sfałszowanych metadanych obrazu GPS", + "Occupation": "Zawód", + "Artists": "Artyści", + "Graphic Design": "Projekt graficzny", + "Import Theme": "Importuj motyw", + "Export Theme": "Eksportuj motyw", + "Custom post submit button text": "Niestandardowy tekst przycisku przesyłania postów", + "Blocked User Agents": "Zablokowani agenci użytkownika", + "Notify me when this account posts": "Powiadom mnie, gdy to konto opublikuje", + "Languages": "Języki", + "Translated": "Przetłumaczony", + "Quantity": "Ilość", + "food": "jedzenie", + "Price": "Cena £", + "Currency": "Waluta", + "List of domains which can access the shared items catalog": "Lista domen, które mogą uzyskać dostęp do katalogu elementów udostępnionych", + "Shares Catalog": "Katalog akcji", + "tool": "narzędzie", + "clothes": "odzież", + "medical": "medyczny", + "Wanted": "Chciał", + "Describe something wanted": "Opisz coś, czego chciałeś", + "Enter the details for your wanted item below.": "Wprowadź poniżej dane dotyczące poszukiwanego przedmiotu.", + "Name of the wanted item": "Nazwa poszukiwanego przedmiotu", + "Description of the item wanted": "Opis poszukiwanego przedmiotu", + "Type of wanted item. eg. hat": "Rodzaj poszukiwanej pozycji. np. kapelusz", + "Category of wanted item. eg. clothes": "Kategoria poszukiwanej pozycji. np. odzież", + "City or location of the wanted item": "Miasto lub lokalizacja poszukiwanego przedmiotu", + "Maximum Price": "Cena maksymalna", + "Create a new wanted item": "Utwórz nowy poszukiwany przedmiot", + "Wanted Items Search": "Wyszukiwanie przedmiotów poszukiwanych", + "Website": "Stronie internetowej", + "Low Bandwidth": "Niska przepustowość", + "accommodation": "zakwaterowanie", + "Forbidden": "Zakazany", + "You're not allowed": "Nie wolno Ci", + "Hours after posting during which replies are allowed": "Godziny po opublikowaniu, w których odpowiedzi są dozwolone", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Domena zastępcza Twittera", + "Buy": "Kupić", + "Request to stay": "Prośba o pobyt", + "Profile": "Profil", + "Introduce yourself and specify the date and time when you wish to stay": "Przedstaw się i określ datę i godzinę, kiedy chcesz zostać", + "Members": "Członkowie", + "Join": "Dołączyć", + "Leave": "Wyjechać", + "System Monitor": "Monitor systemu", + "Add content warnings for the following sites": "Dodaj ostrzeżenia dotyczące treści dla następujących witryn", + "Known Web Crawlers": "Znane roboty internetowe", + "Add to the calendar": "Dodaj do kalendarza", + "Content License": "Licencja na zawartość", + "Reaction by": "Reakcja przez", + "Notify on emoji reactions": "Powiadamiaj o reakcjach emoji", + "Select reaction": "Wybierz reakcję", + "Don't show the Reaction button": "Nie pokazuj przycisku reakcji", + "New feed URL": "Nowy adres URL kanału", + "New link title and URL": "Nowy tytuł linku i adres URL", + "Theme Designer": "Projektant motywów", + "Reset": "Resetowanie", + "Encryption Keys": "Klucze szyfrujące", + "Filtered words within bio": "Filtrowane słowa w bio", + "Write your news report": "Napisz swój raport informacyjny", + "Dyslexic font": "Czcionka dyslektyczna", + "Leave a comment": "zostaw komentarz", + "View comments": "Zobacz komentarze", + "Multi Status": "Wiele statusów", + "Lots of things": "Wiele rzeczy", + "Created": "Utworzony", + "It is done": "Zrobione", + "Time Zone": "Strefa czasowa", + "Show who liked this post": "Pokaż, kto polubił ten post", + "Show who repeated this post": "Pokaż, kto powtórzył ten post", + "Repeated by": "Powtórzone przez", + "Register": "Zarejestrować", + "Web Bots Allowed": "Dozwolone boty internetowe", + "Known Search Bots": "Znane boty wyszukiwania w sieci", + "mitm": "Wiadomość mogła zostać przeczytana lub zmodyfikowana przez osobę trzecią", + "Bold reading": "Odważne czytanie", + "SHOW EDITS": "POKAŻ EDYCJE", + "Attach an image, video or audio file": "Dołącz obraz, plik wideo lub audio", + "Set a place and time": "Ustaw miejsce i czas", + "Describe your attachment": "Opisz swój załącznik", + "Language used": "Użyty język", + "lang_ar": "Arabski", + "lang_bn": "Bengalski", + "lang_cy": "Walijski", + "lang_en": "Angielski", + "lang_fr": "Francuski", + "lang_hi": "Hinduski", + "lang_ja": "Japoński", + "lang_ku": "Kurdyjski", + "lang_pl": "Polski", + "lang_ru": "Rosyjski", + "lang_uk": "Ukraiński", + "lang_ca": "Kataloński", + "lang_de": "Niemiecki", + "lang_es": "Hiszpański", + "lang_ga": "Irlandzki", + "lang_it": "Włoski", + "lang_ko": "Koreański", + "lang_oc": "Prowansalski", + "lang_pt": "Portugalski", + "lang_sw": "Suahili", + "lang_tr": "Turecki", + "lang_zh": "Chiński", + "lang_nl": "Holenderski", + "lang_el": "Grecki", + "lang_yi": "Jidysz", + "Common emoji": "Popularne emotikony", + "Copy and paste into your text": "Skopiuj i wklej do swojego tekstu", + "shrug": "wzruszać ramionami", + "DM warning": "Wiadomości na czacie nie są szyfrowane metodą end-to-end. Nie udostępniaj tutaj żadnych wysoce wrażliwych informacji.", + "Transcript": "Transkrypcja", + "Color contrast is too low": "Kontrast kolorów jest zbyt niski", + "View Larger Map": "Wyświetl Większą Mapę", + "Start Time": "Czas rozpoczęcia", + "End Time": "Koniec czasu", + "Switch to calendar view": "Przełącz na widok kalendarza", + "Save": "Ratować", + "Switch to moderation view": "Przełącz na widok moderacji", + "Minimize attached images": "Zminimalizuj załączone obrazy", + "SHOW MEDIA": "POKAŻ MEDIA", + "ActivityPub Specification": "Specyfikacja ActivityPub", + "Dogwhistle words": "Słowa gwizdka na psa", + "Content warnings will be added for the following": "Ostrzeżenia dotyczące treści zostaną dodane do następujących", + "nowplaying": "terazgra", + "NowPlaying": "TerazGra", + "Import and Export": "Importuj i eksportuj", + "Import Follows": "Importuj obserwuje", + "Post expiry period in days": "Okres po wygaśnięciu w dniach", + "Keep DMs during post expiry": "Zachowaj bezpośrednie wiadomości po wygaśnięciu", + "Notifications": "Powiadomienia", + "ntfy URL": "URL ntfy", + "ntfy topic": "temat ntfy", + "Last hour": "Ostatnia godzina", + "Last 3 hours": "Ostatnie 3 godzin", + "Last 6 hours": "Ostatnie 6 godzin", + "Last 12 hours": "Ostatnie 12 godzin", + "Last day": "Ostatni dzień", + "Last 2 days": "Ostatnie 2 dni", + "Last week": "Zeszły tydzień", + "Last 2 weeks": "Ostatnie 2 tygodnie", + "Last month": "W zeszłym miesiącu", + "Last 6 months": "Ostatnie 6 miesięcy", + "Last year": "Ostatni rok", + "Unauthorized": "Nieautoryzowany", + "No login credentials were posted": "Nie opublikowano danych logowania", + "Credentials are too long": "Poświadczenia są za długie", + "Site DevOps": "Deweloperzy witryny", + "A list of devops nicknames. One per line.": "Lista pseudonimów Devopa. Jeden na linię.", + "devops": "devops", + "Reject spam accounts": "Odrzuć konta spamowe" +} diff --git a/translations/pt.json b/translations/pt.json index d59ecdcc3..ae1b929c0 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -412,6 +412,7 @@ "menuInbox": "Caixa de entrada", "menuSearch": "Pesquisa / Siga", "menuNewPost": "Nova postagem", + "menuNewBlog": "Nova postagem no blog", "menuCalendar": "Calendário", "menuDM": "Mensagens diretas", "menuReplies": "Respostas", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Apresente-se e especifique a data e hora em que deseja ficar", "Members": "Membros", "Join": "Juntar", - "Leave": "Sair" + "Leave": "Sair", + "System Monitor": "Monitor de Sistema", + "Add content warnings for the following sites": "Adicione avisos de conteúdo para os seguintes sites", + "Known Web Crawlers": "Rastreadores da Web conhecidos", + "Add to the calendar": "Adicionar ao calendário", + "Content License": "Licença de Conteúdo", + "Reaction by": "Reazione di", + "Notify on emoji reactions": "Notificar sobre reações de emoji", + "Select reaction": "Selecione a reação", + "Don't show the Reaction button": "Não mostrar o botão de reação", + "New feed URL": "Novo URL de feed", + "New link title and URL": "Novo título e URL do link", + "Theme Designer": "Designer de Tema", + "Reset": "Redefinir", + "Encryption Keys": "Chaves de criptografia", + "Filtered words within bio": "Palavras filtradas na biografia", + "Write your news report": "Escreva sua reportagem", + "Dyslexic font": "Fonte disléxica", + "Leave a comment": "Deixe um comentário", + "View comments": "Ver comentários", + "Multi Status": "Vários status", + "Lots of things": "Muitas coisas", + "Created": "Criada", + "It is done": "Está feito", + "Time Zone": "Fuso horário", + "Show who liked this post": "Mostrar quem gostou deste post", + "Show who repeated this post": "Mostrar quem repetiu esta postagem", + "Repeated by": "Repetido por", + "Register": "Registro", + "Web Bots Allowed": "Webbots permitidos", + "Known Search Bots": "Bots de pesquisa na Web conhecidos", + "mitm": "A mensagem pode ter sido lida ou modificada por terceiros", + "Bold reading": "Leitura em negrito", + "SHOW EDITS": "MOSTRAR EDIÇÕES", + "Attach an image, video or audio file": "Anexe um arquivo de imagem, vídeo ou áudio", + "Set a place and time": "Defina um local e hora", + "Describe your attachment": "Descreva seu anexo", + "Language used": "Idioma usado", + "lang_ar": "árabe", + "lang_bn": "Bengali", + "lang_cy": "Galês", + "lang_en": "Inglês", + "lang_fr": "Francesa", + "lang_hi": "Hindi", + "lang_ja": "Japonês", + "lang_ku": "Curda", + "lang_pl": "Polonês", + "lang_ru": "Russa", + "lang_uk": "Ucraniana", + "lang_ca": "Catalã", + "lang_de": "Alemã", + "lang_es": "Espanhola", + "lang_ga": "Irlandês", + "lang_it": "Italiana", + "lang_ko": "Coreana", + "lang_oc": "Occitano", + "lang_pt": "Português", + "lang_sw": "Suaíli", + "lang_tr": "Turca", + "lang_zh": "Chinês", + "lang_nl": "Holandês", + "lang_el": "Grega", + "lang_yi": "Iídiche", + "Common emoji": "Emoji comum", + "Copy and paste into your text": "Copie e cole no seu texto", + "shrug": "dar de ombros", + "DM warning": "As mensagens diretas não são criptografadas de ponta a ponta. Não compartilhe nenhuma informação altamente sensível aqui.", + "Transcript": "Transcrição", + "Color contrast is too low": "O contraste de cores é muito baixo", + "View Larger Map": "ver o mapa maior", + "Start Time": "Hora de início", + "End Time": "Fim do tempo", + "Switch to calendar view": "Mudar para a vista de calendário", + "Save": "Salvar", + "Switch to moderation view": "Mudar para a visualização de moderação", + "Minimize attached images": "Minimizar imagens anexadas", + "SHOW MEDIA": "MOSTRAR MÍDIA", + "ActivityPub Specification": "Especificação do ActivityPub", + "Dogwhistle words": "Palavras de apito", + "Content warnings will be added for the following": "Avisos de conteúdo serão adicionados para os seguintes", + "nowplaying": "agorajogando", + "NowPlaying": "AgoraJogando", + "Import and Export": "Importar e exportar", + "Import Follows": "Importar seguidores", + "Post expiry period in days": "Prazo de expiração em dias", + "Keep DMs during post expiry": "Manter mensagens diretas durante a expiração da postagem", + "Notifications": "Notificações", + "ntfy URL": "URL ntfy", + "ntfy topic": "tópico ntfy", + "Last hour": "Última hora", + "Last 3 hours": "Últimas 3 horas", + "Last 6 hours": "Últimas 6 horas", + "Last 12 hours": "Últimas 12 horas", + "Last day": "Último dia", + "Last 2 days": "Últimos 2 dias", + "Last week": "Semana Anterior", + "Last 2 weeks": "Últimas 2 semanas", + "Last month": "Mês passado", + "Last 6 months": "Últimos 6 meses", + "Last year": "Ano passado", + "Unauthorized": "Não autorizado", + "No login credentials were posted": "Nenhuma credencial de login foi postada", + "Credentials are too long": "As credenciais são muito longas", + "Site DevOps": "Site DevOps", + "A list of devops nicknames. One per line.": "Uma lista de apelidos de devops. Um por linha.", + "devops": "devops", + "Reject spam accounts": "Rejeitar contas de spam" } diff --git a/translations/ru.json b/translations/ru.json index c9af59f6a..5553be1a6 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -412,6 +412,7 @@ "menuInbox": "Входящие", "menuSearch": "Поиск / следующее", "menuNewPost": "Новый пост", + "menuNewBlog": "Новый пост в блоге", "menuCalendar": "Календарь", "menuDM": "Прямые сообщения", "menuReplies": "Отвечает", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Представьтесь и укажите дату и время, когда вы хотите остаться", "Members": "Члены", "Join": "Присоединиться", - "Leave": "Оставлять" + "Leave": "Оставлять", + "System Monitor": "Системный монитор", + "Add content warnings for the following sites": "Добавить предупреждения о содержании для следующих сайтов", + "Known Web Crawlers": "Известные веб-сканеры", + "Add to the calendar": "Добавить в календарь", + "Content License": "Лицензия на содержание", + "Reaction by": "Реакция со стороны", + "Notify on emoji reactions": "Уведомлять о реакции на смайлики", + "Select reaction": "Выберите реакцию", + "Don't show the Reaction button": "Не показывать кнопку реакции", + "New feed URL": "URL нового канала", + "New link title and URL": "Новое название ссылки и URL", + "Theme Designer": "Дизайнер тем", + "Reset": "Сброс настроек", + "Encryption Keys": "Ключи шифрования", + "Filtered words within bio": "Отфильтрованные слова в биографии", + "Write your news report": "Напишите свой новостной репортаж", + "Dyslexic font": "Дислексический шрифт", + "Leave a comment": "Оставить комментарий", + "View comments": "Посмотреть комментарии", + "Multi Status": "Мульти статус", + "Lots of things": "Много всего", + "Created": "Созданный", + "It is done": "Сделано", + "Time Zone": "Часовой пояс", + "Show who liked this post": "Показать, кому понравился этот пост", + "Show who repeated this post": "Показать, кто повторил этот пост", + "Repeated by": "Повторено", + "Register": "регистр", + "Web Bots Allowed": "Веб-боты разрешены", + "Known Search Bots": "Известные боты веб-поиска", + "mitm": "Сообщение могло быть прочитано или изменено третьим лицом", + "Bold reading": "Смелое чтение", + "SHOW EDITS": "ПОКАЗАТЬ РЕДАКТИРОВАНИЕ", + "Attach an image, video or audio file": "Прикрепите изображение, видео или аудио файл", + "Set a place and time": "Назначить место и время", + "Describe your attachment": "Опишите вашу привязанность", + "Language used": "Используемый язык", + "lang_ar": "арабский", + "lang_bn": "бенгальский", + "lang_cy": "валлийский", + "lang_en": "Английский", + "lang_fr": "Французский", + "lang_hi": "хинди", + "lang_ja": "Японский", + "lang_ku": "курдский", + "lang_pl": "польский", + "lang_ru": "Русский", + "lang_uk": "украинец", + "lang_ca": "Каталонский", + "lang_de": "Немецкий", + "lang_es": "испанский", + "lang_ga": "ирландский", + "lang_it": "итальянский", + "lang_ko": "Корейский", + "lang_oc": "окситанский", + "lang_pt": "португальский", + "lang_sw": "суахили", + "lang_tr": "турецкий", + "lang_zh": "Китайский", + "lang_nl": "Голландский", + "lang_el": "греческий", + "lang_yi": "идиш", + "Common emoji": "Общие смайлики", + "Copy and paste into your text": "Скопируйте и вставьте в свой текст", + "shrug": "пожимание плечами", + "DM warning": "Прямые сообщения не подвергаются сквозному шифрованию. Не делитесь здесь особо конфиденциальной информацией.", + "Transcript": "Стенограмма", + "Color contrast is too low": "Цветовой контраст слишком низкий", + "View Larger Map": "Посмотреть увеличенную карту", + "Start Time": "Время начала", + "End Time": "Время окончания", + "Switch to calendar view": "Переключиться на представление календаря", + "Save": "Сохранять", + "Switch to moderation view": "Перейти в режим модерации", + "Minimize attached images": "Свернуть прикрепленные изображения", + "SHOW MEDIA": "ПОКАЗАТЬ МЕДИА", + "ActivityPub Specification": "Спецификация ActivityPub", + "Dogwhistle words": "Собачий свисток", + "Content warnings will be added for the following": "Предупреждения о содержании будут добавлены для следующих", + "nowplaying": "сейчасиграет", + "NowPlaying": "СейчасИграет", + "Import and Export": "Импорт и экспорт", + "Import Follows": "Импорт подписок", + "Post expiry period in days": "Срок действия в днях", + "Keep DMs during post expiry": "Сохраняйте личные сообщения в течение срока действия после истечения срока действия", + "Notifications": "Уведомления", + "ntfy URL": "URL-адрес ntfy", + "ntfy topic": "ntfy тема", + "Last hour": "Последний час", + "Last 3 hours": "Последние 3 часов", + "Last 6 hours": "Последние 6 часов", + "Last 12 hours": "Последние 12 часов", + "Last day": "Последний день", + "Last 2 days": "Последние 2 дня", + "Last week": "Прошлая неделя", + "Last 2 weeks": "Последние 2 недели", + "Last month": "Прошлый месяц", + "Last 6 months": "Последние 6 месяцев", + "Last year": "Прошедший год", + "Unauthorized": "Неавторизованный", + "No login credentials were posted": "Учетные данные для входа не были отправлены", + "Credentials are too long": "Учетные данные слишком длинные", + "Site DevOps": "DevOps сайта", + "A list of devops nicknames. One per line.": "Список псевдонимов devops. По одному на строку.", + "devops": "devops", + "Reject spam accounts": "Отклонить спам-аккаунты" } diff --git a/translations/sw.json b/translations/sw.json index 326b271d1..19797f9c9 100644 --- a/translations/sw.json +++ b/translations/sw.json @@ -412,6 +412,7 @@ "menuInbox": "Kikasha", "menuSearch": "Tafuta/Kufuata", "menuNewPost": "Ujumbe mpya", + "menuNewBlog": "Chapisho jipya la blogi", "menuCalendar": "Kalenda", "menuDM": "Ujumbe wa moja kwa moja", "menuReplies": "Jibu", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "Jitambulishe na taja tarehe na saa unapotaka kukaa", "Members": "Wanachama", "Join": "Jiunge", - "Leave": "Ondoka" + "Leave": "Ondoka", + "System Monitor": "Ufuatiliaji wa Mfumo", + "Add content warnings for the following sites": "Ongeza maonyo ya yaliyomo kwa wavuti zifuatazo", + "Known Web Crawlers": "Watambaji Wavuti Wanaojulikana", + "Add to the calendar": "Ongeza kwenye kalenda", + "Content License": "Leseni ya Maudhui", + "Reaction by": "Majibu kwa", + "Notify on emoji reactions": "Arifu kuhusu maitikio ya emoji", + "Select reaction": "Chagua majibu", + "Don't show the Reaction button": "Usionyeshe kitufe cha Majibu", + "New feed URL": "URL mpya ya mipasho", + "New link title and URL": "Kichwa kipya cha kiungo na URL", + "Theme Designer": "Mbuni wa Mandhari", + "Reset": "Weka upya", + "Encryption Keys": "Vifunguo vya Usimbaji", + "Filtered words within bio": "Maneno yaliyochujwa ndani ya wasifu", + "Write your news report": "Andika ripoti yako ya habari", + "Dyslexic font": "Fonti ya Dyslexic", + "Leave a comment": "Acha maoni", + "View comments": "Tazama maoni", + "Multi Status": "Hali nyingi", + "Lots of things": "Mambo mengi", + "Created": "Imeundwa", + "It is done": "Imefanyika", + "Time Zone": "Eneo la Saa", + "Show who liked this post": "Onyesha ni nani aliyependa chapisho hili", + "Show who repeated this post": "Onyesha ni nani aliyerudia chapisho hili", + "Repeated by": "Imerudiwa na", + "Register": "Sajili", + "Web Bots Allowed": "Mtandao wa Boti Unaruhusiwa", + "Known Search Bots": "Vijibu vya Utafutaji wa Wavuti vinavyojulikana", + "mitm": "Ujumbe ungeweza kusomwa au kurekebishwa na mtu mwingine", + "Bold reading": "Kusoma kwa ujasiri", + "SHOW EDITS": "ONYESHA MABADILIKO", + "Attach an image, video or audio file": "Ambatisha picha, video au faili ya sauti", + "Set a place and time": "Weka mahali na wakati", + "Describe your attachment": "Eleza kiambatisho chako", + "Language used": "Lugha iliyotumika", + "lang_ar": "Kiarabu", + "lang_bn": "Kibengali", + "lang_cy": "Kiwelisi", + "lang_en": "Kiingereza", + "lang_fr": "Kifaransa", + "lang_hi": "Kihindi", + "lang_ja": "Kijapani", + "lang_ku": "Kikurdi", + "lang_pl": "Kipolandi", + "lang_ru": "Kirusi", + "lang_uk": "Kiukreni", + "lang_ca": "Kikatalani", + "lang_de": "Kijerumani", + "lang_es": "Kihispania", + "lang_ga": "Kiayalandi", + "lang_it": "Kiitaliano", + "lang_ko": "Kikorea", + "lang_oc": "Oksitani", + "lang_pt": "Kireno", + "lang_sw": "Kiswahili", + "lang_tr": "Kituruki", + "lang_zh": "Kichina", + "lang_nl": "Kiholanzi", + "lang_el": "Kigiriki", + "lang_yi": "Kiyidi", + "Common emoji": "Emoji ya kawaida", + "Copy and paste into your text": "Nakili na ubandike kwenye maandishi yako", + "shrug": "piga mabega", + "DM warning": "Ujumbe wa moja kwa moja haujasimbwa kutoka mwisho hadi mwisho. Usishiriki maelezo yoyote nyeti sana hapa.", + "Transcript": "Nakala", + "Color contrast is too low": "Utofautishaji wa rangi uko chini sana", + "View Larger Map": "Tazama Ramani Kubwa", + "Start Time": "Wakati wa Kuanza", + "End Time": "Wakati wa Mwisho", + "Switch to calendar view": "Badili hadi mwonekano wa kalenda", + "Save": "Hifadhi", + "Switch to moderation view": "Badili hadi mwonekano wa udhibiti", + "Minimize attached images": "Punguza picha zilizoambatishwa", + "SHOW MEDIA": "ONESHA VYOMBO VYA HABARI", + "ActivityPub Specification": "Vipimo vya ActivityPub", + "Dogwhistle words": "Maneno ya mbwa", + "Content warnings will be added for the following": "Maonyo ya maudhui yataongezwa kwa yafuatayo", + "nowplaying": "inachezasasa", + "NowPlaying": "InachezaSasa", + "Import and Export": "Ingiza na Hamisha", + "Import Follows": "Ingiza Inafuata", + "Post expiry period in days": "Kipindi cha baada ya kumalizika kwa siku", + "Keep DMs during post expiry": "Weka Ujumbe wa Moja kwa Moja wakati wa kuisha kwa chapisho", + "Notifications": "Arifa", + "ntfy URL": "ntfy URL", + "ntfy topic": "mada ya ntfy", + "Last hour": "Saa iliyopita", + "Last 3 hours": "Saa 3 zilizopita", + "Last 6 hours": "Saa 6 zilizopita", + "Last 12 hours": "Saa 12 zilizopita", + "Last day": "Siku ya mwisho", + "Last 2 days": "Siku 2 zilizopita", + "Last week": "Wiki iliyopita", + "Last 2 weeks": "Wiki 2 zilizopita", + "Last month": "Mwezi uliopita", + "Last 6 months": "Miezi 6 iliyopita", + "Last year": "Mwaka jana", + "Unauthorized": "Haijaidhinishwa", + "No login credentials were posted": "Hakuna kitambulisho cha kuingia kilichochapishwa", + "Credentials are too long": "Kitambulisho ni kirefu sana", + "Site DevOps": "Tovuti ya DevOps", + "A list of devops nicknames. One per line.": "Orodha ya majina ya utani ya devops. Moja kwa kila mstari.", + "devops": "devops", + "Reject spam accounts": "Kataa akaunti za barua taka" } diff --git a/translations/tr.json b/translations/tr.json new file mode 100644 index 000000000..10a912f0e --- /dev/null +++ b/translations/tr.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "DAHA FAZLA GÖSTER", + "Your browser does not support the video tag.": "Tarayıcınız video etiketini desteklemiyor.", + "Your browser does not support the audio tag.": "Tarayıcınız ses etiketini desteklemiyor.", + "Show profile": "Profili Göster", + "Show options for this person": "Bu kişi için seçenekleri göster", + "Repeat this post": "Tekrarlamak", + "Undo the repeat": "Tekrarlamayı geri al", + "Like this post": "Beğenmek", + "Undo the like": "Farklı", + "Delete this post": "Silmek", + "Delete this event": "Silmek", + "Reply to this post": "Cevap vermek", + "Write your post text below.": "Yeni posta", + "Write your reply to": "Cevabını yaz", + "this post": "bu gönderi", + "Write your report below.": "Raporunuzu aşağıya yazın.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Bu mesaj, diğer federe adreslerinden bahsetse bile yalnızca moderatörlere gider.", + "Also see": "Ayrıca bkz", + "Terms of Service": "Kullanım Şartları", + "Enter the details for your shared item below.": "Paylaşılan öğenizin ayrıntılarını aşağıya girin.", + "Subject or Content Warning (optional)": "Konu veya İçerik Uyarısı (isteğe bağlı)", + "Write something": "Bir şey yaz", + "Name of the shared item": "Paylaşılan öğenin adı", + "Description of the item being shared": "Paylaşılan öğenin açıklaması", + "Type of shared item. eg. hat": "Paylaşılan öğenin türü. Örneğin. şapka", + "Category of shared item. eg. clothing": "Paylaşılan öğenin kategorisi. Örneğin. Giyim", + "Duration of listing in days": "Gün olarak listeleme süresi", + "City or location of the shared item": "Paylaşılan öğenin şehri veya konumu", + "Describe a shared item": "Paylaşılan bir öğeyi tanımlayın", + "Public": "Halk", + "Visible to anyone": "herkese görünür", + "Unlisted": "liste dışı", + "Not on public timeline": "Herkese açık zaman çizelgesinde değil", + "Followers": "Takipçiler", + "Only to followers": "Sadece takipçilere", + "DM": "DM", + "Only to mentioned people": "Sadece adı geçen kişilere", + "Report": "Rapor", + "Send to moderators": "moderatörlere gönder", + "Search for emoji": "Emoji ara", + "Cancel": "✘", + "Submit": "Göndermek", + "Image description": "görüntü açıklaması", + "Item image": "Öğe resmi", + "Type": "Tip", + "Category": "Kategori", + "Location": "Konum", + "Login": "Giriş yapmak", + "Edit": "Düzenlemek", + "Switch to timeline view": "zaman çizelgesi görünümü", + "Approve": "Onaylamak", + "Deny": "Reddetmek", + "Posts": "Gönderiler", + "Following": "Takip etmek", + "Followers": "Takipçiler", + "Roles": "Roller", + "Skills": "Yetenekler", + "Shares": "Hisseler", + "Block": "Engellemek", + "Unfollow": "Takibi bırak", + "Your browser does not support the audio element.": "Tarayıcınız ses öğesini desteklemiyor.", + "Your browser does not support the video element.": "Tarayıcınız video öğesini desteklemiyor.", + "Create a new post": "Yeni posta", + "Create a new DM": "Yeni bir DM oluştur", + "Switch to profile view": "Profil görünümü", + "Inbox": "Gelen kutusu", + "Sent": "Gönderilmiş", + "Search and follow": "Ara/takip et", + "Refresh": "Yenile", + "Nickname or URL. Block using *@domain or nickname@domain": "Takma ad veya URL. *@domain veya rumuz@domain kullanarak engelleme", + "Remove the above item": "Yukarıdaki öğeyi kaldır", + "Remove": "Kaldırmak", + "Suspend the above account nickname": "Yukarıdaki hesap takma adını askıya alın", + "Suspend": "Askıya almak", + "Remove a suspension for an account nickname": "Hesap takma adının askıya alınmasını kaldırma", + "Unsuspend": "Askıyı kaldır", + "Block an account on another instance": "Başka bir örnekte bir hesabı engelle", + "Unblock": "engeli kaldırmak", + "Unblock an account on another instance": "Başka bir örnekte bir hesabın engellemesini kaldırın", + "Information about current blocks/suspensions": "Mevcut bloklar/süspansiyonlar hakkında bilgi", + "Info": "Bilgi", + "Remove": "Kaldırmak", + "Yes": "Evet", + "No": "Numara", + "Delete this post?": "Bu gönderiyi sil?", + "Follow": "Takip etmek", + "Stop following": "Takibi bırak", + "Options for": "Seçenekler", + "View": "görüş", + "Stop blocking": "Engellemeyi durdur", + "Enter an emoji name to search for": "Aramak için bir emoji adı girin", + "Search screen text": "Aramak için bir adres, paylaşılan öğe, -save, 'geçmiş, #hashtag, *beceri, .wanted veya :emoji: girin", + "Go Back": "◀", + "Moderation Information": "Denetim Bilgileri", + "Suspended accounts": "Askıya alınan hesaplar", + "These are currently suspended": "Bunlar şu anda askıya alındı", + "Blocked accounts and hashtags": "Engellenen hesaplar ve hashtag'ler", + "These are globally blocked for all accounts on this instance": "Bunlar, bu örnekteki tüm hesaplar için küresel olarak engellendi", + "Any blocks or suspensions made by moderators will be shown here.": "Moderatörler tarafından yapılan engellemeler veya askıya almalar burada gösterilecektir.", + "Welcome. Please enter your login details below.": "Hoş geldin. Lütfen giriş bilgilerinizi aşağıya yazınınz.", + "Welcome. Please login or register a new account.": "Hoş geldin. Lütfen giriş yapın veya yeni bir hesap açın.", + "Please enter some credentials": "Lütfen bazı kimlik bilgileri girin", + "You will become the admin of this site.": "Bu sitenin admini olacaksınız.", + "Terms of Service": "Kullanım Şartları", + "About this Instance": "Bu Örnek hakkında", + "Nickname": "Takma ad", + "Enter Nickname": "Takma ad girin", + "Password": "Parola", + "Enter Password": "En az 8 karakter", + "Profile for": "için profil", + "The files attached below should be no larger than 10MB in total uploaded at once.": "Aşağıda ekli dosyalar, bir kerede yüklenen toplam 10 MB'tan büyük olmamalıdır.", + "Avatar image": "avatar resmi", + "Background image": "Avatarınızın arkasında görünen arka plan resmi", + "Timeline banner image": "Zaman çizelgesi banner resmi", + "Approve follower requests": "Takipçi isteklerini onayla", + "This is a bot account": "Bu bir bot hesabıdır", + "Filtered words": "Filtrelenmiş kelimeler", + "One per line": "Her satıra bir tane", + "Blocked accounts": "Engellenen hesaplar", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Nick@domain veya *@blockeddomain biçiminde satır başına bir tane olmak üzere engellenen hesaplar", + "Federation list": "Federasyon listesi", + "Federate only with a defined set of instances. One domain name per line.": "Yalnızca tanımlanmış bir örnek kümesiyle birleştirin. Satır başına bir alan adı.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Kuruluşlara katılmak istiyorsanız, sahip olduğunuz bazı becerileri ve yaklaşık yeterlilik seviyelerini belirtebilirsiniz. Bu, organizatörlerin uygun becerilere sahip ekipler oluşturmasına yardımcı olur.", + "A list of moderator nicknames. One per line.": "Moderatör takma adlarının listesi. Her satıra bir tane.", + "Moderators": "Moderatörler", + "List of moderator nicknames": "Moderatör takma adlarının listesi", + "Your bio": "Biyografin", + "Skill": "Yetenek", + "Copy the text then paste it into your post": "Metni kopyalayın ve ardından gönderinize yapıştırın", + "Emoji Search": "Emoji Arama", + "No results": "Sonuç yok", + "Skills search": "Beceri arama", + "Shared Items Search": "Paylaşılan Öğe Arama", + "Contact": "İletişim", + "Shared Item": "Paylaşılan Öğe", + "Mod": "Ilıman", + "Approve follow requests": "Takip isteklerini onayla", + "Page down": "Sayfa aşağı", + "Page up": "Sayfa yukarı", + "Vote": "Oy", + "Replies": "Cevaplar", + "Media": "Medya", + "This is a group account": "Bu bir grup hesabıdır", + "Date": "Tarih", + "Time": "Zaman", + "Location": "Konum", + "Calendar": "Takvim", + "Sun": "Paz", + "Mon": "Paz", + "Tue": "Sal", + "Wed": "Çar", + "Thu": "Per", + "Fri": "Cum", + "Sat": "Cum", + "January": "Ocak", + "February": "Şubat", + "March": "Mart", + "April": "Nisan", + "May": "Mayıs", + "June": "Haziran", + "July": "Temmuz", + "August": "Ağustos", + "September": "Eylül", + "October": "Ekim", + "November": "Kasım", + "December": "Aralık", + "Only people I follow can send me DMs": "Sadece takip ettiğim kişiler bana DM gönderebilir", + "Logout": "Çıkış Yap", + "Danger Zone": "Tehlikeli bölge", + "Deactivate this account": "Bu hesabı devre dışı bırak", + "Snooze": "Kestirmek", + "Unsnooze": "Ertelemeyi kaldır", + "Donations link": "Bağış bağlantısı", + "Donate": "Bağış yapmak", + "Change Password": "Şifre değiştir", + "Confirm Password": "Şifreyi Onayla", + "Instance Title": "Örnek Başlığı", + "Instance Short Description": "Örnek Kısa Açıklama", + "Instance Description": "Örnek Açıklama", + "Instance Logo": "Örnek Logosu", + "Bookmark this post": "Yer imi", + "Undo the bookmark": "İşareti kaldır", + "Bookmarks": "Kaydedildi", + "Theme": "Tema", + "Default": "Varsayılan", + "Light": "Işık", + "Purple": "Mor", + "Hacker": "Bilgisayar korsanı", + "HighVis": "Yüksek görünürlük", + "Question": "Soru", + "Enter your question": "Sorunuzu girin", + "Enter the choices for your question below.": "Sorunuz için seçenekleri aşağıya girin.", + "Ask a question": "Bir soru sor", + "Possible answers": "Olası cevaplar", + "replying to": "yanıtlamak", + "replying to themselves": "kendilerine cevap vermek", + "announces": "duyurur", + "Previous month": "Geçtiğimiz ay", + "Next month": "Gelecek ay", + "Get the source code": "Kaynak kodunu alın", + "This is a media instance": "Bu bir medya örneğidir", + "Mute this post": "Sesini kapatmak", + "Undo mute": "Sesi geri al", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "E-posta", + "PGP": "PGP Anahtarı", + "PGP Fingerprint": "PGP Parmak İzi", + "This is a scheduled post.": "Bu planlanmış bir gönderidir.", + "Remove scheduled posts": "Planlanmış gönderileri kaldır", + "Remove Twitter posts": "Twitter gönderilerini kaldır", + "Sensitive": "Hassas", + "Word Replacements": "Kelime Değiştirmeleri", + "Happening Today": "Bugün", + "Happening Tomorrow": "Yarın", + "Happening This Week": "Yakın zamanda", + "Blog": "Blog", + "Blogs": "Blogs", + "Title": "Başlık", + "About the author": "Yazar hakkında", + "Edit blog post": "Blog gönderisini düzenle", + "Publicly visible post": "Herkese açık yayın", + "Your Posts": "Sizin gönderileriniz", + "Git Projects": "Git Projeleri", + "List of project names that you wish to receive git patches for": "Git yamaları almak istediğiniz proje adlarının listesi", + "Show/Hide Buttons": "Göster/Gizle", + "Custom Font": "Özel Yazı Tipi", + "Remove the custom font": "Özel yazı tipini kaldırın", + "Lcd": "LCD", + "Blue": "Mavi", + "Zen": "Zen", + "Night": "Gece", + "Starlight": "Yıldız ışığı", + "Search banner image": "Başlık resmi ara", + "Henge": "Henge", + "QR Code": "QR kod", + "Reminder": "Hatırlatma", + "Scheduled note to yourself": "Kendinize zamanlanmış not", + "Replying to": "Yanıtlamak", + "Send to": "Gönderildi", + "Show a list of addresses to send to": "Gönderilecek adreslerin listesini göster", + "Petname": "Evcil Hayvan adı", + "Ok": "Tamam", + "This is nothing less than an utter triumph": "Bu mutlak bir zaferden başka bir şey değil", + "Not Found": "Bulunamadı", + "These are not the droids you are looking for": "Aradığınız droidler bunlar değil", + "Not changed": "Değişmedi", + "The contents of your local cache are up to date": "Yerel önbelleğinizin içeriği güncel", + "Bad Request": "Geçersiz istek", + "Better luck next time": "Bir dahaki sefere daha iyi şanslar", + "Unavailable": "Kullanım dışı", + "The server is busy. Please try again later": "Sunucu meşgul. Lütfen daha sonra tekrar deneyiniz", + "Receive calendar events from this account": "Bu hesaptan takvim etkinlikleri al", + "Grayscale": "Gri tonlamalı", + "Liked by": "Tarafından beğenildi", + "Solidaric": "Dayanışmacı", + "YouTube Replacement Domain": "YouTube Değiştirme Alanı", + "Notes": "Notlar", + "Allow replies.": "Yanıtlara izin ver.", + "Event": "Etkinlik", + "Event name": "Etkinlik adı", + "Events": "Olaylar", + "Create an event": "Etkinlik oluştur", + "Describe the event": "Olayı anlat", + "Start Date": "Başlangıç ​​tarihi", + "End Date": "Bitiş tarihi", + "Categories": "Kategoriler", + "This is a private event.": "Bu özel bir olaydır.", + "Allow anonymous participation.": "Anonim katılıma izin ver.", + "Anyone can join": "Herkes katılabilir", + "Apply to join": "Katılmak için başvur", + "Invitation only": "Yalnızca davet", + "Joining": "Birleştirme", + "Status of the event": "Etkinliğin durumu", + "Tentative": "Belirsiz", + "Confirmed": "Onaylanmış", + "Cancelled": "İptal edildi", + "Event banner image description": "Etkinlik banner resmi açıklaması", + "Banner image": "Afiş resmi", + "Maximum attendees": "Maksimum katılımcı", + "Ticket URL": "Bilet URL'si", + "Create a new event": "Yeni bir etkinlik oluştur", + "Moderation policy or code of conduct": "Moderasyon politikası veya davranış kuralları", + "Edit event": "Etkinliği düzenle", + "Notify when posts are liked": "Gönderiler beğenildiğinde bildir", + "Don't show the Like button": "Beğen düğmesini gösterme", + "Autogenerated Hashtags": "Otomatik Oluşturulan Hashtag'ler", + "Autogenerated Content Warnings": "Otomatik Oluşturulan İçerik Uyarıları", + "Indymedia": "Indymedia", + "Indymediaclassic": "Indymedia Klasik", + "Indymediamodern": "Indymedia Modern", + "Hashtag Blocked": "Hashtag Engellendi", + "This is a blogging instance": "Bu bir blog örneğidir", + "Edit Links": "Bağlantıları Düzenle", + "One link per line. Description followed by the link.": "Her satıra bir bağlantı. Açıklama ve ardından bağlantı. Başlıklar # ile başlamalıdır", + "Left column image": "Sol sütun resmi", + "Right column image": "Sağ sütun resmi", + "RSS feed for this site": "Bu site için RSS beslemesi", + "Edit newswire": "Haber telini düzenle", + "Add RSS feed links below.": "Aşağıdaki RSS besleme bağlantıları. Bir feed'in denetlenmesi gerektiğini belirtmek için başına veya sonuna * ekleyin. Ekle ! besleme içeriğinin yansıtılması gerektiğini belirtmek için başında veya sonunda.", + "Newswire RSS Feed": "Newswire RSS Beslemesi", + "Nicknames whose blog entries appear on the newswire.": "Blog girişleri haber telinde görünen takma adlar.", + "Posts to be approved": "Onaylanacak gönderiler", + "Discuss": "Tartışmak", + "Moderator Discussion": "Moderatör Tartışması", + "Vote": "Oy", + "Remove Vote": "Oyu Kaldır", + "This is a news instance": "Bu bir haber örneğidir", + "News": "Haberler", + "Read more...": "Daha fazla oku...", + "Edit News Post": "Haber Gönderisini Düzenle", + "A list of editor nicknames. One per line.": "Düzenleyici takma adlarının listesi. Her satıra bir tane.", + "Site Editors": "Site Editörleri", + "Allow news posts": "Haber gönderilerine izin ver", + "Publish": "Yayınla", + "Publish a news article": "Bir haber makalesi yayınlayın", + "News tagging rules": "Haber etiketleme kuralları", + "See instructions": "Talimatlara bakın", + "Search": "Aramak", + "Newswire": "Haber teli", + "Links": "Bağlantılar", + "Post": "Postalamak", + "User": "Kullanıcı", + "Features" : "Özellikler", + "Article": "Madde", + "Create an article": "Bir makale oluşturun", + "Settings": "Ayarlar", + "Citations": "Alıntılar", + "Choose newswire items referenced in your article": "Makalenizde atıfta bulunulan haber teli öğelerini seçin", + "RSS feed for your blog": "Blogunuz için RSS beslemesi", + "Create a new shared item": "Yeni bir paylaşılan öğe oluştur", + "Rc3": "Rc3", + "Hashtag origins": "Hashtag kökenleri", + "admin": "yönetici", + "moderator": "moderatör", + "editor": "editör", + "delegator": "yetki veren", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "RSS beslemeleri eklemek için düzenle simgesini seçin", + "Select the edit icon to add web links": "Web bağlantıları eklemek için düzenle simgesini seçin", + "Hashtag Categories RSS Feed": "Hashtag Kategorileri RSS Beslemesi", + "Ask about a shared item.": "Paylaşılan bir öğe hakkında soru sorun.", + "Account Information": "Hesap Bilgileri", + "This account interacts with the following instances": "Bu hesap aşağıdaki örneklerle etkileşime girer", + "News posts are moderated": "Haber gönderileri yönetiliyor", + "Filter": "Filtre", + "Filter out words": "Kelimeleri filtrele", + "Unfilter": "Filtreyi kaldır", + "Unfilter words": "Kelimelerin filtresini kaldır", + "Show Accounts": "Hesapları Göster", + "Peertube Instances": "Peertube Örnekleri", + "Show video previews for the following Peertube sites.": "Aşağıdaki Peertube siteleri için video önizlemelerini gösterin.", + "Follows you": "Seni takip ediyor", + "Verify all signatures": "Tüm imzaları doğrulayın", + "Blocked followers": "Engellenen takipçiler", + "Blocked following": "Aşağıdakiler engellendi", + "Receives posts from the following accounts": "Aşağıdaki hesaplardan gönderiler alır", + "Sends out posts to the following accounts": "Aşağıdaki hesaplara gönderiler gönderir", + "Word frequencies": "kelime frekansları", + "New account": "Yeni hesap", + "Moved to new account address": "Yeni hesap adresine taşındı", + "Yet another Epicyon Instance": "Yine bir Epicyon Örneği", + "Other accounts": "Diğer federal hesaplar", + "Pin this post to your profile.": "Bu gönderiyi profilinize sabitleyin.", + "Administered by": "Tarafından yönetilmektedir", + "Version": "Sürüm", + "Skip to timeline": "Zaman çizelgesine atla", + "Skip to Newswire": "Newswire'a geç", + "Skip to Links": "Bağlantılara Geç", + "Publish a blog article": "Blog makalesi yayınlayın", + "Featured writer": "Öne çıkan yazar", + "Broch mode": "Broş modu", + "Pixel": "Piksel", + "DM bounce": "Sadece takip edilen hesaplardan mesaj kabul edilir.", + "Next": "Sonraki", + "Preview": "Ön izleme", + "Linked": "Web bağlantılı", + "hashtag": "başlık etiketi", + "smile": "gülümsemek", + "wink": "göz kırpmak", + "mentioning": "bahsetmek", + "sad face": "Üzgün ​​surat", + "thinking emoji": "Düşünme emojisi", + "laughing": "gülmek", + "gender": "cinsiyet", + "He/Him": "O / O", + "She/Her": "o / onun", + "girl": "kız", + "boy": "oğlan", + "pronoun": "zamir", + "Type of instance": "Örnek türü", + "Security": "Güvenlik", + "Enabling broch mode": "Broch modunu etkinleştirmek, saldırıya karşı geçici bir tahkimat sağlar. Yalnızca önceden bilinen örneklerin gönderileri kabul edilecektir. Kapatılmazsa bir hafta sonra geçer.", + "Instance Settings": "Örnek Ayarları", + "Video Settings": "Video ayarları", + "Filtering and Blocking": "Filtreleme ve Engelleme", + "Role Assignment": "Rol Ataması", + "Contact Details": "İletişim detayları", + "Background Images": "Arka Plan Resimleri", + "heart": "kalp", + "counselor": "danışman", + "Counselors": "Danışmanlar", + "shocked": "şok", + "Encrypted": "şifreli", + "Direct Message permitted instances": "Doğrudan Mesaja izin verilen örnekler", + "Direct messages are always allowed from these instances.": "Bu örneklerden doğrudan mesajlara her zaman izin verilir.", + "Key Shortcuts": "Anahtar Kısayollar", + "menuTimeline": "Zaman çizelgesi görünümü", + "menuEdit": "Düzenlemek", + "menuProfile": "Profil görünümü", + "menuInbox": "Gelen kutusu", + "menuSearch": "Ara/takip et", + "menuNewPost": "Yeni posta", + "menuNewBlog": "Yeni blog yazısı", + "menuCalendar": "Takvim", + "menuDM": "Direkt Mesajlar", + "menuReplies": "Cevaplar", + "menuOutbox": "Gönderilmiş", + "menuBookmarks": "Yer imleri", + "menuShares": "Paylaşılan öğeler", + "menuBlogs": "Blogs", + "menuNewswire": "Haber teli", + "menuLinks": "Bağlantılar", + "menuModeration": "Moderasyon", + "menuFollowing": "Takip etmek", + "menuFollowers": "Takipçiler", + "menuRoles": "Roller", + "menuSkills": "Yetenekler", + "menuLogout": "Çıkış Yap", + "menuKeys": "Anahtar Kısayollar", + "submitButton": "Gönder düğmesi", + "menuMedia": "Medya", + "followButton": "Takip et/takip etmeyi bırak düğmesi", + "blockButton": "Engelle düğmesi", + "infoButton": "Bilgi düğmesi", + "snoozeButton": "Erteleme düğmesi", + "reportButton": "Rapor düğmesi", + "viewButton": "Görüntüle düğmesi", + "enterPetname": "Evcil hayvan adını girin", + "enterNotes": "Notları girin", + "These access keys may be used": "Bu erişim tuşları, tipik olarak ALT + SHIFT + tuşu veya ALT + tuşu ile kullanılabilir.", + "Show numbers of accounts within instance metadata": "Örnek meta verilerindeki hesap numaralarını göster", + "Show version number within instance metadata": "Örnek meta verilerinde sürüm numarasını göster", + "Joined": "Katıldı", + "City for spoofed GPS image metadata": "Sahte GPS görüntü meta verileri için şehir", + "Occupation": "Meslek", + "Artists": "Sanatçılar", + "Graphic Design": "Grafik dizayn", + "Import Theme": "Temayı İçe Aktar", + "Export Theme": "Temayı Dışa Aktar", + "Custom post submit button text": "Özel gönderi gönder düğmesi metni", + "Blocked User Agents": "Engellenen Kullanıcı Aracıları", + "Notify me when this account posts": "Bu hesap yayınlandığında bana haber ver", + "Languages": "Diller", + "Translated": "çevrildi", + "Quantity": "Miktar", + "food": "besin", + "Price": "Fiyat", + "Currency": "Para birimi", + "List of domains which can access the shared items catalog": "Paylaşılan öğeler kataloğuna erişebilen alanların listesi", + "Shares Catalog": "Hisse Kataloğu", + "tool": "alet", + "clothes": "kıyafetler", + "medical": "tıbbi", + "Wanted": "Aranan", + "Describe something wanted": "İstenen bir şeyi tarif et", + "Enter the details for your wanted item below.": "İstediğiniz öğenin ayrıntılarını aşağıya girin.", + "Name of the wanted item": "Aranan öğenin adı", + "Description of the item wanted": "İstenen öğenin açıklaması", + "Type of wanted item. eg. hat": "Aranan öğenin türü. Örneğin. şapka", + "Category of wanted item. eg. clothes": "Aranan öğenin kategorisi. Örneğin. kıyafetler", + "City or location of the wanted item": "İstenen öğenin şehri veya yeri", + "Maximum Price": "Maksimum Fiyat", + "Create a new wanted item": "Yeni bir aranan öğe oluşturun", + "Wanted Items Search": "Aranan Eşya Arama", + "Website": "İnternet sitesi", + "Low Bandwidth": "Düşük Bant Genişliği", + "accommodation": "konaklama", + "Forbidden": "Yasaklı", + "You're not allowed": "İzinli değilsin", + "Hours after posting during which replies are allowed": "Gönderi sonrası yanıtlara izin verilen saatler", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter Değiştirme Etki Alanı", + "Buy": "Satın almak", + "Request to stay": "Kalma isteği", + "Profile": "Profil", + "Introduce yourself and specify the date and time when you wish to stay": "Kendinizi tanıtın ve kalmak istediğiniz tarih ve saati belirtin.", + "Members": "Üyeler", + "Join": "Katılmak", + "Leave": "Terk etmek", + "System Monitor": "Sistem Monitörü", + "Add content warnings for the following sites": "Aşağıdaki siteler için içerik uyarıları ekleyin", + "Known Web Crawlers": "Bilinen Web Tarayıcıları", + "Add to the calendar": "Takvime ekle", + "Content License": "İçerik Lisansı", + "Reaction by": "Tarafından tepki", + "Notify on emoji reactions": "Emoji tepkilerini bildir", + "Select reaction": "Reaksiyon", + "Don't show the Reaction button": "Tepki düğmesini gösterme", + "New feed URL": "Yeni besleme URL'si", + "New link title and URL": "Yeni bağlantı başlığı ve URL", + "Theme Designer": "Tema Tasarımcısı", + "Reset": "Sıfırla", + "Encryption Keys": "Şifreleme Anahtarları", + "Filtered words within bio": "Biyografi içindeki filtrelenmiş kelimeler", + "Write your news report": "Haber raporunuzu yazın", + "Dyslexic font": "Disleksik yazı tipi", + "Leave a comment": "Yorum Yap", + "View comments": "Yorumları Görüntüle", + "Multi Status": "Çoklu Durum", + "Lots of things": "Birçok şey", + "Created": "Oluşturuldu", + "It is done": "Tamamdır", + "Time Zone": "Saat dilimi", + "Show who liked this post": "Bu gönderiyi beğenenleri göster", + "Show who repeated this post": "Bu gönderiyi kimin tekrarladığını göster", + "Repeated by": "Tarafından tekrarlandı", + "Register": "Kayıt olmak", + "Web Bots Allowed": "Web Arama Botlarına İzin Verilir", + "Known Search Bots": "Bilinen Web Arama Botları", + "mitm": "Mesaj üçüncü bir tarafça okunmuş veya değiştirilmiş olabilir", + "Bold reading": "Cesur okuma", + "SHOW EDITS": "DÜZENLEMELERİ GÖSTER", + "Attach an image, video or audio file": "Bir resim, video veya ses dosyası ekleyin", + "Set a place and time": "Bir yer ve zaman belirleyin", + "Describe your attachment": "Ekinizi tanımlayın", + "Language used": "Kullanılan dil", + "lang_ar": "Arapça", + "lang_bn": "Bengalce", + "lang_cy": "Galce", + "lang_en": "Ingilizce", + "lang_fr": "Fransızca", + "lang_hi": "Hintçe", + "lang_ja": "Japonca", + "lang_ku": "Kürt", + "lang_pl": "Lehçe", + "lang_ru": "Rusça", + "lang_uk": "Ukrayna", + "lang_ca": "Katalanca", + "lang_de": "Almanca", + "lang_es": "İspanyol", + "lang_ga": "İrlandalı", + "lang_it": "İtalyan", + "lang_ko": "Koreli", + "lang_oc": "Oksitanca", + "lang_pt": "Portekizce", + "lang_sw": "Svahili", + "lang_tr": "Türkçe", + "lang_zh": "Çince", + "lang_nl": "Flemenkçe", + "lang_el": "Yunan", + "lang_yi": "Yidiş", + "Common emoji": "Ortak emoji", + "Copy and paste into your text": "Metninize kopyalayıp yapıştırın", + "shrug": "omuz silkmek", + "DM warning": "Doğrudan mesajlar uçtan uca şifrelenmez. Son derece hassas bilgileri burada paylaşmayın.", + "Transcript": "Transcript", + "Color contrast is too low": "Renk kontrastı çok düşük", + "View Larger Map": "Daha Büyük Haritayı Görüntüle", + "Start Time": "Başlangıç ​​saati", + "End Time": "Bitiş zamanı", + "Switch to calendar view": "Takvim görünümüne geç", + "Save": "Kaydetmek", + "Switch to moderation view": "Denetleme görünümüne geç", + "Minimize attached images": "Ekli resimleri simge durumuna küçült", + "SHOW MEDIA": "MEDYA GÖSTER", + "ActivityPub Specification": "ActivityPub Spesifikasyonu", + "Dogwhistle words": "İtiraf sözleri", + "Content warnings will be added for the following": "Aşağıdakiler için içerik uyarıları eklenecek", + "nowplaying": "şimdioynuyor", + "NowPlaying": "ŞimdiOynuyor", + "Import and Export": "İthalat ve ihracat", + "Import Follows": "Takipleri İçe Aktar", + "Post expiry period in days": "Gün olarak sona erme süresi", + "Keep DMs during post expiry": "Direkt Mesajları sona erme süresi boyunca saklayın", + "Notifications": "Bildirimler", + "ntfy URL": "ntfy URL'si", + "ntfy topic": "ntfy konusu", + "Last hour": "Son saat", + "Last 3 hours": "son 3 saat", + "Last 6 hours": "son 6 saat", + "Last 12 hours": "son 12 saat", + "Last day": "Son gun", + "Last 2 days": "son 2 gün", + "Last week": "Geçen hafta", + "Last 2 weeks": "Son 2 hafta", + "Last month": "Geçen ay", + "Last 6 months": "Son 6 ay", + "Last year": "Geçen yıl", + "Unauthorized": "Yetkisiz", + "No login credentials were posted": "Giriş bilgileri gönderilmedi", + "Credentials are too long": "Kimlik bilgileri çok uzun", + "Site DevOps": "Site DevOps", + "A list of devops nicknames. One per line.": "Devops takma adlarının listesi. Her satıra bir tane.", + "devops": "devops", + "Reject spam accounts": "Spam hesapları reddet" +} diff --git a/translations/uk.json b/translations/uk.json new file mode 100644 index 000000000..f878b705d --- /dev/null +++ b/translations/uk.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "ПОКАЗАТИ БІЛЬШЕ", + "Your browser does not support the video tag.": "Ваш браузер не підтримує тег відео.", + "Your browser does not support the audio tag.": "Ваш браузер не підтримує тег аудіо.", + "Show profile": "Показати профіль", + "Show options for this person": "Показати варіанти для цієї особи", + "Repeat this post": "Повторюйте", + "Undo the repeat": "Скасуйте повтор", + "Like this post": "Люблю", + "Undo the like": "На відміну від", + "Delete this post": "Видалити", + "Delete this event": "Видалити", + "Reply to this post": "Відповісти", + "Write your post text below.": "Новий пост", + "Write your reply to": "Напишіть свою відповідь на", + "this post": "цей пост", + "Write your report below.": "Напишіть свій звіт нижче.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "Це повідомлення надсилається лише модераторам, навіть якщо в ньому згадуються інші адреси fediverse.", + "Also see": "Також див", + "Terms of Service": "Умови обслуговування", + "Enter the details for your shared item below.": "Введіть нижче дані для спільного елемента.", + "Subject or Content Warning (optional)": "Попередження про тему або вміст (необов’язково)", + "Write something": "Щось написати", + "Name of the shared item": "Назва спільного елемента", + "Description of the item being shared": "Опис об’єкта, яким ділиться", + "Type of shared item. eg. hat": "Тип спільного предмета. наприклад капелюх", + "Category of shared item. eg. clothing": "Категорія спільного елемента. наприклад одяг", + "Duration of listing in days": "Тривалість розміщення в днях", + "City or location of the shared item": "Місто або місце розташування спільного елемента", + "Describe a shared item": "Опишіть спільний предмет", + "Public": "Громадський", + "Visible to anyone": "Видно будь-кому", + "Unlisted": "Не входить до списку", + "Not on public timeline": "Не на загальнодоступній шкалі часу", + "Followers": "Послідовники", + "Only to followers": "Тільки для підписників", + "DM": "Пп", + "Only to mentioned people": "Тільки згаданим людям", + "Report": "Звіт", + "Send to moderators": "Надіслати модераторам", + "Search for emoji": "Шукайте емодзі", + "Cancel": "✘", + "Submit": "Подати", + "Image description": "Опис зображення", + "Item image": "Зображення предмета", + "Type": "Тип", + "Category": "Категорія", + "Location": "Розташування", + "Login": "Увійти", + "Edit": "Редагувати", + "Switch to timeline view": "Перегляд хронології", + "Approve": "Затвердити", + "Deny": "Заперечити", + "Posts": "Публікації", + "Following": "Після", + "Followers": "Послідовники", + "Roles": "Ролі", + "Skills": "Навички", + "Shares": "Акції", + "Block": "Блокувати", + "Unfollow": "Скасувати підписку", + "Your browser does not support the audio element.": "Ваш браузер не підтримує елемент аудіо.", + "Your browser does not support the video element.": "Ваш браузер не підтримує елемент відео.", + "Create a new post": "Новий пост", + "Create a new DM": "Створіть новий DM", + "Switch to profile view": "Перегляд профілю", + "Inbox": "Вхідні", + "Sent": "Надісланий", + "Search and follow": "Шукати/слідкувати", + "Refresh": "Оновити", + "Nickname or URL. Block using *@domain or nickname@domain": "Псевдонім або URL-адреса. Блокуйте, використовуючи *@домен або псевдонім@домен", + "Remove the above item": "Видаліть вищевказаний елемент", + "Remove": "Видалити", + "Suspend the above account nickname": "Призупинити вказаний вище псевдонім облікового запису", + "Suspend": "Призупинити", + "Remove a suspension for an account nickname": "Зніміть призупинення для псевдоніму облікового запису", + "Unsuspend": "Розблокувати", + "Block an account on another instance": "Заблокуйте обліковий запис в іншому екземплярі", + "Unblock": "Розблокувати", + "Unblock an account on another instance": "Розблокуйте обліковий запис в іншому екземплярі", + "Information about current blocks/suspensions": "Інформація про поточні блокування/призупинення", + "Info": "Інформація", + "Remove": "Видалити", + "Yes": "Так", + "No": "Ні", + "Delete this post?": "Видалити цю публікацію?", + "Follow": "Слідкуйте", + "Stop following": "Припиніть слідкувати", + "Options for": "Варіанти для", + "View": "Переглянути", + "Stop blocking": "Припиніть блокування", + "Enter an emoji name to search for": "Введіть назву емодзі для пошуку", + "Search screen text": "Введіть адресу, спільний елемент, -save, 'history, #hashtag, *skill, .wanted або :emoji: для пошуку", + "Go Back": "◀", + "Moderation Information": "Інформація про модерацію", + "Suspended accounts": "Призупинені облікові записи", + "These are currently suspended": "Наразі вони призупинені", + "Blocked accounts and hashtags": "Заблоковані акаунти та хештеги", + "These are globally blocked for all accounts on this instance": "Вони глобально заблоковані для всіх облікових записів у цьому екземплярі", + "Any blocks or suspensions made by moderators will be shown here.": "Будь-які блокування або призупинення, зроблені модераторами, будуть показані тут.", + "Welcome. Please enter your login details below.": "Ласкаво просимо. Будь ласка, введіть свої дані для входу нижче.", + "Welcome. Please login or register a new account.": "Ласкаво просимо. Будь ласка, увійдіть або зареєструйте новий обліковий запис.", + "Please enter some credentials": "Будь ласка, введіть деякі облікові дані", + "You will become the admin of this site.": "Ви станете адміністратором цього сайту.", + "Terms of Service": "Умови обслуговування", + "About this Instance": "Про цей Приклад", + "Nickname": "Псевдонім", + "Enter Nickname": "Введіть псевдонім", + "Password": "Пароль", + "Enter Password": "Мінімум 8 символів", + "Profile for": "Профіль для", + "The files attached below should be no larger than 10MB in total uploaded at once.": "Файли, додані нижче, мають бути не більше 10 МБ загалом, які завантажуються одночасно.", + "Avatar image": "Зображення аватара", + "Background image": "Фонове зображення, яке з’являється за вашим аватаром", + "Timeline banner image": "Зображення банера часової шкали", + "Approve follower requests": "Схвалити запити підписників", + "This is a bot account": "Це обліковий запис бота", + "Filtered words": "Відфільтровані слова", + "One per line": "По одному на рядок", + "Blocked accounts": "Заблоковані облікові записи", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Заблоковані облікові записи, по одному на рядок, у формі псевдонім@домен або *@blockeddomain", + "Federation list": "Список федерації", + "Federate only with a defined set of instances. One domain name per line.": "Об’єднувати лише з певним набором екземплярів. Одне доменне ім’я на рядок.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Якщо ви хочете брати участь в організації, ви можете вказати деякі навички, якими ви володієте, і приблизний рівень кваліфікації. Це допомагає організаторам формувати команди з відповідною комбінацією навичок.", + "A list of moderator nicknames. One per line.": "Список нікнеймів модераторів. По одному на рядок.", + "Moderators": "Модератори", + "List of moderator nicknames": "Список нікнеймів модераторів", + "Your bio": "Ваша біографія", + "Skill": "майстерність", + "Copy the text then paste it into your post": "Скопіюйте текст, а потім вставте його у свою публікацію", + "Emoji Search": "Пошук Emoji", + "No results": "Немає результатів", + "Skills search": "Пошук навичок", + "Shared Items Search": "Пошук спільних елементів", + "Contact": "Контакти", + "Shared Item": "Спільний елемент", + "Mod": "Помірний", + "Approve follow requests": "Схвалити запити на підписку", + "Page down": "Сторінка вниз", + "Page up": "Сторінка вгору", + "Vote": "Голосуйте", + "Replies": "Відповіді", + "Media": "ЗМІ", + "This is a group account": "Це обліковий запис групи", + "Date": "Дата", + "Time": "Час", + "Location": "Розташування", + "Calendar": "Календар", + "Sun": "нед", + "Mon": "пон", + "Tue": "вів", + "Wed": "сер", + "Thu": "чет", + "Fri": "п'ят", + "Sat": "суб", + "January": "січня", + "February": "лютий", + "March": "березень", + "April": "квітень", + "May": "Може", + "June": "червень", + "July": "липень", + "August": "серпень", + "September": "Вересень", + "October": "жовтень", + "November": "Листопад", + "December": "Грудень", + "Only people I follow can send me DMs": "Лише люди, за якими я підписався, можуть надсилати мені DM", + "Logout": "Вийти", + "Danger Zone": "Небезпечна зона", + "Deactivate this account": "Деактивуйте цей обліковий запис", + "Snooze": "Відкласти", + "Unsnooze": "Скасувати відкладення", + "Donations link": "Посилання на пожертвування", + "Donate": "Пожертвуйте", + "Change Password": "Змінити пароль", + "Confirm Password": "Підтвердьте пароль", + "Instance Title": "Назва екземпляра", + "Instance Short Description": "Короткий опис екземпляра", + "Instance Description": "Опис екземпляра", + "Instance Logo": "Логотип екземпляра", + "Bookmark this post": "Збережіть це для подальшого перегляду", + "Undo the bookmark": "Зняти закладку", + "Bookmarks": "Збережено", + "Theme": "Тема", + "Default": "За замовчуванням", + "Light": "Світло", + "Purple": "фіолетовий", + "Hacker": "Хакер", + "HighVis": "Привіт Віс", + "Question": "Питання", + "Enter your question": "Введіть своє запитання", + "Enter the choices for your question below.": "Введіть варіанти для свого запитання нижче.", + "Ask a question": "Задайте питання", + "Possible answers": "Можливі відповіді", + "replying to": "відповідаючи на", + "replying to themselves": "відповідаючи собі", + "announces": "оголошує", + "Previous month": "Попередній місяць", + "Next month": "Наступного місяця", + "Get the source code": "Отримати вихідний код", + "This is a media instance": "Це приклад ЗМІ", + "Mute this post": "Вимкнути звук", + "Undo mute": "Відмінити звук", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "Електронна пошта", + "PGP": "Ключ PGP", + "PGP Fingerprint": "Відбиток PGP", + "This is a scheduled post.": "Це запланований пост.", + "Remove scheduled posts": "Видаліть заплановані дописи", + "Remove Twitter posts": "Видаліть дописи в Twitter", + "Sensitive": "Чутливий", + "Word Replacements": "Заміни слів", + "Happening Today": "Сьогодні", + "Happening Tomorrow": "Завтра", + "Happening This Week": "Незабаром", + "Blog": "Блог", + "Blogs": "Блоги", + "Title": "Назва", + "About the author": "Про автора", + "Edit blog post": "Редагувати допис у блозі", + "Publicly visible post": "Загальнодоступна публікація", + "Your Posts": "Ваші дописи", + "Git Projects": "Проекти Git", + "List of project names that you wish to receive git patches for": "Список імен проектів, для яких ви хочете отримати виправлення git", + "Show/Hide Buttons": "Показати сховати", + "Custom Font": "Користувацький шрифт", + "Remove the custom font": "Видаліть спеціальний шрифт", + "Lcd": "РК", + "Blue": "Синій", + "Zen": "дзен", + "Night": "Ніч", + "Starlight": "Зоряне світло", + "Search banner image": "Пошук зображення банера", + "Henge": "Хендж", + "QR Code": "QR-код", + "Reminder": "Нагадування", + "Scheduled note to yourself": "Запланована нотатка для себе", + "Replying to": "Відповідаючи на", + "Send to": "Відправити", + "Show a list of addresses to send to": "Показати список адрес для надсилання", + "Petname": "Ім'я домашньої тварини", + "Ok": "Гаразд", + "This is nothing less than an utter triumph": "Це не що інше, як повний тріумф", + "Not Found": "Не знайдено", + "These are not the droids you are looking for": "Це не ті дроїди, яких ви шукаєте", + "Not changed": "Не змінено", + "The contents of your local cache are up to date": "Вміст вашого локального кешу оновлений", + "Bad Request": "Поганий запит", + "Better luck next time": "Пощастить наступного разу", + "Unavailable": "Недоступно", + "The server is busy. Please try again later": "Сервер зайнятий. Будь-ласка спробуйте пізніше", + "Receive calendar events from this account": "Отримувати події календаря з цього облікового запису", + "Grayscale": "Відтінки сірого", + "Liked by": "Сподобалося", + "Solidaric": "Солідарний", + "YouTube Replacement Domain": "Замінний домен YouTube", + "Notes": "Примітки", + "Allow replies.": "Дозволити відповіді.", + "Event": "Подія", + "Event name": "Назва події", + "Events": "Події", + "Create an event": "Створіть подію", + "Describe the event": "Опишіть подію", + "Start Date": "Дата початку", + "End Date": "Кінцева дата", + "Categories": "Категорії", + "This is a private event.": "Це приватна подія.", + "Allow anonymous participation.": "Дозволити анонімну участь.", + "Anyone can join": "Будь-хто може приєднатися", + "Apply to join": "Подайте заявку на приєднання", + "Invitation only": "Тільки запрошення", + "Joining": "Приєднання", + "Status of the event": "Статус події", + "Tentative": "Орієнтовний", + "Confirmed": "Підтверджено", + "Cancelled": "Скасовано", + "Event banner image description": "Опис зображення банера події", + "Banner image": "Зображення банера", + "Maximum attendees": "Максимальна кількість відвідувачів", + "Ticket URL": "URL-адреса квитка", + "Create a new event": "Створіть нову подію", + "Moderation policy or code of conduct": "Політика модерації або кодекс поведінки", + "Edit event": "Редагувати подію", + "Notify when posts are liked": "Повідомляти, коли публікації ставляться «подобається».", + "Don't show the Like button": "Не показувати кнопку «Подобається».", + "Autogenerated Hashtags": "Автоматично згенеровані хештеги", + "Autogenerated Content Warnings": "Автоматично згенеровані попередження про вміст", + "Indymedia": "Indymedia", + "Indymediaclassic": "Indymedia Classic", + "Indymediamodern": "Indymedia Modern", + "Hashtag Blocked": "Хештег заблокований", + "This is a blogging instance": "Це приклад блогу", + "Edit Links": "Редагувати посилання", + "One link per line. Description followed by the link.": "Одне посилання на рядок. Опис за посиланням. Назви мають починатися з #", + "Left column image": "Зображення лівого стовпця", + "Right column image": "Зображення правого стовпця", + "RSS feed for this site": "RSS-канал для цього сайту", + "Edit newswire": "Редагувати стрічку новин", + "Add RSS feed links below.": "Посилання на RSS-канал нижче. Додайте * на початку або в кінці, щоб вказати, що стрічку потрібно модерувати. Додайте ! на початку або в кінці, щоб вказати, що вміст каналу має бути дзеркальним.", + "Newswire RSS Feed": "RSS-канал новин", + "Nicknames whose blog entries appear on the newswire.": "Псевдоніми, чиї записи в блозі з’являються в новинах.", + "Posts to be approved": "Публікації підлягають затвердженню", + "Discuss": "Обговоріть", + "Moderator Discussion": "Модератор Обговорення", + "Vote": "Голосуйте", + "Remove Vote": "Видалити голосування", + "This is a news instance": "Це новинний випадок", + "News": "Новини", + "Read more...": "Детальніше...", + "Edit News Post": "Редагувати новину", + "A list of editor nicknames. One per line.": "Список псевдонімів редактора. По одному на рядок.", + "Site Editors": "Редактори сайту", + "Allow news posts": "Дозволити публікації новин", + "Publish": "Опублікувати", + "Publish a news article": "Опублікувати статтю новини", + "News tagging rules": "Правила позначення новин", + "See instructions": "Дивіться інструкції", + "Search": "Пошук", + "Newswire": "Newswire", + "Links": "Посилання", + "Post": "Пост", + "User": "Користувач", + "Features" : "Особливості", + "Article": "стаття", + "Create an article": "Створити статтю", + "Settings": "Налаштування", + "Citations": "Цитати", + "Choose newswire items referenced in your article": "Виберіть елементи новин, на які посилаються у вашій статті", + "RSS feed for your blog": "RSS-канал для вашого блогу", + "Create a new shared item": "Створіть новий спільний елемент", + "Rc3": "Rc3", + "Hashtag origins": "Походження хештегу", + "admin": "адмін", + "moderator": "модератор", + "editor": "редактор", + "delegator": "делегат", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "Виберіть піктограму редагування, щоб додати канали RSS", + "Select the edit icon to add web links": "Виберіть піктограму редагування, щоб додати веб-посилання", + "Hashtag Categories RSS Feed": "Категорії хештегу RSS-канал", + "Ask about a shared item.": "Запитайте про спільний предмет.", + "Account Information": "Інформація про обліковий запис", + "This account interacts with the following instances": "Цей обліковий запис взаємодіє з наведеними нижче екземплярами", + "News posts are moderated": "Повідомлення новин модеруються", + "Filter": "Фільтр", + "Filter out words": "Відфільтруйте слова", + "Unfilter": "Відфільтрувати", + "Unfilter words": "Скасувати фільтрацію слів", + "Show Accounts": "Показати облікові записи", + "Peertube Instances": "Приклади Peertube", + "Show video previews for the following Peertube sites.": "Показати попередні перегляди відео для наступних сайтів Peertube.", + "Follows you": "Слідує за вами", + "Verify all signatures": "Перевірте всі підписи", + "Blocked followers": "Заблоковані підписники", + "Blocked following": "Заблоковано підписку", + "Receives posts from the following accounts": "Отримує повідомлення з таких облікових записів", + "Sends out posts to the following accounts": "Надсилає повідомлення на наступні облікові записи", + "Word frequencies": "Частоти слів", + "New account": "Новий акаунт", + "Moved to new account address": "Переміщено на нову адресу облікового запису", + "Yet another Epicyon Instance": "Ще один екземпляр Epicyon", + "Other accounts": "Інші рахунки fediverse", + "Pin this post to your profile.": "Закріпіть цю публікацію у своєму профілі.", + "Administered by": "Адмініструє", + "Version": "Версія", + "Skip to timeline": "Перейти до шкали часу", + "Skip to Newswire": "Перейти до Newswire", + "Skip to Links": "Перейти до посилань", + "Publish a blog article": "Опублікувати статтю в блозі", + "Featured writer": "Відомий письменник", + "Broch mode": "Режим Broch", + "Pixel": "піксель", + "DM bounce": "Повідомлення приймаються лише з облікових записів, за якими ви підписалися", + "Next": "Далі", + "Preview": "Попередній перегляд", + "Linked": "Веб-посилання", + "hashtag": "хеш-тег", + "smile": "посміхатися", + "wink": "підморгування", + "mentioning": "згадуючи", + "sad face": "сумне обличчя", + "thinking emoji": "мислення емодзі", + "laughing": "сміючись", + "gender": "Стать", + "He/Him": "Він/Він", + "She/Her": "Вона/вона", + "girl": "дівчина", + "boy": "хлопчик", + "pronoun": "займенник", + "Type of instance": "Тип екземпляра", + "Security": "Безпека", + "Enabling broch mode": "Увімкнення режиму broch забезпечує тимчасове укріплення проти нападу. Приймаються лише повідомлення від уже відомих екземплярів. Якщо його не вимкнути, він минає через тиждень.", + "Instance Settings": "Налаштування екземпляра", + "Video Settings": "Налаштування відео", + "Filtering and Blocking": "Фільтрація та блокування", + "Role Assignment": "Призначення ролі", + "Contact Details": "Контактні дані", + "Background Images": "Фонові зображення", + "heart": "серце", + "counselor": "радник", + "Counselors": "Радники", + "shocked": "шокований", + "Encrypted": "Зашифровано", + "Direct Message permitted instances": "Дозволені екземпляри прямих повідомлень", + "Direct messages are always allowed from these instances.": "Прямі повідомлення з цих екземплярів завжди дозволені.", + "Key Shortcuts": "Комбінації клавіш", + "menuTimeline": "Перегляд хронології", + "menuEdit": "Редагувати", + "menuProfile": "Перегляд профілю", + "menuInbox": "Вхідні", + "menuSearch": "Шукати/слідкувати", + "menuNewPost": "Новий пост", + "menuNewBlog": "Нова публікація в блозі", + "menuCalendar": "Календар", + "menuDM": "Прямі повідомлення", + "menuReplies": "Відповіді", + "menuOutbox": "Надісланий", + "menuBookmarks": "Закладки", + "menuShares": "Спільні елементи", + "menuBlogs": "Блоги", + "menuNewswire": "Newswire", + "menuLinks": "Посилання", + "menuModeration": "Помірність", + "menuFollowing": "Після", + "menuFollowers": "Послідовники", + "menuRoles": "Ролі", + "menuSkills": "Навички", + "menuLogout": "Вийти", + "menuKeys": "Комбінації клавіш", + "submitButton": "Кнопка надіслати", + "menuMedia": "ЗМІ", + "followButton": "Кнопка підписки/відписки", + "blockButton": "Кнопка блокування", + "infoButton": "Кнопка інформації", + "snoozeButton": "Кнопка відкладення", + "reportButton": "Кнопка Повідомити", + "viewButton": "Кнопка Переглянути", + "enterPetname": "Введіть ім'я тварини", + "enterNotes": "Введіть нотатки", + "These access keys may be used": "Ці клавіші доступу можна використовувати, як правило, разом із клавішею ALT + SHIFT + або ALT +", + "Show numbers of accounts within instance metadata": "Показати кількість облікових записів у метаданих екземпляра", + "Show version number within instance metadata": "Показати номер версії в метаданих екземпляра", + "Joined": "Приєднався", + "City for spoofed GPS image metadata": "Місто для підроблених метаданих зображень GPS", + "Occupation": "Заняття", + "Artists": "Художники", + "Graphic Design": "Графічний дизайн", + "Import Theme": "Імпортувати тему", + "Export Theme": "Експортна тема", + "Custom post submit button text": "Текст кнопки надсилання спеціальної публікації", + "Blocked User Agents": "Заблоковані користувацькі агенти", + "Notify me when this account posts": "Повідомте мене, коли цей обліковий запис публікує", + "Languages": "Мови", + "Translated": "Перекладено", + "Quantity": "Кількість", + "food": "їжа", + "Price": "Ціна", + "Currency": "Валюта", + "List of domains which can access the shared items catalog": "Список доменів, які мають доступ до каталогу спільних елементів", + "Shares Catalog": "Каталог акцій", + "tool": "інструмент", + "clothes": "одяг", + "medical": "медичний", + "Wanted": "У розшуку", + "Describe something wanted": "Опишіть те, що хотілося", + "Enter the details for your wanted item below.": "Нижче введіть деталі шуканого товару.", + "Name of the wanted item": "Назва предмета розшуку", + "Description of the item wanted": "Опис шуканого предмета", + "Type of wanted item. eg. hat": "Тип предмета розшуку. наприклад капелюх", + "Category of wanted item. eg. clothes": "Категорія предмета розшуку. наприклад одяг", + "City or location of the wanted item": "Місто або місце розташування шуканого предмета", + "Maximum Price": "Максимальна ціна", + "Create a new wanted item": "Створіть новий шуканий предмет", + "Wanted Items Search": "Пошук предметів розшуку", + "Website": "веб-сайт", + "Low Bandwidth": "Низька пропускна здатність", + "accommodation": "проживання", + "Forbidden": "Заборонено", + "You're not allowed": "Вам заборонено", + "Hours after posting during which replies are allowed": "Години після публікації, протягом яких дозволено відповідати", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Замінний домен Twitter", + "Buy": "Купуйте", + "Request to stay": "Прохання залишитися", + "Profile": "Профіль", + "Introduce yourself and specify the date and time when you wish to stay": "Представтесь і вкажіть дату та час, коли ви бажаєте залишитися", + "Members": "Члени", + "Join": "Приєднуйтесь", + "Leave": "Залишати", + "System Monitor": "Системний монітор", + "Add content warnings for the following sites": "Додайте попередження щодо вмісту для наступних сайтів", + "Known Web Crawlers": "Відомі веб-сканери", + "Add to the calendar": "Додати до календаря", + "Content License": "Ліцензія на вміст", + "Reaction by": "Реакція з боку", + "Notify on emoji reactions": "Сповіщати про реакції смайлів", + "Select reaction": "Виберіть реакцію", + "Don't show the Reaction button": "Не показувати кнопку \"Реакція\".", + "New feed URL": "Нова URL-адреса каналу", + "New link title and URL": "Нове посилання та URL-адреса", + "Theme Designer": "Дизайнер тем", + "Reset": "Скинути", + "Encryption Keys": "Ключі шифрування", + "Filtered words within bio": "Відфільтровані слова в біографії", + "Write your news report": "Напишіть свій репортаж", + "Dyslexic font": "Дислексичний шрифт", + "Leave a comment": "Залишити коментар", + "View comments": "Переглянути коментарі", + "Multi Status": "Мульти статус", + "Lots of things": "Багато речей", + "Created": "Створено", + "It is done": "Це робиться", + "Time Zone": "Часовий пояс", + "Show who liked this post": "Покажіть, кому сподобався цей пост", + "Show who repeated this post": "Покажіть, хто повторив цей пост", + "Repeated by": "Повторюється за", + "Register": "Реєстрація", + "Web Bots Allowed": "Веб-боти дозволені", + "Known Search Bots": "Відомі пошукові роботи в Інтернеті", + "mitm": "Повідомлення могло бути прочитане або змінене третьою стороною", + "Bold reading": "Сміливе читання", + "SHOW EDITS": "ПОКАЗАТИ ЗМІНИ", + "Attach an image, video or audio file": "Прикріпіть зображення, відео чи аудіофайл", + "Set a place and time": "Встановіть місце і час", + "Describe your attachment": "Опишіть свою прихильність", + "Language used": "Використана мова", + "lang_ar": "aрабська", + "lang_bn": "бенгальська", + "lang_cy": "валлійська", + "lang_en": "англійська", + "lang_fr": "французький", + "lang_hi": "гінді", + "lang_ja": "японські", + "lang_ku": "курдська", + "lang_pl": "польський", + "lang_ru": "російський", + "lang_uk": "український", + "lang_ca": "каталонська", + "lang_de": "німецька", + "lang_es": "іспанська", + "lang_ga": "ірландський", + "lang_it": "італійська", + "lang_ko": "корейський", + "lang_oc": "окситанський", + "lang_pt": "португальська", + "lang_sw": "суахілі", + "lang_tr": "турецька", + "lang_zh": "китайський", + "lang_nl": "голландський", + "lang_el": "грецька", + "lang_yi": "ідиш", + "Common emoji": "Звичайні емодзі", + "Copy and paste into your text": "Скопіюйте та вставте у свій текст", + "shrug": "знизати плечима", + "DM warning": "Прямі повідомлення не наскрізне шифруються. Не публікуйте тут дуже конфіденційну інформацію.", + "Transcript": "Стенограма", + "Color contrast is too low": "Колірна контрастність надто низька", + "View Larger Map": "Переглянути більшу карту", + "Start Time": "Час початку", + "End Time": "Час закінчення", + "Switch to calendar view": "Перейти до перегляду календаря", + "Save": "Зберегти", + "Switch to moderation view": "Перейти до режиму модерації", + "Minimize attached images": "Мінімізуйте вкладені зображення", + "SHOW MEDIA": "ПОКАЗАТИ ЗМІ", + "ActivityPub Specification": "Специфікація ActivityPub", + "Dogwhistle words": "Собачі слова", + "Content warnings will be added for the following": "Попередження про вміст буде додано для наступних", + "nowplaying": "заразграє", + "NowPlaying": "ЗаразГрає", + "Import and Export": "Імпорт та експорт", + "Import Follows": "Імпорт слідує", + "Post expiry period in days": "Термін після закінчення терміну дії в днях", + "Keep DMs during post expiry": "Зберігайте прямі повідомлення протягом терміну дії", + "Notifications": "Сповіщення", + "ntfy URL": "ntfy URL", + "ntfy topic": "Тема ntfy", + "Last hour": "Остання година", + "Last 3 hours": "Останні 3 години", + "Last 6 hours": "Останні 6 години", + "Last 12 hours": "Останні 12 години", + "Last day": "Останній день", + "Last 2 days": "Останні 2 дні", + "Last week": "Минулого тижня", + "Last 2 weeks": "Останні 2 тижні", + "Last month": "Минулого місяця", + "Last 6 months": "Останні 6 місяців", + "Last year": "Минулого року", + "Unauthorized": "Несанкціонований", + "No login credentials were posted": "Облікові дані для входу не опубліковано", + "Credentials are too long": "Облікові дані задовгі", + "Site DevOps": "Сайт DevOps", + "A list of devops nicknames. One per line.": "Список ніків devops. По одному на рядок.", + "devops": "devops", + "Reject spam accounts": "Відхилити спам-акаунти" +} diff --git a/translations/yi.json b/translations/yi.json new file mode 100644 index 000000000..90008d839 --- /dev/null +++ b/translations/yi.json @@ -0,0 +1,598 @@ +{ + "SHOW MORE": "ווייז מער", + "Your browser does not support the video tag.": "דיין בלעטערער שטיצט נישט די ווידעא קוויטל.", + "Your browser does not support the audio tag.": "דיין בלעטערער שטיצט נישט די אַודיאָ קוויטל.", + "Show profile": "ווייַזן פּראָפיל", + "Show options for this person": "ווייַזן אָפּציעס פֿאַר דעם מענטש", + "Repeat this post": "איבערחזרן", + "Undo the repeat": "ופמאַכן די איבערחזרן", + "Like this post": "ווי", + "Undo the like": "ניט ענלעך", + "Delete this post": "ויסמעקן", + "Delete this event": "ויסמעקן", + "Reply to this post": "ענטפער", + "Write your post text below.": "ניו פּאָסטן", + "Write your reply to": "שרייב דיין ענטפער צו", + "this post": "דעם פּאָסטן", + "Write your report below.": "שרייב דיין באַריכט אונטן.", + "This message only goes to moderators, even if it mentions other fediverse addresses.": "דער אָנזאָג גייט בלויז צו מאָדעראַטאָרס, אפילו אויב עס דערמאנט אנדערע פעדיווערס אַדרעסעס.", + "Also see": "זען אויך", + "Terms of Service": "טערמינען פון דינסט", + "Enter the details for your shared item below.": "אַרייַן די דעטאַילס פֿאַר דיין שערד נומער אונטן.", + "Subject or Content Warning (optional)": "טעמע אָדער אינהאַלט ווארענונג (אַפּשאַנאַל)", + "Write something": "שרייב עפעס", + "Name of the shared item": "נאָמען פון די שערד נומער", + "Description of the item being shared": "באַשרייַבונג פון די נומער וואָס איז שערד", + "Type of shared item. eg. hat": "טיפּ פון שערד נומער. למשל. הוט", + "Category of shared item. eg. clothing": "קאַטעגאָריע פון שערד נומער. למשל. קליידער", + "Duration of listing in days": "געדויער פון ליסטינג אין טעג", + "City or location of the shared item": "שטאָט אָדער אָרט פון די שערד נומער", + "Describe a shared item": "באַשרייַבן אַ שערד נומער", + "Public": "עפנטלעך", + "Visible to anyone": "קענטיק צו ווער עס יז", + "Unlisted": "ונליסטעד", + "Not on public timeline": "ניט אויף ציבור טיימליין", + "Followers": "אנהענגערס", + "Only to followers": "נאָר צו חסידים", + "DM": "דירעקט אָנזאָג", + "Only to mentioned people": "נאר צו דערמאנטע מענטשן", + "Report": "באַריכט", + "Send to moderators": "שיקן צו מאָדעראַטאָרס", + "Search for emoji": "זוכן פֿאַר עמאָדזשי", + "Cancel": "✘", + "Submit": "פאָרלייגן", + "Image description": "בילד באַשרייַבונג", + "Item image": "נומער בילד", + "Type": "טיפּ", + "Category": "קאַטעגאָריע", + "Location": "אָרט", + "Login": "צייכן אריין", + "Edit": "רעדאַגירן", + "Switch to timeline view": "טיימליין מיינונג", + "Approve": "אַפּרווו", + "Deny": "לייקענען", + "Posts": "הודעות", + "Following": "ווייַטערדיק", + "Followers": "אנהענגערס", + "Roles": "ראָלעס", + "Skills": "סקיללס", + "Shares": "שאַרעס", + "Block": "פאַרשפּאַרן", + "Unfollow": "ניט נאָכפאָלגן", + "Your browser does not support the audio element.": "דיין בלעטערער שטיצט נישט די אַודיאָ עלעמענט.", + "Your browser does not support the video element.": "דיין בלעטערער שטיצט נישט די ווידעא עלעמענט.", + "Create a new post": "ניו פּאָסטן", + "Create a new DM": "שאַפֿן אַ נייַע דירעקט אָנזאָג", + "Switch to profile view": "פּראָפיל מיינונג", + "Inbox": "ינבאָקס", + "Sent": "געשיקט", + "Search and follow": "זוכן / נאָכגיין", + "Refresh": "דערפרישן", + "Nickname or URL. Block using *@domain or nickname@domain": "ניקקנאַמע אָדער URL. פאַרשפּאַרן ניצן *@domain אָדער nickname@domain", + "Remove the above item": "אַראָפּנעמען די אויבן נומער", + "Remove": "אַראָפּנעמען", + "Suspend the above account nickname": "סוספּענד די אויבן חשבון צונעמעניש", + "Suspend": "סוספּענד", + "Remove a suspension for an account nickname": "אַראָפּנעמען אַ סאַספּענשאַן פֿאַר אַ חשבון ניקקנאַמע", + "Unsuspend": "Unsuspend", + "Block an account on another instance": "פאַרשפּאַרן אַ חשבון אין אן אנדער בייַשפּיל", + "Unblock": "ופשליסן", + "Unblock an account on another instance": "ופשליסן אַ חשבון אין אן אנדער בייַשפּיל", + "Information about current blocks/suspensions": "אינפֿאָרמאַציע וועגן קראַנט בלאַקס / סאַספּענשאַנז", + "Info": "אינפֿאָרמאַציע", + "Remove": "אַראָפּנעמען", + "Yes": "יא", + "No": "ניין", + "Delete this post?": "ויסמעקן דעם פּאָסטן?", + "Follow": "גיי", + "Stop following": "האַלטן נאָך", + "Options for": "אָפּציעס פֿאַר", + "View": "View", + "Stop blocking": "האַלטן בלאַקינג", + "Enter an emoji name to search for": "אַרייַן אַן עמאָדזשי נאָמען צו זוכן פֿאַר", + "Search screen text": "אַרייַן אַן אַדרעס, שערד נומער, -היט, 'געשיכטע, #האַשטאַג, *סkill, .wanted אָדער :emoji: צו זוכן פֿאַר", + "Go Back": "◀", + "Moderation Information": "מאַדעריישאַן אינפֿאָרמאַציע", + "Suspended accounts": "סוספּענדעד אַקאַונץ", + "These are currently suspended": "די זענען דערווייַל סוספּענדעד", + "Blocked accounts and hashtags": "אפגעשטעלט אַקאַונץ און hashtags", + "These are globally blocked for all accounts on this instance": "די זענען גלאָובאַלי בלאַקט פֿאַר אַלע אַקאַונץ אין דעם בייַשפּיל", + "Any blocks or suspensions made by moderators will be shown here.": "קיין בלאַקס אָדער סאַספּענשאַנז געמאכט דורך מאָדעראַטאָרס וועט זיין געוויזן דאָ.", + "Welcome. Please enter your login details below.": "ברוכים הבאים. ביטע אַרייַן דיין לאָגין דעטאַילס אונטן.", + "Welcome. Please login or register a new account.": "ברוכים הבאים. ביטע קלאָץ אין אָדער רעגיסטרירן אַ נייַ חשבון.", + "Please enter some credentials": "ביטע אַרייַן עטלעכע קראַדענטשאַלז", + "You will become the admin of this site.": "איר וועט ווערן דער אַדמיניסטראַטאָר פון דעם פּלאַץ.", + "Terms of Service": "טערמינען פון דינסט", + "About this Instance": "וועגן דעם בייַשפּיל", + "Nickname": "צונעמעניש", + "Enter Nickname": "אַרייַן צונעמעניש", + "Password": "שפּריכוואָרט", + "Enter Password": "מינימום 8 אותיות", + "Profile for": "פּראָפיל פֿאַר", + "The files attached below should be no larger than 10MB in total uploaded at once.": "די טעקעס אַטאַטשט אונטן זאָל זיין ניט גרעסער ווי 10 מב אין גאַנץ ופּלאָאַדעד אין אַמאָל.", + "Avatar image": "אַוואַטאַר בילד", + "Background image": "הינטערגרונט בילד, וואָס איז ארויס הינטער דיין אַוואַטאַר", + "Timeline banner image": "טיימליין פאָן בילד", + "Approve follower requests": "אַפּרווו אנהענגערס ריקוועס", + "This is a bot account": "דאָס איז אַ באָט חשבון", + "Filtered words": "געפילטערטע ווערטער", + "One per line": "איינער פּער שורה", + "Blocked accounts": "אפגעשטעלט אַקאַונץ", + "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "אפגעשטעלט אַקאַונץ, איינער פּער שורה, אין די פאָרעם ניקקנאַמע@domain אָדער *@blockeddomain", + "Federation list": "פעדעריישאַן רשימה", + "Federate only with a defined set of instances. One domain name per line.": "פעדעראַטע בלויז מיט אַ דיפיינד גאַנג פון ינסטאַנסיז. איין פעלד נאָמען פּער שורה.", + "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "אויב איר ווילן צו אָנטייל נעמען אין אָרגאַנאַזיישאַנז, איר קענען אָנווייַזן עטלעכע סקילז וואָס איר האָט און דערנענטערנ די באַהאַוונטקייַט לעוועלס. דאָס העלפּס אָרגאַנייזערז צו בויען טימז מיט אַ צונעמען קאָמבינאַציע פון סקילז.", + "A list of moderator nicknames. One per line.": "א רשימה פון מאָדעראַטאָר ניקניימז. איינער פּער שורה.", + "Moderators": "מאָדעראַטאָרס", + "List of moderator nicknames": "רשימה פון מאָדעראַטאָר ניקניימז", + "Your bio": "דיין ביאגראפיע", + "Skill": "בקיעס", + "Copy the text then paste it into your post": "נאָכמאַכן דעם טעקסט און פּאַפּ עס אין דיין פּאָסטן", + "Emoji Search": "Emoji Search", + "No results": "קיין רעזולטאטן", + "Skills search": "סקיללס זוכן", + "Shared Items Search": "שערד יטעמס זוכן", + "Contact": "קאָנטאַקט", + "Shared Item": "שערד נומער", + "Mod": "מעסיק", + "Approve follow requests": "אַפּרווו נאָכגיין ריקוועס", + "Page down": "בלאַט אַראָפּ", + "Page up": "בלאַט אַרויף", + "Vote": "שטימען", + "Replies": "ענטפֿערס", + "Media": "מעדיע", + "This is a group account": "דאָס איז אַ גרופּע חשבון", + "Date": "טאָג", + "Time": "צייַט", + "Location": "אָרט", + "Calendar": "קאַלענדאַר", + "Sun": "Sun", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "January": "יאנואר", + "February": "פעברואַר", + "March": "מאַרץ", + "April": "אפריל", + "May": "מאי", + "June": "יוני", + "July": "יולי", + "August": "אויגוסט", + "September": "סעפטעמבער", + "October": "אקטאבער", + "November": "נאוועמבער", + "December": "דעצעמבער", + "Only people I follow can send me DMs": "בלויז מענטשן וואָס איך נאָכפאָלגן קענען שיקן מיר דירעקט אַרטיקלען", + "Logout": "Logout", + "Danger Zone": "געפאַר זאָנע", + "Deactivate this account": "דיאַקטיווייט דעם חשבון", + "Snooze": "סנוז", + "Unsnooze": "ונסנווז", + "Donations link": "דאָוניישאַנז לינק", + "Donate": "שענקען", + "Change Password": "טוישן שפּריכוואָרט", + "Confirm Password": "באַשטעטיקן שפּריכוואָרט", + "Instance Title": "בייַשפּיל טיטל", + "Instance Short Description": "בייַשפּיל קורץ באַשרייַבונג", + "Instance Description": "בייַשפּיל באַשרייַבונג", + "Instance Logo": "בייַשפּיל לאָגאָ", + "Bookmark this post": "לייענ - צייכן", + "Undo the bookmark": "ונבאָאָקמאַרק", + "Bookmarks": "געראטעוועט", + "Theme": "טעמע", + "Default": "פעליקייַט", + "Light": "ליכט", + "Purple": "לילאַ", + "Hacker": "העקער", + "HighVis": "היי וויס", + "Question": "פראגע", + "Enter your question": "אַרייַן דיין קשיא", + "Enter the choices for your question below.": "אַרייַן די ברירות פֿאַר דיין קשיא אונטן.", + "Ask a question": "פרעגן אַ קשיא", + "Possible answers": "מעגלעך ענטפֿערס", + "replying to": "ענטפערן צו", + "replying to themselves": "ענטפערן צו זיך", + "announces": "אַנאַונסיז", + "Previous month": "פֿריִערדיקע חודש", + "Next month": "קומענדיגן מאנאט", + "Get the source code": "באַקומען די מקור קאָד", + "This is a media instance": "דאָס איז אַ מעדיע בייַשפּיל", + "Mute this post": "שטום", + "Undo mute": "ופמאַכן שטום", + "XMPP": "XMPP", + "Matrix": "Matrix", + "Email": "בליצפּאָסט", + "PGP": "פּגפּ שליסל", + "PGP Fingerprint": "PGP פינגערפּרינט", + "This is a scheduled post.": "דאָס איז אַ סקעדזשולד פּאָסטן.", + "Remove scheduled posts": "אַראָפּנעמען סקעדזשולד הודעות", + "Remove Twitter posts": "אַראָפּנעמען טוויטטער אַרטיקלען", + "Sensitive": "סענסיטיוו", + "Word Replacements": "וואָרט רעפּלאַסעמענץ", + "Happening Today": "היינט", + "Happening Tomorrow": "מאָרגן", + "Happening This Week": "באלד", + "Blog": "בלאָג", + "Blogs": "בלאָגס", + "Title": "טיטל", + "About the author": "וועגן דעם מחבר", + "Edit blog post": "רעדאַגירן בלאָג פּאָסטן", + "Publicly visible post": "עפנטלעך קענטיק פּאָסטן", + "Your Posts": "דיין הודעות", + "Git Projects": "גיט פּראַדזשעקס", + "List of project names that you wish to receive git patches for": "רשימה פון פּרויעקט נעמען פֿאַר וואָס איר ווילט באַקומען גיט פּאַטשאַז", + "Show/Hide Buttons": "ווייַזן / באַהאַלטן", + "Custom Font": "מנהג פאָנט", + "Remove the custom font": "אַראָפּנעמען די מנהג שריפֿט", + "Lcd": "LCD", + "Blue": "בלוי", + "Zen": "זען", + "Night": "נאַכט", + "Starlight": "שטערןליגהט", + "Search banner image": "זוכן פאָן בילד", + "Henge": "הענגע", + "QR Code": "QR קאָד", + "Reminder": "דערמאָנונג", + "Scheduled note to yourself": "סקעדזשולד טאָן צו זיך", + "Replying to": "ענטפער צו", + "Send to": "שיק צו", + "Show a list of addresses to send to": "ווייַזן אַ רשימה פון אַדרעסעס צו שיקן", + "Petname": "ליבלינג נאָמען", + "Ok": "אקעי", + "This is nothing less than an utter triumph": "דאָס איז גאָרנישט ווייניקער ווי אַ גאָר טריומף", + "Not Found": "ניט געפונען", + "These are not the droids you are looking for": "דאָס זענען נישט די דראָידס איר זוכט פֿאַר", + "Not changed": "ניט געביטן", + "The contents of your local cache are up to date": "דער אינהאַלט פון דיין היגע קאַש איז דערהייַנטיקט", + "Bad Request": "שלעכטע בקשה", + "Better luck next time": "בעסער גליק ווייַטער צייַט", + "Unavailable": "ניט בנימצא", + "The server is busy. Please try again later": "דער סערווער איז פאַרנומען. ביטע פּרובירן ווידער שפּעטער", + "Receive calendar events from this account": "באַקומען קאַלענדאַר געשעענישן פון דעם חשבון", + "Grayscale": "גרייַסקאַלעט", + "Liked by": "לייקט דורך", + "Solidaric": "סאָלידאַריק", + "YouTube Replacement Domain": "יאָוטובע פאַרבייַט פעלד", + "Notes": "הערות", + "Allow replies.": "לאָזן ענטפֿערס.", + "Event": "געשעעניש", + "Event name": "געשעעניש נאָמען", + "Events": "געשעענישן", + "Create an event": "שאַפֿן אַ געשעעניש", + "Describe the event": "באַשרייַבן די געשעעניש", + "Start Date": "אנהייב דאטום", + "End Date": "סוף טאָג", + "Categories": "קאַטעגאָריעס", + "This is a private event.": "דאָס איז אַ פּריוואַט געשעעניש.", + "Allow anonymous participation.": "לאָזן אַנאָנימע באַנוצערס אָנטייל.", + "Anyone can join": "ווער עס יז קענען פאַרבינדן", + "Apply to join": "צולייגן צו פאַרבינדן", + "Invitation only": "נאָר פאַרבעטונג", + "Joining": "דזשוינינג", + "Status of the event": "סטאַטוס פון דער געשעעניש", + "Tentative": "טענטאַטיוו", + "Confirmed": "באשטעטיקט", + "Cancelled": "קאַנסאַלד", + "Event banner image description": "געשעעניש פאָן בילד באַשרייַבונג", + "Banner image": "באַנער בילד", + "Maximum attendees": "מאַקסימום אַטענדיז", + "Ticket URL": "בילעט URL", + "Create a new event": "שאַפֿן אַ נייַע געשעעניש", + "Moderation policy or code of conduct": "מאַדעריישאַן פּאָליטיק אָדער קאָוד פון אָנפירן", + "Edit event": "רעדאַגירן געשעעניש", + "Notify when posts are liked": "געבנ צו וויסן ווען אַרטיקלען זענען לייקט", + "Don't show the Like button": "צי ניט ווייַזן די ווי קנעפּל", + "Autogenerated Hashtags": "אַוטאָגענעראַטעד האַשטאַגס", + "Autogenerated Content Warnings": "אַוטאָגענעראַטעד אינהאַלט וואָרנינגז", + "Indymedia": "ינדימעדיע", + "Indymediaclassic": "ינדימעדיאַ קלאַסיק", + "Indymediamodern": "ינדימעדיאַ מאָדערן", + "Hashtag Blocked": "Hashtag אפגעשטעלט", + "This is a blogging instance": "דאָס איז אַ בלאָגגינג בייַשפּיל", + "Edit Links": "רעדאַגירן לינקס", + "One link per line. Description followed by the link.": "איין לינק פּער שורה. באַשרייַבונג נאכגעגאנגען דורך די לינק. טיטלען זאָל אָנהייבן מיט #", + "Left column image": "לינקס זייַל בילד", + "Right column image": "רעכט זייַל בילד", + "RSS feed for this site": "RSS קאָרמען פֿאַר דעם פּלאַץ", + "Edit newswire": "רעדאַגירן נייַעס", + "Add RSS feed links below.": "RSS קאָרמען לינקס אונטן. לייג אַ * אין די אָנהייב אָדער סוף צו אָנווייַזן אַז אַ קאָרמען זאָל זיין מאַדערייטיד. לייג אַ ! אין די אָנהייב אָדער סוף צו אָנווייַזן אַז די פיטער אינהאַלט זאָל זיין שפּיגל.", + "Newswire RSS Feed": "נעווסווירע רסס פיטער", + "Nicknames whose blog entries appear on the newswire.": "ניקקנאַמע וועמענס בלאָג אַרטיקלען דערשייַנען אויף די נייַעס.", + "Posts to be approved": "הודעות צו זיין באוויליקט", + "Discuss": "דיסקוטירן", + "Moderator Discussion": "מאָדעראַטאָר דיסקוסיע", + "Vote": "שטימען", + "Remove Vote": "אַראָפּנעמען שטימען", + "This is a news instance": "דאָס איז אַ נייַעס בייַשפּיל", + "News": "נייַעס", + "Read more...": "לייענען מער ...", + "Edit News Post": "רעדאַגירן נייַעס פּאָסטן", + "A list of editor nicknames. One per line.": "א רשימה פון רעדאַקטאָר ניקניימז. איינער פּער שורה.", + "Site Editors": "פּלאַץ עדיטאָר", + "Allow news posts": "לאָזן נייַעס הודעות", + "Publish": "אַרויסגעבן", + "Publish a news article": "אַרויסגעבן אַ נייַעס אַרטיקל", + "News tagging rules": "נייַעס טאַגינג כּללים", + "See instructions": "זען ינסטראַקשאַנז", + "Search": "זוכן", + "Newswire": "Newswire", + "Links": "לינקס", + "Post": "פּאָסטן", + "User": "באַניצער", + "Features" : "איינריכטונגען", + "Article": "אַרטיקל", + "Create an article": "שאַפֿן אַן אַרטיקל", + "Settings": "סעטטינגס", + "Citations": "סיטאַטיאָנס", + "Choose newswire items referenced in your article": "קלייַבן נעווווירע זאכן רעפערענסט אין דיין אַרטיקל", + "RSS feed for your blog": "RSS קאָרמען פֿאַר דיין בלאָג", + "Create a new shared item": "שאַפֿן אַ נייַע שערד נומער", + "Rc3": "Rc3", + "Hashtag origins": "האַשטאַג אָריגינס", + "admin": "אַדמין", + "moderator": "מאָדעראַטאָר", + "editor": "רעדאַקטאָר", + "delegator": "דעלעגאַטאָר", + "Debian": "Debian", + "Select the edit icon to add RSS feeds": "סעלעקטירן דעם רעדאַגירן בילדל צו לייגן RSS פידז", + "Select the edit icon to add web links": "אויסקלייַבן די רעדאַגירן ייקאַן צו לייגן וועב לינקס", + "Hashtag Categories RSS Feed": "Hashtag קאַטעגאָריעס רסס פיטער", + "Ask about a shared item.": "פרעגן וועגן אַ שערד נומער.", + "Account Information": "חשבון אינפֿאָרמאַציע", + "This account interacts with the following instances": "דער חשבון ינטעראַקץ מיט די פאלגענדע קאַסעס", + "News posts are moderated": "נייַעס אַרטיקלען זענען מאַדערייטיד", + "Filter": "פילטער", + "Filter out words": "פילטער אויס ווערטער", + "Unfilter": "ונפילטער", + "Unfilter words": "ונפילטער ווערטער", + "Show Accounts": "ווייַזן אַקאַונץ", + "Peertube Instances": "פּעערטובע ינסטאַנסיז", + "Show video previews for the following Peertube sites.": "ווייַזן ווידעא פּריוויוז פֿאַר די פאלגענדע Peertube זייטלעך.", + "Follows you": "פאלגט דיר", + "Verify all signatures": "באַשטעטיקן אַלע סיגנאַטשערז", + "Blocked followers": "אפגעשטעלט אנהענגערס", + "Blocked following": "בלאקירט פאלגנד", + "Receives posts from the following accounts": "נעמט אַרטיקלען פון די פאלגענדע אַקאַונץ", + "Sends out posts to the following accounts": "סענדז הודעות צו די פאלגענדע אַקאַונץ", + "Word frequencies": "וואָרט פריקוואַנסיז", + "New account": "נייַ חשבון", + "Moved to new account address": "אריבערגעפארן צו אַ נייַ חשבון אַדרעס", + "Yet another Epicyon Instance": "נאָך אן אנדער עפּיסיאָן בייַשפּיל", + "Other accounts": "אנדערע פעדעראלע אַקאַונץ", + "Pin this post to your profile.": "לייג דעם פּאָסטן צו דיין פּראָפיל.", + "Administered by": "אַדמינאַסטערד דורך", + "Version": "ווערסיע", + "Skip to timeline": "האָפּקען צו טיימליין", + "Skip to Newswire": "האָפּקען צו Newswire", + "Skip to Links": "גיין צו לינקס", + "Publish a blog article": "אַרויסגעבן אַ בלאָג אַרטיקל", + "Featured writer": "באגעגנט שרייבער", + "Broch mode": "בראָש מאָדע", + "Pixel": "פּיקסעל", + "DM bounce": "אַרטיקלען זענען בלויז אנגענומען פון נאכגעגאנגען אַקאַונץ", + "Next": "ווייַטער", + "Preview": "פאָרויסיקע ווייַזונג", + "Linked": "וועב לינגקט", + "hashtag": "האַשטאַג", + "smile": "שמייכלען", + "wink": "ווינקען", + "mentioning": "דערמאָנען", + "sad face": "טרויעריק פּנים", + "thinking emoji": "טראכטן עמאָדזשי", + "laughing": "לאכן", + "gender": "דזשענדער", + "He/Him": "ער / אים", + "She/Her": "זי / איר", + "girl": "מיידל", + "boy": "יינגל", + "pronoun": "פּראָנאָם", + "Type of instance": "טיפּ פון בייַשפּיל", + "Security": "זיכערהייַט", + "Enabling broch mode": "ענייבאַלינג בראָש מאָדע גיט אַ צייַטווייַליק פאָרטאַפאַקיישאַן קעגן באַפאַלן. בלויז הודעות דורך שוין באקאנט ינסטאַנסיז וועט זיין אנגענומען. אויב ניט אויסגעדרייט אַוועק, עס ילאַפּסיז נאָך אַ וואָך.", + "Instance Settings": "בייַשפּיל סעטטינגס", + "Video Settings": "ווידעא סעטטינגס", + "Filtering and Blocking": "פֿילטרירונג און בלאַקינג", + "Role Assignment": "ראָלע אַסיינמאַנט", + "Contact Details": "קאָנטאַקט דעטאַלן", + "Background Images": "הינטערגרונט בילדער", + "heart": "הארץ", + "counselor": "קאָונסעלאָר", + "Counselors": "קאָונסעלאָרס", + "shocked": "שאַקט", + "Encrypted": "ענקריפּטיד", + "Direct Message permitted instances": "דירעקט אָנזאָג דערלויבט ינסטאַנסיז", + "Direct messages are always allowed from these instances.": "דירעקט אַרטיקלען זענען שטענדיק ערלויבט פֿון די ינסטאַנסיז.", + "Key Shortcuts": "שליסל שאָרטקאַץ", + "menuTimeline": "טיימליין מיינונג", + "menuEdit": "רעדאַגירן", + "menuProfile": "פּראָפיל מיינונג", + "menuInbox": "ינבאָקס", + "menuSearch": "זוכן / נאָכגיין", + "menuNewPost": "ניו פּאָסטן", + "menuNewBlog": "ניו בלאָג פּאָסטן", + "menuCalendar": "קאַלענדאַר", + "menuDM": "דירעקט אַרטיקלען", + "menuReplies": "ענטפֿערס", + "menuOutbox": "געשיקט", + "menuBookmarks": "בוקמאַרקס", + "menuShares": "שערד זאכן", + "menuBlogs": "בלאָגס", + "menuNewswire": "Newswire", + "menuLinks": "לינקס", + "menuModeration": "מאַדעריישאַן", + "menuFollowing": "ווייַטערדיק", + "menuFollowers": "אנהענגערס", + "menuRoles": "ראָלעס", + "menuSkills": "סקיללס", + "menuLogout": "Logout", + "menuKeys": "שליסל שאָרטקאַץ", + "submitButton": "פאָרלייגן קנעפּל", + "menuMedia": "מעדיע", + "followButton": "גיי / ונפאָללאָוו קנעפּל", + "blockButton": "פאַרשפּאַרן קנעפּל", + "infoButton": "אינפֿאָרמאַציע קנעפּל", + "snoozeButton": "סנוז קנעפּל", + "reportButton": "באריכט קנעפּל", + "viewButton": "קוק קנעפּל", + "enterPetname": "אַרייַן ליבלינג נאָמען", + "enterNotes": "אַרייַן הערות", + "These access keys may be used": "די אַקסעס שליסלען קענען זיין געוויינט, טיפּיקלי מיט ALT + SHIFT + שליסל אָדער ALT + שליסל", + "Show numbers of accounts within instance metadata": "ווייַזן נומערן פון אַקאַונץ ין מעטאַדאַטאַ", + "Show version number within instance metadata": "ווייַזן ווערסיע נומער ין מעטאַדאַטאַ פֿאַר בייַשפּיל", + "Joined": "זיך איינגעשריבן", + "City for spoofed GPS image metadata": "שטאָט פֿאַר ספּאָאָפעד גפּס בילד מעטאַדאַטאַ", + "Occupation": "פאַך", + "Artists": "קינסטלער", + "Graphic Design": "גראַפיק פּלאַן", + "Import Theme": "ימפּאָרט טים", + "Export Theme": "עקספּאָרט טים", + "Custom post submit button text": "מנהג פּאָסטן פאָרלייגן קנעפּל טעקסט", + "Blocked User Agents": "אפגעשטעלט באַניצער אַגענץ", + "Notify me when this account posts": "געבנ צו וויסן מיר ווען דעם חשבון הודעות", + "Languages": "שפּראַכן", + "Translated": "איבערגעזעצט", + "Quantity": "קוואַנטיטי", + "food": "עסנוואַרג", + "Price": "פּרייַז", + "Currency": "קראַנטקייַט", + "List of domains which can access the shared items catalog": "רשימה פון דאָומיינז וואָס קענען אַקסעס די שערד ייטאַמז קאַטאַלאָג", + "Shares Catalog": "שאַרעס קאַטאַלאָג", + "tool": "געצייַג", + "clothes": "קליידער", + "medical": "מעדיציניש", + "Wanted": "געוואלט", + "Describe something wanted": "באַשרייַבן עפּעס געוואלט", + "Enter the details for your wanted item below.": "אַרייַן די דעטאַילס פֿאַר דיין געוואלט נומער אונטן.", + "Name of the wanted item": "נאָמען פון די געוואלט נומער", + "Description of the item wanted": "באַשרייַבונג פון די געוואלט נומער", + "Type of wanted item. eg. hat": "טיפּ פון געוואלט נומער. למשל. הוט", + "Category of wanted item. eg. clothes": "קאַטעגאָריע פון געוואלט נומער. למשל. קליידער", + "City or location of the wanted item": "שטאָט אָדער אָרט פון די געוואלט נומער", + "Maximum Price": "מאַקסימום פּרייַז", + "Create a new wanted item": "שאַפֿן אַ נייַע געוואלט נומער", + "Wanted Items Search": "געוואלט יטעמס זוכן", + "Website": "וועבזייַטל", + "Low Bandwidth": "נידעריק באַנדווידט", + "accommodation": "אַקאַמאַדיישאַן", + "Forbidden": "פארבאטן", + "You're not allowed": "איר זענט נישט ערלויבט", + "Hours after posting during which replies are allowed": "שעה נאָך פּאָסטינג בעשאַס וואָס ענטפֿערס זענען ערלויבט", + "Twitter": "טוויטער", + "Twitter Replacement Domain": "טוויטטער פאַרבייַט פעלד", + "Buy": "קויפן", + "Request to stay": "בעטן צו בלייבן", + "Profile": "פּראָפיל", + "Introduce yourself and specify the date and time when you wish to stay": "באַקענען זיך און ספּעציפיצירן די דאַטע און צייט ווען איר ווילט צו בלייַבן", + "Members": "מיטגלידער", + "Join": "פאַרבינדן", + "Leave": "לאָזן", + "System Monitor": "סיסטעם מאָניטאָר", + "Add content warnings for the following sites": "לייג אינהאַלט וואָרנינגז פֿאַר די פאלגענדע זייטלעך", + "Known Web Crawlers": "באַוווסט וועב קראַוולערז", + "Add to the calendar": "לייג צו די קאַלענדאַר", + "Content License": "אינהאַלט ליסענסע", + "Reaction by": "ענטפער דורך", + "Notify on emoji reactions": "געבנ צו וויסן וועגן עמאָדזשי ריאַקשאַנז", + "Select reaction": "אָפּרוף", + "Don't show the Reaction button": "צי ניט ווייַזן די אָפּרוף קנעפּל", + "New feed URL": "נייַ קאָרמען URL", + "New link title and URL": "נייַ לינק טיטל און URL", + "Theme Designer": "טעמע דיזיינער", + "Reset": "באַשטעטיק", + "Encryption Keys": "ענקריפּשאַן קיז", + "Filtered words within bio": "געפֿילט ווערטער אין ביאגראפיע", + "Write your news report": "שרייב דיין נייַעס באַריכט", + "Dyslexic font": "דיסלעקסיק שריפֿט", + "Leave a comment": "לאָזן אַ באַמערקונג", + "View comments": "זען באַמערקונגען", + "Multi Status": "מולטי סטאַטוס", + "Lots of things": "פילע זאכן", + "Created": "באַשאַפן", + "It is done": "עס איז געטאן", + "Time Zone": "צייַט זאָנע", + "Show who liked this post": "ווייַזן ווער לייקט דעם פּאָסטן", + "Show who repeated this post": "ווייַזן ווער ריפּיטיד דעם פּאָסטן", + "Repeated by": "ריפּיטיד דורך", + "Register": "רעגיסטרירן", + "Web Bots Allowed": "וועב זוכן באָץ ערלויבט", + "Known Search Bots": "באַוווסט וועב זוך באָץ", + "mitm": "אָנזאָג קען זיין לייענען אָדער מאַדאַפייד דורך אַ דריט פּאַרטיי", + "Bold reading": "דרייסט לייענען", + "SHOW EDITS": "ווייַז רעדאקציע", + "Attach an image, video or audio file": "צוטשעפּען אַ בילד, ווידעא אָדער אַודיאָ טעקע", + "Set a place and time": "שטעלן אַ אָרט און צייט", + "Describe your attachment": "באַשרייַבן דיין אַטאַטשמאַנט", + "Language used": "שפּראַך געוויינט", + "lang_ar": "אַראַביש", + "lang_bn": "בענגאַליש", + "lang_cy": "וועלש", + "lang_en": "ענגליש", + "lang_fr": "פראנצויזיש", + "lang_hi": "הינדיש", + "lang_ja": "יאַפּאַניש", + "lang_ku": "קורדיש", + "lang_pl": "פּויליש", + "lang_ru": "רוסיש", + "lang_uk": "אוקראַיִניש", + "lang_ca": "קאַטאַלאַניש", + "lang_de": "דײַטש", + "lang_es": "שפּאַניש", + "lang_ga": "איריש", + "lang_it": "איטאַליעניש", + "lang_ko": "קאָרעיִש", + "lang_oc": "אָקסיטאַן", + "lang_pt": "פּאָרטוגעזיש", + "lang_sw": "סוואַהילי", + "lang_tr": "טערקיש", + "lang_zh": "כינעזיש", + "lang_nl": "האָלענדיש", + "lang_el": "גריכיש", + "lang_yi": "יידיש", + "Common emoji": "פּראָסט עמאָדזשי", + "Copy and paste into your text": "קאָפּי און פּאַפּ אין דיין טעקסט", + "shrug": "שעפּן זיך", + "DM warning": "דירעקט אַרטיקלען זענען נישט ענקריפּטיד פון סוף צו סוף. דו זאלסט נישט טיילן קיין העכסט שפּירעוודיק אינפֿאָרמאַציע דאָ.", + "Transcript": "טראַנסקריפּט", + "Color contrast is too low": "קאָליר קאַנטראַסט איז אויך נידעריק", + "View Larger Map": "View גרעסערע מאַפּע", + "Start Time": "אָנהייב צייט", + "End Time": "סוף צייט", + "Switch to calendar view": "באַשטימען צו די קאַלענדאַר מיינונג", + "Save": "היט", + "Switch to moderation view": "באַשטימען צו מאַדעריישאַן מיינונג", + "Minimize attached images": "מינאַמייז אַטאַטשט בילדער", + "SHOW MEDIA": "ווייַז מעדיע", + "ActivityPub Specification": "ActivityPub באַשרייַבונג", + "Dogwhistle words": "דאָגווהיסטלע ווערטער", + "Content warnings will be added for the following": "אינהאַלט וואָרנינגז וועט זיין מוסיף פֿאַר די פאלגענדע", + "nowplaying": "איצט פּלייַינג", + "NowPlaying": "איצט פּלייַינג", + "Import and Export": "אַרייַנפיר און עקספּאָרט", + "Import Follows": "אַרייַנפיר גייט", + "Post expiry period in days": "פּאָסטן עקספּיירי צייַט אין טעג", + "Keep DMs during post expiry": "האַלטן דירעקט אַרטיקלען בעשאַס פּאָסטן עקספּיירי", + "Notifications": "נאָוטאַפאַקיישאַנז", + "ntfy URL": "ntfy URL", + "ntfy topic": "ntfy טעמע", + "Last hour": "לעצטע שעה", + "Last 3 hours": "לעצטע 3 שעה", + "Last 6 hours": "לעצטע 6 שעה", + "Last 12 hours": "לעצטע 12 שעה", + "Last day": "לעצטע טאג", + "Last 2 days": "לעצטע 2 טעג", + "Last week": "לעצטע וואָך", + "Last 2 weeks": "לעצטע 2 וואָכן", + "Last month": "לעצטע מאנאט", + "Last 6 months": "לעצטע 6 חדשים", + "Last year": "לעצטע יאר", + "Unauthorized": "אַנאָטערייזד", + "No login credentials were posted": "קיין לאָגין קראַדענטשאַלז זענען אַרייַנגעשיקט", + "Credentials are too long": "קראַדענטשאַלז זענען צו לאַנג", + "Site DevOps": "וועבזייטל DevOps", + "A list of devops nicknames. One per line.": "א רשימה פון דיוואָפּס ניקניימז. איינער פּער שורה.", + "devops": "devops", + "Reject spam accounts": "אָפּוואַרפן ספּאַם אַקאַונץ" +} diff --git a/translations/zh.json b/translations/zh.json index 4a08be59f..1d435f276 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -412,6 +412,7 @@ "menuInbox": "收件箱", "menuSearch": "搜索/关注", "menuNewPost": "最新帖子", + "menuNewBlog": "新博文", "menuCalendar": "日历", "menuDM": "直接留言", "menuReplies": "答案", @@ -487,5 +488,111 @@ "Introduce yourself and specify the date and time when you wish to stay": "自我介绍并指定您希望入住的日期和时间", "Members": "会员", "Join": "加入", - "Leave": "离开" + "Leave": "离开", + "System Monitor": "系统监视器", + "Add content warnings for the following sites": "为以下网站添加内容警告", + "Known Web Crawlers": "已知的网络爬虫", + "Add to the calendar": "添加到日历", + "Content License": "内容许可", + "Reaction by": "反应由", + "Notify on emoji reactions": "通知表情符号反应", + "Select reaction": "选择反应", + "Don't show the Reaction button": "不显示“反应”按钮", + "New feed URL": "新供稿网址", + "New link title and URL": "新链接标题和 URL", + "Theme Designer": "主题设计师", + "Reset": "重启", + "Encryption Keys": "加密密钥", + "Filtered words within bio": "传记中的过滤词", + "Write your news report": "写你的新闻报道", + "Dyslexic font": "阅读障碍字体", + "Leave a comment": "发表评论", + "View comments": "查看评论", + "Multi Status": "多状态", + "Lots of things": "很多事情", + "Created": "已创建", + "It is done": "完成了", + "Time Zone": "时区", + "Show who liked this post": "显示谁喜欢这篇文章", + "Show who repeated this post": "显示谁重复了这篇文章", + "Repeated by": "重复", + "Register": "登记", + "Web Bots Allowed": "允许网络机器人", + "Known Search Bots": "已知的网络搜索机器人", + "mitm": "消息可能已被第三方阅读或修改", + "Bold reading": "大胆阅读", + "SHOW EDITS": "显示编辑", + "Attach an image, video or audio file": "附加图像、视频或音频文件", + "Set a place and time": "设置地点和时间", + "Describe your attachment": "描述你的附件", + "Language used": "使用的语言", + "lang_ar": "阿拉伯", + "lang_bn": "孟加拉", + "lang_cy": "威尔士语", + "lang_en": "英语", + "lang_fr": "法语", + "lang_hi": "印地语", + "lang_ja": "日本人", + "lang_ku": "库尔德", + "lang_pl": "抛光", + "lang_ru": "俄语", + "lang_uk": "乌克兰", + "lang_ca": "加泰罗尼亚语", + "lang_de": "德语", + "lang_es": "西班牙语", + "lang_ga": "爱尔兰语", + "lang_it": "意大利语", + "lang_ko": "韩国人", + "lang_oc": "奥克西坦", + "lang_pt": "葡萄牙语", + "lang_sw": "斯瓦希里语", + "lang_tr": "土耳其", + "lang_zh": "中国人", + "lang_nl": "荷兰语", + "lang_el": "希腊语", + "lang_yi": "意第绪语", + "Common emoji": "常见表情符号", + "Copy and paste into your text": "复制并粘贴到您的文本中", + "shrug": "耸耸肩", + "DM warning": "直接消息不是端到端加密的。 不要在这里分享任何高度敏感的信息。", + "Transcript": "成绩单", + "Color contrast is too low": "颜色对比度太低", + "View Larger Map": "查看更大的地图", + "Start Time": "开始时间", + "End Time": "时间结束", + "Switch to calendar view": "切换到日历视图", + "Save": "节省", + "Switch to moderation view": "切换到审核视图", + "Minimize attached images": "最小化附加图像", + "SHOW MEDIA": "展示媒体", + "ActivityPub Specification": "ActivityPub 规范", + "Dogwhistle words": "狗哨的话", + "Content warnings will be added for the following": "将为以下内容添加内容警告", + "nowplaying": "现在玩", + "NowPlaying": "现在玩", + "Import and Export": "进出口", + "Import Follows": "导入关注", + "Post expiry period in days": "到期后天数", + "Keep DMs during post expiry": "在帖子到期期间保留直接消息", + "Notifications": "通知", + "ntfy URL": "ntfy 网址", + "ntfy topic": "ntfy 主题", + "Last hour": "上一个小时", + "Last 3 hours": "过去 3 小时", + "Last 6 hours": "过去 6 小时", + "Last 12 hours": "过去 12 小时", + "Last day": "最后一天", + "Last 2 days": "过去 2 天", + "Last week": "上周", + "Last 2 weeks": "过去 2 周", + "Last month": "上个月", + "Last 6 months": "过去 6 个月", + "Last year": "去年", + "Unauthorized": "未经授权", + "No login credentials were posted": "未发布登录凭据", + "Credentials are too long": "凭据太长", + "Site DevOps": "站点 DevOps", + "A list of devops nicknames. One per line.": "devops 昵称列表。 每行一个。", + "devops": "devops", + "Reject spam accounts": "拒绝垃圾邮件帐户" } diff --git a/utils.py b/utils.py index 6411f78e8..fce00339e 100644 --- a/utils.py +++ b/utils.py @@ -1,7 +1,7 @@ __filename__ = "utils.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -15,131 +15,275 @@ import datetime import json import idna import locale +from dateutil.tz import tz from pprint import pprint -from followingCalendar import addPersonToCalendar from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes +from followingCalendar import add_person_to_calendar + +VALID_HASHTAG_CHARS = \ + set('_0123456789' + + 'abcdefghijklmnopqrstuvwxyz' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + '¡¿ÄäÀàÁáÂâÃãÅåǍǎĄąĂăÆæĀā' + + 'ÇçĆćĈĉČčĎđĐďðÈèÉéÊêËëĚěĘęĖėĒē' + + 'ĜĝĢģĞğĤĥÌìÍíÎîÏïıĪīĮįĴĵĶķ' + + 'ĹĺĻļŁłĽľĿŀÑñŃńŇňŅņÖöÒòÓóÔôÕõŐőØøŒœ' + + 'ŔŕŘřẞߌśŜŝŞşŠšȘșŤťŢţÞþȚțÜüÙùÚúÛûŰűŨũŲųŮůŪū' + + 'ŴŵÝýŸÿŶŷŹźŽžŻż') # posts containing these strings will always get screened out, # both incoming and outgoing. # Could include dubious clacks or admin dogwhistles -invalidCharacters = ( +INVALID_CHARACTERS = ( '卐', '卍', '࿕', '࿖', '࿗', '࿘', 'ϟϟ', '🏳️‍🌈🚫', '⚡⚡' ) -def localActorUrl(httpPrefix: str, nickname: str, domainFull: str) -> str: +def _standardize_text_range(text: str, + range_start: int, range_end: int, + offset: str) -> str: + """Convert any fancy characters within the given range into ordinary ones + """ + offset = ord(offset) + ctr = 0 + text = list(text) + while ctr < len(text): + val = ord(text[ctr]) + if val in range(range_start, range_end): + text[ctr] = chr(val - range_start + offset) + ctr += 1 + return "".join(text) + + +def standardize_text(text: str) -> str: + """Converts fancy unicode text to ordinary letters + """ + if not text: + return text + + char_ranges = ( + [65345, 'a'], + [119886, 'a'], + [119990, 'a'], + [120042, 'a'], + [120094, 'a'], + [120146, 'a'], + [120198, 'a'], + [120302, 'a'], + [120354, 'a'], + [120406, 'a'], + [65313, 'A'], + [119912, 'A'], + [119964, 'A'], + [120016, 'A'], + [120068, 'A'], + [120120, 'A'], + [120172, 'A'], + [120224, 'A'], + [120328, 'A'], + [120380, 'A'], + [120432, 'A'] + ) + for char_range in char_ranges: + range_start = char_range[0] + range_end = range_start + 26 + offset = char_range[1] + text = _standardize_text_range(text, range_start, range_end, offset) + + return text + + +def remove_eol(line: str): + """Removes line ending characters + """ + return line.replace('\n', '').replace('\r', '') + + +def text_in_file(text: str, filename: str, + case_sensitive: bool = True) -> bool: + """is the given text in the given file? + """ + if not case_sensitive: + text = text.lower() + try: + with open(filename, 'r', encoding='utf-8') as file: + content = file.read() + if content: + if not case_sensitive: + content = content.lower() + if text in content: + return True + except OSError: + print('EX: unable to find text in missing file ' + filename) + return False + + +def local_actor_url(http_prefix: str, nickname: str, domain_full: str) -> str: """Returns the url for an actor on this instance """ - return httpPrefix + '://' + domainFull + '/users/' + nickname + return http_prefix + '://' + domain_full + '/users/' + nickname -def getActorLanguagesList(actorJson: {}) -> []: +def get_actor_languages_list(actor_json: {}) -> []: """Returns a list containing languages used by the given actor """ - if not actorJson.get('attachment'): + if not actor_json.get('attachment'): return [] - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue['name'].lower().startswith('languages'): + if not name_value.lower().startswith('languages'): continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue.get('value'): + prop_value_name, _ = \ + get_attachment_property_value(property_value) + if not prop_value_name: continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - if isinstance(propertyValue['value'], list): - langList = propertyValue['value'] - langList.sort() - return langList - elif isinstance(propertyValue['value'], str): - langStr = propertyValue['value'] - langListTemp = [] - if ',' in langStr: - langListTemp = langStr.split(',') - elif ';' in langStr: - langListTemp = langStr.split(';') - elif '/' in langStr: - langListTemp = langStr.split('/') - elif '+' in langStr: - langListTemp = langStr.split('+') - elif ' ' in langStr: - langListTemp = langStr.split(' ') - langList = [] - for lang in langListTemp: + if isinstance(property_value[prop_value_name], list): + lang_list = property_value[prop_value_name] + lang_list.sort() + return lang_list + if isinstance(property_value[prop_value_name], str): + lang_str = property_value[prop_value_name] + lang_list_temp = [] + if ',' in lang_str: + lang_list_temp = lang_str.split(',') + elif ';' in lang_str: + lang_list_temp = lang_str.split(';') + elif '/' in lang_str: + lang_list_temp = lang_str.split('/') + elif '+' in lang_str: + lang_list_temp = lang_str.split('+') + elif ' ' in lang_str: + lang_list_temp = lang_str.split(' ') + else: + return [lang_str] + lang_list = [] + for lang in lang_list_temp: lang = lang.strip() - if lang not in langList: - langList.append(lang) - langList.sort() - return langList + if lang not in lang_list: + lang_list.append(lang) + lang_list.sort() + return lang_list return [] -def getContentFromPost(postJsonObject: {}, systemLanguage: str, - languagesUnderstood: []) -> str: +def has_object_dict(post_json_object: {}) -> bool: + """Returns true if the given post has an object dict + """ + if post_json_object.get('object'): + if isinstance(post_json_object['object'], dict): + return True + return False + + +def get_content_from_post(post_json_object: {}, system_language: str, + languages_understood: [], + content_type: str = "content") -> str: """Returns the content from the post in the given language including searching for a matching entry within contentMap """ - thisPostJson = postJsonObject - if hasObjectDict(postJsonObject): - thisPostJson = postJsonObject['object'] - if not thisPostJson.get('content'): + this_post_json = post_json_object + if has_object_dict(post_json_object): + this_post_json = post_json_object['object'] + if not this_post_json.get(content_type): return '' content = '' - if thisPostJson.get('contentMap'): - if isinstance(thisPostJson['contentMap'], dict): - if thisPostJson['contentMap'].get(systemLanguage): - if isinstance(thisPostJson['contentMap'][systemLanguage], str): - return thisPostJson['contentMap'][systemLanguage] + map_dict = content_type + 'Map' + if this_post_json.get(map_dict): + if isinstance(this_post_json[map_dict], dict): + if this_post_json[map_dict].get(system_language): + sys_lang = this_post_json[map_dict][system_language] + if isinstance(sys_lang, str): + content = this_post_json[map_dict][system_language] + return standardize_text(content) else: - # is there a contentMap entry for one of + # is there a contentMap/summaryMap entry for one of # the understood languages? - for lang in languagesUnderstood: - if thisPostJson['contentMap'].get(lang): - return thisPostJson['contentMap'][lang] + for lang in languages_understood: + if this_post_json[map_dict].get(lang): + content = this_post_json[map_dict][lang] + return standardize_text(content) else: - if isinstance(thisPostJson['content'], str): - content = thisPostJson['content'] - return content + if isinstance(this_post_json[content_type], str): + content = this_post_json[content_type] + return standardize_text(content) -def getBaseContentFromPost(postJsonObject: {}, systemLanguage: str) -> str: +def get_media_descriptions_from_post(post_json_object: {}) -> str: + """Returns all attached media descriptions as a single text. + This is used for filtering + """ + this_post_json = post_json_object + if has_object_dict(post_json_object): + this_post_json = post_json_object['object'] + if not this_post_json.get('attachment'): + return '' + descriptions = '' + for attach in this_post_json['attachment']: + if not attach.get('name'): + continue + descriptions += attach['name'] + ' ' + if attach.get('url'): + descriptions += attach['url'] + ' ' + return descriptions.strip() + + +def get_summary_from_post(post_json_object: {}, system_language: str, + languages_understood: []) -> str: + """Returns the summary from the post in the given language + including searching for a matching entry within summaryMap + """ + return get_content_from_post(post_json_object, system_language, + languages_understood, "summary") + + +def get_base_content_from_post(post_json_object: {}, + system_language: str) -> str: """Returns the content from the post in the given language """ - thisPostJson = postJsonObject - if hasObjectDict(postJsonObject): - thisPostJson = postJsonObject['object'] - if not thisPostJson.get('content'): + this_post_json = post_json_object + if has_object_dict(post_json_object): + this_post_json = post_json_object['object'] + if not this_post_json.get('content'): return '' - return thisPostJson['content'] + return this_post_json['content'] -def acctDir(baseDir: str, nickname: str, domain: str) -> str: - return baseDir + '/accounts/' + nickname + '@' + domain +def acct_dir(base_dir: str, nickname: str, domain: str) -> str: + return base_dir + '/accounts/' + nickname + '@' + domain -def isFeaturedWriter(baseDir: str, nickname: str, domain: str) -> bool: +def is_featured_writer(base_dir: str, nickname: str, domain: str) -> bool: """Is the given account a featured writer, appearing in the features timeline on news instances? """ - featuresBlockedFilename = \ - acctDir(baseDir, nickname, domain) + '/.nofeatures' - return not os.path.isfile(featuresBlockedFilename) + features_blocked_filename = \ + acct_dir(base_dir, nickname, domain) + '/.nofeatures' + return not os.path.isfile(features_blocked_filename) -def refreshNewswire(baseDir: str): +def refresh_newswire(base_dir: str): """Causes the newswire to be updates after a change to user accounts """ - refreshNewswireFilename = baseDir + '/accounts/.refresh_newswire' - if os.path.isfile(refreshNewswireFilename): + refresh_newswire_filename = base_dir + '/accounts/.refresh_newswire' + if os.path.isfile(refresh_newswire_filename): return - with open(refreshNewswireFilename, 'w+') as refreshFile: - refreshFile.write('\n') + with open(refresh_newswire_filename, 'w+', + encoding='utf-8') as refresh_file: + refresh_file.write('\n') -def getSHA256(msg: str): +def get_sha_256(msg: str): """Returns a SHA256 hash of the given string """ digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) @@ -147,7 +291,7 @@ def getSHA256(msg: str): return digest.finalize() -def getSHA512(msg: str): +def get_sha_512(msg: str): """Returns a SHA512 hash of the given string """ digest = hashes.Hash(hashes.SHA512(), backend=default_backend()) @@ -155,7 +299,7 @@ def getSHA512(msg: str): return digest.finalize() -def _localNetworkHost(host: str) -> bool: +def local_network_host(host: str) -> bool: """Returns true if the given host is on the local network """ if host.startswith('localhost') or \ @@ -166,135 +310,144 @@ def _localNetworkHost(host: str) -> bool: return False -def decodedHost(host: str) -> str: +def decoded_host(host: str) -> str: """Convert hostname to internationalized domain https://en.wikipedia.org/wiki/Internationalized_domain_name """ if ':' not in host: # eg. mydomain:8000 - if not _localNetworkHost(host): + if not local_network_host(host): if not host.endswith('.onion'): if not host.endswith('.i2p'): return idna.decode(host) return host -def getLockedAccount(actorJson: {}) -> bool: +def get_locked_account(actor_json: {}) -> bool: """Returns whether the given account requires follower approval """ - if not actorJson.get('manuallyApprovesFollowers'): + if not actor_json.get('manuallyApprovesFollowers'): return False - if actorJson['manuallyApprovesFollowers'] is True: + if actor_json['manuallyApprovesFollowers'] is True: return True return False -def hasUsersPath(pathStr: str) -> bool: +def has_users_path(path_str: str) -> bool: """Whether there is a /users/ path (or equivalent) in the given string """ - usersList = getUserPaths() - for usersStr in usersList: - if usersStr in pathStr: + users_list = get_user_paths() + for users_str in users_list: + if users_str in path_str: return True - if '://' in pathStr: - domain = pathStr.split('://')[1] + if '://' in path_str: + domain = path_str.split('://')[1] if '/' in domain: domain = domain.split('/')[0] - if '://' + domain + '/' not in pathStr: + if '://' + domain + '/' not in path_str: return False - nickname = pathStr.split('://' + domain + '/')[1] + nickname = path_str.split('://' + domain + '/')[1] if '/' in nickname or '.' in nickname: return False return True return False -def validPostDate(published: str, maxAgeDays: int = 90, - debug: bool = False) -> bool: +def valid_post_date(published: str, max_age_days: int, debug: bool) -> bool: """Returns true if the published date is recent and is not in the future """ - baselineTime = datetime.datetime(1970, 1, 1) + baseline_time = datetime.datetime(1970, 1, 1) - daysDiff = datetime.datetime.utcnow() - baselineTime - nowDaysSinceEpoch = daysDiff.days + days_diff = datetime.datetime.utcnow() - baseline_time + now_days_since_epoch = days_diff.days try: - postTimeObject = \ + post_time_object = \ datetime.datetime.strptime(published, "%Y-%m-%dT%H:%M:%SZ") except BaseException: + if debug: + print('EX: valid_post_date invalid published date ' + + str(published)) return False - daysDiff = postTimeObject - baselineTime - postDaysSinceEpoch = daysDiff.days + days_diff = post_time_object - baseline_time + post_days_since_epoch = days_diff.days - if postDaysSinceEpoch > nowDaysSinceEpoch: + if post_days_since_epoch > now_days_since_epoch: if debug: print("Inbox post has a published date in the future!") return False - if nowDaysSinceEpoch - postDaysSinceEpoch >= maxAgeDays: + if now_days_since_epoch - post_days_since_epoch >= max_age_days: if debug: print("Inbox post is not recent enough") return False return True -def getFullDomain(domain: str, port: int) -> str: +def get_full_domain(domain: str, port: int) -> str: """Returns the full domain name, including port number """ if not port: return domain if ':' in domain: return domain - if port == 80 or port == 443: + if port in (80, 443): return domain return domain + ':' + str(port) -def isDormant(baseDir: str, nickname: str, domain: str, actor: str, - dormantMonths: int = 3) -> bool: +def is_dormant(base_dir: str, nickname: str, domain: str, actor: str, + dormant_months: int) -> bool: """Is the given followed actor dormant, from the standpoint of the given account """ - lastSeenFilename = acctDir(baseDir, nickname, domain) + \ + last_seen_filename = acct_dir(base_dir, nickname, domain) + \ '/lastseen/' + actor.replace('/', '#') + '.txt' - if not os.path.isfile(lastSeenFilename): + if not os.path.isfile(last_seen_filename): return False - with open(lastSeenFilename, 'r') as lastSeenFile: - daysSinceEpochStr = lastSeenFile.read() - daysSinceEpoch = int(daysSinceEpochStr) - currTime = datetime.datetime.utcnow() - currDaysSinceEpoch = (currTime - datetime.datetime(1970, 1, 1)).days - timeDiffMonths = \ - int((currDaysSinceEpoch - daysSinceEpoch) / 30) - if timeDiffMonths >= dormantMonths: + days_since_epoch_str = None + try: + with open(last_seen_filename, 'r', + encoding='utf-8') as last_seen_file: + days_since_epoch_str = last_seen_file.read() + except OSError: + print('EX: failed to read last seen ' + last_seen_filename) + return False + + if days_since_epoch_str: + days_since_epoch = int(days_since_epoch_str) + curr_time = datetime.datetime.utcnow() + curr_days_since_epoch = \ + (curr_time - datetime.datetime(1970, 1, 1)).days + time_diff_months = \ + int((curr_days_since_epoch - days_since_epoch) / 30) + if time_diff_months >= dormant_months: return True return False -def isEditor(baseDir: str, nickname: str) -> bool: +def is_editor(base_dir: str, nickname: str) -> bool: """Returns true if the given nickname is an editor """ - editorsFile = baseDir + '/accounts/editors.txt' + editors_file = base_dir + '/accounts/editors.txt' - if not os.path.isfile(editorsFile): - adminName = getConfigParam(baseDir, 'admin') - if not adminName: - return False - if adminName == nickname: - return True + if not os.path.isfile(editors_file): + admin_name = get_config_param(base_dir, 'admin') + if admin_name: + if admin_name == nickname: + return True return False - with open(editorsFile, 'r') as f: - lines = f.readlines() + with open(editors_file, 'r', encoding='utf-8') as editors: + lines = editors.readlines() if len(lines) == 0: - adminName = getConfigParam(baseDir, 'admin') - if not adminName: - return False - if adminName == nickname: - return True + admin_name = get_config_param(base_dir, 'admin') + if admin_name: + if admin_name == nickname: + return True for editor in lines: editor = editor.strip('\n').strip('\r') if editor == nickname: @@ -302,27 +455,25 @@ def isEditor(baseDir: str, nickname: str) -> bool: return False -def isArtist(baseDir: str, nickname: str) -> bool: +def is_artist(base_dir: str, nickname: str) -> bool: """Returns true if the given nickname is an artist """ - artistsFile = baseDir + '/accounts/artists.txt' + artists_file = base_dir + '/accounts/artists.txt' - if not os.path.isfile(artistsFile): - adminName = getConfigParam(baseDir, 'admin') - if not adminName: - return False - if adminName == nickname: - return True + if not os.path.isfile(artists_file): + admin_name = get_config_param(base_dir, 'admin') + if admin_name: + if admin_name == nickname: + return True return False - with open(artistsFile, 'r') as f: - lines = f.readlines() + with open(artists_file, 'r', encoding='utf-8') as artists: + lines = artists.readlines() if len(lines) == 0: - adminName = getConfigParam(baseDir, 'admin') - if not adminName: - return False - if adminName == nickname: - return True + admin_name = get_config_param(base_dir, 'admin') + if admin_name: + if admin_name == nickname: + return True for artist in lines: artist = artist.strip('\n').strip('\r') if artist == nickname: @@ -330,102 +481,107 @@ def isArtist(baseDir: str, nickname: str) -> bool: return False -def getVideoExtensions() -> []: +def get_video_extensions() -> []: """Returns a list of the possible video file extensions """ return ('mp4', 'webm', 'ogv') -def getAudioExtensions() -> []: +def get_audio_extensions() -> []: """Returns a list of the possible audio file extensions """ - return ('mp3', 'ogg', 'flac') + return ('mp3', 'ogg', 'flac', 'opus') -def getImageExtensions() -> []: +def get_image_extensions() -> []: """Returns a list of the possible image file extensions """ - return ('png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg') + return ('jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'jxl', 'png') -def getImageMimeType(imageFilename: str) -> str: +def get_image_mime_type(image_filename: str) -> str: """Returns the mime type for the given image """ - extensionsToMime = { + extensions_to_mime = { 'png': 'png', 'jpg': 'jpeg', + 'jxl': 'jxl', 'gif': 'gif', 'avif': 'avif', 'svg': 'svg+xml', - 'webp': 'webp' + 'webp': 'webp', + 'ico': 'x-icon' } - for ext, mimeExt in extensionsToMime.items(): - if imageFilename.endswith('.' + ext): - return 'image/' + mimeExt + for ext, mime_ext in extensions_to_mime.items(): + if image_filename.endswith('.' + ext): + return 'image/' + mime_ext return 'image/png' -def getImageExtensionFromMimeType(contentType: str) -> str: +def get_image_extension_from_mime_type(content_type: str) -> str: """Returns the image extension from a mime type, such as image/jpeg """ - imageMedia = { + image_media = { 'png': 'png', 'jpeg': 'jpg', + 'jxl': 'jxl', 'gif': 'gif', 'svg+xml': 'svg', 'webp': 'webp', - 'avif': 'avif' + 'avif': 'avif', + 'x-icon': 'ico' } - for mimeExt, ext in imageMedia.items(): - if contentType.endswith(mimeExt): + for mime_ext, ext in image_media.items(): + if content_type.endswith(mime_ext): return ext return 'png' -def getMediaExtensions() -> []: +def get_media_extensions() -> []: """Returns a list of the possible media file extensions """ - return getImageExtensions() + getVideoExtensions() + getAudioExtensions() + return get_image_extensions() + \ + get_video_extensions() + get_audio_extensions() -def getImageFormats() -> str: +def get_image_formats() -> str: """Returns a string of permissable image formats used when selecting an image for a new post """ - imageExt = getImageExtensions() + image_ext = get_image_extensions() - imageFormats = '' - for ext in imageExt: - if imageFormats: - imageFormats += ', ' - imageFormats += '.' + ext - return imageFormats + image_formats = '' + for ext in image_ext: + if image_formats: + image_formats += ', ' + image_formats += '.' + ext + return image_formats -def isImageFile(filename: str) -> bool: +def is_image_file(filename: str) -> bool: """Is the given filename an image? """ - for ext in getImageExtensions(): + for ext in get_image_extensions(): if filename.endswith('.' + ext): return True return False -def getMediaFormats() -> str: +def get_media_formats() -> str: """Returns a string of permissable media formats used when selecting an attachment for a new post """ - mediaExt = getMediaExtensions() + media_ext = get_media_extensions() - mediaFormats = '' - for ext in mediaExt: - if mediaFormats: - mediaFormats += ', ' - mediaFormats += '.' + ext - return mediaFormats + media_formats = '' + for ext in media_ext: + if media_formats: + media_formats += ', ' + media_formats += '.' + ext + return media_formats -def removeHtml(content: str) -> str: +def remove_html(content: str) -> str: """Removes html links from the given content. Used to ensure that profile descriptions don't contain dubious content """ @@ -436,146 +592,149 @@ def removeHtml(content: str) -> str: content = content.replace('', '"').replace('', '"') content = content.replace('

    ', '\n\n').replace('
    ', '\n') result = '' - for ch in content: - if ch == '<': + for char in content: + if char == '<': removing = True - elif ch == '>': + elif char == '>': removing = False elif not removing: - result += ch + result += char - plainText = result.replace(' ', ' ') + plain_text = result.replace(' ', ' ') # insert spaces after full stops - strLen = len(plainText) + str_len = len(plain_text) result = '' - for i in range(strLen): - result += plainText[i] - if plainText[i] == '.' and i < strLen - 1: - if plainText[i + 1] >= 'A' and plainText[i + 1] <= 'Z': + for i in range(str_len): + result += plain_text[i] + if plain_text[i] == '.' and i < str_len - 1: + if plain_text[i + 1] >= 'A' and plain_text[i + 1] <= 'Z': result += ' ' result = result.replace(' ', ' ').strip() return result -def firstParagraphFromString(content: str) -> str: +def first_paragraph_from_string(content: str) -> str: """Get the first paragraph from a blog post to be used as a summary in the newswire feed """ if '

    ' not in content or '

    ' not in content: - return removeHtml(content) + return remove_html(content) paragraph = content.split('

    ')[1] if '

    ' in paragraph: paragraph = paragraph.split('

    ')[0] - return removeHtml(paragraph) + return remove_html(paragraph) -def isSystemAccount(nickname: str) -> bool: +def is_system_account(nickname: str) -> bool: """Returns true if the given nickname is a system account """ - if nickname == 'news' or nickname == 'inbox': + if nickname in ('news', 'inbox'): return True return False -def _createConfig(baseDir: str) -> None: +def _create_config(base_dir: str) -> None: """Creates a configuration file """ - configFilename = baseDir + '/config.json' - if os.path.isfile(configFilename): + config_filename = base_dir + '/config.json' + if os.path.isfile(config_filename): return - configJson = { + config_json = { } - saveJson(configJson, configFilename) + save_json(config_json, config_filename) -def setConfigParam(baseDir: str, variableName: str, variableValue) -> None: +def set_config_param(base_dir: str, variable_name: str, + variable_value) -> None: """Sets a configuration value """ - _createConfig(baseDir) - configFilename = baseDir + '/config.json' - configJson = {} - if os.path.isfile(configFilename): - configJson = loadJson(configFilename) - configJson[variableName] = variableValue - saveJson(configJson, configFilename) + _create_config(base_dir) + config_filename = base_dir + '/config.json' + config_json = {} + if os.path.isfile(config_filename): + config_json = load_json(config_filename) + variable_name = _convert_to_camel_case(variable_name) + config_json[variable_name] = variable_value + save_json(config_json, config_filename) -def getConfigParam(baseDir: str, variableName: str): +def get_config_param(base_dir: str, variable_name: str): """Gets a configuration value """ - _createConfig(baseDir) - configFilename = baseDir + '/config.json' - configJson = loadJson(configFilename) - if configJson: - if variableName in configJson: - return configJson[variableName] + _create_config(base_dir) + config_filename = base_dir + '/config.json' + config_json = load_json(config_filename) + if config_json: + variable_name = _convert_to_camel_case(variable_name) + if variable_name in config_json: + return config_json[variable_name] return None -def isSuspended(baseDir: str, nickname: str) -> bool: +def is_suspended(base_dir: str, nickname: str) -> bool: """Returns true if the given nickname is suspended """ - adminNickname = getConfigParam(baseDir, 'admin') - if not adminNickname: + admin_nickname = get_config_param(base_dir, 'admin') + if not admin_nickname: return False - if nickname == adminNickname: + if nickname == admin_nickname: return False - suspendedFilename = baseDir + '/accounts/suspended.txt' - if os.path.isfile(suspendedFilename): - with open(suspendedFilename, 'r') as f: - lines = f.readlines() + suspended_filename = base_dir + '/accounts/suspended.txt' + if os.path.isfile(suspended_filename): + with open(suspended_filename, 'r', encoding='utf-8') as susp_file: + lines = susp_file.readlines() for suspended in lines: if suspended.strip('\n').strip('\r') == nickname: return True return False -def getFollowersList(baseDir: str, - nickname: str, domain: str, - followFile='following.txt') -> []: +def get_followers_list(base_dir: str, + nickname: str, domain: str, + follow_file='following.txt') -> []: """Returns a list of followers for the given account """ - filename = acctDir(baseDir, nickname, domain) + '/' + followFile + filename = acct_dir(base_dir, nickname, domain) + '/' + follow_file if not os.path.isfile(filename): return [] - with open(filename, 'r') as f: - lines = f.readlines() - for i in range(len(lines)): + with open(filename, 'r', encoding='utf-8') as foll_file: + lines = foll_file.readlines() + for i, _ in enumerate(lines): lines[i] = lines[i].strip() return lines return [] -def getFollowersOfPerson(baseDir: str, - nickname: str, domain: str, - followFile='following.txt') -> []: +def get_followers_of_person(base_dir: str, + nickname: str, domain: str, + follow_file='following.txt') -> []: """Returns a list containing the followers of the given person Used by the shared inbox to know who to send incoming mail to """ followers = [] - domain = removeDomainPort(domain) + domain = remove_domain_port(domain) handle = nickname + '@' + domain - if not os.path.isdir(baseDir + '/accounts/' + handle): + if not os.path.isdir(base_dir + '/accounts/' + handle): return followers - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for subdir, dirs, _ in os.walk(base_dir + '/accounts'): for account in dirs: - filename = os.path.join(subdir, account) + '/' + followFile + filename = os.path.join(subdir, account) + '/' + follow_file if account == handle or \ account.startswith('inbox@') or \ + account.startswith('Actor@') or \ account.startswith('news@'): continue if not os.path.isfile(filename): continue - with open(filename, 'r') as followingfile: - for followingHandle in followingfile: - followingHandle2 = followingHandle.replace('\n', '') - followingHandle2 = followingHandle2.replace('\r', '') - if followingHandle2 == handle: + with open(filename, 'r', encoding='utf-8') as followingfile: + for following_handle in followingfile: + following_handle2 = remove_eol(following_handle) + if following_handle2 == handle: if account not in followers: followers.append(account) break @@ -583,29 +742,40 @@ def getFollowersOfPerson(baseDir: str, return followers -def removeIdEnding(idStr: str) -> str: +def remove_id_ending(id_str: str) -> str: """Removes endings such as /activity and /undo """ - if idStr.endswith('/activity'): - idStr = idStr[:-len('/activity')] - elif idStr.endswith('/undo'): - idStr = idStr[:-len('/undo')] - elif idStr.endswith('/event'): - idStr = idStr[:-len('/event')] - elif idStr.endswith('/replies'): - idStr = idStr[:-len('/replies')] - return idStr + if id_str.endswith('/activity'): + id_str = id_str[:-len('/activity')] + elif id_str.endswith('/undo'): + id_str = id_str[:-len('/undo')] + elif id_str.endswith('/event'): + id_str = id_str[:-len('/event')] + elif id_str.endswith('/replies'): + id_str = id_str[:-len('/replies')] + if id_str.endswith('#Create'): + id_str = id_str.split('#Create')[0] + return id_str -def getProtocolPrefixes() -> []: +def remove_hash_from_post_id(post_id: str) -> str: + """Removes any has from a post id + """ + if '#' not in post_id: + return post_id + return post_id.split('#')[0] + + +def get_protocol_prefixes() -> []: """Returns a list of valid prefixes """ return ('https://', 'http://', 'ftp://', 'dat://', 'i2p://', 'gnunet://', + 'ipfs://', 'ipns://', 'hyper://', 'gemini://', 'gopher://') -def getLinkPrefixes() -> []: +def get_link_prefixes() -> []: """Returns a list of valid web link prefixes """ return ('https://', 'http://', 'ftp://', @@ -613,305 +783,406 @@ def getLinkPrefixes() -> []: 'hyper://', 'gemini://', 'gopher://', 'briar:') -def removeAvatarFromCache(baseDir: str, actorStr: str) -> None: +def remove_avatar_from_cache(base_dir: str, actor_str: str) -> None: """Removes any existing avatar entries from the cache This avoids duplicate entries with differing extensions """ - avatarFilenameExtensions = getImageExtensions() - for extension in avatarFilenameExtensions: - avatarFilename = \ - baseDir + '/cache/avatars/' + actorStr + '.' + extension - if os.path.isfile(avatarFilename): + avatar_filename_extensions = get_image_extensions() + for extension in avatar_filename_extensions: + avatar_filename = \ + base_dir + '/cache/avatars/' + actor_str + '.' + extension + if os.path.isfile(avatar_filename): try: - os.remove(avatarFilename) - except BaseException: - pass + os.remove(avatar_filename) + except OSError: + print('EX: remove_avatar_from_cache ' + + 'unable to delete cached avatar ' + + str(avatar_filename)) -def saveJson(jsonObject: {}, filename: str) -> bool: +def save_json(json_object: {}, filename: str) -> bool: """Saves json to a file """ tries = 0 while tries < 5: try: - with open(filename, 'w+') as fp: - fp.write(json.dumps(jsonObject)) + with open(filename, 'w+', encoding='utf-8') as json_file: + json_file.write(json.dumps(json_object)) return True - except BaseException: - print('WARN: saveJson ' + str(tries)) + except OSError: + print('EX: save_json ' + str(tries)) time.sleep(1) tries += 1 return False -def loadJson(filename: str, delaySec: int = 2, maxTries: int = 5) -> {}: +def load_json(filename: str, delay_sec: int = 2, max_tries: int = 5) -> {}: """Makes a few attempts to load a json formatted file """ - jsonObject = None + if '/Actor@' in filename: + filename = filename.replace('/Actor@', '/inbox@') + json_object = None tries = 0 - while tries < maxTries: + while tries < max_tries: try: - with open(filename, 'r') as fp: - data = fp.read() - jsonObject = json.loads(data) + with open(filename, 'r', encoding='utf-8') as json_file: + data = json_file.read() + json_object = json.loads(data) break except BaseException: - print('WARN: loadJson exception') - if delaySec > 0: - time.sleep(delaySec) + print('EX: load_json exception ' + str(filename)) + if delay_sec > 0: + time.sleep(delay_sec) tries += 1 - return jsonObject + return json_object -def loadJsonOnionify(filename: str, domain: str, onionDomain: str, - delaySec: int = 2) -> {}: +def load_json_onionify(filename: str, domain: str, onion_domain: str, + delay_sec: int = 2) -> {}: """Makes a few attempts to load a json formatted file This also converts the domain name to the onion domain """ - jsonObject = None + if '/Actor@' in filename: + filename = filename.replace('/Actor@', '/inbox@') + json_object = None tries = 0 while tries < 5: try: - with open(filename, 'r') as fp: - data = fp.read() + with open(filename, 'r', encoding='utf-8') as json_file: + data = json_file.read() if data: - data = data.replace(domain, onionDomain) + data = data.replace(domain, onion_domain) data = data.replace('https:', 'http:') - print('*****data: ' + data) - jsonObject = json.loads(data) + json_object = json.loads(data) break except BaseException: - print('WARN: loadJson exception') - if delaySec > 0: - time.sleep(delaySec) + print('EX: load_json_onionify exception ' + str(filename)) + if delay_sec > 0: + time.sleep(delay_sec) tries += 1 - return jsonObject + return json_object -def getStatusNumber(publishedStr: str = None) -> (str, str): +def get_status_number(published_str: str = None) -> (str, str): """Returns the status number and published date """ - if not publishedStr: - currTime = datetime.datetime.utcnow() + if not published_str: + curr_time = datetime.datetime.utcnow() else: - currTime = \ - datetime.datetime.strptime(publishedStr, '%Y-%m-%dT%H:%M:%SZ') - daysSinceEpoch = (currTime - datetime.datetime(1970, 1, 1)).days + curr_time = \ + datetime.datetime.strptime(published_str, '%Y-%m-%dT%H:%M:%SZ') + days_since_epoch = (curr_time - datetime.datetime(1970, 1, 1)).days # status is the number of seconds since epoch - statusNumber = \ - str(((daysSinceEpoch * 24 * 60 * 60) + - (currTime.hour * 60 * 60) + - (currTime.minute * 60) + - currTime.second) * 1000 + - int(currTime.microsecond / 1000)) + status_number = \ + str(((days_since_epoch * 24 * 60 * 60) + + (curr_time.hour * 60 * 60) + + (curr_time.minute * 60) + + curr_time.second) * 1000 + + int(curr_time.microsecond / 1000)) # See https://github.com/tootsuite/mastodon/blob/ # 995f8b389a66ab76ec92d9a240de376f1fc13a38/lib/mastodon/snowflake.rb # use the leftover microseconds as the sequence number - sequenceId = currTime.microsecond % 1000 + sequence_id = curr_time.microsecond % 1000 # shift by 16bits "sequence data" - statusNumber = str((int(statusNumber) << 16) + sequenceId) - published = currTime.strftime("%Y-%m-%dT%H:%M:%SZ") - return statusNumber, published + status_number = str((int(status_number) << 16) + sequence_id) + published = curr_time.strftime("%Y-%m-%dT%H:%M:%SZ") + return status_number, published -def evilIncarnate() -> []: - return ('gab.com', 'gabfed.com', 'spinster.xyz', +def evil_incarnate() -> []: + """Hardcoded blocked domains + """ + return ('fedilist.com', 'gab.com', 'gabfed.com', 'spinster.xyz', 'kiwifarms.cc', 'djitter.com') -def isEvil(domain: str) -> bool: - # https://www.youtube.com/watch?v=5qw1hcevmdU +def is_evil(domain: str) -> bool: + """ https://www.youtube.com/watch?v=5qw1hcevmdU + """ if not isinstance(domain, str): print('WARN: Malformed domain ' + str(domain)) return True # if a domain contains any of these strings then it is # declaring itself to be hostile - evilEmporium = ( + evil_emporium = ( 'nazi', 'extremis', 'extreemis', 'gendercritic', 'kiwifarm', 'illegal', 'raplst', 'rapist', - 'antivax', 'plandemic' + 'rapl.st', 'rapi.st', 'antivax', 'plandemic', 'terror' ) - for hostileStr in evilEmporium: - if hostileStr in domain: + for hostile_str in evil_emporium: + if hostile_str in domain: return True - evilDomains = evilIncarnate() - for concentratedEvil in evilDomains: - if domain.endswith(concentratedEvil): + evil_domains = evil_incarnate() + for concentrated_evil in evil_domains: + if domain.endswith(concentrated_evil): return True return False -def containsInvalidChars(jsonStr: str) -> bool: +def contains_invalid_chars(json_str: str) -> bool: """Does the given json string contain invalid characters? """ - for isInvalid in invalidCharacters: - if isInvalid in jsonStr: + for is_invalid in INVALID_CHARACTERS: + if is_invalid in json_str: return True return False -def removeInvalidChars(text: str) -> str: +def remove_invalid_chars(text: str) -> str: """Removes any invalid characters from a string """ - for isInvalid in invalidCharacters: - if isInvalid not in text: + for is_invalid in INVALID_CHARACTERS: + if is_invalid not in text: continue - text = text.replace(isInvalid, '') + text = text.replace(is_invalid, '') return text -def createPersonDir(nickname: str, domain: str, baseDir: str, - dirname: str) -> str: +def create_person_dir(nickname: str, domain: str, base_dir: str, + dir_name: str) -> str: """Create a directory for a person """ handle = nickname + '@' + domain - if not os.path.isdir(baseDir + '/accounts/' + handle): - os.mkdir(baseDir + '/accounts/' + handle) - boxDir = baseDir + '/accounts/' + handle + '/' + dirname - if not os.path.isdir(boxDir): - os.mkdir(boxDir) - return boxDir + if not os.path.isdir(base_dir + '/accounts/' + handle): + os.mkdir(base_dir + '/accounts/' + handle) + box_dir = base_dir + '/accounts/' + handle + '/' + dir_name + if not os.path.isdir(box_dir): + os.mkdir(box_dir) + return box_dir -def createOutboxDir(nickname: str, domain: str, baseDir: str) -> str: +def create_outbox_dir(nickname: str, domain: str, base_dir: str) -> str: """Create an outbox for a person """ - return createPersonDir(nickname, domain, baseDir, 'outbox') + return create_person_dir(nickname, domain, base_dir, 'outbox') -def createInboxQueueDir(nickname: str, domain: str, baseDir: str) -> str: +def create_inbox_queue_dir(nickname: str, domain: str, base_dir: str) -> str: """Create an inbox queue and returns the feed filename and directory """ - return createPersonDir(nickname, domain, baseDir, 'queue') + return create_person_dir(nickname, domain, base_dir, 'queue') -def domainPermitted(domain: str, federationList: []): - if len(federationList) == 0: +def domain_permitted(domain: str, federation_list: []) -> bool: + """Is the given domain permitted according to the federation list? + """ + if len(federation_list) == 0: return True - domain = removeDomainPort(domain) - if domain in federationList: + domain = remove_domain_port(domain) + if domain in federation_list: return True return False -def urlPermitted(url: str, federationList: []): - if isEvil(url): +def url_permitted(url: str, federation_list: []): + if is_evil(url): return False - if not federationList: + if not federation_list: return True - for domain in federationList: + for domain in federation_list: if domain in url: return True return False -def getLocalNetworkAddresses() -> []: +def get_local_network_addresses() -> []: """Returns patterns for local network address detection """ return ('localhost', '127.0.', '192.168', '10.0.') -def isLocalNetworkAddress(ipAddress: str) -> bool: +def is_local_network_address(ip_address: str) -> bool: + """Is the given ip address local? """ - """ - localIPs = getLocalNetworkAddresses() - for ipAddr in localIPs: - if ipAddress.startswith(ipAddr): + local_ips = get_local_network_addresses() + for ip_addr in local_ips: + if ip_address.startswith(ip_addr): return True return False -def _isDangerousString(content: str, allowLocalNetworkAccess: bool, - separators: [], invalidStrings: []) -> bool: +def _is_dangerous_string_tag(content: str, allow_local_network_access: bool, + separators: [], invalid_strings: []) -> bool: """Returns true if the given string is dangerous """ - for separatorStyle in separators: - startChar = separatorStyle[0] - endChar = separatorStyle[1] - if startChar not in content: + for separator_style in separators: + start_char = separator_style[0] + end_char = separator_style[1] + if start_char not in content: continue - if endChar not in content: + if end_char not in content: continue - contentSections = content.split(startChar) - invalidPartials = () - if not allowLocalNetworkAccess: - invalidPartials = getLocalNetworkAddresses() - for markup in contentSections: - if endChar not in markup: + content_sections = content.split(start_char) + invalid_partials = () + if not allow_local_network_access: + invalid_partials = get_local_network_addresses() + for markup in content_sections: + if end_char not in markup: continue - markup = markup.split(endChar)[0].strip() - for partialMatch in invalidPartials: - if partialMatch in markup: + markup = markup.split(end_char)[0].strip() + for partial_match in invalid_partials: + if partial_match in markup: return True if ' ' not in markup: - for badStr in invalidStrings: - if badStr in markup: - return True + for bad_str in invalid_strings: + if not bad_str.endswith('-'): + if bad_str in markup: + return True + else: + if markup.startswith(bad_str): + return True else: - for badStr in invalidStrings: - if badStr + ' ' in markup: - return True + for bad_str in invalid_strings: + if not bad_str.endswith('-'): + if bad_str + ' ' in markup: + return True + else: + if markup.startswith(bad_str): + return True return False -def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool: +def _is_dangerous_string_simple(content: str, allow_local_network_access: bool, + separators: [], invalid_strings: []) -> bool: + """Returns true if the given string is dangerous + """ + for separator_style in separators: + start_char = separator_style[0] + end_char = separator_style[1] + if start_char not in content: + continue + if end_char not in content: + continue + content_sections = content.split(start_char) + invalid_partials = () + if not allow_local_network_access: + invalid_partials = get_local_network_addresses() + for markup in content_sections: + if end_char not in markup: + continue + markup = markup.split(end_char)[0].strip() + for partial_match in invalid_partials: + if partial_match in markup: + return True + for bad_str in invalid_strings: + if bad_str in markup: + return True + return False + + +def _html_tag_has_closing(tag_name: str, content: str) -> bool: + """Does the given tag have opening and closing labels? + """ + content_lower = content.lower() + if '<' + tag_name not in content_lower: + return True + sections = content_lower.split('<' + tag_name) + ctr = 0 + end_tag = '' + for section in sections: + if ctr == 0: + ctr += 1 + continue + # check that an ending tag exists + if end_tag not in section: + return False + if tag_name == 'code': + # check that lines are not too long + code_lines = section.split('\n') + for line in code_lines: + if len(line) >= 60: + return False + ctr += 1 + return True + + +def dangerous_markup(content: str, allow_local_network_access: bool) -> bool: """Returns true if the given content contains dangerous html markup """ separators = [['<', '>'], ['<', '>']] - invalidStrings = [ - 'script', 'noscript', 'code', 'pre', - 'canvas', 'style', 'abbr', - 'frame', 'iframe', 'html', 'body', - 'hr', 'allow-popups', 'allow-scripts' + invalid_strings = [ + 'analytics', 'ampproject', 'googleapis' ] - return _isDangerousString(content, allowLocalNetworkAccess, - separators, invalidStrings) + if _is_dangerous_string_simple(content, allow_local_network_access, + separators, invalid_strings): + return True + if not _html_tag_has_closing('code', content): + return True + invalid_strings = [ + 'script', 'noscript', 'pre', + 'canvas', 'style', 'abbr', 'input', + 'frame', 'iframe', 'html', 'body', + 'hr', 'allow-popups', 'allow-scripts', + 'amp-' + ] + return _is_dangerous_string_tag(content, allow_local_network_access, + separators, invalid_strings) -def dangerousSVG(content: str, allowLocalNetworkAccess: bool) -> bool: +def dangerous_svg(content: str, allow_local_network_access: bool) -> bool: """Returns true if the given svg file content contains dangerous scripts """ separators = [['<', '>'], ['<', '>']] - invalidStrings = [ + invalid_strings = [ 'script' ] - return _isDangerousString(content, allowLocalNetworkAccess, - separators, invalidStrings) + return _is_dangerous_string_tag(content, allow_local_network_access, + separators, invalid_strings) -def getDisplayName(baseDir: str, actor: str, personCache: {}) -> str: +def get_display_name(base_dir: str, actor: str, person_cache: {}) -> str: """Returns the display name for the given actor """ if '/statuses/' in actor: actor = actor.split('/statuses/')[0] - if not personCache.get(actor): + if not person_cache.get(actor): return None - nameFound = None - if personCache[actor].get('actor'): - if personCache[actor]['actor'].get('name'): - nameFound = personCache[actor]['actor']['name'] + name_found = None + if person_cache[actor].get('actor'): + if person_cache[actor]['actor'].get('name'): + name_found = person_cache[actor]['actor']['name'] else: # Try to obtain from the cached actors - cachedActorFilename = \ - baseDir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' - if os.path.isfile(cachedActorFilename): - actorJson = loadJson(cachedActorFilename, 1) - if actorJson: - if actorJson.get('name'): - nameFound = actorJson['name'] - if nameFound: - if dangerousMarkup(nameFound, False): - nameFound = "*ADVERSARY*" - return nameFound + cached_actor_filename = \ + base_dir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' + if os.path.isfile(cached_actor_filename): + actor_json = load_json(cached_actor_filename, 1) + if actor_json: + if actor_json.get('name'): + name_found = actor_json['name'] + if name_found: + if dangerous_markup(name_found, False): + name_found = "*ADVERSARY*" + return standardize_text(name_found) -def _genderFromString(translate: {}, text: str) -> str: +def display_name_is_emoji(display_name: str) -> bool: + """Returns true if the given display name is an emoji + """ + if ' ' in display_name: + words = display_name.split(' ') + for wrd in words: + if not wrd.startswith(':'): + return False + if not wrd.endswith(':'): + return False + return True + if len(display_name) < 2: + return False + if not display_name.startswith(':'): + return False + if not display_name.endswith(':'): + return False + return True + + +def _gender_from_string(translate: {}, text: str) -> str: """Given some text, does it contain a gender description? """ gender = None if not text: return None - textOrig = text + text_orig = text text = text.lower() if translate['He/Him'].lower() in text or \ translate['boy'].lower() in text: @@ -924,137 +1195,148 @@ def _genderFromString(translate: {}, text: str) -> str: elif 'her' in text or 'she' in text or \ 'fem' in text or 'woman' in text: gender = 'She/Her' - elif 'man' in text or 'He' in textOrig: + elif 'man' in text or 'He' in text_orig: gender = 'He/Him' return gender -def getGenderFromBio(baseDir: str, actor: str, personCache: {}, - translate: {}) -> str: +def get_gender_from_bio(base_dir: str, actor: str, person_cache: {}, + translate: {}) -> str: """Tries to ascertain gender from bio description This is for use by text-to-speech for pitch setting """ - defaultGender = 'They/Them' + default_gender = 'They/Them' if '/statuses/' in actor: actor = actor.split('/statuses/')[0] - if not personCache.get(actor): - return defaultGender - bioFound = None + if not person_cache.get(actor): + return default_gender + bio_found = None if translate: - pronounStr = translate['pronoun'].lower() + pronoun_str = translate['pronoun'].lower() else: - pronounStr = 'pronoun' - actorJson = None - if personCache[actor].get('actor'): - actorJson = personCache[actor]['actor'] + pronoun_str = 'pronoun' + actor_json = None + if person_cache[actor].get('actor'): + actor_json = person_cache[actor]['actor'] else: # Try to obtain from the cached actors - cachedActorFilename = \ - baseDir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' - if os.path.isfile(cachedActorFilename): - actorJson = loadJson(cachedActorFilename, 1) - if not actorJson: - return defaultGender + cached_actor_filename = \ + base_dir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' + if os.path.isfile(cached_actor_filename): + actor_json = load_json(cached_actor_filename, 1) + if not actor_json: + return default_gender # is gender defined as a profile tag? - if actorJson.get('attachment'): - tagsList = actorJson['attachment'] - if isinstance(tagsList, list): + if actor_json.get('attachment'): + tags_list = actor_json['attachment'] + if isinstance(tags_list, list): # look for a gender field name - for tag in tagsList: + for tag in tags_list: if not isinstance(tag, dict): continue - if not tag.get('name') or not tag.get('value'): + name_value = None + if tag.get('name'): + name_value = tag['name'] + if tag.get('schema:name'): + name_value = tag['schema:name'] + if not name_value: continue - if tag['name'].lower() == \ + prop_value_name, _ = get_attachment_property_value(tag) + if not prop_value_name: + continue + if name_value.lower() == \ translate['gender'].lower(): - bioFound = tag['value'] + bio_found = tag[prop_value_name] break - elif tag['name'].lower().startswith(pronounStr): - bioFound = tag['value'] + if name_value.lower().startswith(pronoun_str): + bio_found = tag[prop_value_name] break # the field name could be anything, # just look at the value - if not bioFound: - for tag in tagsList: + if not bio_found: + for tag in tags_list: if not isinstance(tag, dict): continue - if not tag.get('name') or not tag.get('value'): + if not tag.get('name') and not tag.get('schema:name'): continue - gender = _genderFromString(translate, tag['value']) + prop_value_name, _ = get_attachment_property_value(tag) + if not prop_value_name: + continue + gender = \ + _gender_from_string(translate, tag[prop_value_name]) if gender: return gender # if not then use the bio - if not bioFound and actorJson.get('summary'): - bioFound = actorJson['summary'] - if not bioFound: - return defaultGender - gender = _genderFromString(translate, bioFound) + if not bio_found and actor_json.get('summary'): + bio_found = actor_json['summary'] + if not bio_found: + return default_gender + gender = _gender_from_string(translate, bio_found) if not gender: - gender = defaultGender + gender = default_gender return gender -def getNicknameFromActor(actor: str) -> str: +def get_nickname_from_actor(actor: str) -> str: """Returns the nickname from an actor url """ if actor.startswith('@'): actor = actor[1:] - usersPaths = getUserPaths() - for possiblePath in usersPaths: - if possiblePath in actor: - nickStr = actor.split(possiblePath)[1].replace('@', '') - if '/' not in nickStr: - return nickStr - else: - return nickStr.split('/')[0] + users_paths = get_user_paths() + for possible_path in users_paths: + if possible_path in actor: + nick_str = actor.split(possible_path)[1].replace('@', '') + if '/' not in nick_str: + return nick_str + return nick_str.split('/')[0] if '/@' in actor: # https://domain/@nick - nickStr = actor.split('/@')[1] - if '/' in nickStr: - nickStr = nickStr.split('/')[0] - return nickStr - elif '@' in actor: - nickStr = actor.split('@')[0] - return nickStr - elif '://' in actor: + nick_str = actor.split('/@')[1] + if '/' in nick_str: + nick_str = nick_str.split('/')[0] + return nick_str + if '@' in actor: + nick_str = actor.split('@')[0] + return nick_str + if '://' in actor: domain = actor.split('://')[1] if '/' in domain: domain = domain.split('/')[0] if '://' + domain + '/' not in actor: return None - nickStr = actor.split('://' + domain + '/')[1] - if '/' in nickStr or '.' in nickStr: + nick_str = actor.split('://' + domain + '/')[1] + if '/' in nick_str or '.' in nick_str: return None - return nickStr + return nick_str return None -def getUserPaths() -> []: +def get_user_paths() -> []: """Returns possible user paths e.g. /users/nickname, /channel/nickname """ return ('/users/', '/profile/', '/accounts/', '/channel/', '/u/', - '/c/', '/video-channels/') + '/c/', '/video-channels/', '/author/') -def getGroupPaths() -> []: +def get_group_paths() -> []: """Returns possible group paths e.g. https://lemmy/c/groupname """ return ['/c/', '/video-channels/'] -def getDomainFromActor(actor: str) -> (str, int): +def get_domain_from_actor(actor: str) -> (str, int): """Returns the domain name from an actor url """ if actor.startswith('@'): actor = actor[1:] port = None - prefixes = getProtocolPrefixes() - usersPaths = getUserPaths() - for possiblePath in usersPaths: - if possiblePath in actor: - domain = actor.split(possiblePath)[0] + prefixes = get_protocol_prefixes() + users_paths = get_user_paths() + for possible_path in users_paths: + if possible_path in actor: + domain = actor.split(possible_path)[0] for prefix in prefixes: domain = domain.replace(prefix, '') break @@ -1071,238 +1353,241 @@ def getDomainFromActor(actor: str) -> (str, int): if '/' in actor: domain = domain.split('/')[0] if ':' in domain: - port = getPortFromDomain(domain) - domain = removeDomainPort(domain) + port = get_port_from_domain(domain) + domain = remove_domain_port(domain) return domain, port -def _setDefaultPetName(baseDir: str, nickname: str, domain: str, - followNickname: str, followDomain: str) -> None: +def _set_default_pet_name(base_dir: str, nickname: str, domain: str, + follow_nickname: str, follow_domain: str) -> None: """Sets a default petname This helps especially when using onion or i2p address """ - domain = removeDomainPort(domain) - userPath = acctDir(baseDir, nickname, domain) - petnamesFilename = userPath + '/petnames.txt' + domain = remove_domain_port(domain) + user_path = acct_dir(base_dir, nickname, domain) + petnames_filename = user_path + '/petnames.txt' - petnameLookupEntry = followNickname + ' ' + \ - followNickname + '@' + followDomain + '\n' - if not os.path.isfile(petnamesFilename): + petname_lookup_entry = follow_nickname + ' ' + \ + follow_nickname + '@' + follow_domain + '\n' + if not os.path.isfile(petnames_filename): # if there is no existing petnames lookup file - with open(petnamesFilename, 'w+') as petnamesFile: - petnamesFile.write(petnameLookupEntry) + with open(petnames_filename, 'w+', encoding='utf-8') as petnames_file: + petnames_file.write(petname_lookup_entry) return - with open(petnamesFilename, 'r') as petnamesFile: - petnamesStr = petnamesFile.read() - if petnamesStr: - petnamesList = petnamesStr.split('\n') - for pet in petnamesList: - if pet.startswith(followNickname + ' '): + with open(petnames_filename, 'r', encoding='utf-8') as petnames_file: + petnames_str = petnames_file.read() + if petnames_str: + petnames_list = petnames_str.split('\n') + for pet in petnames_list: + if pet.startswith(follow_nickname + ' '): # petname already exists return # petname doesn't already exist - with open(petnamesFilename, 'a+') as petnamesFile: - petnamesFile.write(petnameLookupEntry) + with open(petnames_filename, 'a+', encoding='utf-8') as petnames_file: + petnames_file.write(petname_lookup_entry) -def followPerson(baseDir: str, nickname: str, domain: str, - followNickname: str, followDomain: str, - federationList: [], debug: bool, - groupAccount: bool, - followFile: str = 'following.txt') -> bool: +def follow_person(base_dir: str, nickname: str, domain: str, + follow_nickname: str, follow_domain: str, + federation_list: [], debug: bool, + group_account: bool, + follow_file: str = 'following.txt') -> bool: """Adds a person to the follow list """ - followDomainStrLower = followDomain.lower().replace('\n', '') - if not domainPermitted(followDomainStrLower, - federationList): + follow_domain_str_lower1 = follow_domain.lower() + follow_domain_str_lower = remove_eol(follow_domain_str_lower1) + if not domain_permitted(follow_domain_str_lower, + federation_list): if debug: print('DEBUG: follow of domain ' + - followDomain + ' not permitted') + follow_domain + ' not permitted') return False if debug: - print('DEBUG: follow of domain ' + followDomain) + print('DEBUG: follow of domain ' + follow_domain) if ':' in domain: - domainOnly = removeDomainPort(domain) - handle = nickname + '@' + domainOnly + domain_only = remove_domain_port(domain) + handle = nickname + '@' + domain_only else: handle = nickname + '@' + domain - if not os.path.isdir(baseDir + '/accounts/' + handle): + if not os.path.isdir(base_dir + '/accounts/' + handle): print('WARN: account for ' + handle + ' does not exist') return False - if ':' in followDomain: - followDomainOnly = removeDomainPort(followDomain) - handleToFollow = followNickname + '@' + followDomainOnly + if ':' in follow_domain: + follow_domain_only = remove_domain_port(follow_domain) + handle_to_follow = follow_nickname + '@' + follow_domain_only else: - handleToFollow = followNickname + '@' + followDomain + handle_to_follow = follow_nickname + '@' + follow_domain - if groupAccount: - handleToFollow = '!' + handleToFollow + if group_account: + handle_to_follow = '!' + handle_to_follow # was this person previously unfollowed? - unfollowedFilename = baseDir + '/accounts/' + handle + '/unfollowed.txt' - if os.path.isfile(unfollowedFilename): - if handleToFollow in open(unfollowedFilename).read(): + unfollowed_filename = base_dir + '/accounts/' + handle + '/unfollowed.txt' + if os.path.isfile(unfollowed_filename): + if text_in_file(handle_to_follow, unfollowed_filename): # remove them from the unfollowed file - newLines = '' - with open(unfollowedFilename, 'r') as f: - lines = f.readlines() + new_lines = '' + with open(unfollowed_filename, 'r', + encoding='utf-8') as unfoll_file: + lines = unfoll_file.readlines() for line in lines: - if handleToFollow not in line: - newLines += line - with open(unfollowedFilename, 'w+') as f: - f.write(newLines) + if handle_to_follow not in line: + new_lines += line + with open(unfollowed_filename, 'w+', + encoding='utf-8') as unfoll_file: + unfoll_file.write(new_lines) - if not os.path.isdir(baseDir + '/accounts'): - os.mkdir(baseDir + '/accounts') - handleToFollow = followNickname + '@' + followDomain - if groupAccount: - handleToFollow = '!' + handleToFollow - filename = baseDir + '/accounts/' + handle + '/' + followFile + if not os.path.isdir(base_dir + '/accounts'): + os.mkdir(base_dir + '/accounts') + handle_to_follow = follow_nickname + '@' + follow_domain + if group_account: + handle_to_follow = '!' + handle_to_follow + filename = base_dir + '/accounts/' + handle + '/' + follow_file if os.path.isfile(filename): - if handleToFollow in open(filename).read(): + if text_in_file(handle_to_follow, filename): if debug: print('DEBUG: follow already exists') return True # prepend to follow file try: - with open(filename, 'r+') as f: - content = f.read() - if handleToFollow + '\n' not in content: - f.seek(0, 0) - f.write(handleToFollow + '\n' + content) + with open(filename, 'r+', encoding='utf-8') as foll_file: + content = foll_file.read() + if handle_to_follow + '\n' not in content: + foll_file.seek(0, 0) + foll_file.write(handle_to_follow + '\n' + content) print('DEBUG: follow added') - except Exception as e: + except OSError as ex: print('WARN: Failed to write entry to follow file ' + - filename + ' ' + str(e)) + filename + ' ' + str(ex)) else: # first follow if debug: print('DEBUG: ' + handle + - ' creating new following file to follow ' + handleToFollow + + ' creating new following file to follow ' + + handle_to_follow + ', filename is ' + filename) - with open(filename, 'w+') as f: - f.write(handleToFollow + '\n') + with open(filename, 'w+', encoding='utf-8') as foll_file: + foll_file.write(handle_to_follow + '\n') - if followFile.endswith('following.txt'): + if follow_file.endswith('following.txt'): # Default to adding new follows to the calendar. # Possibly this could be made optional # if following a person add them to the list of # calendar follows print('DEBUG: adding ' + - followNickname + '@' + followDomain + ' to calendar of ' + + follow_nickname + '@' + follow_domain + ' to calendar of ' + nickname + '@' + domain) - addPersonToCalendar(baseDir, nickname, domain, - followNickname, followDomain) + add_person_to_calendar(base_dir, nickname, domain, + follow_nickname, follow_domain) # add a default petname - _setDefaultPetName(baseDir, nickname, domain, - followNickname, followDomain) + _set_default_pet_name(base_dir, nickname, domain, + follow_nickname, follow_domain) return True -def votesOnNewswireItem(status: []) -> int: +def votes_on_newswire_item(status: []) -> int: """Returns the number of votes on a newswire item """ - totalVotes = 0 + total_votes = 0 for line in status: if 'vote:' in line: - totalVotes += 1 - return totalVotes + total_votes += 1 + return total_votes -def locateNewsVotes(baseDir: str, domain: str, - postUrl: str) -> str: +def locate_news_votes(base_dir: str, domain: str, + post_url: str) -> str: """Returns the votes filename for a news post within the news user account """ - postUrl = \ - postUrl.strip().replace('\n', '').replace('\r', '') + post_url1 = post_url.strip() + post_url = remove_eol(post_url1) # if this post in the shared inbox? - postUrl = removeIdEnding(postUrl.strip()).replace('/', '#') + post_url = remove_id_ending(post_url.strip()).replace('/', '#') - if postUrl.endswith('.json'): - postUrl = postUrl + '.votes' + if post_url.endswith('.json'): + post_url = post_url + '.votes' else: - postUrl = postUrl + '.json.votes' + post_url = post_url + '.json.votes' - accountDir = baseDir + '/accounts/news@' + domain + '/' - postFilename = accountDir + 'outbox/' + postUrl - if os.path.isfile(postFilename): - return postFilename + account_dir = base_dir + '/accounts/news@' + domain + '/' + post_filename = account_dir + 'outbox/' + post_url + if os.path.isfile(post_filename): + return post_filename return None -def locateNewsArrival(baseDir: str, domain: str, - postUrl: str) -> str: +def locate_news_arrival(base_dir: str, domain: str, + post_url: str) -> str: """Returns the arrival time for a news post within the news user account """ - postUrl = \ - postUrl.strip().replace('\n', '').replace('\r', '') + post_url1 = post_url.strip() + post_url = remove_eol(post_url1) # if this post in the shared inbox? - postUrl = removeIdEnding(postUrl.strip()).replace('/', '#') + post_url = remove_id_ending(post_url.strip()).replace('/', '#') - if postUrl.endswith('.json'): - postUrl = postUrl + '.arrived' + if post_url.endswith('.json'): + post_url = post_url + '.arrived' else: - postUrl = postUrl + '.json.arrived' + post_url = post_url + '.json.arrived' - accountDir = baseDir + '/accounts/news@' + domain + '/' - postFilename = accountDir + 'outbox/' + postUrl - if os.path.isfile(postFilename): - with open(postFilename, 'r') as arrivalFile: - arrival = arrivalFile.read() + account_dir = base_dir + '/accounts/news@' + domain + '/' + post_filename = account_dir + 'outbox/' + post_url + if os.path.isfile(post_filename): + with open(post_filename, 'r', encoding='utf-8') as arrival_file: + arrival = arrival_file.read() if arrival: - arrivalDate = \ + arrival_date = \ datetime.datetime.strptime(arrival, "%Y-%m-%dT%H:%M:%SZ") - return arrivalDate + return arrival_date return None -def clearFromPostCaches(baseDir: str, recentPostsCache: {}, - postId: str) -> None: +def clear_from_post_caches(base_dir: str, recent_posts_cache: {}, + post_id: str) -> None: """Clears cached html for the given post, so that edits to news will appear """ - filename = '/postcache/' + postId + '.html' - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + filename = '/postcache/' + post_id + '.html' + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: if '@' not in acct: continue - if acct.startswith('inbox@'): + if acct.startswith('inbox@') or acct.startswith('Actor@'): continue - cacheDir = os.path.join(baseDir + '/accounts', acct) - postFilename = cacheDir + filename - if os.path.isfile(postFilename): + cache_dir = os.path.join(base_dir + '/accounts', acct) + post_filename = cache_dir + filename + if os.path.isfile(post_filename): try: - os.remove(postFilename) - except BaseException: - print('WARN: clearFromPostCaches file not removed ' + - postFilename) - pass + os.remove(post_filename) + except OSError: + print('EX: clear_from_post_caches file not removed ' + + str(post_filename)) # if the post is in the recent posts cache then remove it - if recentPostsCache.get('index'): - if postId in recentPostsCache['index']: - recentPostsCache['index'].remove(postId) - if recentPostsCache.get('json'): - if recentPostsCache['json'].get(postId): - del recentPostsCache['json'][postId] - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] + if recent_posts_cache.get('index'): + if post_id in recent_posts_cache['index']: + recent_posts_cache['index'].remove(post_id) + if recent_posts_cache.get('json'): + if recent_posts_cache['json'].get(post_id): + del recent_posts_cache['json'][post_id] + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(post_id): + del recent_posts_cache['html'][post_id] break -def locatePost(baseDir: str, nickname: str, domain: str, - postUrl: str, replies: bool = False) -> str: +def locate_post(base_dir: str, nickname: str, domain: str, + post_url: str, replies: bool = False) -> str: """Returns the filename for the given status post url """ if not replies: @@ -1311,44 +1596,43 @@ def locatePost(baseDir: str, nickname: str, domain: str, extension = 'replies' # if this post in the shared inbox? - postUrl = removeIdEnding(postUrl.strip()).replace('/', '#') + post_url = remove_id_ending(post_url.strip()).replace('/', '#') # add the extension - postUrl = postUrl + '.' + extension + post_url = post_url + '.' + extension # search boxes boxes = ('inbox', 'outbox', 'tlblogs') - accountDir = acctDir(baseDir, nickname, domain) + '/' - for boxName in boxes: - postFilename = accountDir + boxName + '/' + postUrl - if os.path.isfile(postFilename): - return postFilename + account_dir = acct_dir(base_dir, nickname, domain) + '/' + for box_name in boxes: + post_filename = account_dir + box_name + '/' + post_url + if os.path.isfile(post_filename): + return post_filename # check news posts - accountDir = baseDir + '/accounts/news' + '@' + domain + '/' - postFilename = accountDir + 'outbox/' + postUrl - if os.path.isfile(postFilename): - return postFilename + account_dir = base_dir + '/accounts/news' + '@' + domain + '/' + post_filename = account_dir + 'outbox/' + post_url + if os.path.isfile(post_filename): + return post_filename # is it in the announce cache? - postFilename = baseDir + '/cache/announce/' + nickname + '/' + postUrl - if os.path.isfile(postFilename): - return postFilename + post_filename = base_dir + '/cache/announce/' + nickname + '/' + post_url + if os.path.isfile(post_filename): + return post_filename - # print('WARN: unable to locate ' + nickname + ' ' + postUrl) + # print('WARN: unable to locate ' + nickname + ' ' + post_url) return None -def _getPublishedDate(postJsonObject: {}) -> str: +def _get_published_date(post_json_object: {}) -> str: """Returns the published date on the given post """ published = None - if postJsonObject.get('published'): - published = postJsonObject['published'] - elif postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('published'): - published = postJsonObject['object']['published'] + if post_json_object.get('published'): + published = post_json_object['published'] + elif has_object_dict(post_json_object): + if post_json_object['object'].get('published'): + published = post_json_object['object']['published'] if not published: return None if not isinstance(published, str): @@ -1356,400 +1640,520 @@ def _getPublishedDate(postJsonObject: {}) -> str: return published -def getReplyIntervalHours(baseDir: str, nickname: str, domain: str, - defaultReplyIntervalHours: int) -> int: +def get_reply_interval_hours(base_dir: str, nickname: str, domain: str, + default_reply_interval_hrs: int) -> int: """Returns the reply interval for the given account. The reply interval is the number of hours after a post being made during which replies are allowed """ - replyIntervalFilename = \ - acctDir(baseDir, nickname, domain) + '/.replyIntervalHours' - if os.path.isfile(replyIntervalFilename): - with open(replyIntervalFilename, 'r') as fp: - hoursStr = fp.read() - if hoursStr.isdigit(): - return int(hoursStr) - return defaultReplyIntervalHours + reply_interval_filename = \ + acct_dir(base_dir, nickname, domain) + '/.reply_interval_hours' + if os.path.isfile(reply_interval_filename): + with open(reply_interval_filename, 'r', + encoding='utf-8') as interval_file: + hours_str = interval_file.read() + if hours_str.isdigit(): + return int(hours_str) + return default_reply_interval_hrs -def setReplyIntervalHours(baseDir: str, nickname: str, domain: str, - replyIntervalHours: int) -> bool: +def set_reply_interval_hours(base_dir: str, nickname: str, domain: str, + reply_interval_hours: int) -> bool: """Sets the reply interval for the given account. The reply interval is the number of hours after a post being made during which replies are allowed """ - replyIntervalFilename = \ - acctDir(baseDir, nickname, domain) + '/.replyIntervalHours' - with open(replyIntervalFilename, 'w+') as fp: - try: - fp.write(str(replyIntervalHours)) + reply_interval_filename = \ + acct_dir(base_dir, nickname, domain) + '/.reply_interval_hours' + try: + with open(reply_interval_filename, 'w+', + encoding='utf-8') as interval_file: + interval_file.write(str(reply_interval_hours)) return True - except BaseException: - pass + except OSError: + print('EX: set_reply_interval_hours unable to save reply interval ' + + str(reply_interval_filename) + ' ' + + str(reply_interval_hours)) return False -def canReplyTo(baseDir: str, nickname: str, domain: str, - postUrl: str, replyIntervalHours: int, - currDateStr: str = None, - postJsonObject: {} = None) -> bool: +def can_reply_to(base_dir: str, nickname: str, domain: str, + post_url: str, reply_interval_hours: int, + curr_date_str: str = None, + post_json_object: {} = None) -> bool: """Is replying to the given post permitted? This is a spam mitigation feature, so that spammers can't add a lot of replies to old post which you don't notice. """ - if '/statuses/' not in postUrl: + if '/statuses/' not in post_url: return True - if not postJsonObject: - postFilename = locatePost(baseDir, nickname, domain, postUrl) - if not postFilename: + if not post_json_object: + post_filename = locate_post(base_dir, nickname, domain, post_url) + if not post_filename: return False - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return False - published = _getPublishedDate(postJsonObject) + published = _get_published_date(post_json_object) if not published: return False try: - pubDate = datetime.datetime.strptime(published, '%Y-%m-%dT%H:%M:%SZ') + pub_date = datetime.datetime.strptime(published, '%Y-%m-%dT%H:%M:%SZ') except BaseException: + print('EX: can_reply_to unrecognized published date ' + str(published)) return False - if not currDateStr: - currDate = datetime.datetime.utcnow() + if not curr_date_str: + curr_date = datetime.datetime.utcnow() else: try: - currDate = datetime.datetime.strptime(currDateStr, - '%Y-%m-%dT%H:%M:%SZ') + curr_date = \ + datetime.datetime.strptime(curr_date_str, '%Y-%m-%dT%H:%M:%SZ') except BaseException: + print('EX: can_reply_to unrecognized current date ' + + str(curr_date_str)) return False - hoursSincePublication = int((currDate - pubDate).total_seconds() / 3600) - if hoursSincePublication < 0 or \ - hoursSincePublication >= replyIntervalHours: + hours_since_publication = \ + int((curr_date - pub_date).total_seconds() / 3600) + if hours_since_publication < 0 or \ + hours_since_publication >= reply_interval_hours: return False return True -def _removeAttachment(baseDir: str, httpPrefix: str, domain: str, - postJson: {}): - if not postJson.get('attachment'): +def _remove_attachment(base_dir: str, http_prefix: str, domain: str, + post_json: {}): + if not post_json.get('attachment'): return - if not postJson['attachment'][0].get('url'): + if not post_json['attachment'][0].get('url'): return - attachmentUrl = postJson['attachment'][0]['url'] - if not attachmentUrl: + attachment_url = post_json['attachment'][0]['url'] + if not attachment_url: return - mediaFilename = baseDir + '/' + \ - attachmentUrl.replace(httpPrefix + '://' + domain + '/', '') - if os.path.isfile(mediaFilename): + media_filename = base_dir + '/' + \ + attachment_url.replace(http_prefix + '://' + domain + '/', '') + if os.path.isfile(media_filename): try: - os.remove(mediaFilename) - except BaseException: - pass - etagFilename = mediaFilename + '.etag' - if os.path.isfile(etagFilename): + os.remove(media_filename) + except OSError: + print('EX: _remove_attachment unable to delete media file ' + + str(media_filename)) + etag_filename = media_filename + '.etag' + if os.path.isfile(etag_filename): try: - os.remove(etagFilename) - except BaseException: - pass - postJson['attachment'] = [] + os.remove(etag_filename) + except OSError: + print('EX: _remove_attachment unable to delete etag file ' + + str(etag_filename)) + post_json['attachment'] = [] -def removeModerationPostFromIndex(baseDir: str, postUrl: str, - debug: bool) -> None: +def remove_moderation_post_from_index(base_dir: str, post_url: str, + debug: bool) -> None: """Removes a url from the moderation index """ - moderationIndexFile = baseDir + '/accounts/moderation.txt' - if not os.path.isfile(moderationIndexFile): + moderation_index_file = base_dir + '/accounts/moderation.txt' + if not os.path.isfile(moderation_index_file): return - postId = removeIdEnding(postUrl) - if postId in open(moderationIndexFile).read(): - with open(moderationIndexFile, 'r') as f: - lines = f.readlines() - with open(moderationIndexFile, 'w+') as f: + post_id = remove_id_ending(post_url) + if text_in_file(post_id, moderation_index_file): + with open(moderation_index_file, 'r', + encoding='utf-8') as file1: + lines = file1.readlines() + with open(moderation_index_file, 'w+', + encoding='utf-8') as file2: for line in lines: - if line.strip("\n").strip("\r") != postId: - f.write(line) - else: - if debug: - print('DEBUG: removed ' + postId + - ' from moderation index') + if line.strip("\n").strip("\r") != post_id: + file2.write(line) + continue + if debug: + print('DEBUG: removed ' + post_id + + ' from moderation index') -def _isReplyToBlogPost(baseDir: str, nickname: str, domain: str, - postJsonObject: str): +def _is_reply_to_blog_post(base_dir: str, nickname: str, domain: str, + post_json_object: str): """Is the given post a reply to a blog post? """ - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if not postJsonObject['object'].get('inReplyTo'): + if not post_json_object['object'].get('inReplyTo'): return False - if not isinstance(postJsonObject['object']['inReplyTo'], str): + if not isinstance(post_json_object['object']['inReplyTo'], str): return False - blogsIndexFilename = acctDir(baseDir, nickname, domain) + '/tlblogs.index' - if not os.path.isfile(blogsIndexFilename): + blogs_index_filename = \ + acct_dir(base_dir, nickname, domain) + '/tlblogs.index' + if not os.path.isfile(blogs_index_filename): return False - postId = removeIdEnding(postJsonObject['object']['inReplyTo']) - postId = postId.replace('/', '#') - if postId in open(blogsIndexFilename).read(): + post_id = remove_id_ending(post_json_object['object']['inReplyTo']) + post_id = post_id.replace('/', '#') + if text_in_file(post_id, blogs_index_filename): return True return False -def _deletePostRemoveReplies(baseDir: str, nickname: str, domain: str, - httpPrefix: str, postFilename: str, - recentPostsCache: {}, debug: bool) -> None: +def _delete_post_remove_replies(base_dir: str, nickname: str, domain: str, + http_prefix: str, post_filename: str, + recent_posts_cache: {}, debug: bool, + manual: bool) -> None: """Removes replies when deleting a post """ - repliesFilename = postFilename.replace('.json', '.replies') - if not os.path.isfile(repliesFilename): + replies_filename = post_filename.replace('.json', '.replies') + if not os.path.isfile(replies_filename): return if debug: - print('DEBUG: removing replies to ' + postFilename) - with open(repliesFilename, 'r') as f: - for replyId in f: - replyFile = locatePost(baseDir, nickname, domain, replyId) - if not replyFile: + print('DEBUG: removing replies to ' + post_filename) + with open(replies_filename, 'r', encoding='utf-8') as replies_file: + for reply_id in replies_file: + reply_file = locate_post(base_dir, nickname, domain, reply_id) + if not reply_file: continue - if os.path.isfile(replyFile): - deletePost(baseDir, httpPrefix, - nickname, domain, replyFile, debug, - recentPostsCache) + if os.path.isfile(reply_file): + delete_post(base_dir, http_prefix, + nickname, domain, reply_file, debug, + recent_posts_cache, manual) # remove the replies file try: - os.remove(repliesFilename) - except BaseException: - pass + os.remove(replies_filename) + except OSError: + print('EX: _delete_post_remove_replies ' + + 'unable to delete replies file ' + str(replies_filename)) -def _isBookmarked(baseDir: str, nickname: str, domain: str, - postFilename: str) -> bool: +def _is_bookmarked(base_dir: str, nickname: str, domain: str, + post_filename: str) -> bool: """Returns True if the given post is bookmarked """ - bookmarksIndexFilename = \ - acctDir(baseDir, nickname, domain) + '/bookmarks.index' - if os.path.isfile(bookmarksIndexFilename): - bookmarkIndex = postFilename.split('/')[-1] + '\n' - if bookmarkIndex in open(bookmarksIndexFilename).read(): + bookmarks_index_filename = \ + acct_dir(base_dir, nickname, domain) + '/bookmarks.index' + if os.path.isfile(bookmarks_index_filename): + bookmark_index = post_filename.split('/')[-1] + '\n' + if text_in_file(bookmark_index, bookmarks_index_filename): return True return False -def removePostFromCache(postJsonObject: {}, recentPostsCache: {}) -> None: +def remove_post_from_cache(post_json_object: {}, + recent_posts_cache: {}) -> None: """ if the post exists in the recent posts cache then remove it """ - if not recentPostsCache: + if not recent_posts_cache: return - if not postJsonObject.get('id'): + if not post_json_object.get('id'): return - if not recentPostsCache.get('index'): + if not recent_posts_cache.get('index'): return - postId = postJsonObject['id'] - if '#' in postId: - postId = postId.split('#', 1)[0] - postId = removeIdEnding(postId).replace('/', '#') - if postId not in recentPostsCache['index']: + post_id = post_json_object['id'] + if '#' in post_id: + post_id = post_id.split('#', 1)[0] + post_id = remove_id_ending(post_id).replace('/', '#') + if post_id not in recent_posts_cache['index']: return - if recentPostsCache.get('index'): - if postId in recentPostsCache['index']: - recentPostsCache['index'].remove(postId) + if recent_posts_cache.get('index'): + if post_id in recent_posts_cache['index']: + recent_posts_cache['index'].remove(post_id) - if recentPostsCache.get('json'): - if recentPostsCache['json'].get(postId): - del recentPostsCache['json'][postId] + if recent_posts_cache.get('json'): + if recent_posts_cache['json'].get(post_id): + del recent_posts_cache['json'][post_id] - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] + if recent_posts_cache.get('html'): + if recent_posts_cache['html'].get(post_id): + del recent_posts_cache['html'][post_id] -def _deleteCachedHtml(baseDir: str, nickname: str, domain: str, - postJsonObject: {}): +def delete_cached_html(base_dir: str, nickname: str, domain: str, + post_json_object: {}): """Removes cached html file for the given post """ - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass + os.remove(cached_post_filename) + except OSError: + print('EX: delete_cached_html ' + + 'unable to delete cached post file ' + + str(cached_post_filename)) + + cached_post_filename = cached_post_filename.replace('.html', '.ssml') + if os.path.isfile(cached_post_filename): + try: + os.remove(cached_post_filename) + except OSError: + print('EX: delete_cached_html ' + + 'unable to delete cached ssml post file ' + + str(cached_post_filename)) + + cached_post_filename = \ + cached_post_filename.replace('/postcache/', '/outbox/') + if os.path.isfile(cached_post_filename): + try: + os.remove(cached_post_filename) + except OSError: + print('EX: delete_cached_html ' + + 'unable to delete cached outbox ssml post file ' + + str(cached_post_filename)) -def _deleteHashtagsOnPost(baseDir: str, postJsonObject: {}) -> None: +def _remove_post_id_from_tag_index(tag_index_filename: str, + post_id: str) -> None: + """Remove post_id from the tag index file + """ + lines = None + with open(tag_index_filename, 'r', encoding='utf-8') as index_file: + lines = index_file.readlines() + if not lines: + return + newlines = '' + for file_line in lines: + if post_id in file_line: + # skip over the deleted post + continue + newlines += file_line + if not newlines.strip(): + # if there are no lines then remove the hashtag file + try: + os.remove(tag_index_filename) + except OSError: + print('EX: _delete_hashtags_on_post ' + + 'unable to delete tag index ' + str(tag_index_filename)) + else: + # write the new hashtag index without the given post in it + with open(tag_index_filename, 'w+', + encoding='utf-8') as index_file: + index_file.write(newlines) + + +def _delete_hashtags_on_post(base_dir: str, post_json_object: {}) -> None: """Removes hashtags when a post is deleted """ - removeHashtagIndex = False - if hasObjectDict(postJsonObject): - if postJsonObject['object'].get('content'): - if '#' in postJsonObject['object']['content']: - removeHashtagIndex = True + remove_hashtag_index = False + if has_object_dict(post_json_object): + if post_json_object['object'].get('content'): + if '#' in post_json_object['object']['content']: + remove_hashtag_index = True - if not removeHashtagIndex: + if not remove_hashtag_index: return - if not postJsonObject['object'].get('id') or \ - not postJsonObject['object'].get('tag'): + if not post_json_object['object'].get('id') or \ + not post_json_object['object'].get('tag'): return # get the id of the post - postId = removeIdEnding(postJsonObject['object']['id']) - for tag in postJsonObject['object']['tag']: + post_id = remove_id_ending(post_json_object['object']['id']) + for tag in post_json_object['object']['tag']: + if not tag.get('type'): + continue if tag['type'] != 'Hashtag': continue if not tag.get('name'): continue # find the index file for this tag - tagIndexFilename = baseDir + '/tags/' + tag['name'][1:] + '.txt' - if not os.path.isfile(tagIndexFilename): - continue - # remove postId from the tag index file - lines = None - with open(tagIndexFilename, 'r') as f: - lines = f.readlines() - if not lines: - continue - newlines = '' - for fileLine in lines: - if postId in fileLine: - # skip over the deleted post - continue - newlines += fileLine - if not newlines.strip(): - # if there are no lines then remove the hashtag file - try: - os.remove(tagIndexFilename) - except BaseException: - pass - else: - # write the new hashtag index without the given post in it - with open(tagIndexFilename, 'w+') as f: - f.write(newlines) + tag_map_filename = base_dir + '/tagmaps/' + tag['name'][1:] + '.txt' + if os.path.isfile(tag_map_filename): + _remove_post_id_from_tag_index(tag_map_filename, post_id) + # find the index file for this tag + tag_index_filename = base_dir + '/tags/' + tag['name'][1:] + '.txt' + if os.path.isfile(tag_index_filename): + _remove_post_id_from_tag_index(tag_index_filename, post_id) -def _deleteConversationPost(baseDir: str, nickname: str, domain: str, - postJsonObject: {}) -> None: +def _delete_conversation_post(base_dir: str, nickname: str, domain: str, + post_json_object: {}) -> None: """Deletes a post from a conversation """ - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if not postJsonObject['object'].get('conversation'): + if not post_json_object['object'].get('conversation'): return False - if not postJsonObject['object'].get('id'): + if not post_json_object['object'].get('id'): return False - conversationDir = acctDir(baseDir, nickname, domain) + '/conversation' - conversationId = postJsonObject['object']['conversation'] - conversationId = conversationId.replace('/', '#') - postId = postJsonObject['object']['id'] - conversationFilename = conversationDir + '/' + conversationId - if not os.path.isfile(conversationFilename): + conversation_dir = \ + acct_dir(base_dir, nickname, domain) + '/conversation' + conversation_id = post_json_object['object']['conversation'] + conversation_id = conversation_id.replace('/', '#') + post_id = post_json_object['object']['id'] + conversation_filename = conversation_dir + '/' + conversation_id + if not os.path.isfile(conversation_filename): return False - conversationStr = '' - with open(conversationFilename, 'r') as fp: - conversationStr = fp.read() - if postId + '\n' not in conversationStr: + conversation_str = '' + with open(conversation_filename, 'r', encoding='utf-8') as conv_file: + conversation_str = conv_file.read() + if post_id + '\n' not in conversation_str: return False - conversationStr = conversationStr.replace(postId + '\n', '') - if conversationStr: - with open(conversationFilename, 'w+') as fp: - fp.write(conversationStr) + conversation_str = conversation_str.replace(post_id + '\n', '') + if conversation_str: + with open(conversation_filename, 'w+', encoding='utf-8') as conv_file: + conv_file.write(conversation_str) else: - if os.path.isfile(conversationFilename + '.muted'): + if os.path.isfile(conversation_filename + '.muted'): try: - os.remove(conversationFilename + '.muted') - except BaseException: - pass + os.remove(conversation_filename + '.muted') + except OSError: + print('EX: _delete_conversation_post ' + + 'unable to remove conversation ' + + str(conversation_filename) + '.muted') try: - os.remove(conversationFilename) - except BaseException: - pass + os.remove(conversation_filename) + except OSError: + print('EX: _delete_conversation_post ' + + 'unable to remove conversation ' + + str(conversation_filename)) -def deletePost(baseDir: str, httpPrefix: str, - nickname: str, domain: str, postFilename: str, - debug: bool, recentPostsCache: {}) -> None: +def is_dm(post_json_object: {}) -> bool: + """Returns true if the given post is a DM + """ + if post_json_object['type'] != 'Create': + return False + if not has_object_dict(post_json_object): + return False + if post_json_object['object']['type'] != 'ChatMessage': + if post_json_object['object']['type'] != 'Note' and \ + post_json_object['object']['type'] != 'Page' and \ + post_json_object['object']['type'] != 'Patch' and \ + post_json_object['object']['type'] != 'EncryptedMessage' and \ + post_json_object['object']['type'] != 'Article': + return False + if post_json_object['object'].get('moderationStatus'): + return False + fields = ('to', 'cc') + for field_name in fields: + if not post_json_object['object'].get(field_name): + continue + for to_address in post_json_object['object'][field_name]: + if to_address.endswith('#Public'): + return False + if to_address.endswith('followers'): + return False + return True + + +def _is_remote_dm(domain_full: str, post_json_object: {}) -> bool: + """Is the given post a DM from a different domain? + """ + if not is_dm(post_json_object): + return False + this_post_json = post_json_object + if has_object_dict(post_json_object): + this_post_json = post_json_object['object'] + if this_post_json.get('attributedTo'): + if isinstance(this_post_json['attributedTo'], str): + if '://' + domain_full not in this_post_json['attributedTo']: + return True + return False + + +def delete_post(base_dir: str, http_prefix: str, + nickname: str, domain: str, post_filename: str, + debug: bool, recent_posts_cache: {}, + manual: bool) -> None: """Recursively deletes a post and its replies and attachments """ - postJsonObject = loadJson(postFilename, 1) - if not postJsonObject: + post_json_object = load_json(post_filename, 1) + if not post_json_object: # remove any replies - _deletePostRemoveReplies(baseDir, nickname, domain, - httpPrefix, postFilename, - recentPostsCache, debug) + _delete_post_remove_replies(base_dir, nickname, domain, + http_prefix, post_filename, + recent_posts_cache, debug, manual) # finally, remove the post itself try: - os.remove(postFilename) - except BaseException: - pass + os.remove(post_filename) + except OSError: + if debug: + print('EX: delete_post unable to delete post ' + + str(post_filename)) return + # don't allow DMs to be deleted if they came from a different instance + # otherwise this breaks expectations about how DMs should operate + # i.e. DMs should only be removed if they are manually deleted + if not manual: + if _is_remote_dm(domain, post_json_object): + return + # don't allow deletion of bookmarked posts - if _isBookmarked(baseDir, nickname, domain, postFilename): + if _is_bookmarked(base_dir, nickname, domain, post_filename): return # don't remove replies to blog posts - if _isReplyToBlogPost(baseDir, nickname, domain, - postJsonObject): + if _is_reply_to_blog_post(base_dir, nickname, domain, + post_json_object): return # remove from recent posts cache in memory - removePostFromCache(postJsonObject, recentPostsCache) + remove_post_from_cache(post_json_object, recent_posts_cache) # remove from conversation index - _deleteConversationPost(baseDir, nickname, domain, postJsonObject) + _delete_conversation_post(base_dir, nickname, domain, post_json_object) # remove any attachment - _removeAttachment(baseDir, httpPrefix, domain, postJsonObject) + _remove_attachment(base_dir, http_prefix, domain, post_json_object) - extensions = ('votes', 'arrived', 'muted', 'tts', 'reject') + extensions = ( + 'votes', 'arrived', 'muted', 'tts', 'reject', 'mitm', 'edits' + ) for ext in extensions: - extFilename = postFilename + '.' + ext - if os.path.isfile(extFilename): + ext_filename = post_filename + '.' + ext + if os.path.isfile(ext_filename): try: - os.remove(extFilename) - except BaseException: - pass + os.remove(ext_filename) + except OSError: + print('EX: delete_post unable to remove ext ' + + str(ext_filename)) + elif post_filename.endswith('.json'): + ext_filename = post_filename.replace('.json', '') + '.' + ext + if os.path.isfile(ext_filename): + try: + os.remove(ext_filename) + except OSError: + print('EX: delete_post unable to remove ext ' + + str(ext_filename)) # remove cached html version of the post - _deleteCachedHtml(baseDir, nickname, domain, postJsonObject) + delete_cached_html(base_dir, nickname, domain, post_json_object) - hasObject = False - if postJsonObject.get('object'): - hasObject = True + has_object = False + if post_json_object.get('object'): + has_object = True # remove from moderation index file - if hasObject: - if hasObjectDict(postJsonObject): - if postJsonObject['object'].get('moderationStatus'): - if postJsonObject.get('id'): - postId = removeIdEnding(postJsonObject['id']) - removeModerationPostFromIndex(baseDir, postId, debug) + if has_object: + if has_object_dict(post_json_object): + if post_json_object['object'].get('moderationStatus'): + if post_json_object.get('id'): + post_id = remove_id_ending(post_json_object['id']) + remove_moderation_post_from_index(base_dir, post_id, debug) # remove any hashtags index entries - if hasObject: - _deleteHashtagsOnPost(baseDir, postJsonObject) + if has_object: + _delete_hashtags_on_post(base_dir, post_json_object) # remove any replies - _deletePostRemoveReplies(baseDir, nickname, domain, - httpPrefix, postFilename, - recentPostsCache, debug) + _delete_post_remove_replies(base_dir, nickname, domain, + http_prefix, post_filename, + recent_posts_cache, debug, manual) # finally, remove the post itself try: - os.remove(postFilename) - except BaseException: - pass + os.remove(post_filename) + except OSError: + if debug: + print('EX: delete_post unable to delete post ' + + str(post_filename)) -def isValidLanguage(text: str) -> bool: +def _is_valid_language(text: str) -> bool: """Returns true if the given text contains a valid natural language string """ - naturalLanguages = { + natural_languages = { "Latin": [65, 866], - "Cyrillic": [1024, 1274], "Greek": [880, 1280], "isArmenian": [1328, 1424], "isHebrew": [1424, 1536], @@ -1777,22 +2181,34 @@ def isValidLanguage(text: str) -> bool: "Ogham": [5760, 5792], "Runic": [5792, 5888], "Khmer": [6016, 6144], - "Mongolian": [6144, 6320] + "Hangul Syllables": [44032, 55203], + "Hangul Jamo": [4352, 4607], + "Hangul Compatibility Jamo": [12592, 12687], + "Hangul Jamo Extended-A": [43360, 43391], + "Hangul Jamo Extended-B": [55216, 55295], + "Mongolian": [6144, 6320], + "Cyrillic": [1024, 1279], + "Cyrillic Supplement": [1280, 1327], + "Cyrillic Extended A": [11744, 11775], + "Cyrillic Extended B": [42560, 42655], + "Cyrillic Extended C": [7296, 7311], + "Phonetic Extensions": [7467, 7544], + "Combining Half Marks": [65070, 65071] } - for langName, langRange in naturalLanguages.items(): - okLang = True - for ch in text: - if ch.isdigit(): + for _, lang_range in natural_languages.items(): + ok_lang = True + for char in text: + if char.isdigit() or char == '_': continue - if ord(ch) not in range(langRange[0], langRange[1]): - okLang = False + if ord(char) not in range(lang_range[0], lang_range[1]): + ok_lang = False break - if okLang: + if ok_lang: return True return False -def _getReservedWords() -> str: +def _get_reserved_words() -> str: return ('inbox', 'dm', 'outbox', 'following', 'public', 'followers', 'category', 'channel', 'calendar', 'video-channels', @@ -1800,9 +2216,9 @@ def _getReservedWords() -> str: 'tlblogs', 'tlfeatures', 'moderation', 'moderationaction', 'activity', 'undo', 'pinned', - 'actor', 'Actor', + 'actor', 'Actor', 'instance.actor', 'reply', 'replies', 'question', 'like', - 'likes', 'users', 'statuses', 'tags', + 'likes', 'users', 'statuses', 'tags', 'author', 'accounts', 'headers', 'channels', 'profile', 'u', 'c', 'updates', 'repeat', 'announce', @@ -1812,16 +2228,16 @@ def _getReservedWords() -> str: 'ignores', 'linksmobile', 'newswiremobile', 'minimal', 'search', 'eventdelete', 'searchemoji', 'catalog', 'conversationId', - 'mention', 'http', 'https', + 'mention', 'http', 'https', 'ipfs', 'ipns', 'ontologies', 'data') -def getNicknameValidationPattern() -> str: +def get_nickname_validation_pattern() -> str: """Returns a html text input validation pattern for nickname """ - reservedNames = _getReservedWords() + reserved_names = _get_reserved_words() pattern = '' - for word in reservedNames: + for word in reserved_names: if pattern: pattern += '(?!.*\\b' + word + '\\b)' else: @@ -1829,97 +2245,124 @@ def getNicknameValidationPattern() -> str: return pattern + '.*${1,30}' -def _isReservedName(nickname: str) -> bool: +def _is_reserved_name(nickname: str) -> bool: """Is the given nickname reserved for some special function? """ - reservedNames = _getReservedWords() - if nickname in reservedNames: + reserved_names = _get_reserved_words() + if nickname in reserved_names: return True return False -def validNickname(domain: str, nickname: str) -> bool: +def valid_nickname(domain: str, nickname: str) -> bool: """Is the given nickname valid? """ if len(nickname) == 0: return False if len(nickname) > 30: return False - if not isValidLanguage(nickname): + if not _is_valid_language(nickname): return False - forbiddenChars = ('.', ' ', '/', '?', ':', ';', '@', '#', '!') - for c in forbiddenChars: - if c in nickname: + forbidden_chars = ('.', ' ', '/', '?', ':', ';', '@', '#', '!') + for char in forbidden_chars: + if char in nickname: return False # this should only apply for the shared inbox if nickname == domain: return False - if _isReservedName(nickname): + if _is_reserved_name(nickname): return False return True -def noOfAccounts(baseDir: str) -> bool: +def no_of_accounts(base_dir: str) -> bool: """Returns the number of accounts on the system """ - accountCtr = 0 - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + account_ctr = 0 + for _, dirs, _ in os.walk(base_dir + '/accounts'): for account in dirs: - if isAccountDir(account): - accountCtr += 1 + if is_account_dir(account): + account_ctr += 1 break - return accountCtr + return account_ctr -def noOfActiveAccountsMonthly(baseDir: str, months: int) -> bool: +def no_of_active_accounts_monthly(base_dir: str, months: int) -> bool: """Returns the number of accounts on the system this month """ - accountCtr = 0 - currTime = int(time.time()) - monthSeconds = int(60*60*24*30*months) - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + account_ctr = 0 + curr_time = int(time.time()) + month_seconds = int(60*60*24*30*months) + for _, dirs, _ in os.walk(base_dir + '/accounts'): for account in dirs: - if not isAccountDir(account): + if not is_account_dir(account): continue - lastUsedFilename = \ - baseDir + '/accounts/' + account + '/.lastUsed' - if not os.path.isfile(lastUsedFilename): + last_used_filename = \ + base_dir + '/accounts/' + account + '/.lastUsed' + if not os.path.isfile(last_used_filename): continue - with open(lastUsedFilename, 'r') as lastUsedFile: - lastUsed = lastUsedFile.read() - if lastUsed.isdigit(): - timeDiff = (currTime - int(lastUsed)) - if timeDiff < monthSeconds: - accountCtr += 1 + with open(last_used_filename, 'r', + encoding='utf-8') as last_used_file: + last_used = last_used_file.read() + if last_used.isdigit(): + time_diff = (curr_time - int(last_used)) + if time_diff < month_seconds: + account_ctr += 1 break - return accountCtr + return account_ctr -def isPublicPostFromUrl(baseDir: str, nickname: str, domain: str, - postUrl: str) -> bool: +def is_public_post_from_url(base_dir: str, nickname: str, domain: str, + post_url: str) -> bool: """Returns whether the given url is a public post """ - postFilename = locatePost(baseDir, nickname, domain, postUrl) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, post_url) + if not post_filename: return False - postJsonObject = loadJson(postFilename, 1) - if not postJsonObject: + post_json_object = load_json(post_filename, 1) + if not post_json_object: return False - return isPublicPost(postJsonObject) + return is_public_post(post_json_object) -def isPublicPost(postJsonObject: {}) -> bool: +def is_public_post(post_json_object: {}) -> bool: """Returns true if the given post is public """ - if not postJsonObject.get('type'): + if not post_json_object.get('type'): return False - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return False - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if not postJsonObject['object'].get('to'): + if not post_json_object['object'].get('to'): return False - for recipient in postJsonObject['object']['to']: + for recipient in post_json_object['object']['to']: + if recipient.endswith('#Public'): + return True + return False + + +def is_unlisted_post(post_json_object: {}) -> bool: + """Returns true if the given post is unlisted + """ + if not post_json_object.get('type'): + return False + if post_json_object['type'] != 'Create': + return False + if not has_object_dict(post_json_object): + return False + if not post_json_object['object'].get('to'): + return False + if not post_json_object['object'].get('cc'): + return False + has_followers = False + for recipient in post_json_object['object']['to']: + if recipient.endswith('/followers'): + has_followers = True + break + if not has_followers: + return False + for recipient in post_json_object['object']['cc']: if recipient.endswith('#Public'): return True return False @@ -1929,227 +2372,214 @@ def copytree(src: str, dst: str, symlinks: str = False, ignore: bool = None): """Copy a directory """ for item in os.listdir(src): - s = os.path.join(src, item) - d = os.path.join(dst, item) - if os.path.isdir(s): - shutil.copytree(s, d, symlinks, ignore) + s_dir = os.path.join(src, item) + d_dir = os.path.join(dst, item) + if os.path.isdir(s_dir): + shutil.copytree(s_dir, d_dir, symlinks, ignore) else: - shutil.copy2(s, d) + shutil.copy2(s_dir, d_dir) -def getCachedPostDirectory(baseDir: str, nickname: str, domain: str) -> str: +def get_cached_post_directory(base_dir: str, + nickname: str, domain: str) -> str: """Returns the directory where the html post cache exists """ - htmlPostCacheDir = acctDir(baseDir, nickname, domain) + '/postcache' - return htmlPostCacheDir + html_post_cache_dir = acct_dir(base_dir, nickname, domain) + '/postcache' + return html_post_cache_dir -def getCachedPostFilename(baseDir: str, nickname: str, domain: str, - postJsonObject: {}) -> str: +def get_cached_post_filename(base_dir: str, nickname: str, domain: str, + post_json_object: {}) -> str: """Returns the html cache filename for the given post """ - cachedPostDir = getCachedPostDirectory(baseDir, nickname, domain) - if not os.path.isdir(cachedPostDir): - # print('ERROR: invalid html cache directory ' + cachedPostDir) + cached_post_dir = get_cached_post_directory(base_dir, nickname, domain) + if not os.path.isdir(cached_post_dir): + # print('ERROR: invalid html cache directory ' + cached_post_dir) return None - if '@' not in cachedPostDir: - # print('ERROR: invalid html cache directory ' + cachedPostDir) + if '@' not in cached_post_dir: + # print('ERROR: invalid html cache directory ' + cached_post_dir) return None - cachedPostId = removeIdEnding(postJsonObject['id']) - cachedPostFilename = cachedPostDir + '/' + cachedPostId.replace('/', '#') - return cachedPostFilename + '.html' + cached_post_id = remove_id_ending(post_json_object['id']) + cached_post_filename = \ + cached_post_dir + '/' + cached_post_id.replace('/', '#') + return cached_post_filename + '.html' -def updateRecentPostsCache(recentPostsCache: {}, maxRecentPosts: int, - postJsonObject: {}, htmlStr: str) -> None: +def update_recent_posts_cache(recent_posts_cache: {}, max_recent_posts: int, + post_json_object: {}, html_str: str) -> None: """Store recent posts in memory so that they can be quickly recalled """ - if not postJsonObject.get('id'): + if not post_json_object.get('id'): return - postId = postJsonObject['id'] - if '#' in postId: - postId = postId.split('#', 1)[0] - postId = removeIdEnding(postId).replace('/', '#') - if recentPostsCache.get('index'): - if postId in recentPostsCache['index']: + post_id = post_json_object['id'] + if '#' in post_id: + post_id = post_id.split('#', 1)[0] + post_id = remove_id_ending(post_id).replace('/', '#') + if recent_posts_cache.get('index'): + if post_id in recent_posts_cache['index']: return - recentPostsCache['index'].append(postId) - postJsonObject['muted'] = False - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - recentPostsCache['html'][postId] = htmlStr + recent_posts_cache['index'].append(post_id) + post_json_object['muted'] = False + recent_posts_cache['json'][post_id] = json.dumps(post_json_object) + recent_posts_cache['html'][post_id] = html_str - while len(recentPostsCache['html'].items()) > maxRecentPosts: - postId = recentPostsCache['index'][0] - recentPostsCache['index'].pop(0) - if recentPostsCache['json'].get(postId): - del recentPostsCache['json'][postId] - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] + while len(recent_posts_cache['html'].items()) > max_recent_posts: + post_id = recent_posts_cache['index'][0] + recent_posts_cache['index'].pop(0) + if recent_posts_cache['json'].get(post_id): + del recent_posts_cache['json'][post_id] + if recent_posts_cache['html'].get(post_id): + del recent_posts_cache['html'][post_id] else: - recentPostsCache['index'] = [postId] - recentPostsCache['json'] = {} - recentPostsCache['html'] = {} - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - recentPostsCache['html'][postId] = htmlStr + recent_posts_cache['index'] = [post_id] + recent_posts_cache['json'] = {} + recent_posts_cache['html'] = {} + recent_posts_cache['json'][post_id] = json.dumps(post_json_object) + recent_posts_cache['html'][post_id] = html_str -def fileLastModified(filename: str) -> str: +def file_last_modified(filename: str) -> str: """Returns the date when a file was last modified """ - t = os.path.getmtime(filename) - modifiedTime = datetime.datetime.fromtimestamp(t) - return modifiedTime.strftime("%Y-%m-%dT%H:%M:%SZ") + time_val = os.path.getmtime(filename) + modified_time = datetime.datetime.fromtimestamp(time_val) + return modified_time.strftime("%Y-%m-%dT%H:%M:%SZ") -def getCSS(baseDir: str, cssFilename: str, cssCache: {}) -> str: +def get_css(base_dir: str, css_filename: str) -> str: """Retrieves the css for a given file, or from a cache """ # does the css file exist? - if not os.path.isfile(cssFilename): + if not os.path.isfile(css_filename): return None - lastModified = fileLastModified(cssFilename) - - # has this already been loaded into the cache? - if cssCache.get(cssFilename): - if cssCache[cssFilename][0] == lastModified: - # file hasn't changed, so return the version in the cache - return cssCache[cssFilename][1] - - with open(cssFilename, 'r') as fpCSS: - css = fpCSS.read() - if cssCache.get(cssFilename): - # alter the cache contents - cssCache[cssFilename][0] = lastModified - cssCache[cssFilename][1] = css - else: - # add entry to the cache - cssCache[cssFilename] = [lastModified, css] + with open(css_filename, 'r', encoding='utf-8') as fp_css: + css = fp_css.read() return css return None -def isBlogPost(postJsonObject: {}) -> bool: +def is_blog_post(post_json_object: {}) -> bool: """Is the given post a blog post? """ - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return False - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if not postJsonObject['object'].get('type'): + if not has_object_string_type(post_json_object, False): return False - if not postJsonObject['object'].get('content'): + if not post_json_object['object'].get('content'): return False - if postJsonObject['object']['type'] != 'Article': + if post_json_object['object']['type'] != 'Article': return False return True -def isNewsPost(postJsonObject: {}) -> bool: +def is_news_post(post_json_object: {}) -> bool: """Is the given post a blog post? """ - return postJsonObject.get('news') + return post_json_object.get('news') -def _searchVirtualBoxPosts(baseDir: str, nickname: str, domain: str, - searchStr: str, maxResults: int, - boxName: str) -> []: +def _search_virtual_box_posts(base_dir: str, nickname: str, domain: str, + search_str: str, max_results: int, + box_name: str) -> []: """Searches through a virtual box, which is typically an index on the inbox """ - indexFilename = \ - acctDir(baseDir, nickname, domain) + '/' + boxName + '.index' - if boxName == 'bookmarks': - boxName = 'inbox' - path = acctDir(baseDir, nickname, domain) + '/' + boxName + index_filename = \ + acct_dir(base_dir, nickname, domain) + '/' + box_name + '.index' + if box_name == 'bookmarks': + box_name = 'inbox' + path = acct_dir(base_dir, nickname, domain) + '/' + box_name if not os.path.isdir(path): return [] - searchStr = searchStr.lower().strip() + search_str = search_str.lower().strip() - if '+' in searchStr: - searchWords = searchStr.split('+') - for index in range(len(searchWords)): - searchWords[index] = searchWords[index].strip() - print('SEARCH: ' + str(searchWords)) + if '+' in search_str: + search_words = search_str.split('+') + for index, _ in enumerate(search_words): + search_words[index] = search_words[index].strip() + print('SEARCH: ' + str(search_words)) else: - searchWords = [searchStr] + search_words = [search_str] res = [] - with open(indexFilename, 'r') as indexFile: - postFilename = 'start' - while postFilename: - postFilename = indexFile.readline() - if not postFilename: + with open(index_filename, 'r', encoding='utf-8') as index_file: + post_filename = 'start' + while post_filename: + post_filename = index_file.readline() + if not post_filename: break - if '.json' not in postFilename: + if '.json' not in post_filename: break - postFilename = path + '/' + postFilename.strip() - if not os.path.isfile(postFilename): + post_filename = path + '/' + post_filename.strip() + if not os.path.isfile(post_filename): continue - with open(postFilename, 'r') as postFile: - data = postFile.read().lower() + with open(post_filename, 'r', encoding='utf-8') as post_file: + data = post_file.read().lower() - notFound = False - for keyword in searchWords: + not_found = False + for keyword in search_words: if keyword not in data: - notFound = True + not_found = True break - if notFound: + if not_found: continue - res.append(postFilename) - if len(res) >= maxResults: + res.append(post_filename) + if len(res) >= max_results: return res return res -def searchBoxPosts(baseDir: str, nickname: str, domain: str, - searchStr: str, maxResults: int, - boxName='outbox') -> []: +def search_box_posts(base_dir: str, nickname: str, domain: str, + search_str: str, max_results: int, + box_name='outbox') -> []: """Search your posts and return a list of the filenames containing matching strings """ - path = acctDir(baseDir, nickname, domain) + '/' + boxName + path = acct_dir(base_dir, nickname, domain) + '/' + box_name # is this a virtual box, such as direct messages? if not os.path.isdir(path): if os.path.isfile(path + '.index'): - return _searchVirtualBoxPosts(baseDir, nickname, domain, - searchStr, maxResults, boxName) + return _search_virtual_box_posts(base_dir, nickname, domain, + search_str, max_results, box_name) return [] - searchStr = searchStr.lower().strip() + search_str = search_str.lower().strip() - if '+' in searchStr: - searchWords = searchStr.split('+') - for index in range(len(searchWords)): - searchWords[index] = searchWords[index].strip() - print('SEARCH: ' + str(searchWords)) + if '+' in search_str: + search_words = search_str.split('+') + for index, _ in enumerate(search_words): + search_words[index] = search_words[index].strip() + print('SEARCH: ' + str(search_words)) else: - searchWords = [searchStr] + search_words = [search_str] res = [] - for root, dirs, fnames in os.walk(path): + for root, _, fnames in os.walk(path): for fname in fnames: - filePath = os.path.join(root, fname) - with open(filePath, 'r') as postFile: - data = postFile.read().lower() + file_path = os.path.join(root, fname) + with open(file_path, 'r', encoding='utf-8') as post_file: + data = post_file.read().lower() - notFound = False - for keyword in searchWords: + not_found = False + for keyword in search_words: if keyword not in data: - notFound = True + not_found = True break - if notFound: + if not_found: continue - res.append(filePath) - if len(res) >= maxResults: + res.append(file_path) + if len(res) >= max_results: return res break return res -def getFileCaseInsensitive(path: str) -> str: +def get_file_case_insensitive(path: str) -> str: """Returns a case specific filename given a case insensitive version of it """ if os.path.isfile(path): @@ -2160,168 +2590,254 @@ def getFileCaseInsensitive(path: str) -> str: return None -def undoLikesCollectionEntry(recentPostsCache: {}, - baseDir: str, postFilename: str, objectUrl: str, - actor: str, domain: str, debug: bool) -> None: +def undo_likes_collection_entry(recent_posts_cache: {}, + base_dir: str, post_filename: str, + object_url: str, + actor: str, domain: str, debug: bool, + post_json_object: {}) -> None: """Undoes a like for a particular actor """ - postJsonObject = loadJson(postFilename) - if not postJsonObject: + if not post_json_object: + post_json_object = load_json(post_filename) + if not post_json_object: return # remove any cached version of this post so that the # like icon is changed - nickname = getNicknameFromActor(actor) - cachedPostFilename = getCachedPostFilename(baseDir, nickname, - domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + nickname = get_nickname_from_actor(actor) + if not nickname: + return + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, + domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass - removePostFromCache(postJsonObject, recentPostsCache) + os.remove(cached_post_filename) + except OSError: + print('EX: undo_likes_collection_entry ' + + 'unable to delete cached post ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) - if not postJsonObject.get('type'): + if not post_json_object.get('type'): return - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return - if not hasObjectDict(postJsonObject): - if debug: - pprint(postJsonObject) - print('DEBUG: post ' + objectUrl + ' has no object') + obj = post_json_object + if has_object_dict(post_json_object): + obj = post_json_object['object'] + if not obj.get('likes'): return - if not postJsonObject['object'].get('likes'): + if not isinstance(obj['likes'], dict): return - if not isinstance(postJsonObject['object']['likes'], dict): + if not obj['likes'].get('items'): return - if not postJsonObject['object']['likes'].get('items'): - return - totalItems = 0 - if postJsonObject['object']['likes'].get('totalItems'): - totalItems = postJsonObject['object']['likes']['totalItems'] - itemFound = False - for likeItem in postJsonObject['object']['likes']['items']: - if likeItem.get('actor'): - if likeItem['actor'] == actor: + total_items = 0 + if obj['likes'].get('totalItems'): + total_items = obj['likes']['totalItems'] + item_found = False + for like_item in obj['likes']['items']: + if like_item.get('actor'): + if like_item['actor'] == actor: if debug: print('DEBUG: like was removed for ' + actor) - postJsonObject['object']['likes']['items'].remove(likeItem) - itemFound = True + obj['likes']['items'].remove(like_item) + item_found = True break - if not itemFound: + if not item_found: return - if totalItems == 1: + if total_items == 1: if debug: print('DEBUG: likes was removed from post') - del postJsonObject['object']['likes'] + del obj['likes'] else: - itlen = len(postJsonObject['object']['likes']['items']) - postJsonObject['object']['likes']['totalItems'] = itlen + itlen = len(obj['likes']['items']) + obj['likes']['totalItems'] = itlen - saveJson(postJsonObject, postFilename) + save_json(post_json_object, post_filename) -def undoAnnounceCollectionEntry(recentPostsCache: {}, - baseDir: str, postFilename: str, - actor: str, domain: str, debug: bool) -> None: +def undo_reaction_collection_entry(recent_posts_cache: {}, + base_dir: str, post_filename: str, + object_url: str, + actor: str, domain: str, debug: bool, + post_json_object: {}, + emoji_content: str) -> None: + """Undoes an emoji reaction for a particular actor + """ + if not post_json_object: + post_json_object = load_json(post_filename) + if not post_json_object: + return + # remove any cached version of this post so that the + # like icon is changed + nickname = get_nickname_from_actor(actor) + if not nickname: + return + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, + domain, post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): + try: + os.remove(cached_post_filename) + except OSError: + print('EX: undo_reaction_collection_entry ' + + 'unable to delete cached post ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) + + if not post_json_object.get('type'): + return + if post_json_object['type'] != 'Create': + return + obj = post_json_object + if has_object_dict(post_json_object): + obj = post_json_object['object'] + if not obj.get('reactions'): + return + if not isinstance(obj['reactions'], dict): + return + if not obj['reactions'].get('items'): + return + total_items = 0 + if obj['reactions'].get('totalItems'): + total_items = obj['reactions']['totalItems'] + item_found = False + for like_item in obj['reactions']['items']: + if like_item.get('actor'): + if like_item['actor'] == actor and \ + like_item['content'] == emoji_content: + if debug: + print('DEBUG: emoji reaction was removed for ' + actor) + obj['reactions']['items'].remove(like_item) + item_found = True + break + if not item_found: + return + if total_items == 1: + if debug: + print('DEBUG: emoji reaction was removed from post') + del obj['reactions'] + else: + itlen = len(obj['reactions']['items']) + obj['reactions']['totalItems'] = itlen + + save_json(post_json_object, post_filename) + + +def undo_announce_collection_entry(recent_posts_cache: {}, + base_dir: str, post_filename: str, + actor: str, domain: str, + debug: bool) -> None: """Undoes an announce for a particular actor by removing it from the "shares" collection within a post. Note that the "shares" collection has no relation to shared items in shares.py. It's shares of posts, not shares of physical objects. """ - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return # remove any cached version of this announce so that the announce # icon is changed - nickname = getNicknameFromActor(actor) - cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain, - postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + nickname = get_nickname_from_actor(actor) + if not nickname: + return + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, + post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass - removePostFromCache(postJsonObject, recentPostsCache) + os.remove(cached_post_filename) + except OSError: + if debug: + print('EX: undo_announce_collection_entry ' + + 'unable to delete cached post ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) - if not postJsonObject.get('type'): + if not post_json_object.get('type'): return - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): if debug: - pprint(postJsonObject) + pprint(post_json_object) print('DEBUG: post has no object') return - if not postJsonObject['object'].get('shares'): + if not post_json_object['object'].get('shares'): return - if not postJsonObject['object']['shares'].get('items'): + if not post_json_object['object']['shares'].get('items'): return - totalItems = 0 - if postJsonObject['object']['shares'].get('totalItems'): - totalItems = postJsonObject['object']['shares']['totalItems'] - itemFound = False - for announceItem in postJsonObject['object']['shares']['items']: - if announceItem.get('actor'): - if announceItem['actor'] == actor: + total_items = 0 + if post_json_object['object']['shares'].get('totalItems'): + total_items = post_json_object['object']['shares']['totalItems'] + item_found = False + for announce_item in post_json_object['object']['shares']['items']: + if announce_item.get('actor'): + if announce_item['actor'] == actor: if debug: print('DEBUG: Announce was removed for ' + actor) - anIt = announceItem - postJsonObject['object']['shares']['items'].remove(anIt) - itemFound = True + an_it = announce_item + post_json_object['object']['shares']['items'].remove(an_it) + item_found = True break - if not itemFound: + if not item_found: return - if totalItems == 1: + if total_items == 1: if debug: print('DEBUG: shares (announcements) ' + 'was removed from post') - del postJsonObject['object']['shares'] + del post_json_object['object']['shares'] else: - itlen = len(postJsonObject['object']['shares']['items']) - postJsonObject['object']['shares']['totalItems'] = itlen + itlen = len(post_json_object['object']['shares']['items']) + post_json_object['object']['shares']['totalItems'] = itlen - saveJson(postJsonObject, postFilename) + save_json(post_json_object, post_filename) -def updateAnnounceCollection(recentPostsCache: {}, - baseDir: str, postFilename: str, - actor: str, - nickname: str, domain: str, debug: bool) -> None: +def update_announce_collection(recent_posts_cache: {}, + base_dir: str, post_filename: str, + actor: str, nickname: str, domain: str, + debug: bool) -> None: """Updates the announcements collection within a post Confusingly this is known as "shares", but isn't the same as shared items within shares.py It's shares of posts, not shares of physical objects. """ - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return # remove any cached version of this announce so that the announce # icon is changed - cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain, - postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, + post_json_object) + if cached_post_filename: + if os.path.isfile(cached_post_filename): try: - os.remove(cachedPostFilename) - except BaseException: - pass - removePostFromCache(postJsonObject, recentPostsCache) + os.remove(cached_post_filename) + except OSError: + if debug: + print('EX: update_announce_collection ' + + 'unable to delete cached post ' + + str(cached_post_filename)) + remove_post_from_cache(post_json_object, recent_posts_cache) - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): if debug: - pprint(postJsonObject) - print('DEBUG: post ' + postFilename + ' has no object') + pprint(post_json_object) + print('DEBUG: post ' + post_filename + ' has no object') return - postUrl = removeIdEnding(postJsonObject['id']) + '/shares' - if not postJsonObject['object'].get('shares'): + post_url = remove_id_ending(post_json_object['id']) + '/shares' + if not post_json_object['object'].get('shares'): if debug: print('DEBUG: Adding initial shares (announcements) to ' + - postUrl) - announcementsJson = { + post_url) + announcements_json = { "@context": "https://www.w3.org/ns/activitystreams", - 'id': postUrl, + 'id': post_url, 'type': 'Collection', "totalItems": 1, 'items': [{ @@ -2329,21 +2845,21 @@ def updateAnnounceCollection(recentPostsCache: {}, 'actor': actor }] } - postJsonObject['object']['shares'] = announcementsJson + post_json_object['object']['shares'] = announcements_json else: - if postJsonObject['object']['shares'].get('items'): - sharesItems = postJsonObject['object']['shares']['items'] - for announceItem in sharesItems: - if announceItem.get('actor'): - if announceItem['actor'] == actor: + if post_json_object['object']['shares'].get('items'): + shares_items = post_json_object['object']['shares']['items'] + for announce_item in shares_items: + if announce_item.get('actor'): + if announce_item['actor'] == actor: return - newAnnounce = { + new_announce = { 'type': 'Announce', 'actor': actor } - postJsonObject['object']['shares']['items'].append(newAnnounce) - itlen = len(postJsonObject['object']['shares']['items']) - postJsonObject['object']['shares']['totalItems'] = itlen + post_json_object['object']['shares']['items'].append(new_announce) + itlen = len(post_json_object['object']['shares']['items']) + post_json_object['object']['shares']['totalItems'] = itlen else: if debug: print('DEBUG: shares (announcements) section of post ' + @@ -2351,19 +2867,19 @@ def updateAnnounceCollection(recentPostsCache: {}, if debug: print('DEBUG: saving post with shares (announcements) added') - pprint(postJsonObject) - saveJson(postJsonObject, postFilename) + pprint(post_json_object) + save_json(post_json_object, post_filename) -def weekDayOfMonthStart(monthNumber: int, year: int) -> int: +def week_day_of_month_start(month_number: int, year: int) -> int: """Gets the day number of the first day of the month 1=sun, 7=sat """ - firstDayOfMonth = datetime.datetime(year, monthNumber, 1, 0, 0) - return int(firstDayOfMonth.strftime("%w")) + 1 + first_day_of_month = datetime.datetime(year, month_number, 1, 0, 0) + return int(first_day_of_month.strftime("%w")) + 1 -def mediaFileMimeType(filename: str) -> str: +def media_file_mime_type(filename: str) -> str: """Given a media filename return its mime type """ if '.' not in filename: @@ -2372,144 +2888,163 @@ def mediaFileMimeType(filename: str) -> str: 'json': 'application/json', 'png': 'image/png', 'jpg': 'image/jpeg', + 'jxl': 'image/jxl', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp', 'avif': 'image/avif', + 'ico': 'image/x-icon', 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg', + 'opus': 'audio/opus', 'flac': 'audio/flac', 'mp4': 'video/mp4', 'ogv': 'video/ogv' } - fileExt = filename.split('.')[-1] - if not extensions.get(fileExt): + file_ext = filename.split('.')[-1] + if not extensions.get(file_ext): return 'image/png' - return extensions[fileExt] + return extensions[file_ext] -def isRecentPost(postJsonObject: {}, maxDays: int = 3) -> bool: +def is_recent_post(post_json_object: {}, max_days: int) -> bool: """ Is the given post recent? """ - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if not postJsonObject['object'].get('published'): + if not post_json_object['object'].get('published'): return False - if not isinstance(postJsonObject['object']['published'], str): + if not isinstance(post_json_object['object']['published'], str): return False - currTime = datetime.datetime.utcnow() - daysSinceEpoch = (currTime - datetime.datetime(1970, 1, 1)).days - recently = daysSinceEpoch - maxDays + curr_time = datetime.datetime.utcnow() + days_since_epoch = (curr_time - datetime.datetime(1970, 1, 1)).days + recently = days_since_epoch - max_days - publishedDateStr = postJsonObject['object']['published'] + published_date_str = post_json_object['object']['published'] + if '.' in published_date_str: + published_date_str = published_date_str.split('.')[0] + 'Z' try: - publishedDate = \ - datetime.datetime.strptime(publishedDateStr, + published_date = \ + datetime.datetime.strptime(published_date_str, "%Y-%m-%dT%H:%M:%SZ") except BaseException: + print('EX: is_recent_post unrecognized published date ' + + str(published_date_str)) return False - publishedDaysSinceEpoch = \ - (publishedDate - datetime.datetime(1970, 1, 1)).days - if publishedDaysSinceEpoch < recently: + published_days_since_epoch = \ + (published_date - datetime.datetime(1970, 1, 1)).days + if published_days_since_epoch < recently: return False return True -def camelCaseSplit(text: str) -> str: +def camel_case_split(text: str) -> str: """ Splits CamelCase into "Camel Case" """ matches = re.finditer('.+?(?:(?<=[a-z])(?=[A-Z])|' + '(?<=[A-Z])(?=[A-Z][a-z])|$)', text) if not matches: return text - resultStr = '' + result_str = '' for word in matches: - resultStr += word.group(0) + ' ' - return resultStr.strip() + result_str += word.group(0) + ' ' + return result_str.strip() -def rejectPostId(baseDir: str, nickname: str, domain: str, - postId: str, recentPostsCache: {}) -> None: +def convert_to_snake_case(text: str) -> str: + """Convert camel case to snake case + """ + return camel_case_split(text).lower().replace(' ', '_') + + +def _convert_to_camel_case(text: str) -> str: + """Convers a snake case string to camel case + """ + if '_' not in text: + return text + words = text.split('_') + result = '' + ctr = 0 + for wrd in words: + if ctr > 0: + result += wrd.title() + else: + result = wrd.lower() + ctr += 1 + return result + + +def reject_post_id(base_dir: str, nickname: str, domain: str, + post_id: str, recent_posts_cache: {}) -> None: """ Marks the given post as rejected, for example an announce which is too old """ - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, post_id) + if not post_filename: return - if recentPostsCache.get('index'): + if recent_posts_cache.get('index'): # if this is a full path then remove the directories - indexFilename = postFilename - if '/' in postFilename: - indexFilename = postFilename.split('/')[-1] + index_filename = post_filename + if '/' in post_filename: + index_filename = post_filename.split('/')[-1] # filename of the post without any extension or path # This should also correspond to any index entry in # the posts cache - postUrl = \ - indexFilename.replace('\n', '').replace('\r', '') - postUrl = postUrl.replace('.json', '').strip() + post_url = remove_eol(index_filename) + post_url = post_url.replace('.json', '').strip() - if postUrl in recentPostsCache['index']: - if recentPostsCache['json'].get(postUrl): - del recentPostsCache['json'][postUrl] - if recentPostsCache['html'].get(postUrl): - del recentPostsCache['html'][postUrl] + if post_url in recent_posts_cache['index']: + if recent_posts_cache['json'].get(post_url): + del recent_posts_cache['json'][post_url] + if recent_posts_cache['html'].get(post_url): + del recent_posts_cache['html'][post_url] - with open(postFilename + '.reject', 'w+') as rejectFile: - rejectFile.write('\n') + with open(post_filename + '.reject', 'w+', + encoding='utf-8') as reject_file: + reject_file.write('\n') -def isDM(postJsonObject: {}) -> bool: - """Returns true if the given post is a DM +def is_chat_message(post_json_object: {}) -> bool: + """Returns true if the given post is a chat message + Note that is_dm should be checked before calling this """ - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return False - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if postJsonObject['object']['type'] != 'Note' and \ - postJsonObject['object']['type'] != 'Patch' and \ - postJsonObject['object']['type'] != 'EncryptedMessage' and \ - postJsonObject['object']['type'] != 'Article': + if post_json_object['object']['type'] != 'ChatMessage': return False - if postJsonObject['object'].get('moderationStatus'): - return False - fields = ('to', 'cc') - for f in fields: - if not postJsonObject['object'].get(f): - continue - for toAddress in postJsonObject['object'][f]: - if toAddress.endswith('#Public'): - return False - if toAddress.endswith('followers'): - return False return True -def isReply(postJsonObject: {}, actor: str) -> bool: +def is_reply(post_json_object: {}, actor: str) -> bool: """Returns true if the given post is a reply to the given actor """ - if postJsonObject['type'] != 'Create': + if post_json_object['type'] != 'Create': return False - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return False - if postJsonObject['object'].get('moderationStatus'): + if post_json_object['object'].get('moderationStatus'): return False - if postJsonObject['object']['type'] != 'Note' and \ - postJsonObject['object']['type'] != 'EncryptedMessage' and \ - postJsonObject['object']['type'] != 'Article': + if post_json_object['object']['type'] != 'Note' and \ + post_json_object['object']['type'] != 'Page' and \ + post_json_object['object']['type'] != 'EncryptedMessage' and \ + post_json_object['object']['type'] != 'ChatMessage' and \ + post_json_object['object']['type'] != 'Article': return False - if postJsonObject['object'].get('inReplyTo'): - if isinstance(postJsonObject['object']['inReplyTo'], str): - if postJsonObject['object']['inReplyTo'].startswith(actor): + if post_json_object['object'].get('inReplyTo'): + if isinstance(post_json_object['object']['inReplyTo'], str): + if post_json_object['object']['inReplyTo'].startswith(actor): return True - if not postJsonObject['object'].get('tag'): + if not post_json_object['object'].get('tag'): return False - if not isinstance(postJsonObject['object']['tag'], list): + if not isinstance(post_json_object['object']['tag'], list): return False - for tag in postJsonObject['object']['tag']: + for tag in post_json_object['object']['tag']: if not tag.get('type'): continue if tag['type'] == 'Mention': @@ -2520,7 +3055,7 @@ def isReply(postJsonObject: {}, actor: str) -> bool: return False -def containsPGPPublicKey(content: str) -> bool: +def contains_pgp_public_key(content: str) -> bool: """Returns true if the given content contains a PGP public key """ if '--BEGIN PGP PUBLIC KEY BLOCK--' in content: @@ -2529,7 +3064,7 @@ def containsPGPPublicKey(content: str) -> bool: return False -def isPGPEncrypted(content: str) -> bool: +def is_pgp_encrypted(content: str) -> bool: """Returns true if the given content is PGP encrypted """ if '--BEGIN PGP MESSAGE--' in content: @@ -2538,148 +3073,158 @@ def isPGPEncrypted(content: str) -> bool: return False -def loadTranslationsFromFile(baseDir: str, language: str) -> ({}, str): +def invalid_ciphertext(content: str) -> bool: + """Returns true if the given content contains an invalid key + """ + if '----BEGIN ' in content or '----END ' in content: + if not contains_pgp_public_key(content) and \ + not is_pgp_encrypted(content): + return True + return False + + +def load_translations_from_file(base_dir: str, language: str) -> ({}, str): """Returns the translations dictionary """ - if not os.path.isdir(baseDir + '/translations'): + if not os.path.isdir(base_dir + '/translations'): print('ERROR: translations directory not found') - return + return None, None if not language: - systemLanguage = locale.getdefaultlocale()[0] + system_language = locale.getdefaultlocale()[0] else: - systemLanguage = language - if not systemLanguage: - systemLanguage = 'en' - if '_' in systemLanguage: - systemLanguage = systemLanguage.split('_')[0] - while '/' in systemLanguage: - systemLanguage = systemLanguage.split('/')[1] - if '.' in systemLanguage: - systemLanguage = systemLanguage.split('.')[0] - translationsFile = baseDir + '/translations/' + \ - systemLanguage + '.json' - if not os.path.isfile(translationsFile): - systemLanguage = 'en' - translationsFile = baseDir + '/translations/' + \ - systemLanguage + '.json' - return loadJson(translationsFile), systemLanguage + system_language = language + if not system_language: + system_language = 'en' + if '_' in system_language: + system_language = system_language.split('_')[0] + while '/' in system_language: + system_language = system_language.split('/')[1] + if '.' in system_language: + system_language = system_language.split('.')[0] + translations_file = base_dir + '/translations/' + \ + system_language + '.json' + if not os.path.isfile(translations_file): + system_language = 'en' + translations_file = base_dir + '/translations/' + \ + system_language + '.json' + return load_json(translations_file), system_language -def dmAllowedFromDomain(baseDir: str, - nickname: str, domain: str, - sendingActorDomain: str) -> bool: +def dm_allowed_from_domain(base_dir: str, + nickname: str, domain: str, + sending_actor_domain: str) -> bool: """When a DM is received and the .followDMs flag file exists Then optionally some domains can be specified as allowed, regardless of individual follows. i.e. Mostly you only want DMs from followers, but there are a few particular instances that you trust """ - dmAllowedInstancesFilename = \ - acctDir(baseDir, nickname, domain) + '/dmAllowedInstances.txt' - if not os.path.isfile(dmAllowedInstancesFilename): + dm_allowed_instances_file = \ + acct_dir(base_dir, nickname, domain) + '/dmAllowedInstances.txt' + if not os.path.isfile(dm_allowed_instances_file): return False - if sendingActorDomain + '\n' in open(dmAllowedInstancesFilename).read(): + if text_in_file(sending_actor_domain + '\n', dm_allowed_instances_file): return True return False -def getOccupationSkills(actorJson: {}) -> []: +def get_occupation_skills(actor_json: {}) -> []: """Returns the list of skills for an actor """ - if 'hasOccupation' not in actorJson: + if 'hasOccupation' not in actor_json: return [] - if not isinstance(actorJson['hasOccupation'], list): + if not isinstance(actor_json['hasOccupation'], list): return [] - for occupationItem in actorJson['hasOccupation']: - if not isinstance(occupationItem, dict): + for occupation_item in actor_json['hasOccupation']: + if not isinstance(occupation_item, dict): continue - if not occupationItem.get('@type'): + if not occupation_item.get('@type'): continue - if not occupationItem['@type'] == 'Occupation': + if not occupation_item['@type'] == 'Occupation': continue - if not occupationItem.get('skills'): + if not occupation_item.get('skills'): continue - if isinstance(occupationItem['skills'], list): - return occupationItem['skills'] - elif isinstance(occupationItem['skills'], str): - return [occupationItem['skills']] + if isinstance(occupation_item['skills'], list): + return occupation_item['skills'] + if isinstance(occupation_item['skills'], str): + return [occupation_item['skills']] break return [] -def getOccupationName(actorJson: {}) -> str: +def get_occupation_name(actor_json: {}) -> str: """Returns the occupation name an actor """ - if not actorJson.get('hasOccupation'): + if not actor_json.get('hasOccupation'): return "" - if not isinstance(actorJson['hasOccupation'], list): + if not isinstance(actor_json['hasOccupation'], list): return "" - for occupationItem in actorJson['hasOccupation']: - if not isinstance(occupationItem, dict): + for occupation_item in actor_json['hasOccupation']: + if not isinstance(occupation_item, dict): continue - if not occupationItem.get('@type'): + if not occupation_item.get('@type'): continue - if occupationItem['@type'] != 'Occupation': + if occupation_item['@type'] != 'Occupation': continue - if not occupationItem.get('name'): + if not occupation_item.get('name'): continue - if isinstance(occupationItem['name'], str): - return occupationItem['name'] + if isinstance(occupation_item['name'], str): + return occupation_item['name'] break return "" -def setOccupationName(actorJson: {}, name: str) -> bool: +def set_occupation_name(actor_json: {}, name: str) -> bool: """Sets the occupation name of an actor """ - if not actorJson.get('hasOccupation'): + if not actor_json.get('hasOccupation'): return False - if not isinstance(actorJson['hasOccupation'], list): + if not isinstance(actor_json['hasOccupation'], list): return False - for index in range(len(actorJson['hasOccupation'])): - occupationItem = actorJson['hasOccupation'][index] - if not isinstance(occupationItem, dict): + for index, _ in enumerate(actor_json['hasOccupation']): + occupation_item = actor_json['hasOccupation'][index] + if not isinstance(occupation_item, dict): continue - if not occupationItem.get('@type'): + if not occupation_item.get('@type'): continue - if occupationItem['@type'] != 'Occupation': + if occupation_item['@type'] != 'Occupation': continue - occupationItem['name'] = name + occupation_item['name'] = name return True return False -def setOccupationSkillsList(actorJson: {}, skillsList: []) -> bool: +def set_occupation_skills_list(actor_json: {}, skills_list: []) -> bool: """Sets the occupation skills for an actor """ - if 'hasOccupation' not in actorJson: + if 'hasOccupation' not in actor_json: return False - if not isinstance(actorJson['hasOccupation'], list): + if not isinstance(actor_json['hasOccupation'], list): return False - for index in range(len(actorJson['hasOccupation'])): - occupationItem = actorJson['hasOccupation'][index] - if not isinstance(occupationItem, dict): + for index, _ in enumerate(actor_json['hasOccupation']): + occupation_item = actor_json['hasOccupation'][index] + if not isinstance(occupation_item, dict): continue - if not occupationItem.get('@type'): + if not occupation_item.get('@type'): continue - if occupationItem['@type'] != 'Occupation': + if occupation_item['@type'] != 'Occupation': continue - occupationItem['skills'] = skillsList + occupation_item['skills'] = skills_list return True return False -def isAccountDir(dirName: str) -> bool: +def is_account_dir(dir_name: str) -> bool: """Is the given directory an account within /accounts ? """ - if '@' not in dirName: + if '@' not in dir_name: return False - if 'inbox@' in dirName or 'news@' in dirName: + if 'inbox@' in dir_name or 'news@' in dir_name or 'Actor@' in dir_name: return False return True -def permittedDir(path: str) -> bool: +def permitted_dir(path: str) -> bool: """These are special paths which should not be accessible directly via GET or POST """ @@ -2690,90 +3235,90 @@ def permittedDir(path: str) -> bool: return True -def userAgentDomain(userAgent: str, debug: bool) -> str: +def user_agent_domain(user_agent: str, debug: bool) -> str: """If the User-Agent string contains a domain then return it """ - if '+http' not in userAgent: + if 'https://' not in user_agent and 'http://' not in user_agent: return None - agentDomain = userAgent.split('+http')[1].strip() - if '://' in agentDomain: - agentDomain = agentDomain.split('://')[1] - if '/' in agentDomain: - agentDomain = agentDomain.split('/')[0] - if ')' in agentDomain: - agentDomain = agentDomain.split(')')[0].strip() - if ' ' in agentDomain: - agentDomain = agentDomain.replace(' ', '') - if ';' in agentDomain: - agentDomain = agentDomain.replace(';', '') - if '.' not in agentDomain: + agent_domain = '' + if 'https://' in user_agent: + agent_domain = user_agent.split('https://')[1].strip() + else: + agent_domain = user_agent.split('http://')[1].strip() + if '/' in agent_domain: + agent_domain = agent_domain.split('/')[0] + if ')' in agent_domain: + agent_domain = agent_domain.split(')')[0].strip() + if ' ' in agent_domain: + agent_domain = agent_domain.replace(' ', '') + if ';' in agent_domain: + agent_domain = agent_domain.replace(';', '') + if '.' not in agent_domain: return None if debug: - print('User-Agent Domain: ' + agentDomain) - return agentDomain + print('User-Agent Domain: ' + agent_domain) + return agent_domain -def hasObjectDict(postJsonObject: {}) -> bool: - """Returns true if the given post has an object dict - """ - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - return True - return False - - -def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str: +def get_alt_path(actor: str, domain_full: str, calling_domain: str) -> str: """Returns alternate path from the actor eg. https://clearnetdomain/path becomes http://oniondomain/path """ - postActor = actor - if callingDomain not in actor and domainFull in actor: - if callingDomain.endswith('.onion') or \ - callingDomain.endswith('.i2p'): - postActor = \ - 'http://' + callingDomain + actor.split(domainFull)[1] - print('Changed POST domain from ' + actor + ' to ' + postActor) - return postActor + post_actor = actor + if calling_domain not in actor and domain_full in actor: + if calling_domain.endswith('.onion') or \ + calling_domain.endswith('.i2p'): + post_actor = \ + 'http://' + calling_domain + actor.split(domain_full)[1] + print('Changed POST domain from ' + actor + ' to ' + post_actor) + return post_actor -def getActorPropertyUrl(actorJson: {}, propertyName: str) -> str: +def get_actor_property_url(actor_json: {}, property_name: str) -> str: """Returns a url property from an actor """ - if not actorJson.get('attachment'): + if not actor_json.get('attachment'): return '' - propertyName = propertyName.lower() - for propertyValue in actorJson['attachment']: - if not propertyValue.get('name'): + property_name = property_name.lower() + for property_value in actor_json['attachment']: + name_value = None + if property_value.get('name'): + name_value = property_value['name'] + elif property_value.get('schema:name'): + name_value = property_value['schema:name'] + if not name_value: continue - if not propertyValue['name'].lower().startswith(propertyName): + if not name_value.lower().startswith(property_name): continue - if not propertyValue.get('type'): + if not property_value.get('type'): continue - if not propertyValue.get('value'): + prop_value_name, _ = \ + get_attachment_property_value(property_value) + if not prop_value_name: continue - if propertyValue['type'] != 'PropertyValue': + if not property_value['type'].endswith('PropertyValue'): continue - propertyValue['value'] = propertyValue['value'].strip() - prefixes = getProtocolPrefixes() - prefixFound = False + property_value['value'] = property_value[prop_value_name].strip() + prefixes = get_protocol_prefixes() + prefix_found = False for prefix in prefixes: - if propertyValue['value'].startswith(prefix): - prefixFound = True + if property_value[prop_value_name].startswith(prefix): + prefix_found = True break - if not prefixFound: + if not prefix_found: continue - if '.' not in propertyValue['value']: + if '.' not in property_value[prop_value_name]: continue - if ' ' in propertyValue['value']: + if ' ' in property_value[prop_value_name]: continue - if ',' in propertyValue['value']: + if ',' in property_value[prop_value_name]: continue - return propertyValue['value'] + return property_value[prop_value_name] return '' -def removeDomainPort(domain: str) -> str: +def remove_domain_port(domain: str) -> str: """If the domain has a port appended then remove it eg. mydomain.com:80 becomes mydomain.com """ @@ -2784,20 +3329,20 @@ def removeDomainPort(domain: str) -> str: return domain -def getPortFromDomain(domain: str) -> int: +def get_port_from_domain(domain: str) -> int: """If the domain has a port number appended then return it eg. mydomain.com:80 returns 80 """ if ':' in domain: if domain.startswith('did:'): return None - portStr = domain.split(':')[1] - if portStr.isdigit(): - return int(portStr) + port_str = domain.split(':')[1] + if port_str.isdigit(): + return int(port_str) return None -def validUrlPrefix(url: str) -> bool: +def valid_url_prefix(url: str) -> bool: """Does the given url have a valid prefix? """ if '/' not in url: @@ -2809,15 +3354,7 @@ def validUrlPrefix(url: str) -> bool: return False -def removeLineEndings(text: str) -> str: - """Removes any newline from the end of a string - """ - text = text.replace('\n', '') - text = text.replace('\r', '') - return text.strip() - - -def validPassword(password: str) -> bool: +def valid_password(password: str) -> bool: """Returns true if the given password is valid """ if len(password) < 8: @@ -2825,7 +3362,9 @@ def validPassword(password: str) -> bool: return True -def isfloat(value): +def is_float(value) -> bool: + """Is the given value a float? + """ try: float(value) return True @@ -2833,80 +3372,82 @@ def isfloat(value): return False -def dateStringToSeconds(dateStr: str) -> int: +def date_string_to_seconds(date_str: str) -> int: """Converts a date string (eg "published") into seconds since epoch """ try: - expiryTime = \ - datetime.datetime.strptime(dateStr, '%Y-%m-%dT%H:%M:%SZ') + expiry_time = \ + datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%SZ') except BaseException: + print('EX: date_string_to_seconds unable to parse date ' + + str(date_str)) return None - return int(datetime.datetime.timestamp(expiryTime)) + return int(datetime.datetime.timestamp(expiry_time)) -def dateSecondsToString(dateSec: int) -> str: +def date_seconds_to_string(date_sec: int) -> str: """Converts a date in seconds since epoch to a string """ - thisDate = datetime.datetime.fromtimestamp(dateSec) - return thisDate.strftime("%Y-%m-%dT%H:%M:%SZ") + this_date = datetime.datetime.fromtimestamp(date_sec) + return this_date.strftime("%Y-%m-%dT%H:%M:%SZ") -def hasGroupType(baseDir: str, actor: str, personCache: {}, - debug: bool = False) -> bool: +def has_group_type(base_dir: str, actor: str, person_cache: {}, + debug: bool = False) -> bool: """Does the given actor url have a group type? """ # does the actor path clearly indicate that this is a group? # eg. https://lemmy/c/groupname - groupPaths = getGroupPaths() - for grpPath in groupPaths: - if grpPath in actor: + group_paths = get_group_paths() + for grp_path in group_paths: + if grp_path in actor: if debug: - print('grpPath ' + grpPath + ' in ' + actor) + print('grpPath ' + grp_path + ' in ' + actor) return True # is there a cached actor which can be examined for Group type? - return isGroupActor(baseDir, actor, personCache, debug) + return is_group_actor(base_dir, actor, person_cache, debug) -def isGroupActor(baseDir: str, actor: str, personCache: {}, - debug: bool = False) -> bool: +def is_group_actor(base_dir: str, actor: str, person_cache: {}, + debug: bool = False) -> bool: """Is the given actor a group? """ - if personCache: - if personCache.get(actor): - if personCache[actor].get('actor'): - if personCache[actor]['actor'].get('type'): - if personCache[actor]['actor']['type'] == 'Group': + if person_cache: + if person_cache.get(actor): + if person_cache[actor].get('actor'): + if person_cache[actor]['actor'].get('type'): + if person_cache[actor]['actor']['type'] == 'Group': if debug: print('Cached actor ' + actor + ' has Group type') return True return False if debug: print('Actor ' + actor + ' not in cache') - cachedActorFilename = \ - baseDir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' - if not os.path.isfile(cachedActorFilename): + cached_actor_filename = \ + base_dir + '/cache/actors/' + (actor.replace('/', '#')) + '.json' + if not os.path.isfile(cached_actor_filename): if debug: - print('Cached actor file not found ' + cachedActorFilename) + print('Cached actor file not found ' + cached_actor_filename) return False - if '"type": "Group"' in open(cachedActorFilename).read(): + if text_in_file('"type": "Group"', cached_actor_filename): if debug: - print('Group type found in ' + cachedActorFilename) + print('Group type found in ' + cached_actor_filename) return True return False -def isGroupAccount(baseDir: str, nickname: str, domain: str) -> bool: +def is_group_account(base_dir: str, nickname: str, domain: str) -> bool: """Returns true if the given account is a group """ - accountFilename = acctDir(baseDir, nickname, domain) + '.json' - if not os.path.isfile(accountFilename): + account_filename = acct_dir(base_dir, nickname, domain) + '.json' + if not os.path.isfile(account_filename): return False - if '"type": "Group"' in open(accountFilename).read(): + if text_in_file('"type": "Group"', account_filename): return True return False -def getCurrencies() -> {}: +def get_currencies() -> {}: """Returns a dictionary of currencies """ return { @@ -2969,54 +3510,408 @@ def getCurrencies() -> {}: } -def getSupportedLanguages(baseDir: str) -> []: +def get_supported_languages(base_dir: str) -> []: """Returns a list of supported languages """ - translationsDir = baseDir + '/translations' - languagesStr = [] - for subdir, dirs, files in os.walk(translationsDir): - for f in files: - if not f.endswith('.json'): + translations_dir = base_dir + '/translations' + languages_str = [] + for _, _, files in os.walk(translations_dir): + for fname in files: + if not fname.endswith('.json'): continue - lang = f.split('.')[0] + lang = fname.split('.')[0] if len(lang) == 2: - languagesStr.append(lang) + languages_str.append(lang) break - return languagesStr + return languages_str -def getCategoryTypes(baseDir: str) -> []: +def get_category_types(base_dir: str) -> []: """Returns the list of ontologies """ - ontologyDir = baseDir + '/ontology' + ontology_dir = base_dir + '/ontology' categories = [] - for subdir, dirs, files in os.walk(ontologyDir): - for f in files: - if not f.endswith('.json'): + for _, _, files in os.walk(ontology_dir): + for fname in files: + if not fname.endswith('.json'): continue - if '#' in f or '~' in f: + if '#' in fname or '~' in fname: continue - if f.startswith('custom'): + if fname.startswith('custom'): continue - ontologyFilename = f.split('.')[0] - if 'Types' in ontologyFilename: - categories.append(ontologyFilename.replace('Types', '')) + ontology_filename = fname.split('.')[0] + if 'Types' in ontology_filename: + categories.append(ontology_filename.replace('Types', '')) break return categories -def getSharesFilesList() -> []: +def get_shares_files_list() -> []: """Returns the possible shares files """ return ('shares', 'wanted') -def replaceUsersWithAt(actor: str) -> str: +def replace_users_with_at(actor: str) -> str: """ https://domain/users/nick becomes https://domain/@nick """ - uPaths = getUserPaths() - for path in uPaths: + u_paths = get_user_paths() + for path in u_paths: if path in actor: actor = actor.replace(path, '/@') break return actor + + +def has_actor(post_json_object: {}, debug: bool) -> bool: + """Does the given post have an actor? + """ + if post_json_object.get('actor'): + if '#' in post_json_object['actor']: + return False + return True + if debug: + if post_json_object.get('type'): + msg = post_json_object['type'] + ' has missing actor' + if post_json_object.get('id'): + msg += ' ' + post_json_object['id'] + print(msg) + return False + + +def has_object_string_type(post_json_object: {}, debug: bool) -> bool: + """Does the given post have a type field within an object dict? + """ + if not has_object_dict(post_json_object): + if debug: + print('has_object_string_type no object found') + return False + if post_json_object['object'].get('type'): + if isinstance(post_json_object['object']['type'], str): + return True + if debug: + if post_json_object.get('type'): + print('DEBUG: ' + post_json_object['type'] + + ' type within object is not a string') + if debug: + print('No type field within object ' + post_json_object['id']) + return False + + +def has_object_string_object(post_json_object: {}, debug: bool) -> bool: + """Does the given post have an object string field within an object dict? + """ + if not has_object_dict(post_json_object): + if debug: + print('has_object_string_type no object found') + return False + if post_json_object['object'].get('object'): + if isinstance(post_json_object['object']['object'], str): + return True + if debug: + if post_json_object.get('type'): + print('DEBUG: ' + post_json_object['type'] + + ' object within dict is not a string') + if debug: + print('No object field within dict ' + post_json_object['id']) + return False + + +def has_object_string(post_json_object: {}, debug: bool) -> bool: + """Does the given post have an object string field? + """ + if post_json_object.get('object'): + if isinstance(post_json_object['object'], str): + return True + if debug: + if post_json_object.get('type'): + print('DEBUG: ' + post_json_object['type'] + + ' object is not a string') + if debug: + print('No object field within post ' + post_json_object['id']) + return False + + +def get_new_post_endpoints() -> []: + """Returns a list of endpoints for new posts + """ + return ( + 'newpost', 'newblog', 'newunlisted', 'newfollowers', 'newdm', + 'newreminder', 'newreport', 'newquestion', 'newshare', 'newwanted', + 'editblogpost' + ) + + +def get_fav_filename_from_url(base_dir: str, favicon_url: str) -> str: + """Returns the cached filename for a favicon based upon its url + """ + if '://' in favicon_url: + favicon_url = favicon_url.split('://')[1] + if '/favicon.' in favicon_url: + favicon_url = favicon_url.replace('/favicon.', '.') + return base_dir + '/favicons/' + favicon_url.replace('/', '-') + + +def valid_hash_tag(hashtag: str) -> bool: + """Returns true if the give hashtag contains valid characters + """ + # long hashtags are not valid + if len(hashtag) >= 32: + return False + # numbers are not permitted to be hashtags + if hashtag.isdigit(): + return False + if set(hashtag).issubset(VALID_HASHTAG_CHARS): + return True + if _is_valid_language(hashtag): + return True + return False + + +def convert_published_to_local_timezone(published, timezone: str) -> str: + """Converts a post published time into local time + """ + from_zone = tz.gettz('UTC') + if timezone: + try: + to_zone = tz.gettz(timezone) + except BaseException: + pass + if not timezone: + return published + + utc = published.replace(tzinfo=from_zone) + local_time = utc.astimezone(to_zone) + return local_time + + +def load_account_timezones(base_dir: str) -> {}: + """Returns a dictionary containing the preferred timezone for each account + """ + account_timezone = {} + for _, dirs, _ in os.walk(base_dir + '/accounts'): + for acct in dirs: + if '@' not in acct: + continue + if acct.startswith('inbox@') or acct.startswith('Actor@'): + continue + acct_directory = os.path.join(base_dir + '/accounts', acct) + tz_filename = acct_directory + '/timezone.txt' + if not os.path.isfile(tz_filename): + continue + timezone = None + with open(tz_filename, 'r', encoding='utf-8') as fp_timezone: + timezone = fp_timezone.read().strip() + if timezone: + nickname = acct.split('@')[0] + account_timezone[nickname] = timezone + break + return account_timezone + + +def load_bold_reading(base_dir: str) -> {}: + """Returns a dictionary containing the bold reading status for each account + """ + bold_reading = {} + for _, dirs, _ in os.walk(base_dir + '/accounts'): + for acct in dirs: + if '@' not in acct: + continue + if acct.startswith('inbox@') or acct.startswith('Actor@'): + continue + bold_reading_filename = \ + base_dir + '/accounts/' + acct + '/.boldReading' + if os.path.isfile(bold_reading_filename): + nickname = acct.split('@')[0] + bold_reading[nickname] = True + break + return bold_reading + + +def get_account_timezone(base_dir: str, nickname: str, domain: str) -> str: + """Returns the timezone for the given account + """ + tz_filename = \ + base_dir + '/accounts/' + nickname + '@' + domain + '/timezone.txt' + if not os.path.isfile(tz_filename): + return None + timezone = None + with open(tz_filename, 'r', encoding='utf-8') as fp_timezone: + timezone = fp_timezone.read().strip() + return timezone + + +def set_account_timezone(base_dir: str, nickname: str, domain: str, + timezone: str) -> None: + """Sets the timezone for the given account + """ + tz_filename = \ + base_dir + '/accounts/' + nickname + '@' + domain + '/timezone.txt' + timezone = timezone.strip() + with open(tz_filename, 'w+', encoding='utf-8') as fp_timezone: + fp_timezone.write(timezone) + + +def is_onion_request(calling_domain: str, referer_domain: str, + domain: str, onion_domain: str) -> bool: + """Do the given domains indicate that this is a request + from an onion instance + """ + if not onion_domain: + return False + if domain == onion_domain: + return True + if calling_domain.endswith('.onion'): + return True + if not referer_domain: + return False + if referer_domain.endswith('.onion'): + return True + return False + + +def is_i2p_request(calling_domain: str, referer_domain: str, + domain: str, i2p_domain: str) -> bool: + """Do the given domains indicate that this is a request + from an i2p instance + """ + if not i2p_domain: + return False + if domain == i2p_domain: + return True + if calling_domain.endswith('.i2p'): + return True + if not referer_domain: + return False + if referer_domain.endswith('.i2p'): + return True + return False + + +def disallow_announce(content: str) -> bool: + """Are announces/boosts not allowed for the given post? + """ + disallow_strings = ( + ':boost_no:', + ':noboost:', + ':noboosts:', + ':no_boost:', + ':no_boosts:', + ':boosts_no:', + 'dont_repeat', + 'dont_announce', + 'dont_boost', + 'do not boost', + "don't boost", + 'boost_denied', + 'boosts_denied', + 'boostdenied', + 'boostsdenied' + ) + content_lower = content.lower() + for diss in disallow_strings: + if diss in content_lower: + return True + return False + + +def disallow_reply(content: str) -> bool: + """Are replies not allowed for the given post? + """ + disallow_strings = ( + ':reply_no:', + ':noreply:', + ':noreplies:', + ':no_reply:', + ':no_replies:', + ':replies_no:', + 'dont_at_me', + 'do not reply', + "don't reply", + "don't @ me", + 'dont@me', + 'dontatme' + ) + content_lower = content.lower() + for diss in disallow_strings: + if diss in content_lower: + return True + return False + + +def get_attachment_property_value(property_value: {}) -> (str, str): + """Returns the fieldname and value for an attachment property + """ + prop_value = None + prop_value_name = None + if property_value.get('value'): + prop_value = property_value['value'] + prop_value_name = 'value' + elif property_value.get('http://schema.org#value'): + prop_value_name = 'http://schema.org#value' + prop_value = property_value[prop_value_name] + elif property_value.get('https://schema.org#value'): + prop_value_name = 'https://schema.org#value' + prop_value = property_value[prop_value_name] + return prop_value_name, prop_value + + +def safe_system_string(text: str) -> str: + """Returns a safe version of a string which can be used within a + system command + """ + text = text.replace('$(', '(').replace('`', '') + return text + + +def get_json_content_from_accept(accept: str) -> str: + """returns the json content type for the given accept + """ + protocol_str = 'application/json' + if accept: + if 'application/ld+json' in accept: + protocol_str = 'application/ld+json' + return protocol_str + + +def remove_inverted_text(text: str, system_language: str) -> str: + """Removes any inverted text from the given string + """ + if system_language != 'en': + return text + + inverted_lower = [*"_ʎ_ʍʌ_ʇ_ɹ____ɯʃʞɾıɥƃɟǝ_ɔ_ɐ"] + inverted_upper = [*"_⅄__ᴧ∩⊥_ᴚΌԀ_ᴎ_⅂⋊ſ__⅁ℲƎ◖Ↄ𐐒∀"] + + start_separator = '' + separator = '\n' + if '

    ' in text: + text = text.replace('

    ', '') + start_separator = '

    ' + separator = '

    ' + paragraphs = text.split(separator) + new_text = '' + inverted_list = (inverted_lower, inverted_upper) + z_value = (ord('z'), ord('Z')) + for para in paragraphs: + replaced_chars = 0 + + for idx in range(2): + index = 0 + for test_ch in inverted_list[idx]: + if test_ch == '_': + index += 1 + continue + if test_ch in para: + para = para.replace(test_ch, chr(z_value[idx] - index)) + replaced_chars += 1 + index += 1 + + if replaced_chars > 2: + para = para[::-1] + if para: + new_text += start_separator + para + if separator in text: + new_text += separator + + return new_text diff --git a/video.py b/video.py index ef42f8086..7e86fb642 100644 --- a/video.py +++ b/video.py @@ -1,45 +1,45 @@ __filename__ = "video.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" -from utils import getFullDomain -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import removeIdEnding -from blocking import isBlocked -from filters import isFiltered +from utils import get_full_domain +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import remove_id_ending +from blocking import is_blocked +from filters import is_filtered -def convertVideoToNote(baseDir: str, nickname: str, domain: str, - systemLanguage: str, - postJsonObject: {}, blockedCache: {}) -> {}: +def convert_video_to_note(base_dir: str, nickname: str, domain: str, + system_language: str, + post_json_object: {}, blocked_cache: {}) -> {}: """Converts a PeerTube Video ActivityPub(ish) object into a Note, so that it can then be displayed in a timeline """ # check that the required fields are present - requiredFields = ( + required_fields = ( 'type', '@context', 'id', 'published', 'to', 'cc', 'attributedTo', 'commentsEnabled', 'content', 'sensitive', 'name', 'url' ) - for fieldName in requiredFields: - if not postJsonObject.get(fieldName): + for field_name in required_fields: + if not post_json_object.get(field_name): return None - if postJsonObject['type'] != 'Video': + if post_json_object['type'] != 'Video': return None # who is this attributed to ? - attributedTo = None - if isinstance(postJsonObject['attributedTo'], str): - attributedTo = postJsonObject['attributedTo'] - elif isinstance(postJsonObject['attributedTo'], list): - for entity in postJsonObject['attributedTo']: + attributed_to = None + if isinstance(post_json_object['attributedTo'], str): + attributed_to = post_json_object['attributedTo'] + elif isinstance(post_json_object['attributedTo'], list): + for entity in post_json_object['attributedTo']: if not isinstance(entity, dict): continue if not entity.get('type'): @@ -48,131 +48,138 @@ def convertVideoToNote(baseDir: str, nickname: str, domain: str, continue if not entity.get('id'): continue - attributedTo = entity['id'] + attributed_to = entity['id'] break - if not attributedTo: + if not attributed_to: return None # get the language of the video - postLanguage = systemLanguage - if postJsonObject.get('language'): - if isinstance(postJsonObject['language'], dict): - if postJsonObject['language'].get('identifier'): - postLanguage = postJsonObject['language']['identifier'] + post_language = system_language + if post_json_object.get('language'): + if isinstance(post_json_object['language'], dict): + if post_json_object['language'].get('identifier'): + post_language = post_json_object['language']['identifier'] # check that the attributed actor is not blocked - postNickname = getNicknameFromActor(attributedTo) - if not postNickname: + post_nickname = get_nickname_from_actor(attributed_to) + if not post_nickname: return None - postDomain, postDomainPort = getDomainFromActor(attributedTo) - if not postDomain: + post_domain, post_domain_port = get_domain_from_actor(attributed_to) + if not post_domain: return None - postDomainFull = getFullDomain(postDomain, postDomainPort) - if isBlocked(baseDir, nickname, domain, - postNickname, postDomainFull, blockedCache): + post_domain_full = get_full_domain(post_domain, post_domain_port) + if is_blocked(base_dir, nickname, domain, + post_nickname, post_domain_full, blocked_cache): return None # check that the content is valid - if isFiltered(baseDir, nickname, domain, postJsonObject['name']): + if is_filtered(base_dir, nickname, domain, post_json_object['name'], + system_language): return None - if isFiltered(baseDir, nickname, domain, postJsonObject['content']): + if is_filtered(base_dir, nickname, domain, post_json_object['content'], + system_language): return None # get the content - content = '

    ' + postJsonObject['name'] + '

    ' - if postJsonObject.get('license'): - if isinstance(postJsonObject['license'], dict): - if postJsonObject['license'].get('name'): - if isFiltered(baseDir, nickname, domain, - postJsonObject['license']['name']): + content = '

    ' + post_json_object['name'] + '

    ' + if post_json_object.get('license'): + if isinstance(post_json_object['license'], dict): + if post_json_object['license'].get('name'): + if is_filtered(base_dir, nickname, domain, + post_json_object['license']['name'], + system_language): return None - content += '

    ' + postJsonObject['license']['name'] + '

    ' - content += postJsonObject['content'] + content += '

    ' + post_json_object['license']['name'] + '

    ' + post_content = post_json_object['content'] + if post_json_object.get('contentMap'): + if post_json_object['contentMap'].get(system_language): + post_content = post_json_object['contentMap'][system_language] + content += post_content - conversationId = removeIdEnding(postJsonObject['id']) + conversation_id = remove_id_ending(post_json_object['id']) - mediaType = None - mediaUrl = None - mediaTorrent = None - mediaMagnet = None - for mediaLink in postJsonObject['url']: - if not isinstance(mediaLink, dict): + media_type = None + media_url = None + media_torrent = None + media_magnet = None + for media_link in post_json_object['url']: + if not isinstance(media_link, dict): continue - if not mediaLink.get('mediaType'): + if not media_link.get('mediaType'): continue - if not mediaLink.get('href'): + if not media_link.get('href'): continue - if mediaLink['mediaType'] == 'application/x-bittorrent': - mediaTorrent = mediaLink['href'] - if mediaLink['href'].startswith('magnet:'): - mediaMagnet = mediaLink['href'] - if mediaLink['mediaType'] != 'video/mp4' and \ - mediaLink['mediaType'] != 'video/ogv': + if media_link['mediaType'] == 'application/x-bittorrent': + media_torrent = media_link['href'] + if media_link['href'].startswith('magnet:'): + media_magnet = media_link['href'] + if media_link['mediaType'] != 'video/mp4' and \ + media_link['mediaType'] != 'video/ogv': continue - if not mediaUrl: - mediaType = mediaLink['mediaType'] - mediaUrl = mediaLink['href'] + if not media_url: + media_type = media_link['mediaType'] + media_url = media_link['href'] - if not mediaUrl: + if not media_url: return None attachment = [{ - 'mediaType': mediaType, - 'name': postJsonObject['content'], + 'mediaType': media_type, + 'name': post_json_object['content'], 'type': 'Document', - 'url': mediaUrl + 'url': media_url }] - if mediaTorrent or mediaMagnet: + if media_torrent or media_magnet: content += '

    ' - if mediaTorrent: - content += ' ' - if mediaMagnet: - content += '🧲' + if media_torrent: + content += ' ' + if media_magnet: + content += '🧲' content += '

    ' - newPostId = removeIdEnding(postJsonObject['id']) - newPost = { - '@context': postJsonObject['@context'], - 'id': newPostId + '/activity', + new_post_id = remove_id_ending(post_json_object['id']) + new_post = { + '@context': post_json_object['@context'], + 'id': new_post_id + '/activity', 'type': 'Create', - 'actor': attributedTo, - 'published': postJsonObject['published'], - 'to': postJsonObject['to'], - 'cc': postJsonObject['cc'], + 'actor': attributed_to, + 'published': post_json_object['published'], + 'to': post_json_object['to'], + 'cc': post_json_object['cc'], 'object': { - 'id': newPostId, - 'conversation': conversationId, + 'id': new_post_id, + 'conversation': conversation_id, 'type': 'Note', 'summary': None, 'inReplyTo': None, - 'published': postJsonObject['published'], - 'url': newPostId, - 'attributedTo': attributedTo, - 'to': postJsonObject['to'], - 'cc': postJsonObject['cc'], - 'sensitive': postJsonObject['sensitive'], - 'atomUri': newPostId, + 'published': post_json_object['published'], + 'url': new_post_id, + 'attributedTo': attributed_to, + 'to': post_json_object['to'], + 'cc': post_json_object['cc'], + 'sensitive': post_json_object['sensitive'], + 'atomUri': new_post_id, 'inReplyToAtomUri': None, - 'commentsEnabled': postJsonObject['commentsEnabled'], - 'rejectReplies': not postJsonObject['commentsEnabled'], + 'commentsEnabled': post_json_object['commentsEnabled'], + 'rejectReplies': not post_json_object['commentsEnabled'], 'mediaType': 'text/html', 'content': content, 'contentMap': { - postLanguage: content + post_language: content }, 'attachment': attachment, 'tag': [], 'replies': { - 'id': newPostId + '/replies', + 'id': new_post_id + '/replies', 'type': 'Collection', 'first': { 'type': 'CollectionPage', - 'partOf': newPostId + '/replies', + 'partOf': new_post_id + '/replies', 'items': [] } } } } - return newPost + return new_post diff --git a/webapp_about.py b/webapp_about.py index ad794d631..befcf8215 100644 --- a/webapp_about.py +++ b/webapp_about.py @@ -1,7 +1,7 @@ __filename__ = "webapp_about.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,57 +9,58 @@ __module_group__ = "Web Interface" import os from shutil import copyfile -from utils import getConfigParam -from webapp_utils import htmlHeaderWithWebsiteMarkup -from webapp_utils import htmlFooter -from markdown import markdownToHtml +from utils import get_config_param +from webapp_utils import html_header_with_website_markup +from webapp_utils import html_footer +from markdown import markdown_to_html -def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str, - domainFull: str, onionDomain: str, translate: {}, - systemLanguage: str) -> str: +def html_about(base_dir: str, http_prefix: str, + domain_full: str, onion_domain: str, translate: {}, + system_language: str) -> str: """Show the about screen """ - adminNickname = getConfigParam(baseDir, 'admin') - if not os.path.isfile(baseDir + '/accounts/about.md'): - copyfile(baseDir + '/default_about.md', - baseDir + '/accounts/about.md') + admin_nickname = get_config_param(base_dir, 'admin') + if not os.path.isfile(base_dir + '/accounts/about.md'): + copyfile(base_dir + '/default_about.md', + base_dir + '/accounts/about.md') - if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'): - if not os.path.isfile(baseDir + '/accounts/login-background.jpg'): - copyfile(baseDir + '/accounts/login-background-custom.jpg', - baseDir + '/accounts/login-background.jpg') + if os.path.isfile(base_dir + '/accounts/login-background-custom.jpg'): + if not os.path.isfile(base_dir + '/accounts/login-background.jpg'): + copyfile(base_dir + '/accounts/login-background-custom.jpg', + base_dir + '/accounts/login-background.jpg') - aboutText = 'Information about this instance goes here.' - if os.path.isfile(baseDir + '/accounts/about.md'): - with open(baseDir + '/accounts/about.md', 'r') as aboutFile: - aboutText = markdownToHtml(aboutFile.read()) + about_text = 'Information about this instance goes here.' + if os.path.isfile(base_dir + '/accounts/about.md'): + with open(base_dir + '/accounts/about.md', 'r', + encoding='utf-8') as fp_about: + about_text = markdown_to_html(fp_about.read()) - aboutForm = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + about_form = '' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - aboutForm = \ - htmlHeaderWithWebsiteMarkup(cssFilename, instanceTitle, - httpPrefix, domainFull, - systemLanguage) - aboutForm += '
    ' + aboutText + '
    ' - if onionDomain: - aboutForm += \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + about_form = \ + html_header_with_website_markup(css_filename, instance_title, + http_prefix, domain_full, + system_language) + about_form += '
    ' + about_text + '
    ' + if onion_domain: + about_form += \ '
    \n' + \ '

    ' + \ - 'http://' + onionDomain + '

    \n
    \n' - if adminNickname: - adminActor = '/users/' + adminNickname - aboutForm += \ + 'http://' + onion_domain + '

    \n
    \n' + if admin_nickname: + admin_actor = '/users/' + admin_nickname + about_form += \ '
    \n' + \ '

    ' + \ translate['Administered by'] + ' ' + adminNickname + '. ' + \ + admin_actor + '">' + admin_nickname + '. ' + \ translate['Version'] + ' ' + __version__ + \ '

    \n
    \n' - aboutForm += htmlFooter() - return aboutForm + about_form += html_footer() + return about_form diff --git a/webapp_accesskeys.py b/webapp_accesskeys.py index ebf26dae8..0bf6d80c5 100644 --- a/webapp_accesskeys.py +++ b/webapp_accesskeys.py @@ -1,116 +1,133 @@ __filename__ = "webapp_accesskeys.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Accessibility" import os -from utils import isAccountDir -from utils import loadJson -from utils import getConfigParam -from utils import acctDir -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter +from utils import is_account_dir +from utils import load_json +from utils import get_config_param +from utils import acct_dir +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_banner_file -def loadAccessKeysForAccounts(baseDir: str, keyShortcuts: {}, - accessKeysTemplate: {}) -> None: +def load_access_keys_for_accounts(base_dir: str, key_shortcuts: {}, + access_keys_template: {}) -> None: """Loads key shortcuts for each account """ - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue - accountDir = os.path.join(baseDir + '/accounts', acct) - accessKeysFilename = accountDir + '/accessKeys.json' - if not os.path.isfile(accessKeysFilename): + account_dir = os.path.join(base_dir + '/accounts', acct) + access_keys_filename = account_dir + '/access_keys.json' + if not os.path.isfile(access_keys_filename): continue nickname = acct.split('@')[0] - accessKeys = loadJson(accessKeysFilename) - if accessKeys: - keyShortcuts[nickname] = accessKeysTemplate.copy() - for variableName, key in accessKeysTemplate.items(): - if accessKeys.get(variableName): - keyShortcuts[nickname][variableName] = \ - accessKeys[variableName] + access_keys = load_json(access_keys_filename) + if access_keys: + key_shortcuts[nickname] = access_keys_template.copy() + for variable_name, _ in access_keys_template.items(): + if access_keys.get(variable_name): + key_shortcuts[nickname][variable_name] = \ + access_keys[variable_name] break -def htmlAccessKeys(cssCache: {}, baseDir: str, - nickname: str, domain: str, - translate: {}, accessKeys: {}, - defaultAccessKeys: {}, - defaultTimeline: str) -> str: +def html_access_keys(base_dir: str, + nickname: str, domain: str, + translate: {}, access_keys: {}, + default_access_keys: {}, + default_timeline: str, theme: str) -> str: """Show and edit key shortcuts """ - accessKeysFilename = \ - acctDir(baseDir, nickname, domain) + '/accessKeys.json' - if os.path.isfile(accessKeysFilename): - accessKeysFromFile = loadJson(accessKeysFilename) - if accessKeysFromFile: - accessKeys = accessKeysFromFile + access_keys_filename = \ + acct_dir(base_dir, nickname, domain) + '/access_keys.json' + if os.path.isfile(access_keys_filename): + access_keys_from_file = load_json(access_keys_filename) + if access_keys_from_file: + access_keys = access_keys_from_file - accessKeysForm = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + timeline_key = access_keys['menuTimeline'] + submit_key = access_keys['submitButton'] - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - accessKeysForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - accessKeysForm += '
    \n' + access_keys_form = '' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - accessKeysForm += \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + access_keys_form = \ + html_header_with_external_style(css_filename, instance_title, None) + + access_keys_form += \ + '
    \n' + \ + '\n' + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + access_keys_form += '\n' + \ + '
    \n' + + access_keys_form += '
    \n' + + access_keys_form += \ '

    ' + translate['Key Shortcuts'] + '

    \n' - accessKeysForm += \ + access_keys_form += \ '

    ' + translate['These access keys may be used'] + \ '

    ' - accessKeysForm += '
    \n' - timelineKey = accessKeys['menuTimeline'] - submitKey = accessKeys['submitButton'] - accessKeysForm += \ + access_keys_form += \ '
    \n' + \ ' \n' + \ ' \n
    \n' + 'name="submitAccessKeys" accesskey="' + submit_key + '">' + \ + translate['Publish'] + '\n \n' - accessKeysForm += ' \n' - accessKeysForm += ' \n' - accessKeysForm += ' \n' - accessKeysForm += ' \n' - accessKeysForm += ' \n' - accessKeysForm += ' \n' + access_keys_form += '
    \n' + access_keys_form += ' \n' + access_keys_form += ' \n' + access_keys_form += ' \n' + access_keys_form += ' \n' + access_keys_form += ' \n' - for variableName, key in defaultAccessKeys.items(): - if not translate.get(variableName): + for variable_name, key in default_access_keys.items(): + if not translate.get(variable_name): continue - keyStr = '' - keyStr += \ + key_str = '' + key_str += \ '' - if accessKeys.get(variableName): - key = accessKeys[variableName] + translate[variable_name] + '' + if access_keys.get(variable_name): + key = access_keys[variable_name] if len(key) > 1: key = key[0] - keyStr += \ + key_str += \ '\n' - accessKeysForm += keyStr + key_str += '\n' + access_keys_form += key_str - accessKeysForm += ' \n' - accessKeysForm += '
    ' - keyStr += '
    \n' - accessKeysForm += '
    \n' - accessKeysForm += '
    \n' - accessKeysForm += htmlFooter() - return accessKeysForm + access_keys_form += ' \n' + access_keys_form += ' \n' + access_keys_form += ' \n' + access_keys_form += '
    \n' + access_keys_form += html_footer() + return access_keys_form diff --git a/webapp_calendar.py b/webapp_calendar.py index 38d0743e2..9dfbda2be 100644 --- a/webapp_calendar.py +++ b/webapp_calendar.py @@ -1,7 +1,7 @@ __filename__ = "webapp_calendar.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -10,454 +10,604 @@ __module_group__ = "Calendar" import os from datetime import datetime from datetime import date -from shutil import copyfile -from utils import getDisplayName -from utils import getConfigParam -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import locatePost -from utils import loadJson -from utils import weekDayOfMonthStart -from utils import getAltPath -from utils import removeDomainPort -from utils import acctDir -from utils import localActorUrl -from utils import replaceUsersWithAt -from happening import getTodaysEvents -from happening import getCalendarEvents -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import htmlHideFromScreenReader -from webapp_utils import htmlKeyboardNavigation +from utils import get_display_name +from utils import get_config_param +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import locate_post +from utils import load_json +from utils import week_day_of_month_start +from utils import get_alt_path +from utils import remove_domain_port +from utils import acct_dir +from utils import local_actor_url +from utils import replace_users_with_at +from happening import get_todays_events +from happening import get_calendar_events +from happening import get_todays_events_icalendar +from happening import get_month_events_icalendar +from webapp_utils import get_banner_file +from webapp_utils import set_custom_background +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import html_hide_from_screen_reader +from webapp_utils import html_keyboard_navigation +from maps import html_open_street_map -def htmlCalendarDeleteConfirm(cssCache: {}, translate: {}, baseDir: str, - path: str, httpPrefix: str, - domainFull: str, postId: str, postTime: str, - year: int, monthNumber: int, - dayNumber: int, callingDomain: str) -> str: +def html_calendar_delete_confirm(translate: {}, base_dir: str, + path: str, http_prefix: str, + domain_full: str, post_id: str, + post_time: str, + year: int, month_number: int, + day_number: int, calling_domain: str) -> str: """Shows a screen asking to confirm the deletion of a calendar event """ - nickname = getNicknameFromActor(path) - actor = localActorUrl(httpPrefix, nickname, domainFull) - domain, port = getDomainFromActor(actor) - messageId = actor + '/statuses/' + postId + nickname = get_nickname_from_actor(path) + if not nickname: + return None + actor = local_actor_url(http_prefix, nickname, domain_full) + domain, _ = get_domain_from_actor(actor) + message_id = actor + '/statuses/' + post_id - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: return None - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return None - if os.path.isfile(baseDir + '/img/delete-background.png'): - if not os.path.isfile(baseDir + '/accounts/delete-background.png'): - copyfile(baseDir + '/img/delete-background.png', - baseDir + '/accounts/delete-background.png') + delete_post_str = None + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - deletePostStr = None - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' - - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - deletePostStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - deletePostStr += \ - '

    ' + postTime + ' ' + str(year) + '/' + \ - str(monthNumber) + \ - '/' + str(dayNumber) + '

    ' - deletePostStr += '
    ' - deletePostStr += '

    ' + \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + delete_post_str = \ + html_header_with_external_style(css_filename, instance_title, None) + delete_post_str += \ + '

    \n

    ' + post_time + ' ' + str(year) + '/' + \ + str(month_number) + \ + '/' + str(day_number) + '

    \n
    \n' + delete_post_str += '
    ' + delete_post_str += '

    ' + \ translate['Delete this event'] + '

    ' - postActor = getAltPath(actor, domainFull, callingDomain) - deletePostStr += \ - '
    \n' - deletePostStr += ' \n' + delete_post_str += ' \n' - deletePostStr += ' \n' - deletePostStr += ' \n' - deletePostStr += \ + delete_post_str += ' \n' + delete_post_str += ' \n' + delete_post_str += \ ' \n' - deletePostStr += \ + delete_post_str += \ ' \n' - deletePostStr += \ + message_id + '">\n' + delete_post_str += \ ' \n' - deletePostStr += \ + delete_post_str += \ ' \n' - deletePostStr += '
    \n' - deletePostStr += '
    \n' - deletePostStr += htmlFooter() - return deletePostStr + delete_post_str += ' \n' + delete_post_str += '
    \n' + delete_post_str += html_footer() + return delete_post_str -def _htmlCalendarDay(personCache: {}, cssCache: {}, translate: {}, - baseDir: str, path: str, - year: int, monthNumber: int, dayNumber: int, - nickname: str, domain: str, dayEvents: [], - monthName: str, actor: str) -> str: +def _html_calendar_day(person_cache: {}, translate: {}, + base_dir: str, path: str, + year: int, month_number: int, day_number: int, + nickname: str, domain: str, day_events: [], + month_name: str, actor: str, + theme: str, access_keys: {}) -> str: """Show a day within the calendar """ - accountDir = acctDir(baseDir, nickname, domain) - calendarFile = accountDir + '/.newCalendar' - if os.path.isfile(calendarFile): + account_dir = acct_dir(base_dir, nickname, domain) + calendar_file = account_dir + '/.newCalendar' + if os.path.isfile(calendar_file): try: - os.remove(calendarFile) - except BaseException: - pass + os.remove(calendar_file) + except OSError: + print('EX: _html_calendar_day unable to delete ' + calendar_file) - cssFilename = baseDir + '/epicyon-calendar.css' - if os.path.isfile(baseDir + '/calendar.css'): - cssFilename = baseDir + '/calendar.css' + css_filename = base_dir + '/epicyon-calendar.css' + if os.path.isfile(base_dir + '/calendar.css'): + css_filename = base_dir + '/calendar.css' - calActor = actor + cal_actor = actor if '/users/' in actor: - calActor = '/users/' + actor.split('/users/')[1] + cal_actor = '/users/' + actor.split('/users/')[1] - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - calendarStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - calendarStr += '
    \n' - calendarStr += '\n' - calendarStr += '\n' + instance_title = get_config_param(base_dir, 'instanceTitle') + calendar_str = \ + html_header_with_external_style(css_filename, instance_title, None) - if dayEvents: - for eventPost in dayEvents: - eventTime = None - eventDescription = None - eventPlace = None - postId = None - senderName = '' - senderActor = None - eventIsPublic = False + calendar_link = cal_actor + \ + '/calendar?year=' + str(year) + '?month=' + str(month_number) + + # show banner + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + calendar_str += \ + '
    \n\n' + calendar_str += \ + '\n' + \ + '
    \n
    \n' + + calendar_str += '
    \n' + # day header + calendar_str += \ + '
    \n

    \n\n' + datetime_str = str(year) + '-' + str(month_number) + '-' + str(day_number) + calendar_str += \ + '
    ' + str(year) + '\n' + calendar_str += '

    \n
    \n' + calendar_str += '
    \n' - calendarStr += \ - ' \n' - calendarStr += \ - '

    ' + str(dayNumber) + ' ' + monthName + \ - '


    ' + str(year) + '\n' - calendarStr += '
    \n' + # day events list + calendar_str += '\n' + if day_events: + for event_post in day_events: + event_time = None + event_end_time = None + start_time_str = '' + end_time_str = '' + event_description = None + event_place = None + post_id = None + sender_name = '' + sender_actor = None + event_is_public = False # get the time place and description - for ev in eventPost: - if ev['type'] == 'Event': - if ev.get('postId'): - postId = ev['postId'] - if ev.get('startTime'): - eventDate = \ - datetime.strptime(ev['startTime'], + for evnt in event_post: + if evnt['type'] == 'Event': + if evnt.get('post_id'): + post_id = evnt['post_id'] + if evnt.get('startTime'): + start_time_str = evnt['startTime'] + event_date = \ + datetime.strptime(start_time_str, "%Y-%m-%dT%H:%M:%S%z") - eventTime = eventDate.strftime("%H:%M").strip() - if 'public' in ev: - if ev['public'] is True: - eventIsPublic = True - if ev.get('sender'): + event_time = event_date.strftime("%H:%M").strip() + if evnt.get('endTime'): + end_time_str = evnt['endTime'] + event_end_date = \ + datetime.strptime(end_time_str, + "%Y-%m-%dT%H:%M:%S%z") + event_end_time = \ + event_end_date.strftime("%H:%M").strip() + if 'public' in evnt: + if evnt['public'] is True: + event_is_public = True + if evnt.get('sender'): # get display name from sending actor - if ev.get('sender'): - senderActor = ev['sender'] - dispName = \ - getDisplayName(baseDir, senderActor, - personCache) - if dispName: - senderName = \ - '' + \ - dispName + ': ' - if ev.get('name'): - eventDescription = ev['name'].strip() - elif ev['type'] == 'Place': - if ev.get('name'): - eventPlace = ev['name'] + if evnt.get('sender'): + sender_actor = evnt['sender'] + disp_name = \ + get_display_name(base_dir, sender_actor, + person_cache) + if disp_name: + sender_name = \ + '' + \ + disp_name + ': ' + if evnt.get('name'): + event_description = evnt['name'].strip() + elif evnt['type'] == 'Place': + if evnt.get('name'): + event_place = evnt['name'] + if '://' in event_place: + bounding_box_degrees = 0.001 + event_map = \ + html_open_street_map(event_place, + bounding_box_degrees, + translate, + '320', '320') + if event_map: + event_place = event_map # prepend a link to the sender of the calendar item - if senderName and eventDescription: + if sender_name and event_description: # if the sender is also mentioned within the event # description then this is a reminder - senderActor2 = replaceUsersWithAt(senderActor) - if senderActor not in eventDescription and \ - senderActor2 not in eventDescription: - eventDescription = senderName + eventDescription + sender_actor2 = replace_users_with_at(sender_actor) + if sender_actor not in event_description and \ + sender_actor2 not in event_description: + event_description = sender_name + event_description else: - eventDescription = \ - translate['Reminder'] + ': ' + eventDescription + event_description = \ + translate['Reminder'] + ': ' + event_description - deleteButtonStr = '' - if postId: - deleteButtonStr = \ - '\n' - eventClass = 'calendar__day__event' - calItemClass = 'calItem' - if eventIsPublic: - eventClass = 'calendar__day__event__public' - calItemClass = 'calItemPublic' - if eventTime and eventDescription and eventPlace: - calendarStr += \ - '' + \ - '' + \ + '' + deleteButtonStr + '\n' - elif eventTime and eventDescription and not eventPlace: - calendarStr += \ - '' + \ - '' + deleteButtonStr + '\n' - elif not eventTime and eventDescription and not eventPlace: - calendarStr += \ - '' + \ + event_place + '
    ' + event_description + \ + '' + delete_button_str + '\n' + elif event_time and event_description and not event_place: + calendar_str += \ + '' + \ + '' + delete_button_str + '\n' + elif not event_time and event_description and not event_place: + calendar_str += \ + '' + \ '' + deleteButtonStr + '\n' - elif not eventTime and eventDescription and eventPlace: - calendarStr += \ - '' + \ + '' + delete_button_str + '\n' + elif not event_time and event_description and event_place: + calendar_str += \ + '' + \ '' + \ - '' + deleteButtonStr + '\n' - elif eventTime and not eventDescription and eventPlace: - calendarStr += \ - '' + \ - '' + delete_button_str + '\n' + elif event_time and not event_description and event_place: + calendar_str += \ + '' + \ + '' + \ - deleteButtonStr + '\n' + event_place + '' + \ + delete_button_str + '\n' - calendarStr += '\n' - calendarStr += '
    \n' + \
+            delete_button_str = ''
+            if post_id:
+                delete_button_str = \
+                    '<td class=\n' + \
                     translate['Delete this event'] + ' |
    ' + eventTime + \ - '' + \ + event_class = 'calendar__day__event' + cal_item_class = 'calItem' + if event_is_public: + event_class = 'calendar__day__event__public' + cal_item_class = 'calItemPublic' + if event_time: + if event_end_time: + event_time = \ + ' - ' + \ + '' + else: + event_time = \ + '' + if event_time and event_description and event_place: + calendar_str += \ + '
    ' + event_time + \ + '' + \ '' + \ - eventPlace + '
    ' + eventDescription + \ - '
    ' + eventTime + \ - '' + \ - eventDescription + '
    ' + event_time + \ + '' + \ + event_description + '
    ' + \ - '' + \ - eventDescription + '
    ' + \ + event_description + '
    ' + \ - eventPlace + '
    ' + eventDescription + \ - '
    ' + eventTime + \ - '' + \ + '' + \ + event_place + '
    ' + event_description + \ + '
    ' + event_time + \ + '' + \ '' + \ - eventPlace + '
    \n' - calendarStr += htmlFooter() + calendar_str += '\n' + calendar_str += '\n\n' - return calendarStr + # icalendar download link + calendar_str += \ + ' ' + \ + 'iCalendar\n' + + calendar_str += html_footer() + + return calendar_str -def htmlCalendar(personCache: {}, cssCache: {}, translate: {}, - baseDir: str, path: str, - httpPrefix: str, domainFull: str, - textModeBanner: str, accessKeys: {}) -> str: +def html_calendar(person_cache: {}, translate: {}, + base_dir: str, path: str, + http_prefix: str, domain_full: str, + text_mode_banner: str, access_keys: {}, + icalendar: bool, system_language: str, + default_timeline: str, theme: str) -> str: """Show the calendar for a person """ - domain = removeDomainPort(domainFull) + domain = remove_domain_port(domain_full) - monthNumber = 0 - dayNumber = None - year = 1970 - actor = httpPrefix + '://' + domainFull + path.replace('/calendar', '') + text_match = '' + default_year = 1970 + default_month = 0 + month_number = default_month + day_number = None + year = default_year + actor = http_prefix + '://' + domain_full + path.replace('/calendar', '') if '?' in actor: first = True - for p in actor.split('?'): + for part in actor.split('?'): if not first: - if '=' in p: - if p.split('=')[0] == 'year': - numStr = p.split('=')[1] - if numStr.isdigit(): - year = int(numStr) - elif p.split('=')[0] == 'month': - numStr = p.split('=')[1] - if numStr.isdigit(): - monthNumber = int(numStr) - elif p.split('=')[0] == 'day': - numStr = p.split('=')[1] - if numStr.isdigit(): - dayNumber = int(numStr) + if '=' in part: + if part.split('=')[0] == 'year': + num_str = part.split('=')[1] + if len(num_str) <= 5: + if num_str.isdigit(): + year = int(num_str) + elif part.split('=')[0] == 'month': + num_str = part.split('=')[1] + if len(num_str) <= 3: + if num_str.isdigit(): + month_number = int(num_str) + elif part.split('=')[0] == 'day': + num_str = part.split('=')[1] + if len(num_str) <= 3: + if num_str.isdigit(): + day_number = int(num_str) + elif part.split('=')[0] == 'ical': + bool_str = part.split('=')[1] + if bool_str.lower().startswith('t'): + icalendar = True first = False actor = actor.split('?')[0] - currDate = datetime.now() - if year == 1970 and monthNumber == 0: - year = currDate.year - monthNumber = currDate.month + curr_date = datetime.now() + if year == default_year and month_number == default_month: + year = curr_date.year + month_number = curr_date.month - nickname = getNicknameFromActor(actor) + nickname = get_nickname_from_actor(actor) + if not nickname: + return '' - if os.path.isfile(baseDir + '/img/calendar-background.png'): - if not os.path.isfile(baseDir + '/accounts/calendar-background.png'): - copyfile(baseDir + '/img/calendar-background.png', - baseDir + '/accounts/calendar-background.png') + set_custom_background(base_dir, 'calendar-background', + 'calendar-background') - months = ('January', 'February', 'March', 'April', - 'May', 'June', 'July', 'August', 'September', - 'October', 'November', 'December') - monthName = translate[months[monthNumber - 1]] + months = ( + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ) + month_name = translate[months[month_number - 1]] - if dayNumber: - dayEvents = None + if day_number: + if icalendar: + return get_todays_events_icalendar(base_dir, + nickname, domain, + year, month_number, + day_number, + person_cache, + text_match, + system_language) + day_events = None events = \ - getTodaysEvents(baseDir, nickname, domain, - year, monthNumber, dayNumber) + get_todays_events(base_dir, nickname, domain, + year, month_number, day_number, + text_match, system_language) if events: - if events.get(str(dayNumber)): - dayEvents = events[str(dayNumber)] - return _htmlCalendarDay(personCache, cssCache, - translate, baseDir, path, - year, monthNumber, dayNumber, - nickname, domain, dayEvents, - monthName, actor) + if events.get(str(day_number)): + day_events = events[str(day_number)] + return _html_calendar_day(person_cache, + translate, base_dir, path, + year, month_number, day_number, + nickname, domain, day_events, + month_name, actor, + theme, access_keys) + + if icalendar: + return get_month_events_icalendar(base_dir, nickname, domain, + year, month_number, person_cache, + text_match) events = \ - getCalendarEvents(baseDir, nickname, domain, year, monthNumber) + get_calendar_events(base_dir, nickname, domain, year, month_number, + text_match) - prevYear = year - prevMonthNumber = monthNumber - 1 - if prevMonthNumber < 1: - prevMonthNumber = 12 - prevYear = year - 1 + prev_year = year + prev_month_number = month_number - 1 + if prev_month_number < 1: + prev_month_number = 12 + prev_year = year - 1 - nextYear = year - nextMonthNumber = monthNumber + 1 - if nextMonthNumber > 12: - nextMonthNumber = 1 - nextYear = year + 1 + next_year = year + next_month_number = month_number + 1 + if next_month_number > 12: + next_month_number = 1 + next_year = year + 1 - print('Calendar year=' + str(year) + ' month=' + str(monthNumber) + - ' ' + str(weekDayOfMonthStart(monthNumber, year))) + print('Calendar year=' + str(year) + ' month=' + str(month_number) + + ' ' + str(week_day_of_month_start(month_number, year))) - if monthNumber < 12: - daysInMonth = \ - (date(year, monthNumber + 1, 1) - date(year, monthNumber, 1)).days + if month_number < 12: + days_in_month = \ + (date(year, month_number + 1, 1) - + date(year, month_number, 1)).days else: - daysInMonth = \ - (date(year + 1, 1, 1) - date(year, monthNumber, 1)).days - # print('daysInMonth ' + str(monthNumber) + ': ' + str(daysInMonth)) + days_in_month = \ + (date(year + 1, 1, 1) - date(year, month_number, 1)).days + # print('days_in_month ' + str(month_number) + ': ' + str(days_in_month)) - cssFilename = baseDir + '/epicyon-calendar.css' - if os.path.isfile(baseDir + '/calendar.css'): - cssFilename = baseDir + '/calendar.css' + css_filename = base_dir + '/epicyon-calendar.css' + if os.path.isfile(base_dir + '/calendar.css'): + css_filename = base_dir + '/calendar.css' - calActor = actor + cal_actor = actor if '/users/' in actor: - calActor = '/users/' + actor.split('/users/')[1] + cal_actor = '/users/' + actor.split('/users/')[1] - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - headerStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + header_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + # show banner + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + calendar_str = \ + '
    \n\n' + calendar_str += '\n' + \ + '
    \n' # the main graphical calendar as a table - calendarStr = '
    \n' - calendarStr += '\n' - calendarStr += '\n' - calendarStr += '\n' + # calendar table + calendar_str += '

    \n\n
    \n' - calendarStr += \ - ' ' - calendarStr += \ - ' ' + translate['Previous month'] + \
+    calendar_str += '<main>\n<center>\n<p class=\n' + # previous month + calendar_str += \ + ' ' + calendar_str += \ + ' \n' - calendarStr += ' ' - calendarStr += '

    ' + monthName + '

    \n' - calendarStr += \ - ' ' - calendarStr += \ - ' ' + translate['Next month'] + \
+    # header
+    calendar_str += \
+        '  <a href=' + calendar_str += \ + ' \n' + # next month + calendar_str += \ + ' ' + calendar_str += \ + ' \n' - calendarStr += '
    \n' + calendar_str += '\n' + calendar_str += '\n' days = ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat') - for d in days: - calendarStr += ' \n' - calendarStr += '\n' - calendarStr += '\n' - calendarStr += '\n' + for day in days: + calendar_str += ' \n' + calendar_str += '\n' + calendar_str += '\n' + calendar_str += '\n' # beginning of the links used for accessibility - navLinks = {} - timelineLinkStr = htmlHideFromScreenReader('🏠') + ' ' + \ + nav_links = {} + timeline_link_str = html_hide_from_screen_reader('🏠') + ' ' + \ translate['Switch to timeline view'] - navLinks[timelineLinkStr] = calActor + '/inbox' + nav_links[timeline_link_str] = cal_actor + '/' + default_timeline - dayOfMonth = 0 - dow = weekDayOfMonthStart(monthNumber, year) - for weekOfMonth in range(1, 7): - if dayOfMonth == daysInMonth: + day_of_month = 0 + dow = week_day_of_month_start(month_number, year) + for week_of_month in range(1, 7): + if day_of_month == days_in_month: continue - calendarStr += ' \n' - for dayNumber in range(1, 8): - if (weekOfMonth > 1 and dayOfMonth < daysInMonth) or \ - (weekOfMonth == 1 and dayNumber >= dow): - dayOfMonth += 1 + calendar_str += ' \n' + for day_number in range(1, 8): + if (week_of_month > 1 and day_of_month < days_in_month) or \ + (week_of_month == 1 and day_number >= dow): + day_of_month += 1 - isToday = False - if year == currDate.year: - if currDate.month == monthNumber: - if dayOfMonth == currDate.day: - isToday = True - if events.get(str(dayOfMonth)): - url = calActor + '/calendar?year=' + \ + is_today = False + if year == curr_date.year: + if curr_date.month == month_number: + if day_of_month == curr_date.day: + is_today = True + if events.get(str(day_of_month)): + url = cal_actor + '/calendar?year=' + \ str(year) + '?month=' + \ - str(monthNumber) + '?day=' + str(dayOfMonth) - dayDescription = monthName + ' ' + str(dayOfMonth) - dayLink = '' + \ - str(dayOfMonth) + '' + str(month_number) + '?day=' + str(day_of_month) + day_description = month_name + ' ' + str(day_of_month) + datetime_str = \ + str(year) + '-' + str(month_number) + '-' + \ + str(day_of_month) + day_link = '' + \ + '' # accessibility menu links - menuOptionStr = \ - htmlHideFromScreenReader('📅') + ' ' + \ - dayDescription - navLinks[menuOptionStr] = url + menu_option_str = \ + html_hide_from_screen_reader('📅') + ' ' + \ + '' + nav_links[menu_option_str] = url # there are events for this day - if not isToday: - calendarStr += \ + if not is_today: + calendar_str += \ ' \n' + day_link + '\n' else: - calendarStr += \ + calendar_str += \ ' \n' + day_link + '\n' else: # No events today - if not isToday: - calendarStr += \ + if not is_today: + calendar_str += \ ' \n' + str(day_of_month) + '\n' else: - calendarStr += \ + calendar_str += \ ' \n' + 'data-today="">' + str(day_of_month) + '\n' else: - calendarStr += ' \n' - calendarStr += ' \n' + calendar_str += ' \n' + calendar_str += ' \n' - calendarStr += '\n' - calendarStr += '
    ' + \ - translate[d] + '
    ' + \ + translate[day] + '
    ' + \ - dayLink + '' + \ - dayLink + '' + \ - str(dayOfMonth) + '' + str(dayOfMonth) + '
    \n' + calendar_str += '\n' + calendar_str += '\n\n' # end of the links used for accessibility - nextMonthStr = \ - htmlHideFromScreenReader('→') + ' ' + translate['Next month'] - navLinks[nextMonthStr] = calActor + '/calendar?year=' + str(nextYear) + \ - '?month=' + str(nextMonthNumber) - prevMonthStr = \ - htmlHideFromScreenReader('←') + ' ' + translate['Previous month'] - navLinks[prevMonthStr] = calActor + '/calendar?year=' + str(prevYear) + \ - '?month=' + str(prevMonthNumber) - navAccessKeys = { + next_month_str = \ + html_hide_from_screen_reader('→') + ' ' + translate['Next month'] + nav_links[next_month_str] = \ + cal_actor + '/calendar?year=' + str(next_year) + \ + '?month=' + str(next_month_number) + prev_month_str = \ + html_hide_from_screen_reader('←') + ' ' + translate['Previous month'] + nav_links[prev_month_str] = \ + cal_actor + '/calendar?year=' + str(prev_year) + \ + '?month=' + str(prev_month_number) + nav_access_keys = { } - screenReaderCal = \ - htmlKeyboardNavigation(textModeBanner, navLinks, navAccessKeys, - monthName) + screen_reader_cal = \ + html_keyboard_navigation(text_mode_banner, nav_links, nav_access_keys, + month_name) - return headerStr + screenReaderCal + calendarStr + htmlFooter() + new_event_str = \ + '
    \n

    \n' + \ + '➕ ' + \ + translate['Add to the calendar'] + '\n

    \n
    \n' + + calendar_icon_str = \ + ' ' + \ + 'iCalendar\n' + + cal_str = \ + header_str + screen_reader_cal + calendar_str + \ + new_event_str + calendar_icon_str + html_footer() + + return cal_str diff --git a/webapp_column_left.py b/webapp_column_left.py index 7ffdd4636..6d7225e2a 100644 --- a/webapp_column_left.py +++ b/webapp_column_left.py @@ -1,239 +1,259 @@ __filename__ = "webapp_column_left.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" import os -from utils import getConfigParam -from utils import getNicknameFromActor -from utils import isEditor -from utils import removeDomainPort -from utils import localActorUrl -from webapp_utils import sharesTimelineJson -from webapp_utils import htmlPostSeparator -from webapp_utils import getLeftImageFile -from webapp_utils import headerButtonsFrontScreen -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import getBannerFile -from shares import shareCategoryIcon +from utils import get_config_param +from utils import get_nickname_from_actor +from utils import is_editor +from utils import is_artist +from utils import remove_domain_port +from utils import local_actor_url +from webapp_utils import shares_timeline_json +from webapp_utils import html_post_separator +from webapp_utils import get_left_image_file +from webapp_utils import header_buttons_front_screen +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_banner_file +from webapp_utils import edit_text_field +from shares import share_category_icon -def _linksExist(baseDir: str) -> bool: +def _links_exist(base_dir: str) -> bool: """Returns true if links have been created """ - linksFilename = baseDir + '/accounts/links.txt' - return os.path.isfile(linksFilename) + links_filename = base_dir + '/accounts/links.txt' + return os.path.isfile(links_filename) -def _getLeftColumnShares(baseDir: str, - httpPrefix: str, domain: str, domainFull: str, - nickname: str, - maxSharesInLeftColumn: int, - translate: {}, - sharedItemsFederatedDomains: []) -> []: +def _get_left_column_shares(base_dir: str, + http_prefix: str, domain: str, domain_full: str, + nickname: str, + max_shares_in_left_column: int, + translate: {}, + shared_items_federated_domains: []) -> []: """get any shares and turn them into the left column links format """ - pageNumber = 1 - actor = localActorUrl(httpPrefix, nickname, domainFull) + page_number = 1 + actor = local_actor_url(http_prefix, nickname, domain_full) # NOTE: this could potentially be slow if the number of federated # shared items is large - sharesJson, lastPage = \ - sharesTimelineJson(actor, pageNumber, maxSharesInLeftColumn, - baseDir, domain, nickname, maxSharesInLeftColumn, - sharedItemsFederatedDomains, 'shares') - if not sharesJson: + shares_json, _ = \ + shares_timeline_json(actor, page_number, max_shares_in_left_column, + base_dir, domain, nickname, + max_shares_in_left_column, + shared_items_federated_domains, 'shares') + if not shares_json: return [] - linksList = [] + links_list = [] ctr = 0 - for published, item in sharesJson.items(): + for _, item in shares_json.items(): sharedesc = item['displayName'] if '<' in sharedesc or '?' in sharedesc: continue - shareId = item['shareId'] - # selecting this link calls htmlShowShare - shareLink = actor + '?showshare=' + shareId + share_id = item['shareId'] + # selecting this link calls html_show_share + share_link = actor + '?showshare=' + share_id if item.get('category'): - shareLink += '?category=' + item['category'] - shareCategory = shareCategoryIcon(item['category']) + share_link += '?category=' + item['category'] + share_category = share_category_icon(item['category']) - linksList.append(shareCategory + sharedesc + ' ' + shareLink) + links_list.append(share_category + sharedesc + ' ' + share_link) ctr += 1 - if ctr >= maxSharesInLeftColumn: + if ctr >= max_shares_in_left_column: break - if linksList: - linksList = ['* ' + translate['Shares']] + linksList - return linksList + if links_list: + links_list = ['* ' + translate['Shares']] + links_list + return links_list -def _getLeftColumnWanted(baseDir: str, - httpPrefix: str, domain: str, domainFull: str, - nickname: str, - maxSharesInLeftColumn: int, - translate: {}, - sharedItemsFederatedDomains: []) -> []: +def _get_left_column_wanted(base_dir: str, + http_prefix: str, domain: str, domain_full: str, + nickname: str, + max_shares_in_left_column: int, + translate: {}, + shared_items_federated_domains: []) -> []: """get any wanted items and turn them into the left column links format """ - pageNumber = 1 - actor = localActorUrl(httpPrefix, nickname, domainFull) + page_number = 1 + actor = local_actor_url(http_prefix, nickname, domain_full) # NOTE: this could potentially be slow if the number of federated # wanted items is large - sharesJson, lastPage = \ - sharesTimelineJson(actor, pageNumber, maxSharesInLeftColumn, - baseDir, domain, nickname, maxSharesInLeftColumn, - sharedItemsFederatedDomains, 'wanted') - if not sharesJson: + shares_json, _ = \ + shares_timeline_json(actor, page_number, max_shares_in_left_column, + base_dir, domain, nickname, + max_shares_in_left_column, + shared_items_federated_domains, 'wanted') + if not shares_json: return [] - linksList = [] + links_list = [] ctr = 0 - for published, item in sharesJson.items(): + for _, item in shares_json.items(): sharedesc = item['displayName'] if '<' in sharedesc or ';' in sharedesc: continue - shareId = item['shareId'] - # selecting this link calls htmlShowShare - shareLink = actor + '?showwanted=' + shareId - linksList.append(sharedesc + ' ' + shareLink) + share_id = item['shareId'] + # selecting this link calls html_show_share + share_link = actor + '?showwanted=' + share_id + links_list.append(sharedesc + ' ' + share_link) ctr += 1 - if ctr >= maxSharesInLeftColumn: + if ctr >= max_shares_in_left_column: break - if linksList: - linksList = ['* ' + translate['Wanted']] + linksList - return linksList + if links_list: + links_list = ['* ' + translate['Wanted']] + links_list + return links_list -def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str, - httpPrefix: str, translate: {}, - editor: bool, - showBackButton: bool, timelinePath: str, - rssIconAtTop: bool, showHeaderImage: bool, - frontPage: bool, theme: str, - accessKeys: {}, - sharedItemsFederatedDomains: []) -> str: +def get_left_column_content(base_dir: str, nickname: str, domain_full: str, + http_prefix: str, translate: {}, + editor: bool, artist: bool, + show_back_button: bool, timeline_path: str, + rss_icon_at_top: bool, show_header_image: bool, + front_page: bool, theme: str, + access_keys: {}, + shared_items_federated_domains: []) -> str: """Returns html content for the left column """ - htmlStr = '' + html_str = '' - separatorStr = htmlPostSeparator(baseDir, 'left') - domain = removeDomainPort(domainFull) + separator_str = html_post_separator(base_dir, 'left') + domain = remove_domain_port(domain_full) - editImageClass = '' - if showHeaderImage: - leftImageFile, leftColumnImageFilename = \ - getLeftImageFile(baseDir, nickname, domain, theme) + edit_image_class = '' + if show_header_image: + left_image_file, left_column_image_filename = \ + get_left_image_file(base_dir, nickname, domain, theme) # show the image at the top of the column - editImageClass = 'leftColEdit' - if os.path.isfile(leftColumnImageFilename): - editImageClass = 'leftColEditImage' - htmlStr += \ + edit_image_class = 'leftColEdit' + if os.path.isfile(left_column_image_filename): + edit_image_class = 'leftColEditImage' + html_str += \ '\n
    \n \n' + \ + 'alt="" loading="lazy" decoding="async" src="/users/' + \ + nickname + '/' + left_image_file + '" />\n' + \ '
    \n' - if showBackButton: - htmlStr += \ - '
    ' + \ + if show_back_button: + html_str += \ + '
    ' + \ '\n' - if (editor or rssIconAtTop) and not showHeaderImage: - htmlStr += '
    ' + if (editor or rss_icon_at_top) and not show_header_image: + html_str += '
    ' - if editImageClass == 'leftColEdit': - htmlStr += '\n
    \n' + if edit_image_class == 'leftColEdit': + html_str += '\n
    \n' + + html_str += '
    \n' - htmlStr += '
    \n' if editor: # show the edit icon - htmlStr += \ + html_str += \ ' ' + \ - '' + \
+            'accesskey=' + \ + '' + \
             translate['Edit Links'] + ' | \n' + if artist: + # show the theme designer icon + html_str += \ + ' ' + \ + '' + \
+            translate['Theme Designer'] + ' | \n' + # RSS icon if nickname != 'news': # rss feed for this account - rssUrl = httpPrefix + '://' + domainFull + \ + rss_url = http_prefix + '://' + domain_full + \ '/blog/' + nickname + '/rss.xml' else: # rss feed for all accounts on the instance - rssUrl = httpPrefix + '://' + domainFull + '/blog/rss.xml' - if not frontPage: - rssTitle = translate['RSS feed for your blog'] + rss_url = http_prefix + '://' + domain_full + '/blog/rss.xml' + if not front_page: + rss_title = translate['RSS feed for your blog'] else: - rssTitle = translate['RSS feed for this site'] - rssIconStr = \ - ' ' + rssTitle + '' + \ + '' + \
+        rss_title + '\n' - if rssIconAtTop: - htmlStr += rssIconStr - htmlStr += '
    \n' + if rss_icon_at_top: + html_str += rss_icon_str + html_str += '
    \n' - if editImageClass == 'leftColEdit': - htmlStr += '
    \n' + if edit_image_class == 'leftColEdit': + html_str += '
    \n' - if (editor or rssIconAtTop) and not showHeaderImage: - htmlStr += '

    ' + if (editor or rss_icon_at_top) and not show_header_image: + html_str += '

    ' - # if showHeaderImage: - # htmlStr += '
    ' + # if show_header_image: + # html_str += '
    ' # flag used not to show the first separator - firstSeparatorAdded = False + first_separator_added = False - linksFilename = baseDir + '/accounts/links.txt' - linksFileContainsEntries = False - linksList = None - if os.path.isfile(linksFilename): - with open(linksFilename, 'r') as f: - linksList = f.readlines() + links_filename = base_dir + '/accounts/links.txt' + links_file_contains_entries = False + links_list = None + if os.path.isfile(links_filename): + with open(links_filename, 'r', encoding='utf-8') as fp_links: + links_list = fp_links.readlines() - if not frontPage: + if not front_page: # show a number of shares - maxSharesInLeftColumn = 3 - sharesList = \ - _getLeftColumnShares(baseDir, - httpPrefix, domain, domainFull, nickname, - maxSharesInLeftColumn, translate, - sharedItemsFederatedDomains) - if linksList and sharesList: - linksList = sharesList + linksList + max_shares_in_left_column = 3 + shares_list = \ + _get_left_column_shares(base_dir, + http_prefix, domain, domain_full, nickname, + max_shares_in_left_column, translate, + shared_items_federated_domains) + if links_list and shares_list: + links_list = shares_list + links_list - wantedList = \ - _getLeftColumnWanted(baseDir, - httpPrefix, domain, domainFull, nickname, - maxSharesInLeftColumn, translate, - sharedItemsFederatedDomains) - if linksList and wantedList: - linksList = wantedList + linksList + wanted_list = \ + _get_left_column_wanted(base_dir, + http_prefix, domain, domain_full, nickname, + max_shares_in_left_column, translate, + shared_items_federated_domains) + if links_list and wanted_list: + links_list = wanted_list + links_list - newTabStr = ' target="_blank" rel="nofollow noopener noreferrer"' - if linksList: - htmlStr += '\n' - if firstSeparatorAdded: - htmlStr += separatorStr - htmlStr += \ + if first_separator_added: + html_str += separator_str + html_str += \ '' - htmlStr += \ + html_str += \ '' - htmlStr += \ + html_str += \ '' - htmlStr += \ + html_str += \ + '' + html_str += \ '' - if linksFileContainsEntries and not rssIconAtTop: - htmlStr += '
    ' + rssIconStr + '
    ' + if links_file_contains_entries and not rss_icon_at_top: + html_str += '
    ' + rss_icon_str + '
    ' - return htmlStr + return html_str -def htmlLinksMobile(cssCache: {}, baseDir: str, - nickname: str, domainFull: str, - httpPrefix: str, translate, - timelinePath: str, authorized: bool, - rssIconAtTop: bool, - iconsAsButtons: bool, - defaultTimeline: str, - theme: str, accessKeys: {}, - sharedItemsFederatedDomains: []) -> str: +def html_links_mobile(base_dir: str, + nickname: str, domain_full: str, + http_prefix: str, translate, + timeline_path: str, authorized: bool, + rss_icon_at_top: bool, + icons_as_buttons: bool, + default_timeline: str, + theme: str, access_keys: {}, + shared_items_federated_domains: []) -> str: """Show the left column links within mobile view """ - htmlStr = '' + html_str = '' # the css filename - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' # is the user a site editor? if nickname == 'news': editor = False + artist = False else: - editor = isEditor(baseDir, nickname) + editor = is_editor(base_dir, nickname) + artist = is_artist(base_dir, nickname) - domain = removeDomainPort(domainFull) + domain = remove_domain_port(domain_full) - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) - htmlStr += \ - '' + \ - '' + \ + '\n' + 'src="/users/' + nickname + '/' + banner_file + '" />\n' - htmlStr += '
    \n' - htmlStr += '
    ' + \ - headerButtonsFrontScreen(translate, nickname, - 'links', authorized, - iconsAsButtons) + '
    ' - if _linksExist(baseDir): - htmlStr += \ - getLeftColumnContent(baseDir, nickname, domainFull, - httpPrefix, translate, - editor, - False, timelinePath, - rssIconAtTop, False, False, - theme, accessKeys, - sharedItemsFederatedDomains) - else: - if editor: - htmlStr += '


    \n
    \n ' - htmlStr += translate['Select the edit icon to add web links'] - htmlStr += '\n
    \n' + html_str += '
    \n' + html_str += '
    ' + \ + header_buttons_front_screen(translate, nickname, + 'links', authorized, + icons_as_buttons) + '
    ' + html_str += \ + get_left_column_content(base_dir, nickname, domain_full, + http_prefix, translate, + editor, artist, + False, timeline_path, + rss_icon_at_top, False, False, + theme, access_keys, + shared_items_federated_domains) + if editor and not _links_exist(base_dir): + html_str += '


    \n
    \n ' + html_str += translate['Select the edit icon to add web links'] + html_str += '\n
    \n' # end of col-left-mobile - htmlStr += '
    \n' + html_str += '
    \n' - htmlStr += '
    \n' + htmlFooter() - return htmlStr + html_str += '
    \n' + html_footer() + return html_str -def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, - domain: str, port: int, httpPrefix: str, - defaultTimeline: str, theme: str, - accessKeys: {}) -> str: +def html_edit_links(translate: {}, base_dir: str, path: str, + domain: str, port: int, http_prefix: str, + default_timeline: str, theme: str, + access_keys: {}) -> str: """Shows the edit links screen """ if '/users/' not in path: @@ -412,114 +436,139 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, path = path.replace('/inbox', '').replace('/outbox', '') path = path.replace('/shares', '').replace('/wanted', '') - nickname = getNicknameFromActor(path) + nickname = get_nickname_from_actor(path) if not nickname: return '' # is the user a moderator? - if not isEditor(baseDir, nickname): + if not is_editor(base_dir, nickname): return '' - cssFilename = baseDir + '/epicyon-links.css' - if os.path.isfile(baseDir + '/links.css'): - cssFilename = baseDir + '/links.css' + css_filename = base_dir + '/epicyon-links.css' + if os.path.isfile(base_dir + '/links.css'): + css_filename = base_dir + '/links.css' # filename of the banner shown at the top - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - editLinksForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + edit_links_form = \ + html_header_with_external_style(css_filename, instance_title, None) # top banner - editLinksForm += \ + edit_links_form += \ '
    \n' + \ - '\n' - editLinksForm += \ - '\n' + edit_links_form += \ + '\n' + \ + '/users/' + nickname + '/' + banner_file + '" />\n' + \ '
    \n' - editLinksForm += \ + edit_links_form += \ '
    \n' - editLinksForm += \ + edit_links_form += \ '
    \n' - editLinksForm += \ + edit_links_form += \ '
    \n' - editLinksForm += \ + edit_links_form += \ '

    ' + translate['Edit Links'] + '

    ' - editLinksForm += \ + edit_links_form += \ ' \n' - editLinksForm += \ + translate['Publish'] + '" ' + \ + 'accesskey="' + access_keys['submitButton'] + '">\n' + edit_links_form += \ '
    \n' - linksFilename = baseDir + '/accounts/links.txt' - linksStr = '' - if os.path.isfile(linksFilename): - with open(linksFilename, 'r') as fp: - linksStr = fp.read() + links_filename = base_dir + '/accounts/links.txt' + links_str = '' + if os.path.isfile(links_filename): + with open(links_filename, 'r', encoding='utf-8') as fp_links: + links_str = fp_links.read() - editLinksForm += \ + edit_links_form += \ '
    ' - editLinksForm += \ + edit_links_form += \ ' ' + \ translate['One link per line. Description followed by the link.'] + \ '
    ' - editLinksForm += \ + new_col_link_str = translate['New link title and URL'] + edit_links_form += \ + edit_text_field(None, 'newColLink', '', new_col_link_str) + edit_links_form += \ ' ' - editLinksForm += \ + 'style="height:80vh" spellcheck="false">' + links_str + '' + edit_links_form += \ '
    ' - # the admin can edit terms of service and about text - adminNickname = getConfigParam(baseDir, 'admin') - if adminNickname: - if nickname == adminNickname: - aboutFilename = baseDir + '/accounts/about.md' - aboutStr = '' - if os.path.isfile(aboutFilename): - with open(aboutFilename, 'r') as fp: - aboutStr = fp.read() + # the admin can edit terms of service, about and specification text + admin_nickname = get_config_param(base_dir, 'admin') + if admin_nickname: + if nickname == admin_nickname: + about_filename = base_dir + '/accounts/about.md' + about_str = '' + if os.path.isfile(about_filename): + with open(about_filename, 'r', encoding='utf-8') as fp_about: + about_str = fp_about.read() - editLinksForm += \ + edit_links_form += \ '
    ' - editLinksForm += \ + edit_links_form += \ ' ' + \ translate['About this Instance'] + \ '
    ' - editLinksForm += \ + edit_links_form += \ ' ' - editLinksForm += \ + about_str + '' + edit_links_form += \ '
    ' - TOSFilename = baseDir + '/accounts/tos.md' - TOSStr = '' - if os.path.isfile(TOSFilename): - with open(TOSFilename, 'r') as fp: - TOSStr = fp.read() + tos_filename = base_dir + '/accounts/tos.md' + tos_str = '' + if os.path.isfile(tos_filename): + with open(tos_filename, 'r', encoding='utf-8') as fp_tos: + tos_str = fp_tos.read() - editLinksForm += \ + edit_links_form += \ '
    ' - editLinksForm += \ + edit_links_form += \ ' ' + \ translate['Terms of Service'] + \ '
    ' - editLinksForm += \ + edit_links_form += \ ' ' - editLinksForm += \ + tos_str + '' + edit_links_form += \ '
    ' - editLinksForm += htmlFooter() - return editLinksForm + specification_filename = base_dir + '/accounts/activitypub.md' + specification_str = '' + if os.path.isfile(specification_filename): + with open(specification_filename, 'r', + encoding='utf-8') as fp_specification: + specification_str = fp_specification.read() + + edit_links_form += \ + '
    ' + edit_links_form += \ + ' ' + \ + translate['ActivityPub Specification'] + \ + '
    ' + edit_links_form += \ + ' ' + edit_links_form += \ + '
    ' + + edit_links_form += html_footer() + return edit_links_form diff --git a/webapp_column_right.py b/webapp_column_right.py index 74079928f..52399c00d 100644 --- a/webapp_column_right.py +++ b/webapp_column_right.py @@ -1,7 +1,7 @@ __filename__ = "webapp_column_right.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,230 +9,224 @@ __module_group__ = "Web Interface Columns" import os from datetime import datetime -from content import removeLongWords -from content import limitRepeatedWords -from utils import getBaseContentFromPost -from utils import removeHtml -from utils import locatePost -from utils import loadJson -from utils import votesOnNewswireItem -from utils import getNicknameFromActor -from utils import isEditor -from utils import getConfigParam -from utils import removeDomainPort -from utils import acctDir -from posts import isModerator -from webapp_utils import getRightImageFile -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import getBannerFile -from webapp_utils import htmlPostSeparator -from webapp_utils import headerButtonsFrontScreen +from content import remove_long_words +from content import limit_repeated_words +from utils import get_fav_filename_from_url +from utils import get_base_content_from_post +from utils import remove_html +from utils import locate_post +from utils import load_json +from utils import votes_on_newswire_item +from utils import get_nickname_from_actor +from utils import is_editor +from utils import get_config_param +from utils import remove_domain_port +from utils import acct_dir +from posts import is_moderator +from newswire import get_newswire_favicon_url +from webapp_utils import get_right_image_file +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_banner_file +from webapp_utils import html_post_separator +from webapp_utils import header_buttons_front_screen +from webapp_utils import edit_text_field -def _votesIndicator(totalVotes: int, positiveVoting: bool) -> str: +def _votes_indicator(total_votes: int, positive_voting: bool) -> str: """Returns an indicator of the number of votes on a newswire item """ - if totalVotes <= 0: + if total_votes <= 0: return '' - totalVotesStr = ' ' - for v in range(totalVotes): - if positiveVoting: - totalVotesStr += '✓' + total_votes_str = ' ' + for _ in range(total_votes): + if positive_voting: + total_votes_str += '✓' else: - totalVotesStr += '✗' - return totalVotesStr + total_votes_str += '✗' + return total_votes_str -def getRightColumnContent(baseDir: str, nickname: str, domainFull: str, - httpPrefix: str, translate: {}, - moderator: bool, editor: bool, - newswire: {}, positiveVoting: bool, - showBackButton: bool, timelinePath: str, - showPublishButton: bool, - showPublishAsIcon: bool, - rssIconAtTop: bool, - publishButtonAtTop: bool, - authorized: bool, - showHeaderImage: bool, - theme: str, - defaultTimeline: str, - accessKeys: {}) -> str: +def get_right_column_content(base_dir: str, nickname: str, domain_full: str, + http_prefix: str, translate: {}, + moderator: bool, editor: bool, + newswire: {}, positive_voting: bool, + show_back_button: bool, timeline_path: str, + show_publish_button: bool, + show_publish_as_icon: bool, + rss_icon_at_top: bool, + publish_button_at_top: bool, + authorized: bool, + show_header_image: bool, + theme: str, + default_timeline: str, + access_keys: {}) -> str: """Returns html content for the right column """ - htmlStr = '' + html_str = '' - domain = removeDomainPort(domainFull) + domain = remove_domain_port(domain_full) if authorized: # only show the publish button if logged in, otherwise replace it with # a login button - titleStr = translate['Publish a blog article'] - if defaultTimeline == 'tlfeatures': - titleStr = translate['Publish a news article'] - publishButtonStr = \ + title_str = translate['Publish a blog article'] + if default_timeline == 'tlfeatures': + title_str = translate['Publish a news article'] + publish_button_str = \ ' ' + \ - '\n' else: # if not logged in then replace the publish button with # a login button - publishButtonStr = \ - ' \n' # show publish button at the top if needed - if publishButtonAtTop: - htmlStr += '
    ' + publishButtonStr + '
    ' + if publish_button_at_top: + html_str += '
    ' + publish_button_str + '
    ' # show a column header image, eg. title of the theme or newswire banner - editImageClass = '' - if showHeaderImage: - rightImageFile, rightColumnImageFilename = \ - getRightImageFile(baseDir, nickname, domain, theme) + edit_image_class = '' + if show_header_image: + right_image_file, right_column_image_filename = \ + get_right_image_file(base_dir, nickname, domain, theme) # show the image at the top of the column - editImageClass = 'rightColEdit' - if os.path.isfile(rightColumnImageFilename): - editImageClass = 'rightColEditImage' - htmlStr += \ + edit_image_class = 'rightColEdit' + if os.path.isfile(right_column_image_filename): + edit_image_class = 'rightColEditImage' + html_str += \ '\n
    \n' + \ ' \n' + \ + 'alt="" loading="lazy" decoding="async" src="/users/' + \ + nickname + '/' + right_image_file + '" />\n' + \ '
    \n' - if (showPublishButton or editor or rssIconAtTop) and not showHeaderImage: - htmlStr += '
    ' + if show_publish_button or editor or rss_icon_at_top: + if not show_header_image: + html_str += '
    ' - if editImageClass == 'rightColEdit': - htmlStr += '\n
    \n' + if edit_image_class == 'rightColEdit': + html_str += '\n
    \n' # whether to show a back icon # This is probably going to be osolete soon - if showBackButton: - htmlStr += \ - ' ' + \ + if show_back_button: + html_str += \ + ' ' + \ '\n' - if showPublishButton and not publishButtonAtTop: - if not showPublishAsIcon: - htmlStr += publishButtonStr + if show_publish_button and not publish_button_at_top: + if not show_publish_as_icon: + html_str += publish_button_str # show the edit icon if editor: - if os.path.isfile(baseDir + '/accounts/newswiremoderation.txt'): + if os.path.isfile(base_dir + '/accounts/newswiremoderation.txt'): # show the edit icon highlighted - htmlStr += \ + html_str += \ ' ' + \ - '' + \
+                'accesskey=' + \ + '' + \
                 translate['Edit newswire'] + ' | \n' else: # show the edit icon - htmlStr += \ + html_str += \ ' ' + \ - '' + \
+                'accesskey=' + \ + '' + \
                 translate['Edit newswire'] + ' | \n' # show the RSS icons - rssIconStr = \ - ' ' + \ - '' + \
+    rss_icon_str = \
+        '        <a href=' + \ + '' + \
         translate['Hashtag Categories RSS Feed'] + ' | \n' - rssIconStr += \ - ' ' + \ - '' + \
+    rss_icon_str += \
+        '        <a href=' + \ + '' + \
         translate['Newswire RSS Feed'] + ' | \n' - if rssIconAtTop: - htmlStr += rssIconStr + if rss_icon_at_top: + html_str += rss_icon_str # show publish icon at top - if showPublishButton: - if showPublishAsIcon: - titleStr = translate['Publish a blog article'] - if defaultTimeline == 'tlfeatures': - titleStr = translate['Publish a news article'] - htmlStr += \ + if show_publish_button: + if show_publish_as_icon: + title_str = translate['Publish a blog article'] + if default_timeline == 'tlfeatures': + title_str = translate['Publish a news article'] + html_str += \ ' ' + \ - '' + \
-                titleStr + '' + \ + '' + \
+                title_str + '\n' - if editImageClass == 'rightColEdit': - htmlStr += '
    \n' + if edit_image_class == 'rightColEdit': + html_str += '
    \n' else: - if showHeaderImage: - htmlStr += '
    \n' + if show_header_image: + html_str += '
    \n' - if (showPublishButton or editor or rssIconAtTop) and not showHeaderImage: - htmlStr += '

    ' + if show_publish_button or editor or rss_icon_at_top: + if not show_header_image: + html_str += '

    ' # show the newswire lines - newswireContentStr = \ - _htmlNewswire(baseDir, newswire, nickname, moderator, translate, - positiveVoting) - htmlStr += newswireContentStr + newswire_content_str = \ + _html_newswire(base_dir, newswire, nickname, moderator, translate, + positive_voting) + html_str += newswire_content_str # show the rss icon at the bottom, typically on the right hand side - if newswireContentStr and not rssIconAtTop: - htmlStr += '
    ' + rssIconStr + '
    ' - return htmlStr + if newswire_content_str and not rss_icon_at_top: + html_str += '
    ' + rss_icon_str + '
    ' + return html_str -def _getBrokenFavSubstitute() -> str: +def _get_broken_fav_substitute() -> str: """Substitute link used if a favicon is not available """ return " onerror=\"this.onerror=null; this.src='/newswire_favicon.ico'\"" -def _getNewswireFavicon(url: str) -> str: - """Returns a favicon url from the given article link - """ - if '://' not in url: - return '/newswire_favicon.ico' - if url.startswith('http://'): - if not (url.endswith('.onion') or url.endswith('.i2p')): - return '/newswire_favicon.ico' - domain = url.split('://')[1] - if '/' not in domain: - return url + '/favicon.ico' - else: - domain = domain.split('/')[0] - return url.split('://')[0] + '://' + domain + '/favicon.ico' - - -def _htmlNewswire(baseDir: str, newswire: {}, nickname: str, moderator: bool, - translate: {}, positiveVoting: bool) -> str: +def _html_newswire(base_dir: str, newswire: {}, nickname: str, moderator: bool, + translate: {}, positive_voting: bool) -> str: """Converts a newswire dict into html """ - separatorStr = htmlPostSeparator(baseDir, 'right') - htmlStr = '' - for dateStr, item in newswire.items(): - item[0] = removeHtml(item[0]).strip() + separator_str = html_post_separator(base_dir, 'right') + html_str = '' + for date_str, item in newswire.items(): + item[0] = remove_html(item[0]).strip() if not item[0]: continue # remove any CDATA @@ -241,169 +235,205 @@ def _htmlNewswire(baseDir: str, newswire: {}, nickname: str, moderator: bool, if ']' in item[0]: item[0] = item[0].split(']')[0] try: - publishedDate = \ - datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S%z") + published_date = \ + datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S%z") except BaseException: - print('WARN: bad date format ' + dateStr) + print('EX: _html_newswire bad date format ' + date_str) continue - dateShown = publishedDate.strftime("%Y-%m-%d %H:%M") + date_shown = published_date.strftime("%Y-%m-%d %H:%M") - dateStrLink = dateStr.replace('T', ' ') - dateStrLink = dateStrLink.replace('Z', '') + date_str_link = date_str.replace('T', ' ') + date_str_link = date_str_link.replace('Z', '') url = item[1] - faviconUrl = _getNewswireFavicon(url) - faviconLink = '' - if faviconUrl: - faviconLink = \ - '' - moderatedItem = item[5] - htmlStr += separatorStr - if moderatedItem and 'vote:' + nickname in item[2]: - totalVotesStr = '' - totalVotes = 0 - if moderator: - totalVotes = votesOnNewswireItem(item[2]) - totalVotesStr = \ - _votesIndicator(totalVotes, positiveVoting) + favicon_url = get_newswire_favicon_url(url) + favicon_link = '' + if favicon_url: + cached_favicon_filename = \ + get_fav_filename_from_url(base_dir, favicon_url) + if os.path.isfile(cached_favicon_filename): + favicon_url = \ + cached_favicon_filename.replace(base_dir, '') + else: + extensions = \ + ('png', 'jpg', 'gif', 'avif', 'svg', 'webp', 'jxl') + for ext in extensions: + cached_favicon_filename = \ + get_fav_filename_from_url(base_dir, favicon_url) + cached_favicon_filename = \ + cached_favicon_filename.replace('.ico', '.' + ext) + if os.path.isfile(cached_favicon_filename): + favicon_url = \ + cached_favicon_filename.replace(base_dir, '') - title = removeLongWords(item[0], 16, []).replace('\n', '
    ') - title = limitRepeatedWords(title, 6) - htmlStr += '

    ' + \ - '' + moderated_item = item[5] + link_url = url + + # is this a podcast episode? + if len(item) > 8: + # change the link url to a podcast episode screen + podcast_properties = item[8] + if podcast_properties: + if podcast_properties.get('image'): + episode_id = date_str.replace(' ', '__') + episode_id = episode_id.replace(':', 'aa') + link_url = \ + '/users/' + nickname + '/?podepisode=' + episode_id + + html_str += separator_str + if moderated_item and 'vote:' + nickname in item[2]: + total_votes_str = '' + total_votes = 0 + if moderator: + total_votes = votes_on_newswire_item(item[2]) + total_votes_str = \ + _votes_indicator(total_votes, positive_voting) + + title = remove_long_words(item[0], 16, []).replace('\n', '
    ') + title = limit_repeated_words(title, 6) + html_str += '

    ' + \ + '' + \ '' + \ - faviconLink + title + '' + totalVotesStr + favicon_link + title + '' + total_votes_str if moderator: - htmlStr += \ - ' ' + dateShown + '' - htmlStr += '' + html_str += '

    \n' else: - htmlStr += ' ' - htmlStr += dateShown + '

    \n' + html_str += ' ' + html_str += date_shown + '

    \n' else: - totalVotesStr = '' - totalVotes = 0 + total_votes_str = '' + total_votes = 0 if moderator: - if moderatedItem: - totalVotes = votesOnNewswireItem(item[2]) + if moderated_item: + total_votes = votes_on_newswire_item(item[2]) # show a number of ticks or crosses for how many # votes for or against - totalVotesStr = \ - _votesIndicator(totalVotes, positiveVoting) + total_votes_str = \ + _votes_indicator(total_votes, positive_voting) - title = removeLongWords(item[0], 16, []).replace('\n', '
    ') - title = limitRepeatedWords(title, 6) - if moderator and moderatedItem: - htmlStr += '

    ' + \ - '') + title = limit_repeated_words(title, 6) + if moderator and moderated_item: + html_str += '

    ' + \ + '' + \ - faviconLink + title + '' + totalVotesStr - htmlStr += ' ' + dateShown - htmlStr += '' - htmlStr += '' + total_votes_str + html_str += ' ' + date_shown + html_str += '' + html_str += '' - htmlStr += '

    \n' + html_str += '

    \n' else: - htmlStr += '

    ' + \ - '' + \ - faviconLink + title + '' + totalVotesStr - htmlStr += ' ' - htmlStr += dateShown + '

    \n' + favicon_link + title + '' + total_votes_str + html_str += ' ' + html_str += date_shown + '

    \n' - if htmlStr: - htmlStr = '\n' - return htmlStr + if html_str: + html_str = \ + '\n' + return html_str -def htmlCitations(baseDir: str, nickname: str, domain: str, - httpPrefix: str, defaultTimeline: str, - translate: {}, newswire: {}, cssCache: {}, - blogTitle: str, blogContent: str, - blogImageFilename: str, - blogImageAttachmentMediaType: str, - blogImageDescription: str, - theme: str) -> str: +def html_citations(base_dir: str, nickname: str, domain: str, + http_prefix: str, default_timeline: str, + translate: {}, newswire: {}, + blog_title: str, blog_content: str, + blog_image_filename: str, + blog_image_attachment_media_type: str, + blog_image_description: str, + theme: str) -> str: """Show the citations screen when creating a blog """ - htmlStr = '' + html_str = '' # create a list of dates for citations # these can then be used to re-select checkboxes later - citationsFilename = \ - acctDir(baseDir, nickname, domain) + '/.citations.txt' - citationsSelected = [] - if os.path.isfile(citationsFilename): - citationsSeparator = '#####' - with open(citationsFilename, 'r') as f: - citations = f.readlines() + citations_filename = \ + acct_dir(base_dir, nickname, domain) + '/.citations.txt' + citations_selected = [] + if os.path.isfile(citations_filename): + citations_separator = '#####' + with open(citations_filename, 'r', encoding='utf-8') as fp_cit: + citations = fp_cit.readlines() for line in citations: - if citationsSeparator not in line: + if citations_separator not in line: continue - sections = line.strip().split(citationsSeparator) + sections = line.strip().split(citations_separator) if len(sections) != 3: continue - dateStr = sections[0] - citationsSelected.append(dateStr) + date_str = sections[0] + citations_selected.append(date_str) # the css filename - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + html_str = \ + html_header_with_external_style(css_filename, instance_title, None) # top banner - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) - htmlStr += \ + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + html_str += \ '\n' - htmlStr += '\n' + translate['Go Back'] + '" class="imageAnchor">\n' + html_str += '\n' - htmlStr += \ + html_str += \ '\n' - htmlStr += '
    \n' - htmlStr += translate['Choose newswire items ' + - 'referenced in your article'] + '
    ' - if blogTitle is None: - blogTitle = '' - htmlStr += \ + html_str += '
    \n' + html_str += translate['Choose newswire items ' + + 'referenced in your article'] + '
    ' + if blog_title is None: + blog_title = '' + html_str += \ ' \n' - if blogContent is None: - blogContent = '' - htmlStr += \ + blog_title + '">\n' + if blog_content is None: + blog_content = '' + html_str += \ ' \n' + blog_content + '">\n' # submit button - htmlStr += \ + html_str += \ ' \n' - htmlStr += '
    \n' + translate['Publish'] + '">\n' + html_str += '
    \n' - citationsSeparator = '#####' + citations_separator = '#####' # list of newswire items if newswire: ctr = 0 - for dateStr, item in newswire.items(): - item[0] = removeHtml(item[0]).strip() + for date_str, item in newswire.items(): + item[0] = remove_html(item[0]).strip() if not item[0]: continue # remove any CDATA @@ -412,114 +442,114 @@ def htmlCitations(baseDir: str, nickname: str, domain: str, if ']' in item[0]: item[0] = item[0].split(']')[0] # should this checkbox be selected? - selectedStr = '' - if dateStr in citationsSelected: - selectedStr = ' checked' + selected_str = '' + if date_str in citations_selected: + selected_str = ' checked' - publishedDate = \ - datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S%z") - dateShown = publishedDate.strftime("%Y-%m-%d %H:%M") + published_date = \ + datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S%z") + date_shown = published_date.strftime("%Y-%m-%d %H:%M") - title = removeLongWords(item[0], 16, []).replace('\n', '
    ') - title = limitRepeatedWords(title, 6) + title = remove_long_words(item[0], 16, []).replace('\n', '
    ') + title = limit_repeated_words(title, 6) link = item[1] - citationValue = \ - dateStr + citationsSeparator + \ - title + citationsSeparator + \ + citation_value = \ + date_str + citations_separator + \ + title + citations_separator + \ link - htmlStr += \ + html_str += \ '' + \ + '" value="' + citation_value + '"' + selected_str + '/>' + \ '' + title + ' ' - htmlStr += '' + \ - dateShown + '
    \n' + html_str += '' + \ + date_shown + '
    \n' ctr += 1 - htmlStr += '\n' - return htmlStr + htmlFooter() + html_str += '\n' + return html_str + html_footer() -def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str, - domain: str, domainFull: str, - httpPrefix: str, translate: {}, - newswire: {}, - positiveVoting: bool, - timelinePath: str, - showPublishAsIcon: bool, - authorized: bool, - rssIconAtTop: bool, - iconsAsButtons: bool, - defaultTimeline: str, - theme: str, - accessKeys: {}) -> str: +def html_newswire_mobile(base_dir: str, nickname: str, + domain: str, domain_full: str, + http_prefix: str, translate: {}, + newswire: {}, + positive_voting: bool, + timeline_path: str, + show_publish_as_icon: bool, + authorized: bool, + rss_icon_at_top: bool, + icons_as_buttons: bool, + default_timeline: str, + theme: str, + access_keys: {}) -> str: """Shows the mobile version of the newswire right column """ - htmlStr = '' + html_str = '' # the css filename - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' if nickname == 'news': editor = False moderator = False else: # is the user a moderator? - moderator = isModerator(baseDir, nickname) + moderator = is_moderator(base_dir, nickname) # is the user a site editor? - editor = isEditor(baseDir, nickname) + editor = is_editor(base_dir, nickname) - showPublishButton = editor + show_publish_button = editor - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + html_str = \ + html_header_with_external_style(css_filename, instance_title, None) - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) - htmlStr += \ - '' + \ - '' + \ + '\n' + 'src="/users/' + nickname + '/' + banner_file + '" />\n' - htmlStr += '
    \n' + html_str += '
    \n' - htmlStr += '
    ' + \ - headerButtonsFrontScreen(translate, nickname, - 'newswire', authorized, - iconsAsButtons) + '
    ' - if newswire: - htmlStr += \ - getRightColumnContent(baseDir, nickname, domainFull, - httpPrefix, translate, - moderator, editor, - newswire, positiveVoting, - False, timelinePath, showPublishButton, - showPublishAsIcon, rssIconAtTop, False, - authorized, False, theme, - defaultTimeline, accessKeys) - else: - if editor: - htmlStr += '


    \n' - htmlStr += '
    \n ' - htmlStr += translate['Select the edit icon to add RSS feeds'] - htmlStr += '\n
    \n' + html_str += '
    ' + \ + header_buttons_front_screen(translate, nickname, + 'newswire', authorized, + icons_as_buttons) + '
    ' + html_str += \ + get_right_column_content(base_dir, nickname, domain_full, + http_prefix, translate, + moderator, editor, + newswire, positive_voting, + False, timeline_path, show_publish_button, + show_publish_as_icon, rss_icon_at_top, False, + authorized, False, theme, + default_timeline, access_keys) + if editor and not newswire: + html_str += '


    \n' + html_str += '
    \n ' + html_str += translate['Select the edit icon to add RSS feeds'] + html_str += '\n
    \n' # end of col-right-mobile - htmlStr += '' + html_str += '' - htmlStr += htmlFooter() - return htmlStr + html_str += html_footer() + return html_str -def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, - domain: str, port: int, httpPrefix: str, - defaultTimeline: str, theme: str, - accessKeys: {}) -> str: +def html_edit_newswire(translate: {}, base_dir: str, path: str, + domain: str, port: int, http_prefix: str, + default_timeline: str, theme: str, + access_keys: {}, dogwhistles: {}) -> str: """Shows the edit newswire screen """ if '/users/' not in path: @@ -527,185 +557,209 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, path = path.replace('/inbox', '').replace('/outbox', '') path = path.replace('/shares', '').replace('/wanted', '') - nickname = getNicknameFromActor(path) + nickname = get_nickname_from_actor(path) if not nickname: return '' # is the user a moderator? - if not isModerator(baseDir, nickname): + if not is_moderator(base_dir, nickname): return '' - cssFilename = baseDir + '/epicyon-links.css' - if os.path.isfile(baseDir + '/links.css'): - cssFilename = baseDir + '/links.css' + css_filename = base_dir + '/epicyon-links.css' + if os.path.isfile(base_dir + '/links.css'): + css_filename = base_dir + '/links.css' # filename of the banner shown at the top - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - editNewswireForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + edit_newswire_form = \ + html_header_with_external_style(css_filename, instance_title, None) # top banner - editNewswireForm += \ + edit_newswire_form += \ '
    ' + \ - '\n' - editNewswireForm += '\n' + edit_newswire_form += \ + '\n
    ' - editNewswireForm += \ + edit_newswire_form += \ '
    \n' - editNewswireForm += \ + edit_newswire_form += \ '
    \n' - editNewswireForm += \ + edit_newswire_form += \ '

    ' + translate['Edit newswire'] + '

    ' - editNewswireForm += \ + edit_newswire_form += \ '
    \n' - editNewswireForm += \ + edit_newswire_form += \ ' \n' - editNewswireForm += \ + translate['Publish'] + '" ' + \ + 'accesskey="' + access_keys['submitButton'] + '">\n' + edit_newswire_form += \ '
    \n' - newswireFilename = baseDir + '/accounts/newswire.txt' - newswireStr = '' - if os.path.isfile(newswireFilename): - with open(newswireFilename, 'r') as fp: - newswireStr = fp.read() + newswire_filename = base_dir + '/accounts/newswire.txt' + newswire_str = '' + if os.path.isfile(newswire_filename): + with open(newswire_filename, 'r', encoding='utf-8') as fp_news: + newswire_str = fp_news.read() - editNewswireForm += \ + edit_newswire_form += \ '
    ' - editNewswireForm += \ + edit_newswire_form += \ ' ' + \ translate['Add RSS feed links below.'] + \ '
    ' - editNewswireForm += \ + new_feed_str = translate['New feed URL'] + edit_newswire_form += \ + edit_text_field(None, 'newNewswireFeed', '', new_feed_str) + edit_newswire_form += \ ' ' + newswire_str + '' - filterStr = '' - filterFilename = \ - baseDir + '/accounts/news@' + domain + '/filters.txt' - if os.path.isfile(filterFilename): - with open(filterFilename, 'r') as filterfile: - filterStr = filterfile.read() + filter_str = '' + filter_filename = \ + base_dir + '/accounts/news@' + domain + '/filters.txt' + if os.path.isfile(filter_filename): + with open(filter_filename, 'r', encoding='utf-8') as filterfile: + filter_str = filterfile.read() - editNewswireForm += \ + edit_newswire_form += \ '
    \n' - editNewswireForm += '
    ' - editNewswireForm += htmlFooter() - return editNewswireForm + edit_newswire_form += html_footer() + return edit_newswire_form -def htmlEditNewsPost(cssCache: {}, translate: {}, baseDir: str, path: str, - domain: str, port: int, - httpPrefix: str, postUrl: str, - systemLanguage: str) -> str: +def html_edit_news_post(translate: {}, base_dir: str, path: str, + domain: str, port: int, http_prefix: str, postUrl: str, + system_language: str) -> str: """Edits a news post on the news/features timeline """ if '/users/' not in path: return '' - pathOriginal = path + path_original = path - nickname = getNicknameFromActor(path) + nickname = get_nickname_from_actor(path) if not nickname: return '' # is the user an editor? - if not isEditor(baseDir, nickname): + if not is_editor(base_dir, nickname): return '' postUrl = postUrl.replace('/', '#') - postFilename = locatePost(baseDir, nickname, domain, postUrl) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, postUrl) + if not post_filename: return '' - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return '' - cssFilename = baseDir + '/epicyon-links.css' - if os.path.isfile(baseDir + '/links.css'): - cssFilename = baseDir + '/links.css' + css_filename = base_dir + '/epicyon-links.css' + if os.path.isfile(base_dir + '/links.css'): + css_filename = base_dir + '/links.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - editNewsPostForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - editNewsPostForm += \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + edit_news_post_form = \ + html_header_with_external_style(css_filename, instance_title, None) + edit_news_post_form += \ '\n' - editNewsPostForm += \ + edit_news_post_form += \ '
    \n' - editNewsPostForm += \ + edit_news_post_form += \ '

    ' + translate['Edit News Post'] + '

    ' - editNewsPostForm += \ + edit_news_post_form += \ '
    \n' - editNewsPostForm += \ - ' ' + \ + edit_news_post_form += \ + ' ' + \ '\n' - editNewsPostForm += \ + edit_news_post_form += \ ' \n' - editNewsPostForm += \ + translate['Publish'] + '">\n' + edit_news_post_form += \ '
    \n' - editNewsPostForm += \ + edit_news_post_form += \ '
    ' - editNewsPostForm += \ + edit_news_post_form += \ ' \n' - newsPostTitle = postJsonObject['object']['summary'] - editNewsPostForm += \ + news_post_title = post_json_object['object']['summary'] + edit_news_post_form += \ '
    \n' + news_post_title + '">
    \n' - newsPostContent = getBaseContentFromPost(postJsonObject, systemLanguage) - editNewsPostForm += \ + news_post_content = get_base_content_from_post(post_json_object, + system_language) + edit_news_post_form += \ ' ' + news_post_content + '' - editNewsPostForm += \ + edit_news_post_form += \ '
    ' - editNewsPostForm += htmlFooter() - return editNewsPostForm + edit_news_post_form += html_footer() + return edit_news_post_form diff --git a/webapp_confirm.py b/webapp_confirm.py index b24ce5422..9d2034b36 100644 --- a/webapp_confirm.py +++ b/webapp_confirm.py @@ -1,7 +1,7 @@ __filename__ = "webapp_confirm.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,305 +9,376 @@ __module_group__ = "Web Interface" import os from shutil import copyfile -from utils import getFullDomain -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import locatePost -from utils import loadJson -from utils import getConfigParam -from utils import getAltPath -from utils import acctDir -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_post import individualPostAsHtml +from utils import get_full_domain +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import locate_post +from utils import load_json +from utils import get_config_param +from utils import get_alt_path +from utils import acct_dir +from utils import get_account_timezone +from webapp_utils import set_custom_background +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_post import individual_post_as_html -def htmlConfirmDelete(cssCache: {}, - recentPostsCache: {}, maxRecentPosts: int, - translate, pageNumber: int, - session, baseDir: str, messageId: str, - httpPrefix: str, projectVersion: str, - cachedWebfingers: {}, personCache: {}, - callingDomain: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, signingPrivateKeyPem: str) -> str: +def html_confirm_delete(server, + recent_posts_cache: {}, max_recent_posts: int, + translate, page_number: int, + session, base_dir: str, message_id: str, + http_prefix: str, project_version: str, + cached_webfingers: {}, person_cache: {}, + calling_domain: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + dogwhistles: {}) -> str: """Shows a screen asking to confirm the deletion of a post """ - if '/statuses/' not in messageId: + if '/statuses/' not in message_id: return None - actor = messageId.split('/statuses/')[0] - nickname = getNicknameFromActor(actor) - domain, port = getDomainFromActor(actor) - domainFull = getFullDomain(domain, port) + actor = message_id.split('/statuses/')[0] + nickname = get_nickname_from_actor(actor) + if not nickname: + return None + domain, port = get_domain_from_actor(actor) + domain_full = get_full_domain(domain, port) - postFilename = locatePost(baseDir, nickname, domain, messageId) - if not postFilename: + post_filename = locate_post(base_dir, nickname, domain, message_id) + if not post_filename: return None - postJsonObject = loadJson(postFilename) - if not postJsonObject: + post_json_object = load_json(post_filename) + if not post_json_object: return None - if os.path.isfile(baseDir + '/img/delete-background.png'): - if not os.path.isfile(baseDir + '/accounts/delete-background.png'): - copyfile(baseDir + '/img/delete-background.png', - baseDir + '/accounts/delete-background.png') + delete_post_str = None + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - deletePostStr = None - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' - - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - deletePostStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - deletePostStr += \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, maxRecentPosts, - translate, pageNumber, - baseDir, session, cachedWebfingers, personCache, - nickname, domain, port, postJsonObject, - None, True, False, - httpPrefix, projectVersion, 'outbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, allowLocalNetworkAccess, - themeName, systemLanguage, maxLikeCount, - False, False, False, False, False, False) - deletePostStr += '
    ' - deletePostStr += \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + delete_post_str = \ + html_header_with_external_style(css_filename, instance_title, None) + timezone = get_account_timezone(base_dir, nickname, domain) + mitm = False + if os.path.isfile(post_filename.replace('.json', '') + '.mitm'): + mitm = True + bold_reading = False + if server.bold_reading.get(nickname): + bold_reading = True + delete_post_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, max_recent_posts, + translate, page_number, + base_dir, session, + cached_webfingers, person_cache, + nickname, domain, port, post_json_object, + None, True, False, + http_prefix, project_version, 'outbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, allow_local_network_access, + theme_name, system_language, max_like_count, + False, False, False, False, False, False, + cw_lists, lists_enabled, timezone, mitm, + bold_reading, dogwhistles) + delete_post_str += '
    ' + delete_post_str += \ '

    ' + \ translate['Delete this post?'] + '

    ' - postActor = getAltPath(actor, domainFull, callingDomain) - deletePostStr += \ - ' \n' - deletePostStr += \ + post_actor = get_alt_path(actor, domain_full, calling_domain) + delete_post_str += \ + ' \n' + delete_post_str += \ ' \n' - deletePostStr += \ + str(page_number) + '">\n' + delete_post_str += \ ' \n' - deletePostStr += \ + message_id + '">\n' + delete_post_str += \ ' \n' - deletePostStr += \ + delete_post_str += \ ' \n' - deletePostStr += ' \n' - deletePostStr += '
    \n' - deletePostStr += htmlFooter() - return deletePostStr + delete_post_str += ' \n' + delete_post_str += '
    \n' + delete_post_str += html_footer() + return delete_post_str -def htmlConfirmRemoveSharedItem(cssCache: {}, translate: {}, baseDir: str, - actor: str, itemID: str, - callingDomain: str, - sharesFileType: str) -> str: +def html_confirm_remove_shared_item(translate: {}, + base_dir: str, + actor: str, item_id: str, + calling_domain: str, + shares_file_type: str) -> str: """Shows a screen asking to confirm the removal of a shared item """ - nickname = getNicknameFromActor(actor) - domain, port = getDomainFromActor(actor) - domainFull = getFullDomain(domain, port) - sharesFile = \ - acctDir(baseDir, nickname, domain) + '/' + sharesFileType + '.json' - if not os.path.isfile(sharesFile): - print('ERROR: no ' + sharesFileType + ' file ' + sharesFile) + nickname = get_nickname_from_actor(actor) + if not nickname: return None - sharesJson = loadJson(sharesFile) - if not sharesJson: - print('ERROR: unable to load ' + sharesFileType + '.json') + domain, port = get_domain_from_actor(actor) + domain_full = get_full_domain(domain, port) + shares_file = \ + acct_dir(base_dir, nickname, domain) + '/' + shares_file_type + '.json' + if not os.path.isfile(shares_file): + print('ERROR: no ' + shares_file_type + ' file ' + shares_file) return None - if not sharesJson.get(itemID): - print('ERROR: share named "' + itemID + '" is not in ' + sharesFile) + shares_json = load_json(shares_file) + if not shares_json: + print('ERROR: unable to load ' + shares_file_type + '.json') return None - sharedItemDisplayName = sharesJson[itemID]['displayName'] - sharedItemImageUrl = None - if sharesJson[itemID].get('imageUrl'): - sharedItemImageUrl = sharesJson[itemID]['imageUrl'] + if not shares_json.get(item_id): + print('ERROR: share named "' + item_id + '" is not in ' + shares_file) + return None + shared_item_display_name = shares_json[item_id]['displayName'] + shared_item_image_url = None + if shares_json[item_id].get('imageUrl'): + shared_item_image_url = shares_json[item_id]['imageUrl'] - if os.path.isfile(baseDir + '/img/shares-background.png'): - if not os.path.isfile(baseDir + '/accounts/shares-background.png'): - copyfile(baseDir + '/img/shares-background.png', - baseDir + '/accounts/shares-background.png') + set_custom_background(base_dir, 'shares-background', 'follow-background') - cssFilename = baseDir + '/epicyon-follow.css' - if os.path.isfile(baseDir + '/follow.css'): - cssFilename = baseDir + '/follow.css' + css_filename = base_dir + '/epicyon-follow.css' + if os.path.isfile(base_dir + '/follow.css'): + css_filename = base_dir + '/follow.css' - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - sharesStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - sharesStr += '\n' + shares_str += html_footer() + return shares_str -def htmlConfirmFollow(cssCache: {}, translate: {}, baseDir: str, - originPathStr: str, - followActor: str, - followProfileUrl: str) -> str: +def html_confirm_follow(translate: {}, base_dir: str, + origin_path_str: str, + follow_actor: str, + follow_profile_url: str) -> str: """Asks to confirm a follow """ - followDomain, port = getDomainFromActor(followActor) + follow_domain, _ = get_domain_from_actor(follow_actor) - if os.path.isfile(baseDir + '/accounts/follow-background-custom.jpg'): - if not os.path.isfile(baseDir + '/accounts/follow-background.jpg'): - copyfile(baseDir + '/accounts/follow-background-custom.jpg', - baseDir + '/accounts/follow-background.jpg') + if os.path.isfile(base_dir + '/accounts/follow-background-custom.jpg'): + if not os.path.isfile(base_dir + '/accounts/follow-background.jpg'): + copyfile(base_dir + '/accounts/follow-background-custom.jpg', + base_dir + '/accounts/follow-background.jpg') - cssFilename = baseDir + '/epicyon-follow.css' - if os.path.isfile(baseDir + '/follow.css'): - cssFilename = baseDir + '/follow.css' + css_filename = base_dir + '/epicyon-follow.css' + if os.path.isfile(base_dir + '/follow.css'): + css_filename = base_dir + '/follow.css' - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - followStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - followStr += '\n' + follow_str += html_footer() + return follow_str -def htmlConfirmUnfollow(cssCache: {}, translate: {}, baseDir: str, - originPathStr: str, - followActor: str, - followProfileUrl: str) -> str: +def html_confirm_unfollow(translate: {}, base_dir: str, + origin_path_str: str, + follow_actor: str, + follow_profile_url: str) -> str: """Asks to confirm unfollowing an actor """ - followDomain, port = getDomainFromActor(followActor) + follow_domain, _ = get_domain_from_actor(follow_actor) - if os.path.isfile(baseDir + '/accounts/follow-background-custom.jpg'): - if not os.path.isfile(baseDir + '/accounts/follow-background.jpg'): - copyfile(baseDir + '/accounts/follow-background-custom.jpg', - baseDir + '/accounts/follow-background.jpg') + if os.path.isfile(base_dir + '/accounts/follow-background-custom.jpg'): + if not os.path.isfile(base_dir + '/accounts/follow-background.jpg'): + copyfile(base_dir + '/accounts/follow-background-custom.jpg', + base_dir + '/accounts/follow-background.jpg') - cssFilename = baseDir + '/epicyon-follow.css' - if os.path.isfile(baseDir + '/follow.css'): - cssFilename = baseDir + '/follow.css' + css_filename = base_dir + '/epicyon-follow.css' + if os.path.isfile(base_dir + '/follow.css'): + css_filename = base_dir + '/follow.css' - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - followStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - followStr += '\n' + follow_str += html_footer() + return follow_str -def htmlConfirmUnblock(cssCache: {}, translate: {}, baseDir: str, - originPathStr: str, - blockActor: str, - blockProfileUrl: str) -> str: +def html_confirm_unblock(translate: {}, base_dir: str, + origin_path_str: str, + block_actor: str, + block_profile_url: str) -> str: """Asks to confirm unblocking an actor """ - blockDomain, port = getDomainFromActor(blockActor) + block_domain, _ = get_domain_from_actor(block_actor) - if os.path.isfile(baseDir + '/img/block-background.png'): - if not os.path.isfile(baseDir + '/accounts/block-background.png'): - copyfile(baseDir + '/img/block-background.png', - baseDir + '/accounts/block-background.png') + set_custom_background(base_dir, 'block-background', 'follow-background') - cssFilename = baseDir + '/epicyon-follow.css' - if os.path.isfile(baseDir + '/follow.css'): - cssFilename = baseDir + '/follow.css' + css_filename = base_dir + '/epicyon-follow.css' + if os.path.isfile(base_dir + '/follow.css'): + css_filename = base_dir + '/follow.css' - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - blockStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - blockStr += '
    \n' - blockStr += '
    \n' - blockStr += '
    \n' - blockStr += ' \n' - blockStr += ' \n' - blockStr += \ - '

    ' + translate['Stop blocking'] + ' ' + \ - getNicknameFromActor(blockActor) + '@' + blockDomain + ' ?

    \n' - blockStr += '
    \n' - blockStr += ' \n' - blockStr += \ + instance_title = get_config_param(base_dir, 'instanceTitle') + block_str = html_header_with_external_style(css_filename, + instance_title, None) + block_str += '
    \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += ' \n' + block_str += \ + ' \n' + block_actor_nick = get_nickname_from_actor(block_actor) + if block_actor_nick: + block_str += \ + '

    ' + translate['Stop blocking'] + ' ' + \ + block_actor_nick + '@' + block_domain + ' ?

    \n' + block_str += ' \n' + block_str += ' \n' + block_str += \ ' \n' - blockStr += \ - ' \n' - blockStr += ' \n' - blockStr += '
    \n' - blockStr += '
    \n' - blockStr += '
    \n' - blockStr += htmlFooter() - return blockStr + block_str += ' \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += html_footer() + return block_str + + +def html_confirm_block(translate: {}, base_dir: str, + origin_path_str: str, + block_actor: str, + block_profile_url: str) -> str: + """Asks to confirm blocking an actor + """ + block_domain, _ = get_domain_from_actor(block_actor) + + set_custom_background(base_dir, 'block-background', 'follow-background') + + css_filename = base_dir + '/epicyon-follow.css' + if os.path.isfile(base_dir + '/follow.css'): + css_filename = base_dir + '/follow.css' + + instance_title = get_config_param(base_dir, 'instanceTitle') + block_str = html_header_with_external_style(css_filename, + instance_title, None) + block_str += '
    \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += ' \n' + block_str += \ + ' \n' + block_actor_nick = get_nickname_from_actor(block_actor) + if block_actor_nick: + block_str += \ + '

    ' + translate['Block'] + ' ' + \ + block_actor_nick + '@' + block_domain + ' ?

    \n' + block_str += '
    \n' + block_str += ' \n' + block_str += \ + ' \n' + block_str += \ + ' \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += '
    \n' + block_str += html_footer() + return block_str diff --git a/webapp_create_post.py b/webapp_create_post.py index 9128983ff..53b991782 100644 --- a/webapp_create_post.py +++ b/webapp_create_post.py @@ -1,790 +1,964 @@ __filename__ = "webapp_create_post.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" import os -from utils import isPublicPostFromUrl -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import getMediaFormats -from utils import getConfigParam -from utils import acctDir -from utils import getCurrencies -from utils import getCategoryTypes -from webapp_utils import getBannerFile -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import editTextField -from webapp_utils import editNumberField -from webapp_utils import editCurrencyField +from utils import get_new_post_endpoints +from utils import is_public_post_from_url +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import get_media_formats +from utils import get_config_param +from utils import acct_dir +from utils import get_currencies +from utils import get_category_types +from utils import get_account_timezone +from utils import get_supported_languages +from webapp_utils import html_common_emoji +from webapp_utils import begin_edit_section +from webapp_utils import end_edit_section +from webapp_utils import get_banner_file +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import edit_text_field +from webapp_utils import edit_number_field +from webapp_utils import edit_currency_field +from webapp_post import individual_post_as_html +from maps import get_map_preferences_url +from maps import get_map_preferences_coords -def _htmlFollowingDataList(baseDir: str, nickname: str, - domain: str, domainFull: str) -> str: +def _html_following_data_list(base_dir: str, nickname: str, + domain: str, domain_full: str) -> str: """Returns a datalist of handles being followed """ - listStr = '\n' - followingFilename = \ - acctDir(baseDir, nickname, domain) + '/following.txt' + list_str = '\n' + following_filename = \ + acct_dir(base_dir, nickname, domain) + '/following.txt' msg = None - if os.path.isfile(followingFilename): - with open(followingFilename, 'r') as followingFile: - msg = followingFile.read() + if os.path.isfile(following_filename): + with open(following_filename, 'r', + encoding='utf-8') as following_file: + msg = following_file.read() # add your own handle, so that you can send DMs # to yourself as reminders - msg += nickname + '@' + domainFull + '\n' + msg += nickname + '@' + domain_full + '\n' if msg: # include petnames - petnamesFilename = \ - acctDir(baseDir, nickname, domain) + '/petnames.txt' - if os.path.isfile(petnamesFilename): - followingList = [] - with open(petnamesFilename, 'r') as petnamesFile: - petStr = petnamesFile.read() + petnames_filename = \ + acct_dir(base_dir, nickname, domain) + '/petnames.txt' + if os.path.isfile(petnames_filename): + following_list = [] + with open(petnames_filename, 'r', + encoding='utf-8') as petnames_file: + pet_str = petnames_file.read() # extract each petname and append it - petnamesList = petStr.split('\n') - for pet in petnamesList: - followingList.append(pet.split(' ')[0]) + petnames_list = pet_str.split('\n') + for pet in petnames_list: + following_list.append(pet.split(' ')[0]) # add the following.txt entries - followingList += msg.split('\n') + following_list += msg.split('\n') else: # no petnames list exists - just use following.txt - followingList = msg.split('\n') - followingList.sort() - if followingList: - for followingAddress in followingList: - if followingAddress: - listStr += '\n' - listStr += '\n' - return listStr + following_list = msg.split('\n') + following_list.sort() + if following_list: + for following_address in following_list: + if following_address: + list_str += '\n' + list_str += '\n' + return list_str -def _htmlNewPostDropDown(scopeIcon: str, scopeDescription: str, - replyStr: str, - translate: {}, - showPublicOnDropdown: bool, - defaultTimeline: str, - pathBase: str, - dropdownNewPostSuffix: str, - dropdownNewBlogSuffix: str, - dropdownUnlistedSuffix: str, - dropdownFollowersSuffix: str, - dropdownDMSuffix: str, - dropdownReminderSuffix: str, - dropdownReportSuffix: str, - noDropDown: bool, - accessKeys: {}) -> str: +def _html_new_post_drop_down(scope_icon: str, scope_description: str, + reply_str: str, + translate: {}, + show_public_on_dropdown: bool, + default_timeline: str, + path_base: str, + dropdown_new_post_suffix: str, + dropdown_new_blog_suffix: str, + dropdown_unlisted_suffix: str, + dropdown_followers_suffix: str, + dropdown_dm_suffix: str, + dropdown_reminder_suffix: str, + dropdown_report_suffix: str, + no_drop_down: bool, + access_keys: {}) -> str: """Returns the html for a drop down list of new post types """ - dropDownContent = '\n' - return dropDownContent + if no_drop_down: + drop_down_content += '
    \n' + return drop_down_content - dropDownContent += ' \n' + drop_down_content += ' \n' - dropDownContent += '
    \n' - return dropDownContent + drop_down_content += '
    \n' + return drop_down_content -def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, - baseDir: str, httpPrefix: str, - path: str, inReplyTo: str, - mentions: [], - shareDescription: str, - reportUrl: str, pageNumber: int, - category: str, - nickname: str, domain: str, - domainFull: str, - defaultTimeline: str, newswire: {}, - theme: str, noDropDown: bool, - accessKeys: {}, customSubmitText: str, - conversationId: str) -> str: +def html_new_post(media_instance: bool, translate: {}, + base_dir: str, http_prefix: str, + path: str, in_reply_to: str, + mentions: [], + share_description: str, + report_url: str, page_number: int, + category: str, + nickname: str, domain: str, + domain_full: str, + default_timeline: str, newswire: {}, + theme: str, no_drop_down: bool, + access_keys: {}, custom_submit_text: str, + conversation_id: str, + recent_posts_cache: {}, max_recent_posts: int, + session, cached_webfingers: {}, + person_cache: {}, port: int, + post_json_object: {}, + project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + system_language: str, + max_like_count: int, signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + box_name: str, + reply_is_chat: bool, bold_reading: bool, + dogwhistles: {}) -> str: """New post screen """ - replyStr = '' + reply_str = '' - showPublicOnDropdown = True - messageBoxHeight = 400 + is_new_reminder = False + if path.endswith('/newreminder'): + is_new_reminder = True + + # the date and time + date_and_time_str = '

    \n' + if not is_new_reminder: + date_and_time_str += \ + '\n' + # select a date and time for this post + date_and_time_str += '\n' + date_and_time_str += '\n' + date_and_time_str += '\n
    \n' + date_and_time_str += '\n

    \n' + + show_public_on_dropdown = True + message_box_height = 400 + image_description_height = 150 # filename of the banner shown at the top - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) if not path.endswith('/newshare') and not path.endswith('/newwanted'): if not path.endswith('/newreport'): - if not inReplyTo or path.endswith('/newreminder'): - newPostText = '

    ' + \ + if not in_reply_to or is_new_reminder: + new_post_text = '

    ' + \ translate['Write your post text below.'] + '

    \n' else: - newPostText = '' + new_post_text = '' if category != 'accommodation': - newPostText = \ + new_post_text = \ '

    ' + \ translate['Write your reply to'] + \ - ' ' + \ translate['this post'] + '

    \n' - replyStr = '\n' + if post_json_object: + timezone = \ + get_account_timezone(base_dir, nickname, domain) + new_post_text += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, + None, True, False, + http_prefix, + project_version, + box_name, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme, system_language, + max_like_count, + False, False, False, + False, False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) + + reply_str = '\n' # if replying to a non-public post then also make # this post non-public - if not isPublicPostFromUrl(baseDir, nickname, domain, - inReplyTo): - newPostPath = path - if '?' in newPostPath: - newPostPath = newPostPath.split('?')[0] - if newPostPath.endswith('/newpost'): + if not is_public_post_from_url(base_dir, nickname, domain, + in_reply_to): + new_post_path = path + if '?' in new_post_path: + new_post_path = new_post_path.split('?')[0] + if new_post_path.endswith('/newpost'): path = path.replace('/newpost', '/newfollowers') - elif newPostPath.endswith('/newunlisted'): - path = path.replace('/newunlisted', '/newfollowers') - showPublicOnDropdown = False + show_public_on_dropdown = False else: - newPostText = \ + new_post_text = \ '

    ' + translate['Write your report below.'] + '

    \n' # custom report header with any additional instructions - if os.path.isfile(baseDir + '/accounts/report.txt'): - with open(baseDir + '/accounts/report.txt', 'r') as file: - customReportText = file.read() - if '

    ' not in customReportText: - customReportText = \ + if os.path.isfile(base_dir + '/accounts/report.txt'): + with open(base_dir + '/accounts/report.txt', 'r', + encoding='utf-8') as file: + custom_report_text = file.read() + if '

    ' not in custom_report_text: + custom_report_text = \ '\n' - repStr = '

    ', repStr) - newPostText += customReportText + custom_report_text + '

    \n' + rep_str = '

    ', rep_str) + new_post_text += custom_report_text idx = 'This message only goes to moderators, even if it ' + \ 'mentions other fediverse addresses.' - newPostText += \ + new_post_text += \ '

    ' + translate[idx] + '

    \n' + \ '

    ' + translate['Also see'] + \ ' ' + \ translate['Terms of Service'] + '

    \n' else: if path.endswith('/newshare'): - newPostText = \ + new_post_text = \ '

    ' + \ translate['Enter the details for your shared item below.'] + \ '

    \n' else: - newPostText = \ + new_post_text = \ '

    ' + \ translate['Enter the details for your wanted item below.'] + \ '

    \n' if path.endswith('/newquestion'): - newPostText = \ + new_post_text = \ '

    ' + \ translate['Enter the choices for your question below.'] + \ '

    \n' - if os.path.isfile(baseDir + '/accounts/newpost.txt'): - with open(baseDir + '/accounts/newpost.txt', 'r') as file: - newPostText = \ + if os.path.isfile(base_dir + '/accounts/newpost.txt'): + with open(base_dir + '/accounts/newpost.txt', 'r', + encoding='utf-8') as file: + new_post_text = \ '

    ' + file.read() + '

    \n' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' if '?' in path: path = path.split('?')[0] - pathBase = path.replace('/newreport', '').replace('/newpost', '') - pathBase = pathBase.replace('/newblog', '').replace('/newshare', '') - pathBase = pathBase.replace('/newunlisted', '').replace('/newwanted', '') - pathBase = pathBase.replace('/newreminder', '') - pathBase = pathBase.replace('/newfollowers', '').replace('/newdm', '') + new_post_endpoints = get_new_post_endpoints() + path_base = path + for curr_post_type in new_post_endpoints: + path_base = path_base.replace('/' + curr_post_type, '') - newPostImageSection = '
    ' - newPostImageSection += \ - editTextField(translate['Image description'], 'imageDescription', '') - - newPostImageSection += \ + attach_str = 'Attach an image, video or audio file' + new_post_image_section = begin_edit_section('📷 ' + translate[attach_str]) + new_post_image_section += \ ' \n' - newPostImageSection += '
    \n' + formats_string = get_media_formats() + new_post_image_section += \ + ' accept="' + formats_string + '">\n' + new_post_image_section += \ + ' \n' + new_post_image_section += \ + ' \n' - scopeIcon = 'scope_public.png' - scopeDescription = translate['Public'] - if shareDescription: + new_post_image_section += end_edit_section() + + new_post_emoji_section = '' + common_emoji_str = html_common_emoji(base_dir, 16) + if common_emoji_str: + new_post_emoji_section = \ + begin_edit_section('😀 ' + translate['Common emoji']) + new_post_emoji_section += \ + '
    \n' + new_post_emoji_section += common_emoji_str + new_post_emoji_section += end_edit_section() + + scope_icon = 'scope_public.png' + scope_description = translate['Public'] + if share_description: if category == 'accommodation': - placeholderSubject = translate['Request to stay'] + placeholder_subject = translate['Request to stay'] else: - placeholderSubject = translate['Ask about a shared item.'] + '..' + placeholder_subject = translate['Ask about a shared item.'] + '..' else: - placeholderSubject = \ + placeholder_subject = \ translate['Subject or Content Warning (optional)'] + '...' - placeholderMentions = '' - if inReplyTo: - placeholderMentions = \ + placeholder_mentions = '' + if in_reply_to: + placeholder_mentions = \ translate['Replying to'] + '...' - placeholderMessage = '' + placeholder_message = '' if category != 'accommodation': - placeholderMessage = translate['Write something'] + '...' + if default_timeline == 'tlfeatures': + placeholder_message = translate['Write your news report'] + '...' + else: + placeholder_message = translate['Write something'] + '...' else: idx = 'Introduce yourself and specify the date ' + \ 'and time when you wish to stay' - placeholderMessage = translate[idx] - extraFields = '' + placeholder_message = translate[idx] + extra_fields = '' endpoint = 'newpost' if path.endswith('/newblog'): - placeholderSubject = translate['Title'] - scopeIcon = 'scope_blog.png' - if defaultTimeline != 'tlfeatures': - scopeDescription = translate['Blog'] + placeholder_subject = translate['Title'] + scope_icon = 'scope_blog.png' + if default_timeline != 'tlfeatures': + scope_description = translate['Blog'] else: - scopeDescription = translate['Article'] + scope_description = translate['Article'] endpoint = 'newblog' elif path.endswith('/newunlisted'): - scopeIcon = 'scope_unlisted.png' - scopeDescription = translate['Unlisted'] + scope_icon = 'scope_unlisted.png' + scope_description = translate['Unlisted'] endpoint = 'newunlisted' elif path.endswith('/newfollowers'): - scopeIcon = 'scope_followers.png' - scopeDescription = translate['Followers'] + scope_icon = 'scope_followers.png' + scope_description = translate['Followers'] endpoint = 'newfollowers' elif path.endswith('/newdm'): - scopeIcon = 'scope_dm.png' - scopeDescription = translate['DM'] + scope_icon = 'scope_dm.png' + scope_description = translate['DM'] endpoint = 'newdm' - elif path.endswith('/newreminder'): - scopeIcon = 'scope_reminder.png' - scopeDescription = translate['Reminder'] + placeholder_message = '⚠️ ' + translate['DM warning'] + elif is_new_reminder: + scope_icon = 'scope_reminder.png' + scope_description = translate['Reminder'] endpoint = 'newreminder' elif path.endswith('/newreport'): - scopeIcon = 'scope_report.png' - scopeDescription = translate['Report'] + scope_icon = 'scope_report.png' + scope_description = translate['Report'] endpoint = 'newreport' elif path.endswith('/newquestion'): - scopeIcon = 'scope_question.png' - scopeDescription = translate['Question'] - placeholderMessage = translate['Enter your question'] + '...' + scope_icon = 'scope_question.png' + scope_description = translate['Question'] + placeholder_message = translate['Enter your question'] + '...' endpoint = 'newquestion' - extraFields = '
    \n' - extraFields += '
    ' elif path.endswith('/newshare'): - scopeIcon = 'scope_share.png' - scopeDescription = translate['Shared Item'] - placeholderSubject = translate['Name of the shared item'] + '...' - placeholderMessage = \ + scope_icon = 'scope_share.png' + scope_description = translate['Shared Item'] + placeholder_subject = translate['Name of the shared item'] + '...' + placeholder_message = \ translate['Description of the item being shared'] + '...' endpoint = 'newshare' - extraFields = '
    \n' - extraFields += \ - editNumberField(translate['Quantity'], - 'itemQty', 1, 1, 999999, 1) - extraFields += '
    ' + \ - editTextField(translate['Type of shared item. eg. hat'] + ':', - 'itemType', '', '', True) - categoryTypes = getCategoryTypes(baseDir) - catStr = translate['Category of shared item. eg. clothing'] - extraFields += '
    \n' + extra_fields = '
    \n' + extra_fields += \ + edit_number_field(translate['Quantity'], + 'itemQty', 1, 1, 999999, 1) + extra_fields += '
    ' + \ + edit_text_field(translate['Type of shared item. eg. hat'] + ':', + 'itemType', '', '', True) + category_types = get_category_types(base_dir) + cat_str = translate['Category of shared item. eg. clothing'] + extra_fields += '
    \n' - extraFields += '
    \n' - extraFields += \ - editNumberField(translate['Duration of listing in days'], - 'duration', 14, 1, 365, 1) - extraFields += '
    \n' - extraFields += '
    \n' - cityOrLocStr = translate['City or location of the shared item'] - extraFields += editTextField(cityOrLocStr + ':', 'location', '') - extraFields += '
    \n' - extraFields += '
    \n' - extraFields += \ - editCurrencyField(translate['Price'] + ':', 'itemPrice', '0.00', - '0.00', True) - extraFields += '
    ' - extraFields += \ + extra_fields += '
    \n' + extra_fields += \ + edit_number_field(translate['Duration of listing in days'], + 'duration', 14, 1, 365, 1) + extra_fields += '
    \n' + extra_fields += '
    \n' + city_or_loc_str = translate['City or location of the shared item'] + extra_fields += edit_text_field(city_or_loc_str + ':', 'location', '') + extra_fields += '
    \n' + extra_fields += '
    \n' + extra_fields += \ + edit_currency_field(translate['Price'] + ':', 'itemPrice', '0.00', + '0.00', True) + extra_fields += '
    ' + extra_fields += \ '
    \n' - currencies = getCurrencies() - extraFields += ' \n' + extra_fields += ' \n' + extra_fields += ' \n' - extraFields += '
    \n' + extra_fields += '
    \n' elif path.endswith('/newwanted'): - scopeIcon = 'scope_wanted.png' - scopeDescription = translate['Wanted'] - placeholderSubject = translate['Name of the wanted item'] + '...' - placeholderMessage = \ + scope_icon = 'scope_wanted.png' + scope_description = translate['Wanted'] + placeholder_subject = translate['Name of the wanted item'] + '...' + placeholder_message = \ translate['Description of the item wanted'] + '...' endpoint = 'newwanted' - extraFields = '
    \n' - extraFields += \ - editNumberField(translate['Quantity'], - 'itemQty', 1, 1, 999999, 1) - extraFields += '
    ' + \ - editTextField(translate['Type of wanted item. eg. hat'] + ':', - 'itemType', '', '', True) - categoryTypes = getCategoryTypes(baseDir) - catStr = translate['Category of wanted item. eg. clothes'] - extraFields += '
    \n' + extra_fields = '
    \n' + extra_fields += \ + edit_number_field(translate['Quantity'], + 'itemQty', 1, 1, 999999, 1) + extra_fields += '
    ' + \ + edit_text_field(translate['Type of wanted item. eg. hat'] + ':', + 'itemType', '', '', True) + category_types = get_category_types(base_dir) + cat_str = translate['Category of wanted item. eg. clothes'] + extra_fields += '
    \n' - extraFields += '
    \n' - extraFields += \ - editNumberField(translate['Duration of listing in days'], - 'duration', 14, 1, 365, 1) - extraFields += '
    \n' - extraFields += '
    \n' - cityOrLocStr = translate['City or location of the wanted item'] - extraFields += editTextField(cityOrLocStr + ':', 'location', '') - extraFields += '
    \n' - extraFields += '
    \n' - extraFields += \ - editCurrencyField(translate['Maximum Price'] + ':', - 'itemPrice', '0.00', '0.00', True) - extraFields += '
    ' - extraFields += \ + extra_fields += '
    \n' + extra_fields += \ + edit_number_field(translate['Duration of listing in days'], + 'duration', 14, 1, 365, 1) + extra_fields += '
    \n' + extra_fields += '
    \n' + city_or_loc_str = translate['City or location of the wanted item'] + extra_fields += edit_text_field(city_or_loc_str + ':', 'location', '') + extra_fields += '
    \n' + extra_fields += '
    \n' + extra_fields += \ + edit_currency_field(translate['Maximum Price'] + ':', + 'itemPrice', '0.00', '0.00', True) + extra_fields += '
    ' + extra_fields += \ '
    \n' - currencies = getCurrencies() - extraFields += ' \n' + extra_fields += ' \n' + extra_fields += ' \n' - extraFields += '
    \n' + extra_fields += '
    \n' - citationsStr = '' + citations_str = '' if endpoint == 'newblog': - citationsFilename = \ - acctDir(baseDir, nickname, domain) + '/.citations.txt' - if os.path.isfile(citationsFilename): - citationsStr = '
    \n' - citationsStr += '

    \n' + citations_str += '

    \n' - citationsStr += '
      \n' - citationsSeparator = '#####' - with open(citationsFilename, 'r') as f: - citations = f.readlines() + citations_str += '
        \n' + citations_separator = '#####' + with open(citations_filename, 'r', encoding='utf-8') as cit_file: + citations = cit_file.readlines() for line in citations: - if citationsSeparator not in line: + if citations_separator not in line: continue - sections = line.strip().split(citationsSeparator) + sections = line.strip().split(citations_separator) if len(sections) != 3: continue title = sections[1] link = sections[2] - citationsStr += \ + citations_str += \ '
      • ' + \ title + '
      • ' - citationsStr += '
      \n' - citationsStr += '
    \n' + citations_str += ' \n' + citations_str += '
    \n' - dateAndLocation = '' - if endpoint != 'newshare' and \ - endpoint != 'newwanted' and \ - endpoint != 'newreport' and \ - endpoint != 'newquestion': - dateAndLocation = \ - '
    \n' - if category != 'accommodation': - dateAndLocation += \ - '

    \n' - else: - dateAndLocation += \ - '\n' + replies_section = '' + date_and_location = '' + if endpoint not in ('newshare', 'newwanted', 'newreport', 'newquestion'): - if endpoint == 'newpost': - dateAndLocation += \ - '

    \n' + if not is_new_reminder: + replies_section = \ + '
    \n' + if category != 'accommodation': + replies_section += \ + '

    \n' + else: + replies_section += \ + '\n' + supported_languages = get_supported_languages(base_dir) + languages_dropdown = '
    ' + languages_dropdown = \ + languages_dropdown.replace('
    \n' - if not inReplyTo: - dateAndLocation += \ - '

    \n' + date_and_location = \ + begin_edit_section('🗓️ ' + translate['Set a place and time']) + if endpoint == 'newpost': + date_and_location += \ + '

    \n' - dateAndLocation += \ - '

    \n' - # select a date and time for this post - dateAndLocation += '\n' - dateAndLocation += '\n' - dateAndLocation += '

    \n' + if not in_reply_to: + date_and_location += \ + '

    \n' - dateAndLocation += '
    \n' - dateAndLocation += '
    \n' - dateAndLocation += \ - editTextField(translate['Location'], 'location', '') - dateAndLocation += '
    \n' + date_and_location += date_and_time_str - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - newPostForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + maps_url = get_map_preferences_url(base_dir, nickname, domain) + if not maps_url: + maps_url = 'https://www.openstreetmap.org' + if '://' not in maps_url: + maps_url = 'https://' + maps_url + maps_latitude, maps_longitude, maps_zoom = \ + get_map_preferences_coords(base_dir, nickname, domain) + if maps_latitude and maps_longitude and maps_zoom: + if 'openstreetmap.org' in maps_url: + maps_url = \ + 'https://www.openstreetmap.org/#map=' + \ + str(maps_zoom) + '/' + \ + str(maps_latitude) + '/' + \ + str(maps_longitude) + elif '.google.co' in maps_url: + maps_url = \ + 'https://www.google.com/maps/@' + \ + str(maps_latitude) + ',' + \ + str(maps_longitude) + ',' + \ + str(maps_zoom) + 'z' + elif '.bing.co' in maps_url: + maps_url = \ + 'https://www.bing.com/maps?cp=' + \ + str(maps_latitude) + '~' + \ + str(maps_longitude) + '&lvl=' + \ + str(maps_zoom) + elif '.waze.co' in maps_url: + maps_url = \ + 'https://ul.waze.com/ul?ll=' + \ + str(maps_latitude) + '%2C' + \ + str(maps_longitude) + '&zoom=' + \ + str(maps_zoom) + elif 'wego.here.co' in maps_url: + maps_url = \ + 'https://wego.here.com/?x=ep&map=' + \ + str(maps_latitude) + ',' + \ + str(maps_longitude) + ',' + \ + str(maps_zoom) + ',normal' + location_label_with_link = \ + '🗺️ ' + \ + translate['Location'] + '' + date_and_location += '

    \n' + \ + edit_text_field(location_label_with_link, 'location', '', + 'https://www.openstreetmap.org/#map=') + '

    \n' + date_and_location += end_edit_section() - newPostForm += \ + instance_title = get_config_param(base_dir, 'instanceTitle') + new_post_form = html_header_with_external_style(css_filename, + instance_title, None) + + new_post_form += \ '
    \n' + \ - '\n' - newPostForm += '\n' + \ + 'accesskey="' + access_keys['menuTimeline'] + '">\n' + new_post_form += '\n' + \ '
    \n' - mentionsStr = '' - for m in mentions: - mentionNickname = getNicknameFromActor(m) - if not mentionNickname: + mentions_str = '' + for ment in mentions: + mention_nickname = get_nickname_from_actor(ment) + if not mention_nickname: continue - mentionDomain, mentionPort = getDomainFromActor(m) - if not mentionDomain: + mention_domain, mention_port = get_domain_from_actor(ment) + if not mention_domain: continue - if mentionPort: - mentionsHandle = \ - '@' + mentionNickname + '@' + \ - mentionDomain + ':' + str(mentionPort) + if mention_port: + mentions_handle = \ + '@' + mention_nickname + '@' + \ + mention_domain + ':' + str(mention_port) else: - mentionsHandle = '@' + mentionNickname + '@' + mentionDomain - if mentionsHandle not in mentionsStr: - mentionsStr += mentionsHandle + ' ' + mentions_handle = '@' + mention_nickname + '@' + mention_domain + if mentions_handle not in mentions_str: + mentions_str += mentions_handle + ' ' # build suffixes so that any replies or mentions are # preserved when switching between scopes - dropdownNewPostSuffix = '/newpost' - dropdownNewBlogSuffix = '/newblog' - dropdownUnlistedSuffix = '/newunlisted' - dropdownFollowersSuffix = '/newfollowers' - dropdownDMSuffix = '/newdm' - dropdownReminderSuffix = '/newreminder' - dropdownReportSuffix = '/newreport' - if inReplyTo or mentions: - dropdownNewPostSuffix = '' - dropdownNewBlogSuffix = '' - dropdownUnlistedSuffix = '' - dropdownFollowersSuffix = '' - dropdownDMSuffix = '' - dropdownReminderSuffix = '' - dropdownReportSuffix = '' - if inReplyTo: - dropdownNewPostSuffix += '?replyto=' + inReplyTo - dropdownNewBlogSuffix += '?replyto=' + inReplyTo - dropdownUnlistedSuffix += '?replyto=' + inReplyTo - dropdownFollowersSuffix += '?replyfollowers=' + inReplyTo - dropdownDMSuffix += '?replydm=' + inReplyTo - for mentionedActor in mentions: - dropdownNewPostSuffix += '?mention=' + mentionedActor - dropdownNewBlogSuffix += '?mention=' + mentionedActor - dropdownUnlistedSuffix += '?mention=' + mentionedActor - dropdownFollowersSuffix += '?mention=' + mentionedActor - dropdownDMSuffix += '?mention=' + mentionedActor - dropdownReportSuffix += '?mention=' + mentionedActor - if conversationId and inReplyTo: - dropdownNewPostSuffix += '?conversationId=' + conversationId - dropdownNewBlogSuffix += '?conversationId=' + conversationId - dropdownUnlistedSuffix += '?conversationId=' + conversationId - dropdownFollowersSuffix += '?conversationId=' + conversationId - dropdownDMSuffix += '?conversationId=' + conversationId + dropdown_new_post_suffix = '/newpost' + dropdown_new_blog_suffix = '/newblog' + dropdown_unlisted_suffix = '/newunlisted' + dropdown_followers_suffix = '/newfollowers' + dropdown_dm_suffix = '/newdm' + dropdown_reminder_suffix = '/newreminder' + dropdown_report_suffix = '/newreport' + if in_reply_to or mentions: + dropdown_new_post_suffix = '' + dropdown_new_blog_suffix = '' + dropdown_unlisted_suffix = '' + dropdown_followers_suffix = '' + dropdown_dm_suffix = '' + dropdown_reminder_suffix = '' + dropdown_report_suffix = '' + if in_reply_to: + dropdown_new_post_suffix += '?replyto=' + in_reply_to + dropdown_new_blog_suffix += '?replyto=' + in_reply_to + dropdown_unlisted_suffix += '?replyunlisted=' + in_reply_to + dropdown_followers_suffix += '?replyfollowers=' + in_reply_to + if reply_is_chat: + dropdown_dm_suffix += '?replychat=' + in_reply_to + else: + dropdown_dm_suffix += '?replydm=' + in_reply_to + for mentioned_actor in mentions: + dropdown_new_post_suffix += '?mention=' + mentioned_actor + dropdown_new_blog_suffix += '?mention=' + mentioned_actor + dropdown_unlisted_suffix += '?mention=' + mentioned_actor + dropdown_followers_suffix += '?mention=' + mentioned_actor + dropdown_dm_suffix += '?mention=' + mentioned_actor + dropdown_report_suffix += '?mention=' + mentioned_actor + if conversation_id and in_reply_to: + dropdown_new_post_suffix += '?conversationId=' + conversation_id + dropdown_new_blog_suffix += '?conversationId=' + conversation_id + dropdown_unlisted_suffix += '?conversationId=' + conversation_id + dropdown_followers_suffix += '?conversationId=' + conversation_id + dropdown_dm_suffix += '?conversationId=' + conversation_id - dropDownContent = '' - if not reportUrl and not shareDescription: - dropDownContent = \ - _htmlNewPostDropDown(scopeIcon, scopeDescription, - replyStr, - translate, - showPublicOnDropdown, - defaultTimeline, - pathBase, - dropdownNewPostSuffix, - dropdownNewBlogSuffix, - dropdownUnlistedSuffix, - dropdownFollowersSuffix, - dropdownDMSuffix, - dropdownReminderSuffix, - dropdownReportSuffix, - noDropDown, accessKeys) + drop_down_content = '' + if not report_url and not share_description: + drop_down_content = \ + _html_new_post_drop_down(scope_icon, scope_description, + reply_str, + translate, + show_public_on_dropdown, + default_timeline, + path_base, + dropdown_new_post_suffix, + dropdown_new_blog_suffix, + dropdown_unlisted_suffix, + dropdown_followers_suffix, + dropdown_dm_suffix, + dropdown_reminder_suffix, + dropdown_report_suffix, + no_drop_down, access_keys) else: - if not shareDescription: + if not share_description: # reporting a post to moderator - mentionsStr = 'Re: ' + reportUrl + '\n\n' + mentionsStr + mentions_str = 'Re: ' + report_url + '\n\n' + mentions_str - newPostForm += \ + new_post_form += \ '
    \n' - if conversationId: - newPostForm += \ + path + '?' + endpoint + '?page=' + str(page_number) + '">\n' + if reply_is_chat: + new_post_form += \ + ' \n' + if conversation_id: + new_post_form += \ ' \n' - newPostForm += '
    \n' - newPostForm += \ - ' \n' - newPostForm += '
    \n' - newPostForm += ' \n' - newPostForm += ' \n' - newPostForm += ' \n' - newPostForm += ' \n' + conversation_id + '">\n' + new_post_form += '
    \n' + new_post_form += \ + ' \n' + new_post_form += '
    \n' + new_post_form += '
    \n' + new_post_form += ' \n' + new_post_form += ' \n' + new_post_form += ' \n' if newswire and path.endswith('/newblog'): - newPostForm += ' \n' - newPostForm += ' \n' + new_post_form += ' \n' + new_post_form += ' \n' else: - newPostForm += ' \n' - newPostForm += ' \n' - newPostForm += '\n' - newPostForm += '\n' + new_post_form += ' \n' + new_post_form += ' \n' + new_post_form += '\n' + new_post_form += '\n' - newPostForm += \ - ' \n' # for a new blog if newswire items exist then add a citations button if newswire and path.endswith('/newblog'): - newPostForm += \ + new_post_form += \ ' \n' - submitText = translate['Submit'] - if customSubmitText: - submitText = customSubmitText - newPostForm += \ + submit_text = translate['Publish'] + if custom_submit_text: + submit_text = custom_submit_text + new_post_form += \ ' \n' + submit_text + '" ' + \ + 'accesskey="' + access_keys['submitButton'] + '">\n' - newPostForm += ' \n
    ' + dropDownContent + '
    ' + drop_down_content + '' + \
         translate['Search for emoji'] + '
    \n' - newPostForm += '
    \n' + new_post_form += ' \n\n' + new_post_form += '
    \n' - newPostForm += '
    \n' + new_post_form += '
    \n' - newPostForm += '
    \n' + new_post_form += '
    \n' - newPostForm += replyStr - if mediaInstance and not replyStr: - newPostForm += newPostImageSection + new_post_form += reply_str + if media_instance and not reply_str: + new_post_form += new_post_image_section - if not shareDescription: - shareDescription = '' - newPostForm += \ - editTextField(placeholderSubject, 'subject', shareDescription) - newPostForm += '' + if not share_description: + share_description = '' - selectedStr = ' selected' - if inReplyTo or endpoint == 'newdm': - if inReplyTo: - newPostForm += \ - '
    \n' + \ '\n' - if not reportUrl: - newPostForm = \ - newPostForm.replace('', '') - - newPostForm += htmlFooter() - return newPostForm + new_post_form += html_footer() + return new_post_form diff --git a/webapp_frontscreen.py b/webapp_frontscreen.py index 8462d80e2..f70ae203a 100644 --- a/webapp_frontscreen.py +++ b/webapp_frontscreen.py @@ -1,145 +1,157 @@ __filename__ = "webapp_frontscreen.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" import os -from utils import isSystemAccount -from utils import getDomainFromActor -from utils import getConfigParam -from person import personBoxJson -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import getBannerFile -from webapp_utils import htmlPostSeparator -from webapp_utils import headerButtonsFrontScreen -from webapp_column_left import getLeftColumnContent -from webapp_column_right import getRightColumnContent -from webapp_post import individualPostAsHtml +from utils import is_system_account +from utils import get_domain_from_actor +from utils import get_config_param +from utils import get_account_timezone +from person import person_box_json +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_banner_file +from webapp_utils import html_post_separator +from webapp_utils import header_buttons_front_screen +from webapp_column_left import get_left_column_content +from webapp_column_right import get_right_column_content +from webapp_post import individual_post_as_html -def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, - translate: {}, - baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - session, cachedWebfingers: {}, personCache: {}, - projectVersion: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, - signingPrivateKeyPem: str) -> str: +def _html_front_screen_posts(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + session, cached_webfingers: {}, person_cache: {}, + project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, + signing_priv_key_pem: str, cw_lists: {}, + lists_enabled: str, + bold_reading: bool, + dogwhistles: {}) -> str: """Shows posts on the front screen of a news instance These should only be public blog posts from the features timeline which is the blog timeline of the news actor """ - separatorStr = htmlPostSeparator(baseDir, None) - profileStr = '' - maxItems = 4 + separator_str = html_post_separator(base_dir, None) + profile_str = '' + max_items = 4 ctr = 0 - currPage = 1 - boxName = 'tlfeatures' + curr_page = 1 + box_name = 'tlfeatures' authorized = True - while ctr < maxItems and currPage < 4: - outboxFeedPathStr = \ - '/users/' + nickname + '/' + boxName + \ - '?page=' + str(currPage) - outboxFeed = \ - personBoxJson({}, session, baseDir, domain, port, - outboxFeedPathStr, - httpPrefix, 10, boxName, - authorized, 0, False, 0) - if not outboxFeed: + while ctr < max_items and curr_page < 4: + outbox_feed_path_str = \ + '/users/' + nickname + '/' + box_name + \ + '?page=' + str(curr_page) + outbox_feed = \ + person_box_json({}, base_dir, domain, port, + outbox_feed_path_str, + http_prefix, 10, box_name, + authorized, 0, False, 0) + if not outbox_feed: break - if len(outboxFeed['orderedItems']) == 0: + if len(outbox_feed['orderedItems']) == 0: break - for item in outboxFeed['orderedItems']: + for item in outbox_feed['orderedItems']: if item['type'] == 'Create': - postStr = \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, - maxRecentPosts, - translate, None, - baseDir, session, - cachedWebfingers, - personCache, - nickname, domain, port, item, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, - maxLikeCount, - False, False, False, - True, False, False) - if postStr: - profileStr += postStr + separatorStr + timezone = get_account_timezone(base_dir, nickname, domain) + post_str = \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, item, + None, True, False, + http_prefix, + project_version, 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, False, False, + True, False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) + if post_str: + profile_str += post_str + separator_str ctr += 1 - if ctr >= maxItems: + if ctr >= max_items: break - currPage += 1 - return profileStr + curr_page += 1 + return profile_str -def htmlFrontScreen(signingPrivateKeyPem: str, - rssIconAtTop: bool, - cssCache: {}, iconsAsButtons: bool, - defaultTimeline: str, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, projectVersion: str, - baseDir: str, httpPrefix: str, authorized: bool, - profileJson: {}, selected: str, - session, cachedWebfingers: {}, personCache: {}, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - newswire: {}, theme: str, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - accessKeys: {}, - systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: [], - extraJson: {} = None, - pageNumber: int = None, - maxItemsPerPage: int = None) -> str: +def html_front_screen(signing_priv_key_pem: str, + rss_icon_at_top: bool, + icons_as_buttons: bool, + default_timeline: str, + recent_posts_cache: {}, max_recent_posts: int, + translate: {}, project_version: str, + base_dir: str, http_prefix: str, authorized: bool, + profile_json: {}, selected: str, + session, cached_webfingers: {}, person_cache: {}, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + newswire: {}, theme: str, + peertube_instances: [], + allow_local_network_access: bool, + access_keys: {}, + system_language: str, max_like_count: int, + shared_items_federated_domains: [], + extra_json: {}, + page_number: int, + max_items_per_page: int, + cw_lists: {}, lists_enabled: str, + dogwhistles: {}) -> str: """Show the news instance front screen """ - nickname = profileJson['preferredUsername'] + bold_reading = False + nickname = profile_json['preferredUsername'] if not nickname: return "" - if not isSystemAccount(nickname): + if not is_system_account(nickname): return "" - domain, port = getDomainFromActor(profileJson['id']) + domain, port = get_domain_from_actor(profile_json['id']) if not domain: return "" - domainFull = domain + domain_full = domain if port: - domainFull = domain + ':' + str(port) + domain_full = domain + ':' + str(port) - loginButton = headerButtonsFrontScreen(translate, nickname, - 'features', authorized, - iconsAsButtons) + login_button = header_buttons_front_screen(translate, nickname, + 'features', authorized, + icons_as_buttons) # If this is the news account then show a different banner - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) - profileHeaderStr = \ - '\n' - if loginButton: - profileHeaderStr += '
    ' + loginButton + '
    \n' + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + profile_header_str = \ + '\n' + if login_button: + profile_header_str += '
    ' + login_button + '
    \n' - profileHeaderStr += \ + profile_header_str += \ '\n' + \ ' \n' + \ ' \n' + \ @@ -148,61 +160,65 @@ def htmlFrontScreen(signingPrivateKeyPem: str, ' \n' + \ ' \n' + \ ' \n' + \ - ' \n' + \ - ' \n' - profileFooterStr += ' \n' + profile_footer_str += \ + ' \n' + \ ' \n' + \ ' \n' + \ '
    \n' - profileHeaderStr += \ - getLeftColumnContent(baseDir, 'news', domainFull, - httpPrefix, translate, - False, False, None, rssIconAtTop, True, - True, theme, accessKeys, - sharedItemsFederatedDomains) - profileHeaderStr += \ + ' \n' + profile_header_str += \ + get_left_column_content(base_dir, 'news', domain_full, + http_prefix, translate, + False, False, + False, None, rss_icon_at_top, True, + True, theme, access_keys, + shared_items_federated_domains) + profile_header_str += \ ' \n' + ' \n' - profileStr = profileHeaderStr + profile_str = profile_header_str - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - licenseStr = '' - bannerFile, bannerFilename = \ - getBannerFile(baseDir, nickname, domain, theme) - profileStr += \ - _htmlFrontScreenPosts(recentPostsCache, maxRecentPosts, - translate, - baseDir, httpPrefix, - nickname, domain, port, - session, cachedWebfingers, personCache, - projectVersion, - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - theme, systemLanguage, - maxLikeCount, - signingPrivateKeyPem) + licenseStr + license_str = '' + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + profile_str += \ + _html_front_screen_posts(recent_posts_cache, max_recent_posts, + translate, + base_dir, http_prefix, + nickname, domain, port, + session, cached_webfingers, person_cache, + project_version, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme, system_language, + max_like_count, + signing_priv_key_pem, + cw_lists, lists_enabled, + bold_reading, dogwhistles) + license_str # Footer which is only used for system accounts - profileFooterStr = ' \n' - profileFooterStr += \ - getRightColumnContent(baseDir, 'news', domainFull, - httpPrefix, translate, - False, False, newswire, False, - False, None, False, False, - False, True, authorized, True, theme, - defaultTimeline, accessKeys) - profileFooterStr += \ + profile_footer_str = ' \n' + profile_footer_str += \ + get_right_column_content(base_dir, 'news', domain_full, + http_prefix, translate, + False, False, newswire, False, + False, None, False, False, + False, True, authorized, True, theme, + default_timeline, access_keys) + profile_footer_str += \ '
    \n' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - profileStr = \ - htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + \ - profileStr + profileFooterStr + htmlFooter() - return profileStr + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + profile_str = \ + html_header_with_external_style(css_filename, instance_title, None) + \ + profile_str + profile_footer_str + html_footer() + return profile_str diff --git a/webapp_hashtagswarm.py b/webapp_hashtagswarm.py index 6eca2a7d7..9cc0aeeeb 100644 --- a/webapp_hashtagswarm.py +++ b/webapp_hashtagswarm.py @@ -1,245 +1,252 @@ __filename__ = "webapp_hashtagswarm.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" import os -from shutil import copyfile from datetime import datetime -from utils import getNicknameFromActor -from utils import getConfigParam -from categories import getHashtagCategories -from categories import getHashtagCategory -from webapp_utils import getSearchBannerFile -from webapp_utils import getContentWarningButton -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter +from utils import get_nickname_from_actor +from utils import get_config_param +from categories import get_hashtag_categories +from categories import get_hashtag_category +from webapp_utils import set_custom_background +from webapp_utils import get_search_banner_file +from webapp_utils import get_content_warning_button +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer -def getHashtagCategoriesFeed(baseDir: str, - hashtagCategories: {} = None) -> str: +def get_hashtag_categories_feed(base_dir: str, + hashtag_categories: {} = None) -> str: """Returns an rss feed for hashtag categories """ - if not hashtagCategories: - hashtagCategories = getHashtagCategories(baseDir) - if not hashtagCategories: + if not hashtag_categories: + hashtag_categories = get_hashtag_categories(base_dir) + if not hashtag_categories: return None - rssStr = \ + rss_str = \ "\n" + \ "\n" + \ '\n' + \ ' #categories\n' - rssDateStr = \ + rss_date_str = \ datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S UT") - for categoryStr, hashtagList in hashtagCategories.items(): - rssStr += \ + for category_str, hashtag_list in hashtag_categories.items(): + rss_str += \ '\n' + \ - ' ' + categoryStr + '\n' - listStr = '' - for hashtag in hashtagList: + ' ' + category_str + '\n' + list_str = '' + for hashtag in hashtag_list: if ':' in hashtag: continue if '&' in hashtag: continue - listStr += hashtag + ' ' - rssStr += \ - ' ' + listStr.strip() + '\n' + \ + list_str += hashtag + ' ' + rss_str += \ + ' ' + list_str.strip() + '\n' + \ ' \n' + \ - ' ' + rssDateStr + '\n' + \ + ' ' + rss_date_str + '\n' + \ '\n' - rssStr += \ + rss_str += \ '\n' + \ '\n' - return rssStr + return rss_str -def htmlHashTagSwarm(baseDir: str, actor: str, translate: {}) -> str: +def html_hash_tag_swarm(base_dir: str, actor: str, translate: {}) -> str: """Returns a tag swarm of today's hashtags """ - maxTagLength = 42 - currTime = datetime.utcnow() - daysSinceEpoch = (currTime - datetime(1970, 1, 1)).days - daysSinceEpochStr = str(daysSinceEpoch) + ' ' - daysSinceEpochStr2 = str(daysSinceEpoch - 1) + ' ' - recently = daysSinceEpoch - 1 - tagSwarm = [] - categorySwarm = [] - domainHistogram = {} + max_tag_length = 42 + curr_time = datetime.utcnow() + days_since_epoch = (curr_time - datetime(1970, 1, 1)).days + days_since_epoch_str = str(days_since_epoch) + ' ' + days_since_epoch_str2 = str(days_since_epoch - 1) + ' ' + recently = days_since_epoch - 1 + tag_swarm = [] + category_swarm = [] + domain_histogram = {} # Load the blocked hashtags into memory. # This avoids needing to repeatedly load the blocked file for each hashtag - blockedStr = '' - globalBlockingFilename = baseDir + '/accounts/blocking.txt' - if os.path.isfile(globalBlockingFilename): - with open(globalBlockingFilename, 'r') as fp: - blockedStr = fp.read() + blocked_str = '' + global_blocking_filename = base_dir + '/accounts/blocking.txt' + if os.path.isfile(global_blocking_filename): + with open(global_blocking_filename, 'r', + encoding='utf-8') as fp_block: + blocked_str = fp_block.read() - for subdir, dirs, files in os.walk(baseDir + '/tags'): - for f in files: - if not f.endswith('.txt'): + for _, _, files in os.walk(base_dir + '/tags'): + for fname in files: + if not fname.endswith('.txt'): continue - tagsFilename = os.path.join(baseDir + '/tags', f) - if not os.path.isfile(tagsFilename): + tags_filename = os.path.join(base_dir + '/tags', fname) + if not os.path.isfile(tags_filename): continue # get last modified datetime - modTimesinceEpoc = os.path.getmtime(tagsFilename) - lastModifiedDate = datetime.fromtimestamp(modTimesinceEpoc) - fileDaysSinceEpoch = (lastModifiedDate - datetime(1970, 1, 1)).days + mod_time_since_epoc = os.path.getmtime(tags_filename) + last_modified_date = datetime.fromtimestamp(mod_time_since_epoc) + file_days_since_epoch = \ + (last_modified_date - datetime(1970, 1, 1)).days # check if the file was last modified within the previous # two days - if fileDaysSinceEpoch < recently: + if file_days_since_epoch < recently: continue - hashTagName = f.split('.')[0] - if len(hashTagName) > maxTagLength: + hash_tag_name = fname.split('.')[0] + if len(hash_tag_name) > max_tag_length: # NoIncrediblyLongAndBoringHashtagsShownHere continue - if '#' in hashTagName or \ - '&' in hashTagName or \ - '"' in hashTagName or \ - "'" in hashTagName: + if '#' in hash_tag_name or \ + '&' in hash_tag_name or \ + '"' in hash_tag_name or \ + "'" in hash_tag_name: continue - if '#' + hashTagName + '\n' in blockedStr: + if '#' + hash_tag_name + '\n' in blocked_str: continue - with open(tagsFilename, 'r') as fp: + with open(tags_filename, 'r', encoding='utf-8') as fp_tags: # only read one line, which saves time and memory - lastTag = fp.readline() - if not lastTag.startswith(daysSinceEpochStr): - if not lastTag.startswith(daysSinceEpochStr2): + last_tag = fp_tags.readline() + if not last_tag.startswith(days_since_epoch_str): + if not last_tag.startswith(days_since_epoch_str2): continue - with open(tagsFilename, 'r') as tagsFile: + with open(tags_filename, 'r', encoding='utf-8') as fp_tags: while True: - line = tagsFile.readline() + line = fp_tags.readline() if not line: break - elif ' ' not in line: + if ' ' not in line: break sections = line.split(' ') if len(sections) != 3: break - postDaysSinceEpochStr = sections[0] - if not postDaysSinceEpochStr.isdigit(): + post_days_since_epoch_str = sections[0] + if not post_days_since_epoch_str.isdigit(): break - postDaysSinceEpoch = int(postDaysSinceEpochStr) - if postDaysSinceEpoch < recently: + post_days_since_epoch = int(post_days_since_epoch_str) + if post_days_since_epoch < recently: break - else: - postUrl = sections[2] - if '##' not in postUrl: - break - postDomain = postUrl.split('##')[1] - if '#' in postDomain: - postDomain = postDomain.split('#')[0] + post_url = sections[2] + if '##' not in post_url: + break + post_domain = post_url.split('##')[1] + if '#' in post_domain: + post_domain = post_domain.split('#')[0] - if domainHistogram.get(postDomain): - domainHistogram[postDomain] = \ - domainHistogram[postDomain] + 1 - else: - domainHistogram[postDomain] = 1 - tagSwarm.append(hashTagName) - categoryFilename = \ - tagsFilename.replace('.txt', '.category') - if os.path.isfile(categoryFilename): - categoryStr = \ - getHashtagCategory(baseDir, hashTagName) - if len(categoryStr) < maxTagLength: - if '#' not in categoryStr and \ - '&' not in categoryStr and \ - '"' not in categoryStr and \ - "'" not in categoryStr: - if categoryStr not in categorySwarm: - categorySwarm.append(categoryStr) - break + if domain_histogram.get(post_domain): + domain_histogram[post_domain] = \ + domain_histogram[post_domain] + 1 + else: + domain_histogram[post_domain] = 1 + tag_swarm.append(hash_tag_name) + category_filename = \ + tags_filename.replace('.txt', '.category') + if os.path.isfile(category_filename): + category_str = \ + get_hashtag_category(base_dir, hash_tag_name) + if len(category_str) < max_tag_length: + if '#' not in category_str and \ + '&' not in category_str and \ + '"' not in category_str and \ + "'" not in category_str: + if category_str not in category_swarm: + category_swarm.append(category_str) + break break - if not tagSwarm: + if not tag_swarm: return '' - tagSwarm.sort() + tag_swarm.sort() # swarm of categories - categorySwarmStr = '' - if categorySwarm: - if len(categorySwarm) > 3: - categorySwarm.sort() - for categoryStr in categorySwarm: - categorySwarmStr += \ - '' + categoryStr + '\n' - categorySwarmStr += '
    \n' + category_swarm_str = '' + if category_swarm: + if len(category_swarm) > 3: + category_swarm.sort() + for category_str in category_swarm: + category_swarm_str += \ + '' + category_str + '\n' + category_swarm_str += '
    \n' # swarm of tags - tagSwarmStr = '' - for tagName in tagSwarm: - tagSwarmStr += \ - '' + tagName + '\n' + tag_swarm_str = '' + for tag_name in tag_swarm: + tag_display_name = tag_name + tag_map_filename = \ + os.path.join(base_dir + '/tagmaps', tag_name + '.txt') + if os.path.isfile(tag_map_filename): + tag_display_name = '📌' + tag_name + tag_swarm_str += \ + '' + tag_display_name + '\n' - if categorySwarmStr: - tagSwarmStr = \ - getContentWarningButton('alltags', translate, tagSwarmStr) + if category_swarm_str: + tag_swarm_str = \ + get_content_warning_button('alltags', translate, tag_swarm_str) - tagSwarmHtml = categorySwarmStr + tagSwarmStr.strip() + '\n' - return tagSwarmHtml + tag_swarm_html = category_swarm_str + tag_swarm_str.strip() + '\n' + return tag_swarm_html -def htmlSearchHashtagCategory(cssCache: {}, translate: {}, - baseDir: str, path: str, domain: str, - theme: str) -> str: +def html_search_hashtag_category(translate: {}, + base_dir: str, path: str, domain: str, + theme: str) -> str: """Show hashtags after selecting a category on the main search screen """ actor = path.split('/category/')[0] - categoryStr = path.split('/category/')[1].strip() - searchNickname = getNicknameFromActor(actor) + category_str = path.split('/category/')[1].strip() + search_nickname = get_nickname_from_actor(actor) + if not search_nickname: + return '' - if os.path.isfile(baseDir + '/img/search-background.png'): - if not os.path.isfile(baseDir + '/accounts/search-background.png'): - copyfile(baseDir + '/img/search-background.png', - baseDir + '/accounts/search-background.png') + set_custom_background(base_dir, 'search-background', 'follow-background') - cssFilename = baseDir + '/epicyon-search.css' - if os.path.isfile(baseDir + '/search.css'): - cssFilename = baseDir + '/search.css' + css_filename = base_dir + '/epicyon-search.css' + if os.path.isfile(base_dir + '/search.css'): + css_filename = base_dir + '/search.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + html_str = \ + html_header_with_external_style(css_filename, instance_title, None) # show a banner above the search box - searchBannerFile, searchBannerFilename = \ - getSearchBannerFile(baseDir, searchNickname, domain, theme) + search_banner_file, search_banner_filename = \ + get_search_banner_file(base_dir, search_nickname, domain, theme) - if os.path.isfile(searchBannerFilename): - htmlStr += '\n' - htmlStr += '\n' + if os.path.isfile(search_banner_filename): + html_str += '\n' + html_str += '\n' - htmlStr += \ + html_str += \ '
    ' + \ '



    ' + \ '

    ' + \ - translate['Category'] + ': ' + categoryStr + '

    ' + translate['Category'] + ': ' + category_str + '
    ' - hashtagsDict = getHashtagCategories(baseDir, True, categoryStr) - if hashtagsDict: - for categoryStr2, hashtagList in hashtagsDict.items(): - hashtagList.sort() - for tagName in hashtagList: - htmlStr += \ - '' + tagName + '\n' + hashtags_dict = get_hashtag_categories(base_dir, True, category_str) + if hashtags_dict: + for _, hashtag_list in hashtags_dict.items(): + hashtag_list.sort() + for tag_name in hashtag_list: + html_str += \ + '' + tag_name + '\n' - htmlStr += \ + html_str += \ '
    ' + \ '
    ' - htmlStr += htmlFooter() - return htmlStr + html_str += html_footer() + return html_str diff --git a/webapp_headerbuttons.py b/webapp_headerbuttons.py index 507835bbf..5ee09a099 100644 --- a/webapp_headerbuttons.py +++ b/webapp_headerbuttons.py @@ -1,7 +1,7 @@ __filename__ = "webapp_headerbuttons.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -10,327 +10,396 @@ __module_group__ = "Timeline" import os import time -from utils import acctDir +from utils import acct_dir from datetime import datetime from datetime import timedelta -from happening import dayEventsCheck -from webapp_utils import htmlHighlightLabel +from happening import day_events_check +from webapp_utils import html_highlight_label -def headerButtonsTimeline(defaultTimeline: str, - boxName: str, - pageNumber: int, - translate: {}, - usersPath: str, - mediaButton: str, - blogsButton: str, - featuresButton: str, - newsButton: str, - inboxButton: str, - dmButton: str, - newDM: str, - repliesButton: str, - newReply: str, - minimal: bool, - sentButton: str, - sharesButtonStr: str, - wantedButtonStr: str, - bookmarksButtonStr: str, - eventsButtonStr: str, - moderationButtonStr: str, - newPostButtonStr: str, - baseDir: str, - nickname: str, domain: str, - timelineStartTime, - newCalendarEvent: bool, - calendarPath: str, - calendarImage: str, - followApprovals: str, - iconsAsButtons: bool, - accessKeys: {}) -> str: +def header_buttons_timeline(default_timeline: str, + box_name: str, + page_number: int, + translate: {}, + users_path: str, + media_button: str, + blogs_button: str, + features_button: str, + news_button: str, + inbox_button: str, + dm_button: str, + new_dm: str, + replies_button: str, + new_reply: str, + minimal: bool, + sent_button: str, + shares_button_str: str, + wanted_button_str: str, + bookmarks_button_str: str, + events_button_str: str, + moderation_button_str: str, + new_post_button_str: str, + base_dir: str, + nickname: str, domain: str, + timeline_start_time, + new_calendar_event: bool, + calendar_path: str, + calendar_image: str, + follow_approvals: str, + icons_as_buttons: bool, + access_keys: {}, + is_text_browser: str) -> str: """Returns the header at the top of the timeline, containing buttons for inbox, outbox, search, calendar, etc """ # start of the button header with inbox, outbox, etc - tlStr = '
    ' # end of the button header with inbox, outbox, etc - tlStr += '
    \n' - return tlStr + tl_str += ' \n' + return tl_str diff --git a/webapp_likers.py b/webapp_likers.py new file mode 100644 index 000000000..9650e19bd --- /dev/null +++ b/webapp_likers.py @@ -0,0 +1,168 @@ +__filename__ = "webapp_likers.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.3.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "ActivityPub" + +import os +from utils import locate_post +from utils import get_config_param +from utils import get_account_timezone +from utils import get_display_name +from utils import get_nickname_from_actor +from utils import has_object_dict +from utils import load_json +from person import get_person_avatar_url +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_banner_file +from webapp_utils import add_emoji_to_display_name +from webapp_post import individual_post_as_html + + +def html_likers_of_post(base_dir: str, nickname: str, + domain: str, port: int, + post_url: str, translate: {}, + http_prefix: str, + theme: str, access_keys: {}, + recent_posts_cache: {}, max_recent_posts: int, + session, cached_webfingers: {}, + person_cache: {}, + project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + system_language: str, + max_like_count: int, signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + box_name: str, default_timeline: str, + bold_reading: bool, dogwhistles: {}, + dict_name: str = 'likes') -> str: + """Returns html for a screen showing who liked a post + """ + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + + instance_title = get_config_param(base_dir, 'instanceTitle') + html_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + # get the post which was liked + filename = locate_post(base_dir, nickname, domain, post_url) + if not filename: + return None + post_json_object = load_json(filename) + if not post_json_object: + return None + if not post_json_object.get('actor') or not post_json_object.get('object'): + return None + + # show the top banner + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + html_str += \ + '
    \n' + \ + '\n' + html_str += '\n' + \ + '
    \n' + + # show the post which was liked + timezone = get_account_timezone(base_dir, nickname, domain) + mitm = False + if os.path.isfile(filename.replace('.json', '') + '.mitm'): + mitm = True + html_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, + None, True, False, + http_prefix, + project_version, + box_name, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme, system_language, + max_like_count, + False, False, False, + False, False, False, + cw_lists, lists_enabled, + timezone, mitm, bold_reading, + dogwhistles) + + # show likers beneath the post + obj = post_json_object + if has_object_dict(post_json_object): + obj = post_json_object['object'] + if not obj.get(dict_name): + return None + if not isinstance(obj[dict_name], dict): + return None + if not obj[dict_name].get('items'): + return None + + if dict_name == 'likes': + html_str += \ + '

    ' + translate['Liked by'] + '

    \n' + else: + html_str += \ + '

    ' + translate['Repeated by'] + '

    \n' + + likers_list = '' + for like_item in obj[dict_name]['items']: + if not like_item.get('actor'): + continue + liker_actor = like_item['actor'] + liker_display_name = \ + get_display_name(base_dir, liker_actor, person_cache) + if liker_display_name: + liker_name = liker_display_name + if ':' in liker_name: + liker_name = \ + add_emoji_to_display_name(session, base_dir, + http_prefix, + nickname, domain, + liker_name, False, + translate) + else: + liker_name = get_nickname_from_actor(liker_actor) + if not liker_name: + liker_name = 'unknown' + if likers_list: + likers_list += ' ' + liker_avatar_url = \ + get_person_avatar_url(base_dir, liker_actor, person_cache) + if not liker_avatar_url: + liker_avatar_url = '' + else: + liker_avatar_url = ';' + liker_avatar_url + liker_options_link = \ + '/users/' + nickname + '?options=' + \ + liker_actor + ';1' + liker_avatar_url + likers_list += \ + '' + html_str += '
    \n' + likers_list + '\n
    \n' + + return html_str + html_footer() diff --git a/webapp_login.py b/webapp_login.py index 97e54811c..1d4490289 100644 --- a/webapp_login.py +++ b/webapp_login.py @@ -1,7 +1,7 @@ __filename__ = "webapp_login.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -10,33 +10,36 @@ __module_group__ = "Web Interface" import os import time from shutil import copyfile -from utils import getConfigParam -from utils import noOfAccounts -from utils import getNicknameValidationPattern -from webapp_utils import htmlHeaderWithWebsiteMarkup -from webapp_utils import htmlFooter -from webapp_utils import htmlKeyboardNavigation -from theme import getTextModeLogo +from utils import get_config_param +from utils import no_of_accounts +from utils import get_nickname_validation_pattern +from webapp_utils import set_custom_background +from webapp_utils import html_header_with_website_markup +from webapp_utils import html_footer +from webapp_utils import html_keyboard_navigation +from webapp_utils import text_mode_browser +from theme import get_text_mode_logo -def htmlGetLoginCredentials(loginParams: str, - lastLoginTime: int, - domain: str) -> (str, str, bool): +def html_get_login_credentials(login_params: str, + last_login_time: int, + domain: str) -> (str, str, bool): """Receives login credentials via HTTPServer POST """ - if not loginParams.startswith('username='): - return None, None, None + if not login_params.startswith('username='): + if '&username=' not in login_params: + return None, None, None # minimum time between login attempts - currTime = int(time.time()) - if currTime < lastLoginTime+10: + curr_time = int(time.time()) + if curr_time < last_login_time + 10: return None, None, None - if '&' not in loginParams: + if '&' not in login_params: return None, None, None - loginArgs = loginParams.split('&') + login_args = login_params.split('&') nickname = None password = None register = False - for arg in loginArgs: + for arg in login_args: if '=' not in arg: continue if arg.split('=', 1)[0] == 'username': @@ -53,139 +56,151 @@ def htmlGetLoginCredentials(loginParams: str, return nickname, password, register -def htmlLogin(cssCache: {}, translate: {}, - baseDir: str, - httpPrefix: str, domain: str, - systemLanguage: str, - autocomplete: bool) -> str: +def html_login(translate: {}, + base_dir: str, + http_prefix: str, domain: str, + system_language: str, + autocomplete: bool, + ua_str: str) -> str: """Shows the login screen """ - accounts = noOfAccounts(baseDir) + accounts = no_of_accounts(base_dir) - loginImage = 'login.png' - loginImageFilename = None - if os.path.isfile(baseDir + '/accounts/' + loginImage): - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.jpg'): - loginImage = 'login.jpg' - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.jpeg'): - loginImage = 'login.jpeg' - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.gif'): - loginImage = 'login.gif' - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.svg'): - loginImage = 'login.svg' - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.webp'): - loginImage = 'login.webp' - loginImageFilename = baseDir + '/accounts/' + loginImage - elif os.path.isfile(baseDir + '/accounts/login.avif'): - loginImage = 'login.avif' - loginImageFilename = baseDir + '/accounts/' + loginImage + login_image = 'login.png' + login_image_filename = None + if os.path.isfile(base_dir + '/accounts/' + login_image): + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.jpg'): + login_image = 'login.jpg' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.jpeg'): + login_image = 'login.jpeg' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.gif'): + login_image = 'login.gif' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.svg'): + login_image = 'login.svg' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.webp'): + login_image = 'login.webp' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.avif'): + login_image = 'login.avif' + login_image_filename = base_dir + '/accounts/' + login_image + elif os.path.isfile(base_dir + '/accounts/login.jxl'): + login_image = 'login.jxl' + login_image_filename = base_dir + '/accounts/' + login_image - if not loginImageFilename: - loginImageFilename = baseDir + '/accounts/' + loginImage - copyfile(baseDir + '/img/login.png', loginImageFilename) + if not login_image_filename: + login_image_filename = base_dir + '/accounts/' + login_image + copyfile(base_dir + '/img/login.png', login_image_filename) - textModeLogo = getTextModeLogo(baseDir) - textModeLogoHtml = htmlKeyboardNavigation(textModeLogo, {}, {}) + text_mode_logo = get_text_mode_logo(base_dir) + text_mode_logo_html = html_keyboard_navigation(text_mode_logo, {}, {}) - if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'): - if not os.path.isfile(baseDir + '/accounts/login-background.jpg'): - copyfile(baseDir + '/accounts/login-background-custom.jpg', - baseDir + '/accounts/login-background.jpg') + set_custom_background(base_dir, 'login-background-custom', + 'login-background') if accounts > 0: - loginText = \ + login_text = \ '

    ' + \ translate['Welcome. Please enter your login details below.'] + \ '

    ' else: - loginText = \ + login_text = \ '

    ' + \ translate['Please enter some credentials'] + '

    ' + \ '

    ' + \ translate['You will become the admin of this site.'] + \ '

    ' - if os.path.isfile(baseDir + '/accounts/login.txt'): + if os.path.isfile(base_dir + '/accounts/login.txt'): # custom login message - with open(baseDir + '/accounts/login.txt', 'r') as file: - loginText = '

    ' + file.read() + '

    ' + with open(base_dir + '/accounts/login.txt', 'r', + encoding='utf-8') as file: + login_text = '

    ' + file.read() + '

    ' - cssFilename = baseDir + '/epicyon-login.css' - if os.path.isfile(baseDir + '/login.css'): - cssFilename = baseDir + '/login.css' + css_filename = base_dir + '/epicyon-login.css' + if os.path.isfile(base_dir + '/login.css'): + css_filename = base_dir + '/login.css' # show the register button - registerButtonStr = '' - if getConfigParam(baseDir, 'registration') == 'open': - if int(getConfigParam(baseDir, 'registrationsRemaining')) > 0: + register_button_str = '' + if get_config_param(base_dir, 'registration') == 'open': + if int(get_config_param(base_dir, 'registrationsRemaining')) > 0: if accounts > 0: idx = 'Welcome. Please login or register a new account.' - loginText = \ + login_text = \ '

    ' + \ translate[idx] + \ '

    ' - registerButtonStr = \ - '' + register_button_str = \ + '' - TOSstr = \ - '

    ' + \ + tos_str = \ + '

    ' + \ translate['About this Instance'] + '

    ' + \ - '

    ' + \ + '

    ' + \ translate['Terms of Service'] + '

    ' - loginButtonStr = '' + login_button_str = '' if accounts > 0: - loginButtonStr = \ - '' - autocompleteNicknameStr = 'autocomplete="username"' - autocompletePasswordStr = 'autocomplete="current-password"' + autocomplete_nickname_str = 'autocomplete="username"' + autocomplete_password_str = 'autocomplete="current-password"' if not autocomplete: - autocompleteNicknameStr = 'autocomplete="username" value=""' - autocompletePasswordStr = 'autocomplete="off" value=""' + autocomplete_nickname_str = 'autocomplete="username" value=""' + autocomplete_password_str = 'autocomplete="off" value=""' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - loginForm = \ - htmlHeaderWithWebsiteMarkup(cssFilename, instanceTitle, - httpPrefix, domain, - systemLanguage) - nicknamePattern = getNicknameValidationPattern() - instanceTitle = getConfigParam(baseDir, 'instanceTitle') - loginForm += \ + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + login_form = \ + html_header_with_website_markup(css_filename, instance_title, + http_prefix, domain, + system_language) + + nickname_pattern = get_nickname_validation_pattern() + instance_title = get_config_param(base_dir, 'instanceTitle') + login_form += \ '
    \n' + \ '
    \n' + \ '
    \n' + \ - textModeLogoHtml + '\n' + \ - ' ' + instanceTitle + '\n' + \ - loginText + TOSstr + '\n' + \ + text_mode_logo_html + '\n' + \ + ' ' + instance_title + '\n' + \ + login_text + tos_str + '\n' + \ '
    \n' + \ '\n' + \ '
    \n' + \ ' \n' + \ - ' \n' + \ - '\n' + \ + 'pattern="' + nickname_pattern + '" name="username" tabindex="1" ' + \ + 'required autofocus>' + in_text_mode = text_mode_browser(ua_str) + if in_text_mode: + login_form += '
    ' + login_form += \ + '\n\n' + \ ' \n' + \ - ' \n' + \ - loginButtonStr + registerButtonStr + '\n' + \ + 'pattern="{8,256}" name="password" tabindex="1" required>' + if in_text_mode: + login_form += '

    ' + login_form += \ + '\n' + login_button_str + register_button_str + '\n' + \ '
    \n' + \ '
    \n' + \ - '' + \ - '' + \ + '' + \
         translate['Get the source code'] + '\n' - loginForm += htmlFooter() - return loginForm + login_form += html_footer() + return login_form diff --git a/webapp_media.py b/webapp_media.py index aa01b74b4..9d4d3e5db 100644 --- a/webapp_media.py +++ b/webapp_media.py @@ -1,131 +1,195 @@ __filename__ = "webapp_media.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" import os -from utils import validUrlPrefix +from utils import valid_url_prefix -def loadPeertubeInstances(baseDir: str, peertubeInstances: []) -> None: +def load_peertube_instances(base_dir: str, peertube_instances: []) -> None: """Loads peertube instances from file into the given list """ - peertubeList = None - peertubeInstancesFilename = baseDir + '/accounts/peertube.txt' - if os.path.isfile(peertubeInstancesFilename): - with open(peertubeInstancesFilename, 'r') as fp: - peertubeStr = fp.read() - if peertubeStr: - peertubeStr = peertubeStr.replace('\r', '') - peertubeList = peertubeStr.split('\n') - if not peertubeList: + peertube_list = None + peertube_instances_filename = base_dir + '/accounts/peertube.txt' + if os.path.isfile(peertube_instances_filename): + with open(peertube_instances_filename, 'r', + encoding='utf-8') as fp_inst: + peertube_str = fp_inst.read() + if peertube_str: + peertube_str = peertube_str.replace('\r', '') + peertube_list = peertube_str.split('\n') + if not peertube_list: return - for url in peertubeList: - if url in peertubeInstances: + for url in peertube_list: + if url in peertube_instances: continue - peertubeInstances.append(url) + peertube_instances.append(url) -def _addEmbeddedVideoFromSites(translate: {}, content: str, - peertubeInstances: [], - width: int = 400, height: int = 300) -> str: +def _add_embedded_video_from_sites(translate: {}, content: str, + peertube_instances: [], + width: int, height: int) -> str: """Adds embedded videos """ if '>vimeo.com/' in content: url = content.split('>vimeo.com/')[1] if '<' in url: url = url.split('<')[0] - content = \ - content + "
    \n\n
    \n" + "\" frameborder=\"0\" allow=\"" + \ + "fullscreen\" allowfullscreen " + \ + "tabindex=\"10\">\n" + \ + "\n\n" return content - videoSite = 'https://www.youtube.com' - if '"' + videoSite in content: - url = content.split('"' + videoSite)[1] + video_site = 'https://www.youtube.com' + if 'https://m.youtube.com' in content: + content = content.replace('https://m.youtube.com', video_site) + if '"' + video_site in content: + url = content.split('"' + video_site)[1] if '"' in url: - url = url.split('"')[0].replace('/watch?v=', '/embed/') - if '&' in url: - url = url.split('&')[0] - if '?utm_' in url: - url = url.split('?utm_')[0] - content = \ - content + "
    \n\n
    \n" - return content + url = url.split('"')[0] + if '/channel/' not in url and '/playlist' not in url: + url = url.replace('/watch?v=', '/embed/') + if '&' in url: + url = url.split('&')[0] + if '?utm_' in url: + url = url.split('?utm_')[0] + content += \ + "
    \n\n" + \ + "\n" + \ + "
    \n" + return content - invidiousSites = ('https://invidious.snopyta.org', - 'https://yewtu.be', - 'https://tube.connect.cafe', - 'https://invidious.kavin.rocks', - 'https://invidiou.site', - 'https://invidious.tube', - 'https://invidious.xyz', - 'https://invidious.zapashcanon.fr', - 'http://c7hqkpkpemu6e7emz5b4vy' + - 'z7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion', - 'http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' + - 'bzzsg2ii4fv2iid.onion') - for videoSite in invidiousSites: - if '"' + videoSite in content: - url = content.split('"' + videoSite)[1] + video_site = 'https://youtu.be/' + if '"' + video_site in content: + url = content.split('"' + video_site)[1] + if '"' in url: + url = url.split('"')[0] + if '/channel/' not in url and '/playlist' not in url: + url = 'embed/' + url + if '&' in url: + url = url.split('&')[0] + if '?utm_' in url: + url = url.split('?utm_')[0] + video_site = 'https://www.youtube.com/' + content += \ + "
    \n\n" + \ + "\n" + \ + "
    \n" + return content + + invidious_sites = ( + 'https://invidious.snopyta.org', + 'https://yewtu.be', + 'https://tube.connect.cafe', + 'https://invidious.kavin.rocks', + 'https://invidiou.site', + 'https://invidious.tube', + 'https://invidious.xyz', + 'https://invidious.zapashcanon.fr', + 'http://c7hqkpkpemu6e7emz5b4vy' + + 'z7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion', + 'http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' + + 'bzzsg2ii4fv2iid.onion' + ) + for video_site in invidious_sites: + if '"' + video_site in content: + url = content.split('"' + video_site)[1] if '"' in url: url = url.split('"')[0].replace('/watch?v=', '/embed/') if '&' in url: url = url.split('&')[0] if '?utm_' in url: url = url.split('?utm_')[0] - content = \ - content + "
    \n\n
    \n" + "\" frameborder=\"0\" allow=\"fullscreen\" " + \ + "allowfullscreen tabindex=\"10\">\n" + \ + "\n\n" return content - videoSite = 'https://media.ccc.de' - if '"' + videoSite in content: - url = content.split('"' + videoSite)[1] + video_site = 'https://media.ccc.de' + if '"' + video_site in content: + url = content.split('"' + video_site)[1] if '"' in url: url = url.split('"')[0] + video_site_settings = '' + if '#' in url: + video_site_settings = '#' + url.split('#', 1)[1] + url = url.split('#')[0] if not url.endswith('/oembed'): url = url + '/oembed' - content = \ - content + "
    \n\n
    \n" + "allowfullscreen tabindex=\"10\">\n" + \ + "\n\n" return content if '"https://' in content: - if peertubeInstances: + if peertube_instances: # only create an embedded video for a limited set of # peertube sites. - peerTubeSites = peertubeInstances + peertube_sites = peertube_instances else: # A default minimal set of peertube instances # Also see https://peertube_isolation.frama.io/list/ for # adversarial instances. Nothing in that list should be # in the defaults below. - peerTubeSites = ('share.tube', - 'visionon.tv', - 'peertube.fr', - 'kolektiva.media', - 'peertube.social', - 'videos.lescommuns.org') - for site in peerTubeSites: + peertube_sites = ( + 'share.tube', + 'visionon.tv', + 'anarchy.tube', + 'peertube.fr', + 'video.nerdcave.site', + 'kolektiva.media', + 'peertube.social', + 'videos.lescommuns.org' + ) + for site in peertube_sites: site = site.strip() if not site: continue @@ -133,38 +197,67 @@ def _addEmbeddedVideoFromSites(translate: {}, content: str, continue if '.' not in site: continue - siteStr = site + site_str = site if site.startswith('http://'): site = site.replace('http://', '') elif site.startswith('https://'): site = site.replace('https://', '') if site.endswith('.onion') or site.endswith('.i2p'): - siteStr = 'http://' + site + site_str = 'http://' + site else: - siteStr = 'https://' + site - siteStr = '"' + siteStr - if siteStr not in content: + site_str = 'https://' + site + site_str = '"' + site_str + if site_str not in content: continue - url = content.split(siteStr)[1] + url = content.split(site_str)[1] if '"' not in url: continue - url = url.split('"')[0].replace('/watch/', '/embed/') - content = \ - content + "
    \n\n
    \n" + "\" frameborder=\"0\" allow=\"" + \ + "fullscreen\" allowfullscreen tabindex=\"10\">' + \ + '\n" + \ + "\n\n" return content return content -def _addEmbeddedAudio(translate: {}, content: str) -> str: - """Adds embedded audio for mp3/ogg +def _add_embedded_audio(translate: {}, content: str) -> str: + """Adds embedded audio for mp3/ogg/opus """ - if not ('.mp3' in content or '.ogg' in content): + if not ('.mp3' in content or + '.ogg' in content or + '.opus' in content or + '.flac' in content): return content if '\n\n\n' return content -def _addEmbeddedVideo(translate: {}, content: str) -> str: +def _add_embedded_video(translate: {}, content: str) -> str: """Adds embedded video for mp4/webm/ogv """ if not ('.mp4' in content or '.webm' in content or '.ogv' in content): @@ -217,39 +315,40 @@ def _addEmbeddedVideo(translate: {}, content: str) -> str: extension = '.ogv' words = content.strip('\n').split(' ') - for w in words: - if extension not in w: + for wrd in words: + if extension not in wrd: continue - w = w.replace('href="', '').replace('">', '') - if w.endswith('.'): - w = w[:-1] - if w.endswith('"'): - w = w[:-1] - if w.endswith(';'): - w = w[:-1] - if w.endswith(':'): - w = w[:-1] - if not w.endswith(extension): + wrd = wrd.replace('href="', '').replace('">', '') + if wrd.endswith('.'): + wrd = wrd[:-1] + if wrd.endswith('"'): + wrd = wrd[:-1] + if wrd.endswith(';'): + wrd = wrd[:-1] + if wrd.endswith(':'): + wrd = wrd[:-1] + if not wrd.endswith(extension): continue - if not validUrlPrefix(w): + if not valid_url_prefix(wrd): continue content += \ - '
    \n' + \ + '
    \n' + \ ' \n
    \n
    \n' + '\n\n\n\n' return content -def addEmbeddedElements(translate: {}, content: str, - peertubeInstances: []) -> str: +def add_embedded_elements(translate: {}, content: str, + peertube_instances: []) -> str: """Adds embedded elements for various media types """ - content = _addEmbeddedVideoFromSites(translate, content, - peertubeInstances) - content = _addEmbeddedAudio(translate, content) - return _addEmbeddedVideo(translate, content) + content = _add_embedded_video_from_sites(translate, content, + peertube_instances, 400, 300) + content = _add_embedded_audio(translate, content) + return _add_embedded_video(translate, content) diff --git a/webapp_minimalbutton.py b/webapp_minimalbutton.py index 8b6e6f6e4..38337d99d 100644 --- a/webapp_minimalbutton.py +++ b/webapp_minimalbutton.py @@ -1,43 +1,46 @@ __filename__ = "webapp_minimalbutton.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" import os -from utils import acctDir +from utils import acct_dir -def isMinimal(baseDir: str, domain: str, nickname: str) -> bool: +def is_minimal(base_dir: str, domain: str, nickname: str) -> bool: """Returns true if minimal buttons should be shown for the given account """ - accountDir = acctDir(baseDir, nickname, domain) - if not os.path.isdir(accountDir): + account_dir = acct_dir(base_dir, nickname, domain) + if not os.path.isdir(account_dir): return True - minimalFilename = accountDir + '/.notminimal' - if os.path.isfile(minimalFilename): + minimal_filename = account_dir + '/.notminimal' + if os.path.isfile(minimal_filename): return False return True -def setMinimal(baseDir: str, domain: str, nickname: str, - minimal: bool) -> None: +def set_minimal(base_dir: str, domain: str, nickname: str, + minimal: bool) -> None: """Sets whether an account should display minimal buttons """ - accountDir = acctDir(baseDir, nickname, domain) - if not os.path.isdir(accountDir): + account_dir = acct_dir(base_dir, nickname, domain) + if not os.path.isdir(account_dir): return - minimalFilename = accountDir + '/.notminimal' - minimalFileExists = os.path.isfile(minimalFilename) - if minimal and minimalFileExists: + minimal_filename = account_dir + '/.notminimal' + minimal_file_exists = os.path.isfile(minimal_filename) + if minimal and minimal_file_exists: try: - os.remove(minimalFilename) - except BaseException: - pass - elif not minimal and not minimalFileExists: - with open(minimalFilename, 'w+') as fp: - fp.write('\n') + os.remove(minimal_filename) + except OSError: + print('EX: set_minimal unable to delete ' + minimal_filename) + elif not minimal and not minimal_file_exists: + try: + with open(minimal_filename, 'w+', encoding='utf-8') as fp_min: + fp_min.write('\n') + except OSError: + print('EX: unable to write minimal ' + minimal_filename) diff --git a/webapp_moderation.py b/webapp_moderation.py index ef455e412..ed41862ad 100644 --- a/webapp_moderation.py +++ b/webapp_moderation.py @@ -1,299 +1,344 @@ __filename__ = "webapp_moderation.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Moderation" import os -from utils import isAccountDir -from utils import getFullDomain -from utils import isEditor -from utils import loadJson -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import getConfigParam -from utils import localActorUrl -from posts import downloadFollowCollection -from posts import getPublicPostInfo -from posts import isModerator -from webapp_timeline import htmlTimeline -# from webapp_utils import getPersonAvatarUrl -from webapp_utils import getContentWarningButton -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from blocking import isBlockedDomain -from blocking import isBlocked -from session import createSession +from utils import is_artist +from utils import is_account_dir +from utils import get_full_domain +from utils import is_editor +from utils import load_json +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import get_config_param +from utils import local_actor_url +from utils import remove_eol +from posts import download_follow_collection +from posts import get_public_post_info +from posts import is_moderator +from webapp_timeline import html_timeline +# from webapp_utils import get_person_avatar_url +from webapp_utils import get_banner_file +from webapp_utils import get_content_warning_button +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from blocking import is_blocked_domain +from blocking import is_blocked +from session import create_session -def htmlModeration(cssCache: {}, defaultTimeline: str, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, pageNumber: int, itemsPerPage: int, - session, baseDir: str, wfRequest: {}, personCache: {}, - nickname: str, domain: str, port: int, inboxJson: {}, - allowDeletion: bool, - httpPrefix: str, projectVersion: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - newswire: {}, positiveVoting: bool, - showPublishAsIcon: bool, - fullWidthTimelineButtonHeader: bool, - iconsAsButtons: bool, - rssIconAtTop: bool, - publishButtonAtTop: bool, - authorized: bool, moderationActionStr: str, - theme: str, peertubeInstances: [], - allowLocalNetworkAccess: bool, - textModeBanner: str, - accessKeys: {}, systemLanguage: str, - maxLikeCount: int, - sharedItemsFederatedDomains: [], - signingPrivateKeyPem: str) -> str: +def html_moderation(default_timeline: str, + recent_posts_cache: {}, max_recent_posts: int, + translate: {}, page_number: int, items_per_page: int, + session, base_dir: str, wf_request: {}, person_cache: {}, + nickname: str, domain: str, port: int, inbox_json: {}, + allow_deletion: bool, + http_prefix: str, project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + newswire: {}, positive_voting: bool, + show_publish_as_icon: bool, + full_width_tl_button_header: bool, + icons_as_buttons: bool, + rss_icon_at_top: bool, + publish_button_at_top: bool, + authorized: bool, moderation_action_str: str, + theme: str, peertube_instances: [], + allow_local_network_access: bool, + text_mode_banner: str, + access_keys: {}, system_language: str, + max_like_count: int, + shared_items_federated_domains: [], + signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + timezone: str, bold_reading: bool, + dogwhistles: {}, ua_str: str) -> str: """Show the moderation feed as html This is what you see when selecting the "mod" timeline """ - return htmlTimeline(cssCache, defaultTimeline, - recentPostsCache, maxRecentPosts, - translate, pageNumber, - itemsPerPage, session, baseDir, wfRequest, personCache, - nickname, domain, port, inboxJson, 'moderation', - allowDeletion, httpPrefix, projectVersion, True, False, - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - newswire, False, False, positiveVoting, - showPublishAsIcon, fullWidthTimelineButtonHeader, - iconsAsButtons, rssIconAtTop, publishButtonAtTop, - authorized, moderationActionStr, theme, - peertubeInstances, allowLocalNetworkAccess, - textModeBanner, accessKeys, systemLanguage, - maxLikeCount, sharedItemsFederatedDomains, - signingPrivateKeyPem) + artist = is_artist(base_dir, nickname) + return html_timeline(default_timeline, + recent_posts_cache, max_recent_posts, + translate, page_number, + items_per_page, session, base_dir, + wf_request, person_cache, + nickname, domain, port, inbox_json, 'moderation', + allow_deletion, http_prefix, + project_version, True, False, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + newswire, False, False, artist, positive_voting, + show_publish_as_icon, + full_width_tl_button_header, + icons_as_buttons, rss_icon_at_top, + publish_button_at_top, + authorized, moderation_action_str, theme, + peertube_instances, allow_local_network_access, + text_mode_banner, access_keys, system_language, + max_like_count, shared_items_federated_domains, + signing_priv_key_pem, cw_lists, lists_enabled, + timezone, bold_reading, dogwhistles, ua_str) -def htmlAccountInfo(cssCache: {}, translate: {}, - baseDir: str, httpPrefix: str, - nickname: str, domain: str, port: int, - searchHandle: str, debug: bool, - systemLanguage: str, signingPrivateKeyPem: str) -> str: +def html_account_info(translate: {}, + base_dir: str, http_prefix: str, + nickname: str, domain: str, port: int, + search_handle: str, debug: bool, + system_language: str, signing_priv_key_pem: str) -> str: """Shows which domains a search handle interacts with. This screen is shown if a moderator enters a handle and selects info on the moderation screen """ - signingPrivateKeyPem = None - msgStr1 = 'This account interacts with the following instances' + signing_priv_key_pem = None + msg_str1 = 'This account interacts with the following instances' - infoForm = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + info_form = '' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - infoForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + info_form = \ + html_header_with_external_style(css_filename, instance_title, None) - searchNickname = getNicknameFromActor(searchHandle) - searchDomain, searchPort = getDomainFromActor(searchHandle) + search_nickname = get_nickname_from_actor(search_handle) + if not search_nickname: + return '' + search_domain, search_port = get_domain_from_actor(search_handle) - searchHandle = searchNickname + '@' + searchDomain - searchActor = \ - localActorUrl(httpPrefix, searchNickname, searchDomain) - infoForm += \ + search_handle = search_nickname + '@' + search_domain + search_actor = \ + local_actor_url(http_prefix, search_nickname, search_domain) + info_form += \ '

    ' + \ - translate['Account Information'] + ': ' + searchHandle + '


    \n' + translate['Account Information'] + ': ' + search_handle + '
    \n' - infoForm += translate[msgStr1] + '


    \n' + info_form += translate[msg_str1] + '

    \n' - proxyType = 'tor' + proxy_type = 'tor' if not os.path.isfile('/usr/bin/tor'): - proxyType = None + proxy_type = None if domain.endswith('.i2p'): - proxyType = None + proxy_type = None - session = createSession(proxyType) + session = create_session(proxy_type) - wordFrequency = {} - originDomain = None - domainDict = getPublicPostInfo(session, - baseDir, searchNickname, searchDomain, - originDomain, - proxyType, searchPort, - httpPrefix, debug, - __version__, wordFrequency, systemLanguage, - signingPrivateKeyPem) + word_frequency = {} + origin_domain = None + domain_dict = get_public_post_info(session, base_dir, + search_nickname, search_domain, + origin_domain, + proxy_type, search_port, + http_prefix, debug, + __version__, word_frequency, + system_language, + signing_priv_key_pem) # get a list of any blocked followers - followersList = \ - downloadFollowCollection(signingPrivateKeyPem, - 'followers', session, - httpPrefix, searchActor, 1, 5) - blockedFollowers = [] - for followerActor in followersList: - followerNickname = getNicknameFromActor(followerActor) - followerDomain, followerPort = getDomainFromActor(followerActor) - followerDomainFull = getFullDomain(followerDomain, followerPort) - if isBlocked(baseDir, nickname, domain, - followerNickname, followerDomainFull): - blockedFollowers.append(followerActor) + followers_list = \ + download_follow_collection(signing_priv_key_pem, + 'followers', session, + http_prefix, search_actor, 1, 5, debug) + blocked_followers = [] + for follower_actor in followers_list: + follower_nickname = get_nickname_from_actor(follower_actor) + if not follower_nickname: + return '' + follower_domain, follower_port = get_domain_from_actor(follower_actor) + follower_domain_full = get_full_domain(follower_domain, follower_port) + if is_blocked(base_dir, nickname, domain, + follower_nickname, follower_domain_full): + blocked_followers.append(follower_actor) # get a list of any blocked following - followingList = \ - downloadFollowCollection(signingPrivateKeyPem, - 'following', session, - httpPrefix, searchActor, 1, 5) - blockedFollowing = [] - for followingActor in followingList: - followingNickname = getNicknameFromActor(followingActor) - followingDomain, followingPort = getDomainFromActor(followingActor) - followingDomainFull = getFullDomain(followingDomain, followingPort) - if isBlocked(baseDir, nickname, domain, - followingNickname, followingDomainFull): - blockedFollowing.append(followingActor) + following_list = \ + download_follow_collection(signing_priv_key_pem, + 'following', session, + http_prefix, search_actor, 1, 5, debug) + blocked_following = [] + for following_actor in following_list: + following_nickname = get_nickname_from_actor(following_actor) + if not following_nickname: + return '' + following_domain, following_port = \ + get_domain_from_actor(following_actor) + following_domain_full = \ + get_full_domain(following_domain, following_port) + if is_blocked(base_dir, nickname, domain, + following_nickname, following_domain_full): + blocked_following.append(following_actor) - infoForm += '
    \n' - usersPath = '/users/' + nickname + '/accountinfo' + info_form += '
    \n' + users_path = '/users/' + nickname + '/accountinfo' ctr = 1 - for postDomain, blockedPostUrls in domainDict.items(): - infoForm += '' + \ - postDomain + ' ' - if isBlockedDomain(baseDir, postDomain): - blockedPostsLinks = '' - urlCtr = 0 - for url in blockedPostUrls: - if urlCtr > 0: - blockedPostsLinks += '
    ' - blockedPostsLinks += \ + post_domain + ' ' + if is_blocked_domain(base_dir, post_domain): + blocked_posts_links = '' + url_ctr = 0 + for url in blocked_post_urls: + if url_ctr > 0: + blocked_posts_links += '
    ' + blocked_posts_links += \ '' + \ url + '' - urlCtr += 1 - blockedPostsHtml = '' - if blockedPostsLinks: - blockNoStr = 'blockNumber' + str(ctr) - blockedPostsHtml = \ - getContentWarningButton(blockNoStr, - translate, blockedPostsLinks) + url_ctr += 1 + blocked_posts_html = '' + if blocked_posts_links: + block_no_str = 'blockNumber' + str(ctr) + blocked_posts_html = \ + get_content_warning_button(block_no_str, + translate, + blocked_posts_links) ctr += 1 - infoForm += \ - '' - infoForm += ' ' + \ - blockedPostsHtml + '\n' + blocked_posts_html + '\n' else: - infoForm += \ - '' - if postDomain != domain: - infoForm += '' - infoForm += '\n' - infoForm += '
    \n' + info_form += '\n' + info_form += '
    \n' - infoForm += '
    \n' + info_form += '
    \n' - if blockedFollowing: - blockedFollowing.sort() - infoForm += '
    \n' - infoForm += '

    ' + translate['Blocked following'] + '

    \n' - infoForm += \ + if blocked_following: + blocked_following.sort() + info_form += '
    \n' + info_form += '

    ' + translate['Blocked following'] + '

    \n' + info_form += \ '

    ' + \ translate['Receives posts from the following accounts'] + \ ':

    \n' - for actor in blockedFollowing: - followingNickname = getNicknameFromActor(actor) - followingDomain, followingPort = getDomainFromActor(actor) - followingDomainFull = \ - getFullDomain(followingDomain, followingPort) - infoForm += '' + \ - followingNickname + '@' + followingDomainFull + \ + following_nickname + '@' + following_domain_full + \ '

    \n' - infoForm += '
    \n' + info_form += '
    \n' - if blockedFollowers: - blockedFollowers.sort() - infoForm += '
    \n' - infoForm += '

    ' + translate['Blocked followers'] + '

    \n' - infoForm += \ + if blocked_followers: + blocked_followers.sort() + info_form += '
    \n' + info_form += '

    ' + translate['Blocked followers'] + '

    \n' + info_form += \ '

    ' + \ translate['Sends out posts to the following accounts'] + \ ':

    \n' - for actor in blockedFollowers: - followerNickname = getNicknameFromActor(actor) - followerDomain, followerPort = getDomainFromActor(actor) - followerDomainFull = getFullDomain(followerDomain, followerPort) - infoForm += '' + \ - followerNickname + '@' + followerDomainFull + '

    \n' - infoForm += '
    \n' + follower_nickname + '@' + \ + follower_domain_full + '

    \n' + info_form += '
    \n' - if wordFrequency: - maxCount = 1 - for word, count in wordFrequency.items(): - if count > maxCount: - maxCount = count - minimumWordCount = int(maxCount / 2) - if minimumWordCount >= 3: - infoForm += '
    \n' - infoForm += '

    ' + translate['Word frequencies'] + '

    \n' - wordSwarm = '' + if word_frequency: + max_count = 1 + for word, count in word_frequency.items(): + if count > max_count: + max_count = count + minimum_word_count = int(max_count / 2) + if minimum_word_count >= 3: + info_form += '
    \n' + info_form += '

    ' + translate['Word frequencies'] + '

    \n' + word_swarm = '' ctr = 0 - for word, count in wordFrequency.items(): - if count >= minimumWordCount: + for word, count in word_frequency.items(): + if count >= minimum_word_count: if ctr > 0: - wordSwarm += ' ' - if count < maxCount - int(maxCount / 4): - wordSwarm += word + word_swarm += ' ' + if count < max_count - int(max_count / 4): + word_swarm += word else: - if count != maxCount: - wordSwarm += '' + word + '' + if count != max_count: + word_swarm += '' + word + '' else: - wordSwarm += '' + word + '' + word_swarm += '' + word + '' ctr += 1 - infoForm += wordSwarm - infoForm += '
    \n' + info_form += word_swarm + info_form += '
    \n' - infoForm += htmlFooter() - return infoForm + info_form += html_footer() + return info_form -def htmlModerationInfo(cssCache: {}, translate: {}, - baseDir: str, httpPrefix: str, - nickname: str) -> str: - msgStr1 = \ +def html_moderation_info(translate: {}, base_dir: str, + nickname: str, domain: str, theme: str, + access_keys: {}) -> str: + msg_str1 = \ 'These are globally blocked for all accounts on this instance' - msgStr2 = \ + msg_str2 = \ 'Any blocks or suspensions made by moderators will be shown here.' - infoForm = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + info_form = '' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - infoForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + info_form = html_header_with_external_style(css_filename, + instance_title, None) - infoForm += \ - '

    ' + \ + # show banner + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + moderation_link = '/users/' + nickname + '/moderation' + info_form += \ + '
    \n\n' + info_form += \ + '\n' + \ + '
    \n
    \n' + + info_form += \ + '

    ' + \ translate['Moderation Information'] + \ '


    ' - infoShown = False + info_shown = False accounts = [] - for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for _, dirs, _ in os.walk(base_dir + '/accounts'): for acct in dirs: - if not isAccountDir(acct): + if not is_account_dir(acct): continue accounts.append(acct) break @@ -301,107 +346,115 @@ def htmlModerationInfo(cssCache: {}, translate: {}, cols = 5 if len(accounts) > 10: - infoForm += '
    ' + translate['Show Accounts'] - infoForm += '\n' - infoForm += '
    \n' - infoForm += '\n' - infoForm += ' \n' + info_form += '
    ' + translate['Show Accounts'] + info_form += '\n' + info_form += '
    \n' + info_form += '
    \n' + info_form += ' \n' for col in range(cols): - infoForm += ' \n' - infoForm += ' \n' - infoForm += '\n' + info_form += ' \n' + info_form += ' \n' + info_form += '\n' col = 0 for acct in accounts: - acctNickname = acct.split('@')[0] - accountDir = os.path.join(baseDir + '/accounts', acct) - actorJson = loadJson(accountDir + '.json') - if not actorJson: + acct_nickname = acct.split('@')[0] + account_dir = os.path.join(base_dir + '/accounts', acct) + actor_json = load_json(account_dir + '.json') + if not actor_json: continue - actor = actorJson['id'] - avatarUrl = '' + actor = actor_json['id'] + avatar_url = '' ext = '' - if actorJson.get('icon'): - if actorJson['icon'].get('url'): - avatarUrl = actorJson['icon']['url'] - if '.' in avatarUrl: - ext = '.' + avatarUrl.split('.')[-1] - acctUrl = \ + if actor_json.get('icon'): + if actor_json['icon'].get('url'): + avatar_url = actor_json['icon']['url'] + if '.' in avatar_url: + ext = '.' + avatar_url.split('.')[-1] + acct_url = \ '/users/' + nickname + '?options=' + actor + ';1;' + \ - '/members/' + acctNickname + ext - infoForm += '\n' + info_form += acct_nickname + if is_editor(base_dir, acct_nickname): + info_form += ' ✍' + info_form += '\n\n' col += 1 if col == cols: # new row of accounts - infoForm += '\n\n' - infoForm += '\n
    \n' - infoForm += '' - infoForm += '
    ' - if isModerator(baseDir, acctNickname): - infoForm += '' + acctNickname + '' + '/members/' + acct_nickname + ext + info_form += '
    \n' + info_form += '' + info_form += '
    ' + if is_moderator(base_dir, acct_nickname): + info_form += '' + acct_nickname + '' else: - infoForm += acctNickname - if isEditor(baseDir, acctNickname): - infoForm += ' ✍' - infoForm += '
    \n
    \n' - infoForm += '
    \n' + info_form += '\n\n' + info_form += '\n\n' + info_form += '\n' if len(accounts) > 10: - infoForm += '
    \n' + info_form += '\n' - suspendedFilename = baseDir + '/accounts/suspended.txt' - if os.path.isfile(suspendedFilename): - with open(suspendedFilename, 'r') as f: - suspendedStr = f.read() - infoForm += '
    \n' - infoForm += '
    ' + \ + suspended_filename = base_dir + '/accounts/suspended.txt' + if os.path.isfile(suspended_filename): + with open(suspended_filename, 'r', encoding='utf-8') as fp_sus: + suspended_str = fp_sus.read() + info_form += '
    \n' + info_form += '
    ' + \ translate['Suspended accounts'] + '' - infoForm += '
    ' + \ + info_form += '
    ' + \ translate['These are currently suspended'] - infoForm += \ + info_form += \ ' \n' - infoForm += '
    \n' - infoShown = True + suspended_str + '\n' + info_form += '
    \n' + info_shown = True - blockingFilename = baseDir + '/accounts/blocking.txt' - if os.path.isfile(blockingFilename): - with open(blockingFilename, 'r') as f: - blockedStr = f.read() - infoForm += '
    \n' - infoForm += \ + blocking_filename = base_dir + '/accounts/blocking.txt' + if os.path.isfile(blocking_filename): + with open(blocking_filename, 'r', encoding='utf-8') as fp_block: + blocked_lines = fp_block.readlines() + blocked_str = '' + if blocked_lines: + blocked_lines.sort() + for line in blocked_lines: + if not line: + continue + line = remove_eol(line).strip() + blocked_str += line + '\n' + info_form += '
    \n' + info_form += \ '
    ' + \ translate['Blocked accounts and hashtags'] + '' - infoForm += \ + info_form += \ '
    ' + \ - translate[msgStr1] - infoForm += \ + translate[msg_str1] + info_form += \ ' \n' - infoForm += '
    \n' - infoShown = True + 'name="blocked" style="height:2000px" spellcheck="false">' + \ + blocked_str + '\n' + info_form += '
    \n' + info_shown = True - filtersFilename = baseDir + '/accounts/filters.txt' - if os.path.isfile(filtersFilename): - with open(filtersFilename, 'r') as f: - filteredStr = f.read() - infoForm += '
    \n' - infoForm += \ + filters_filename = base_dir + '/accounts/filters.txt' + if os.path.isfile(filters_filename): + with open(filters_filename, 'r', encoding='utf-8') as fp_filt: + filtered_str = fp_filt.read() + info_form += '
    \n' + info_form += \ '
    ' + \ translate['Filtered words'] + '' - infoForm += \ + info_form += \ ' \n' - infoForm += '
    \n' - infoShown = True + filtered_str + '\n' + info_form += '
    \n' + info_shown = True - if not infoShown: - infoForm += \ + if not info_shown: + info_form += \ '

    ' + \ - translate[msgStr2] + \ + translate[msg_str2] + \ '

    \n' - infoForm += htmlFooter() - return infoForm + info_form += html_footer() + return info_form diff --git a/webapp_person_options.py b/webapp_person_options.py index 1ff3bcfc4..2b152b075 100644 --- a/webapp_person_options.py +++ b/webapp_person_options.py @@ -1,7 +1,7 @@ __filename__ = "webapp_person_options.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,420 +9,570 @@ __module_group__ = "Web Interface" import os from shutil import copyfile -from petnames import getPetName -from person import isPersonSnoozed -from posts import isModerator -from utils import getFullDomain -from utils import getConfigParam -from utils import isDormant -from utils import removeHtml -from utils import getDomainFromActor -from utils import getNicknameFromActor -from utils import isFeaturedWriter -from utils import acctDir -from blocking import isBlocked -from follow import isFollowerOfPerson -from follow import isFollowingActor -from followingCalendar import receivingCalendarEvents -from notifyOnPost import notifyWhenPersonPosts -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import getBrokenLinkSubstitute -from webapp_utils import htmlKeyboardNavigation +from petnames import get_pet_name +from person import is_person_snoozed +from posts import is_moderator +from utils import get_full_domain +from utils import get_config_param +from utils import is_dormant +from utils import remove_html +from utils import get_domain_from_actor +from utils import get_nickname_from_actor +from utils import is_featured_writer +from utils import acct_dir +from utils import text_in_file +from utils import remove_domain_port +from blocking import is_blocked +from follow import is_follower_of_person +from follow import is_following_actor +from followingCalendar import receiving_calendar_events +from notifyOnPost import notify_when_person_posts +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_broken_link_substitute +from webapp_utils import html_keyboard_navigation +from webapp_utils import get_banner_file +from webapp_utils import html_hide_from_screen_reader +from webapp_utils import minimizing_attached_images -def htmlPersonOptions(defaultTimeline: str, - cssCache: {}, translate: {}, baseDir: str, - domain: str, domainFull: str, - originPathStr: str, - optionsActor: str, - optionsProfileUrl: str, - optionsLink: str, - pageNumber: int, - donateUrl: str, - webAddress: str, - xmppAddress: str, - matrixAddress: str, - ssbAddress: str, - blogAddress: str, - toxAddress: str, - briarAddress: str, - jamiAddress: str, - cwtchAddress: str, - PGPpubKey: str, - PGPfingerprint: str, - emailAddress: str, - dormantMonths: int, - backToPath: str, - lockedAccount: bool, - movedTo: str, - alsoKnownAs: [], - textModeBanner: str, - newsInstance: bool, - authorized: bool, - accessKeys: {}, - isGroup: bool) -> str: +def _minimize_attached_images(base_dir: str, nickname: str, domain: str, + following_nickname: str, + following_domain: str, + add: bool) -> None: + """Adds or removes a handle from the following.txt list into a list + indicating whether to minimize images from that account + """ + # check that a following file exists + domain = remove_domain_port(domain) + following_filename = \ + acct_dir(base_dir, nickname, domain) + '/following.txt' + if not os.path.isfile(following_filename): + print("WARN: following.txt doesn't exist for " + + nickname + '@' + domain) + return + handle = following_nickname + '@' + following_domain + + # check that you are following this handle + if not text_in_file(handle + '\n', following_filename): + print('WARN: ' + handle + ' is not in ' + following_filename) + return + + minimize_filename = \ + acct_dir(base_dir, nickname, domain) + '/followingMinimizeImages.txt' + + # get the contents of the minimize file, which is + # a set of handles + minimize_handles = '' + if os.path.isfile(minimize_filename): + print('Minimize file exists') + try: + with open(minimize_filename, 'r', + encoding='utf-8') as minimize_file: + minimize_handles = minimize_file.read() + except OSError: + print('EX: minimize_attached_images ' + minimize_filename) + else: + # create a new minimize file from the following file + print('Creating minimize file ' + minimize_filename) + if add: + try: + with open(minimize_filename, 'w+', + encoding='utf-8') as fp_min: + fp_min.write('') + except OSError: + print('EX: minimize_attached_images unable to write ' + + minimize_filename) + + # already in the minimize file? + if handle + '\n' in minimize_handles: + print(handle + ' exists in followingMinimizeImages.txt') + if add: + # already added + return + # remove from minimize file + minimize_handles = minimize_handles.replace(handle + '\n', '') + try: + with open(minimize_filename, 'w+', + encoding='utf-8') as fp_min: + fp_min.write(minimize_handles) + except OSError: + print('EX: minimize_attached_images 3 ' + minimize_filename) + else: + print(handle + ' not in followingMinimizeImages.txt') + # not already in the minimize file + if add: + # append to the list of handles + minimize_handles += handle + '\n' + try: + with open(minimize_filename, 'w+', + encoding='utf-8') as fp_min: + fp_min.write(minimize_handles) + except OSError: + print('EX: minimize_attached_images 4 ' + minimize_filename) + + +def person_minimize_images(base_dir: str, nickname: str, domain: str, + following_nickname: str, + following_domain: str) -> None: + """Images from this person are minimized by default + """ + _minimize_attached_images(base_dir, nickname, domain, + following_nickname, following_domain, True) + + +def person_undo_minimize_images(base_dir: str, nickname: str, domain: str, + following_nickname: str, + following_domain: str) -> None: + """Images from this person are no longer minimized by default + """ + _minimize_attached_images(base_dir, nickname, domain, + following_nickname, following_domain, False) + + +def html_person_options(default_timeline: str, + translate: {}, base_dir: str, + domain: str, domain_full: str, + origin_path_str: str, + options_actor: str, + options_profile_url: str, + options_link: str, + page_number: int, + donate_url: str, + web_address: str, + xmpp_address: str, + matrix_address: str, + ssb_address: str, + blog_address: str, + tox_address: str, + briar_address: str, + cwtch_address: str, + enigma_pub_key: str, + pgp_pub_key: str, + pgp_fingerprint: str, + email_address: str, + dormant_months: int, + back_to_path: str, + locked_account: bool, + moved_to: str, + also_known_as: [], + text_mode_banner: str, + news_instance: bool, + authorized: bool, + access_keys: {}, + is_group: bool, + theme: str) -> str: """Show options for a person: view/follow/block/report """ - optionsDomain, optionsPort = getDomainFromActor(optionsActor) - optionsDomainFull = getFullDomain(optionsDomain, optionsPort) + options_domain, options_port = get_domain_from_actor(options_actor) + if not options_domain: + return None + options_domain_full = get_full_domain(options_domain, options_port) - if os.path.isfile(baseDir + '/accounts/options-background-custom.jpg'): - if not os.path.isfile(baseDir + '/accounts/options-background.jpg'): - copyfile(baseDir + '/accounts/options-background.jpg', - baseDir + '/accounts/options-background.jpg') + if os.path.isfile(base_dir + '/accounts/options-background-custom.jpg'): + if not os.path.isfile(base_dir + '/accounts/options-background.jpg'): + copyfile(base_dir + '/accounts/options-background.jpg', + base_dir + '/accounts/options-background.jpg') dormant = False - followStr = 'Follow' - if isGroup: - followStr = 'Join' - blockStr = 'Block' + follow_str = 'Follow' + if is_group: + follow_str = 'Join' + block_str = 'Block' nickname = None - optionsNickname = None - followsYou = False - if originPathStr.startswith('/users/'): - nickname = originPathStr.split('/users/')[1] + options_nickname = None + follows_you = False + if origin_path_str.startswith('/users/'): + nickname = origin_path_str.split('/users/')[1] if '/' in nickname: nickname = nickname.split('/')[0] if '?' in nickname: nickname = nickname.split('?')[0] - followerDomain, followerPort = getDomainFromActor(optionsActor) - if isFollowingActor(baseDir, nickname, domain, optionsActor): - followStr = 'Unfollow' - if isGroup: - followStr = 'Leave' +# follower_domain, follower_port = get_domain_from_actor(options_actor) + if is_following_actor(base_dir, nickname, domain, options_actor): + follow_str = 'Unfollow' + if is_group: + follow_str = 'Leave' dormant = \ - isDormant(baseDir, nickname, domain, optionsActor, - dormantMonths) + is_dormant(base_dir, nickname, domain, options_actor, + dormant_months) - optionsNickname = getNicknameFromActor(optionsActor) - optionsDomainFull = getFullDomain(optionsDomain, optionsPort) - followsYou = \ - isFollowerOfPerson(baseDir, - nickname, domain, - optionsNickname, optionsDomainFull) - if isBlocked(baseDir, nickname, domain, - optionsNickname, optionsDomainFull): - blockStr = 'Block' + options_nickname = get_nickname_from_actor(options_actor) + if not options_nickname: + return None + options_domain_full = get_full_domain(options_domain, options_port) + follows_you = \ + is_follower_of_person(base_dir, + nickname, domain, + options_nickname, options_domain_full) + if is_blocked(base_dir, nickname, domain, + options_nickname, options_domain_full): + block_str = 'Block' - optionsLinkStr = '' - if optionsLink: - optionsLinkStr = \ + options_link_str = '' + if options_link: + options_link_str = \ ' \n' - cssFilename = baseDir + '/epicyon-options.css' - if os.path.isfile(baseDir + '/options.css'): - cssFilename = baseDir + '/options.css' + options_link + '">\n' + css_filename = base_dir + '/epicyon-options.css' + if os.path.isfile(base_dir + '/options.css'): + css_filename = base_dir + '/options.css' # To snooze, or not to snooze? That is the question - snoozeButtonStr = 'Snooze' + snooze_button_str = 'Snooze' if nickname: - if isPersonSnoozed(baseDir, nickname, domain, optionsActor): - snoozeButtonStr = 'Unsnooze' + if is_person_snoozed(base_dir, nickname, domain, options_actor): + snooze_button_str = 'Unsnooze' - donateStr = '' - if donateUrl: - donateStr = \ - ' \n' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - optionsStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - optionsStr += htmlKeyboardNavigation(textModeBanner, {}, {}) - optionsStr += '

    \n' - optionsStr += '
    \n' - optionsStr += '
    \n' - optionsStr += '
    \n' - optionsStr += ' \n' - optionsStr += ' \n' - handle = getNicknameFromActor(optionsActor) + '@' + optionsDomain - handleShown = handle - if lockedAccount: - handleShown += '🔒' - if movedTo: - handleShown += ' ⌂' + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + options_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + # show banner + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme) + back_path = '/' + if nickname: + back_path = '/users/' + nickname + '/' + default_timeline + if 'moderation' in back_to_path: + back_path = '/users/' + nickname + '/moderation' + if authorized and origin_path_str == '/users/' + nickname: + banner_link = back_path + else: + banner_link = origin_path_str + options_str += \ + '
    \n\n' + options_str += \ + '\n' + \ + '
    \n

    \n' + + nav_links = {} + timeline_link_str = html_hide_from_screen_reader('🏠') + ' ' + \ + translate['Switch to timeline view'] + nav_links[timeline_link_str] = \ + '/users/' + nickname + '/' + default_timeline + nav_access_keys = { + } + options_str += \ + html_keyboard_navigation(text_mode_banner, nav_links, nav_access_keys) + + options_str += '
    \n' + options_str += '
    \n' + options_str += '
    \n' + options_str += ' \n' + options_str += ' \n' + handle_nick = get_nickname_from_actor(options_actor) + if not handle_nick: + return None + handle = handle_nick + '@' + options_domain + handle_shown = handle + if locked_account: + handle_shown += '🔒' + if moved_to: + handle_shown += ' ⌂' if dormant: - handleShown += ' 💤' - optionsStr += \ + handle_shown += ' 💤' + options_str += \ '

    ' + translate['Options for'] + \ - ' @' + handleShown + '

    \n' - if followsYou: - optionsStr += \ + ' @' + handle_shown + '

    \n' + if follows_you and authorized: + options_str += \ '

    ' + translate['Follows you'] + '

    \n' - if movedTo: - newNickname = getNicknameFromActor(movedTo) - newDomain, newPort = getDomainFromActor(movedTo) - if newNickname and newDomain: - newHandle = newNickname + '@' + newDomain - optionsStr += \ + if moved_to: + new_nickname = get_nickname_from_actor(moved_to) + new_domain, _ = get_domain_from_actor(moved_to) + if new_nickname and new_domain: + new_handle = new_nickname + '@' + new_domain + options_str += \ '

    ' + \ translate['New account'] + \ - ': @' + newHandle + '

    \n' - elif alsoKnownAs: - otherAccountsHtml = \ + ': @' + new_handle + '

    \n' + elif also_known_as: + other_accounts_html = \ '

    ' + \ translate['Other accounts'] + ': ' ctr = 0 - if isinstance(alsoKnownAs, list): - for altActor in alsoKnownAs: - if altActor == optionsActor: + if isinstance(also_known_as, list): + for alt_actor in also_known_as: + if alt_actor == options_actor: continue if ctr > 0: - otherAccountsHtml += ' ' + other_accounts_html += ' ' ctr += 1 - altDomain, altPort = getDomainFromActor(altActor) - otherAccountsHtml += \ - '' + altDomain + '' - elif isinstance(alsoKnownAs, str): - if alsoKnownAs != optionsActor: + alt_domain, _ = get_domain_from_actor(alt_actor) + other_accounts_html += \ + '' + alt_domain + '' + elif isinstance(also_known_as, str): + if also_known_as != options_actor: ctr += 1 - altDomain, altPort = getDomainFromActor(alsoKnownAs) - otherAccountsHtml += \ - '' + altDomain + '' - otherAccountsHtml += '

    \n' + alt_domain, _ = get_domain_from_actor(also_known_as) + other_accounts_html += \ + '' + alt_domain + '' + other_accounts_html += '

    \n' if ctr > 0: - optionsStr += otherAccountsHtml - if emailAddress: - optionsStr += \ + options_str += other_accounts_html + + if email_address: + options_str += \ '

    ' + translate['Email'] + \ ': ' + removeHtml(emailAddress) + '

    \n' - if xmppAddress: - optionsStr += \ + email_address + '">' + remove_html(email_address) + '

    \n' + if web_address: + web_str = remove_html(web_address) + if '://' not in web_str: + web_str = 'https://' + web_str + options_str += \ + '

    🌐 ' + \ + web_address + '

    \n' + if xmpp_address: + options_str += \ '

    ' + translate['XMPP'] + \ - ': ' + \ - xmppAddress + '

    \n' - if matrixAddress: - optionsStr += \ + ': ' + \ + xmpp_address + '

    \n' + if matrix_address: + options_str += \ '

    ' + translate['Matrix'] + ': ' + \ - removeHtml(matrixAddress) + '

    \n' - if ssbAddress: - optionsStr += \ - '

    SSB: ' + removeHtml(ssbAddress) + '

    \n' - if blogAddress: - optionsStr += \ + remove_html(matrix_address) + '

    \n' + if ssb_address: + options_str += \ + '

    SSB: ' + remove_html(ssb_address) + '

    \n' + if blog_address: + options_str += \ '

    Blog: ' + \ - removeHtml(blogAddress) + '

    \n' - if toxAddress: - optionsStr += \ - '

    Tox: ' + removeHtml(toxAddress) + '

    \n' - if briarAddress: - if briarAddress.startswith('briar://'): - optionsStr += \ + remove_html(blog_address) + '">' + \ + remove_html(blog_address) + '

    \n' + if tox_address: + options_str += \ + '

    Tox: ' + remove_html(tox_address) + '

    \n' + if briar_address: + if briar_address.startswith('briar://'): + options_str += \ '

    ' + \ - removeHtml(briarAddress) + '

    \n' + remove_html(briar_address) + '

    \n' else: - optionsStr += \ + options_str += \ '

    briar://' + \ - removeHtml(briarAddress) + '

    \n' - if jamiAddress: - optionsStr += \ - '

    Jami: ' + removeHtml(jamiAddress) + '

    \n' - if cwtchAddress: - optionsStr += \ - '

    Cwtch: ' + removeHtml(cwtchAddress) + '

    \n' - if PGPfingerprint: - optionsStr += '

    PGP: ' + \ - removeHtml(PGPfingerprint).replace('\n', '
    ') + '

    \n' - if PGPpubKey: - optionsStr += '

    ' + \ - removeHtml(PGPpubKey).replace('\n', '
    ') + '

    \n' - optionsStr += '
    \n' - optionsStr += ' \n' - optionsStr += ' \n' - optionsStr += ' \n' + remove_html(briar_address) + '

    \n' + if cwtch_address: + options_str += \ + '

    Cwtch: ' + remove_html(cwtch_address) + '

    \n' + if enigma_pub_key: + options_str += \ + '

    Enigma: ' + \ + remove_html(enigma_pub_key) + '

    \n' + if pgp_fingerprint: + options_str += '

    PGP: ' + \ + remove_html(pgp_fingerprint).replace('\n', '
    ') + '

    \n' + if pgp_pub_key: + options_str += '

    ' + \ + remove_html(pgp_pub_key).replace('\n', '
    ') + '

    \n' + options_str += ' \n' + options_str += ' \n' + options_str += ' \n' + options_str += ' \n' + if authorized: - if originPathStr == '/users/' + nickname: - if optionsNickname: - # handle = optionsNickname + '@' + optionsDomainFull - petname = getPetName(baseDir, nickname, domain, handle) - optionsStr += \ + if origin_path_str == '/users/' + nickname: + if options_nickname: + # handle = options_nickname + '@' + options_domain_full + petname = get_pet_name(base_dir, nickname, domain, handle) + options_str += \ ' ' + translate['Petname'] + ': \n' + \ ' \n' \ + 'accesskey="' + access_keys['enterPetname'] + '">\n' \ '
    \n' + translate['Save'] + '
    \n' # Notify when a post arrives from this person - if isFollowingActor(baseDir, nickname, domain, optionsActor): - checkboxStr = \ + if is_following_actor(base_dir, nickname, domain, options_actor): + checkbox_str = \ ' 🔔' + \ translate['Notify me when this account posts'] + \ '\n
    \n' - if not notifyWhenPersonPosts(baseDir, nickname, domain, - optionsNickname, - optionsDomainFull): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + translate['Save'] + '
    \n' + if not notify_when_person_posts(base_dir, nickname, domain, + options_nickname, + options_domain_full): + checkbox_str = checkbox_str.replace(' checked>', '>') + options_str += checkbox_str - checkboxStr = \ + checkbox_str = \ ' ' + \ translate['Receive calendar events from this account'] + \ '\n
    \n' - if not receivingCalendarEvents(baseDir, nickname, domain, - optionsNickname, - optionsDomainFull): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + translate['Save'] + '
    \n' + if not receiving_calendar_events(base_dir, nickname, domain, + options_nickname, + options_domain_full): + checkbox_str = checkbox_str.replace(' checked>', '>') + options_str += checkbox_str + + checkbox_str = \ + ' ' + \ + translate['Minimize attached images'] + \ + '\n
    \n' + if not minimizing_attached_images(base_dir, nickname, domain, + options_nickname, + options_domain_full): + checkbox_str = checkbox_str.replace(' checked>', '>') + options_str += checkbox_str # checkbox for permission to post to newswire - newswirePostsPermitted = False - if optionsDomainFull == domainFull: - adminNickname = getConfigParam(baseDir, 'admin') - if (nickname == adminNickname or - (isModerator(baseDir, nickname) and - not isModerator(baseDir, optionsNickname))): - newswireBlockedFilename = \ - baseDir + '/accounts/' + \ - optionsNickname + '@' + optionsDomain + '/.nonewswire' - checkboxStr = \ + newswire_posts_permitted = False + if options_domain_full == domain_full: + admin_nickname = get_config_param(base_dir, 'admin') + if (nickname == admin_nickname or + (is_moderator(base_dir, nickname) and + not is_moderator(base_dir, options_nickname))): + newswire_blocked_filename = \ + base_dir + '/accounts/' + \ + options_nickname + '@' + options_domain + \ + '/.nonewswire' + checkbox_str = \ ' ' + \ translate['Allow news posts'] + \ '\n
    \n' - if os.path.isfile(newswireBlockedFilename): - checkboxStr = checkboxStr.replace(' checked>', '>') + translate['Save'] + '
    \n' + if os.path.isfile(newswire_blocked_filename): + checkbox_str = checkbox_str.replace(' checked>', '>') else: - newswirePostsPermitted = True - optionsStr += checkboxStr + newswire_posts_permitted = True + options_str += checkbox_str # whether blogs created by this account are moderated on # the newswire - if newswirePostsPermitted: - moderatedFilename = \ - baseDir + '/accounts/' + \ - optionsNickname + '@' + \ - optionsDomain + '/.newswiremoderated' - checkboxStr = \ + if newswire_posts_permitted: + moderated_filename = \ + base_dir + '/accounts/' + \ + options_nickname + '@' + \ + options_domain + '/.newswiremoderated' + checkbox_str = \ ' ' + \ translate['News posts are moderated'] + \ '\n
    \n' - if not os.path.isfile(moderatedFilename): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + translate['Save'] + '
    \n' + if not os.path.isfile(moderated_filename): + checkbox_str = checkbox_str.replace(' checked>', '>') + options_str += checkbox_str # checkbox for permission to post to featured articles - if newsInstance and optionsDomainFull == domainFull: - adminNickname = getConfigParam(baseDir, 'admin') - if (nickname == adminNickname or - (isModerator(baseDir, nickname) and - not isModerator(baseDir, optionsNickname))): - checkboxStr = \ + if news_instance and options_domain_full == domain_full: + admin_nickname = get_config_param(base_dir, 'admin') + if (nickname == admin_nickname or + (is_moderator(base_dir, nickname) and + not is_moderator(base_dir, options_nickname))): + checkbox_str = \ ' ' + \ translate['Featured writer'] + \ '\n
    \n' - if not isFeaturedWriter(baseDir, optionsNickname, - optionsDomain): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + translate['Save'] + '
    \n' + if not is_featured_writer(base_dir, options_nickname, + options_domain): + checkbox_str = checkbox_str.replace(' checked>', '>') + options_str += checkbox_str - optionsStr += optionsLinkStr - backPath = '/' - if nickname: - backPath = '/users/' + nickname + '/' + defaultTimeline - if 'moderation' in backToPath: - backPath = '/users/' + nickname + '/moderation' - if authorized and originPathStr == '/users/' + nickname: - optionsStr += \ - ' \n' - else: - optionsStr += \ - ' \n' + options_str += options_link_str if authorized: - optionsStr += \ + options_str += \ ' \n' - optionsStr += donateStr + options_str += donate_str if authorized: - optionsStr += \ + options_str += \ ' \n' - optionsStr += \ - ' \n' - optionsStr += \ + follow_str + \ + '" accesskey="' + access_keys['followButton'] + '">' + \ + translate[follow_str] + '\n' + options_str += \ ' \n' - optionsStr += \ + options_str += \ ' \n' - optionsStr += \ + snooze_button_str + '" accesskey="' + \ + access_keys['snoozeButton'] + '">' + \ + translate[snooze_button_str] + '\n' + options_str += \ ' \n' - if isModerator(baseDir, nickname): - optionsStr += \ + if is_moderator(base_dir, nickname): + options_str += \ ' \n' + options_str += \ + ' \n' - personNotes = '' - if originPathStr == '/users/' + nickname: - personNotesFilename = \ - acctDir(baseDir, nickname, domain) + \ + person_notes = '' + if origin_path_str == '/users/' + nickname: + person_notes_filename = \ + acct_dir(base_dir, nickname, domain) + \ '/notes/' + handle + '.txt' - if os.path.isfile(personNotesFilename): - with open(personNotesFilename, 'r') as fp: - personNotes = fp.read() + if os.path.isfile(person_notes_filename): + with open(person_notes_filename, 'r', + encoding='utf-8') as fp_notes: + person_notes = fp_notes.read() - optionsStr += \ + options_str += \ '

    ' + translate['Notes'] + ': \n' - optionsStr += '
    \n' - optionsStr += \ + translate['Save'] + '
    \n' + options_str += \ ' \n' + 'accesskey="' + access_keys['enterNotes'] + '">' + \ + person_notes + '\n' - optionsStr += \ + options_str += \ '
    \n' + \ '
    \n' + \ '
    \n' + \ '
    \n' - optionsStr += htmlFooter() - return optionsStr + options_str += html_footer() + return options_str diff --git a/webapp_podcast.py b/webapp_podcast.py new file mode 100644 index 000000000..94bfc0a43 --- /dev/null +++ b/webapp_podcast.py @@ -0,0 +1,461 @@ +__filename__ = "webapp_podcast.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.3.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "Web Interface Columns" + +import os +import html +import datetime +import urllib.parse +from shutil import copyfile +from utils import get_config_param +from utils import remove_html +from media import path_is_audio +from content import safe_web_text +from webapp_utils import get_broken_link_substitute +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import html_keyboard_navigation +from session import get_json + + +def _html_podcast_chapters(link_url: str, + session, session_onion, session_i2p, + http_prefix: str, domain: str, + podcast_properties: {}, translate: {}, + debug: bool) -> str: + """Returns html for chapters of a podcast + """ + if not podcast_properties: + return '' + key = 'chapters' + if not podcast_properties.get(key): + return '' + if not isinstance(podcast_properties[key], dict): + return '' + if podcast_properties[key].get('url'): + chapters_url = podcast_properties[key]['url'] + elif podcast_properties[key].get('uri'): + chapters_url = podcast_properties[key]['uri'] + else: + return '' + html_str = '' + if podcast_properties[key].get('type'): + url_type = podcast_properties[key]['type'] + + curr_session = session + if chapters_url.endswith('.onion'): + curr_session = session_onion + elif chapters_url.endswith('.i2p'): + curr_session = session_i2p + + as_header = { + 'Accept': url_type + } + + if 'json' in url_type: + chapters_json = \ + get_json(None, curr_session, chapters_url, + as_header, None, debug, __version__, + http_prefix, domain) + if not chapters_json: + return '' + if not chapters_json.get('chapters'): + return '' + if not isinstance(chapters_json['chapters'], list): + return '' + chapters_html = '' + for chapter in chapters_json['chapters']: + if not isinstance(chapter, dict): + continue + if not chapter.get('title'): + continue + if not chapter.get('startTime'): + continue + chapter_title = chapter['title'] + chapter_url = '' + if chapter.get('url'): + chapter_url = chapter['url'] + chapter_title = \ + '' + \ + chapter['title'] + '<\a>' + start_sec = chapter['startTime'] + skip_url = link_url + '#t=' + str(start_sec) + start_time_str = \ + '' + \ + str(datetime.timedelta(seconds=start_sec)) + \ + '' + if chapter.get('img'): + chapters_html += \ + '
  • \n' + \ + ' ' + start_time_str + '\n' + \ + ' \n' + \ + ' ' + chapter_title + '\n' + \ + '
  • \n' + if chapters_html: + html_str = \ + '
    \n' + \ + ' \n' + chapters_html + ' \n
    \n' + return html_str + + +def _html_podcast_transcripts(podcast_properties: {}, translate: {}) -> str: + """Returns html for transcripts of a podcast + """ + if not podcast_properties: + return '' + key = 'transcripts' + if not podcast_properties.get(key): + return '' + if not isinstance(podcast_properties[key], list): + return '' + ctr = 1 + html_str = '' + for _ in podcast_properties[key]: + transcript_url = None + if podcast_properties[key].get('url'): + transcript_url = podcast_properties[key]['url'] + elif podcast_properties[key].get('uri'): + transcript_url = podcast_properties[key]['uri'] + if not transcript_url: + continue + if ctr > 1: + html_str += '
    ' + html_str += '' + html_str += translate['Transcript'] + if ctr > 1: + html_str += ' ' + str(ctr) + html_str += '\n' + ctr += 1 + return html_str + + +def _html_podcast_social_interactions(podcast_properties: {}, + translate: {}, + nickname: str) -> str: + """Returns html for social interactions with a podcast + """ + if not podcast_properties: + return '' + key = 'discussion' + if not podcast_properties.get(key): + key = 'socialInteract' + if not podcast_properties.get(key): + return '' + if not isinstance(podcast_properties[key], dict): + return '' + if podcast_properties[key].get('uri'): + episode_post_url = podcast_properties[key]['uri'] + elif podcast_properties[key].get('url'): + episode_post_url = podcast_properties[key]['url'] + elif podcast_properties[key].get('text'): + episode_post_url = podcast_properties[key]['text'] + else: + return '' + actor_str = '' + podcast_account_id = None + if podcast_properties[key].get('accountId'): + podcast_account_id = podcast_properties[key]['accountId'] + elif podcast_properties[key].get('podcastAccountUrl'): + podcast_account_id = \ + podcast_properties[key]['podcastAccountUrl'] + if podcast_account_id: + actor_handle = podcast_account_id + if actor_handle.startswith('@'): + actor_handle = actor_handle[1:] + actor_str = '?actor=' + actor_handle + + podcast_str = \ + '
    \n' + \ + ' 💬 ' + \ + translate['Leave a comment'] + '\n' + \ + ' \n' + \ + ' ' + \ + translate['View comments'] + '\n \n' + \ + '
    \n' + return podcast_str + + +def _html_podcast_performers(podcast_properties: {}) -> str: + """Returns html for performers of a podcast + """ + if not podcast_properties: + return '' + key = 'persons' + if not podcast_properties.get(key): + return '' + if not isinstance(podcast_properties[key], list): + return '' + + # list of performers + podcast_str = '
    \n' + podcast_str += '
    \n' + podcast_str += '
      \n' + for performer in podcast_properties[key]: + if not performer.get('text'): + continue + performer_name = \ + '' + performer['text'] + '' + performer_title = performer_name + + if performer.get('role'): + performer_title += \ + ' (' + \ + performer['role'] + ')' + if performer.get('group'): + performer_title += ', ' + performer['group'] + '' + performer_title = remove_html(performer_title) + + performer_url = '' + if performer.get('href'): + performer_url = performer['href'] + + performer_img = '' + if performer.get('img'): + performer_img = performer['img'] + + podcast_str += '
    • \n' + podcast_str += '
      \n' + podcast_str += ' \n' + podcast_str += \ + ' \n' + podcast_str += '
      \n' + podcast_str += '
    • \n' + + podcast_str += '
    \n' + podcast_str += '
    \n' + return podcast_str + + +def _html_podcast_soundbites(link_url: str, extension: str, + podcast_properties: {}, + translate: {}) -> str: + """Returns html for podcast soundbites + """ + if not podcast_properties: + return '' + if not podcast_properties.get('soundbites'): + return '' + + podcast_str = '
    \n' + podcast_str += '
    \n' + podcast_str += '
      \n' + ctr = 1 + for performer in podcast_properties['soundbites']: + if not performer.get('startTime'): + continue + if not performer['startTime'].isdigit(): + continue + if not performer.get('duration'): + continue + if not performer['duration'].isdigit(): + continue + end_time = str(float(performer['startTime']) + + float(performer['duration'])) + + podcast_str += '
    • \n' + preview_url = \ + link_url + '#t=' + performer['startTime'] + ',' + end_time + soundbite_title = translate['Preview'] + if ctr > 0: + soundbite_title += ' ' + str(ctr) + podcast_str += \ + ' \n' + \ + ' \n \n' + podcast_str += '
    • \n' + ctr += 1 + + podcast_str += '
    \n' + podcast_str += '
    \n' + return podcast_str + + +def html_podcast_episode(translate: {}, + base_dir: str, nickname: str, domain: str, + newswire_item: [], theme: str, + default_timeline: str, + text_mode_banner: str, access_keys: {}, + session, session_onion, session_i2p, + http_prefix: str, debug: bool) -> str: + """Returns html for a podcast episode, giebn an item from the newswire + """ + css_filename = base_dir + '/epicyon-podcast.css' + if os.path.isfile(base_dir + '/podcast.css'): + css_filename = base_dir + '/podcast.css' + + if os.path.isfile(base_dir + '/accounts/podcast-background-custom.jpg'): + if not os.path.isfile(base_dir + '/accounts/podcast-background.jpg'): + copyfile(base_dir + '/accounts/podcast-background.jpg', + base_dir + '/accounts/podcast-background.jpg') + + instance_title = get_config_param(base_dir, 'instanceTitle') + podcast_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + podcast_properties = newswire_item[8] + image_url = '' + image_src = 'src' + if podcast_properties.get('images'): + if podcast_properties['images'].get('srcset'): + image_url = podcast_properties['images']['srcset'] + image_src = 'srcset' + if not image_url and podcast_properties.get('image'): + image_url = podcast_properties['image'] + + link_url = newswire_item[1] + + podcast_str += html_keyboard_navigation(text_mode_banner, {}, {}) + podcast_str += '

    \n' + podcast_str += \ + '
    \n' + podcast_str += '
    \n' + podcast_str += '
    \n' + podcast_str += ' \n' + podcast_str += '
    \n' + podcast_str += '
    \n' + + podcast_str += '
    \n' + audio_extension = None + if path_is_audio(link_url): + if '.mp3' in link_url: + audio_extension = 'mpeg' + elif '.opus' in link_url: + audio_extension = 'opus' + elif '.flac' in link_url: + audio_extension = 'flac' + else: + audio_extension = 'ogg' + else: + if podcast_properties.get('linkMimeType'): + if 'audio' in podcast_properties['linkMimeType']: + audio_extension = \ + podcast_properties['linkMimeType'].split('/')[1] + # show widgets for soundbites + if audio_extension: + podcast_str += _html_podcast_soundbites(link_url, audio_extension, + podcast_properties, + translate) + + # podcast player widget + podcast_str += \ + ' \n' + \ + ' \n \n' + elif podcast_properties.get('linkMimeType'): + if '/youtube' in podcast_properties['linkMimeType']: + url = link_url.replace('/watch?v=', '/embed/') + if '&' in url: + url = url.split('&')[0] + if '?utm_' in url: + url = url.split('?utm_')[0] + podcast_str += \ + ' \n' + \ + " \n \n" + elif 'video' in podcast_properties['linkMimeType']: + video_mime_type = podcast_properties['linkMimeType'] + video_msg = 'Your browser does not support the video element.' + podcast_str += \ + ' \n' + \ + '
    \n' + \ + ' \n
    \n
    \n' + + podcast_title = \ + remove_html(html.unescape(urllib.parse.unquote_plus(newswire_item[0]))) + if podcast_title: + podcast_str += \ + '

    \n' + transcripts = _html_podcast_transcripts(podcast_properties, translate) + if transcripts: + podcast_str += '

    ' + transcripts + '

    \n' + if newswire_item[4]: + podcast_description = \ + html.unescape(urllib.parse.unquote_plus(newswire_item[4])) + podcast_description = safe_web_text(podcast_description) + if podcast_description: + podcast_str += \ + '

    ' + \ + podcast_description + '

    \n' + + # donate button + if podcast_properties.get('funding'): + if podcast_properties['funding'].get('url'): + donate_url = podcast_properties['funding']['url'] + podcast_str += \ + '

    \n' + + if podcast_properties['categories']: + tags_str = '' + for tag in podcast_properties['categories']: + tag = tag.replace('#', '') + tag_link = '/users/' + nickname + '/tags/' + tag + tags_str += \ + '#' + \ + '' + tag + '' + \ + ' ' + podcast_str += '

    ' + tags_str.strip() + '

    \n' + + podcast_str += _html_podcast_performers(podcast_properties) + podcast_str += \ + _html_podcast_social_interactions(podcast_properties, translate, + nickname) + podcast_str += \ + _html_podcast_chapters(link_url, + session, session_onion, session_i2p, + http_prefix, domain, + podcast_properties, translate, debug) + + podcast_str += '
    \n' + podcast_str += '
    \n' + + podcast_str += html_footer() + return podcast_str diff --git a/webapp_post.py b/webapp_post.py index c27a94ce2..7c4eec8f8 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -1,7 +1,7 @@ __filename__ = "webapp_post.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,87 +9,209 @@ __module_group__ = "Web Interface" import os import time +import urllib.parse from dateutil.parser import parse -from auth import createPassword -from git import isGitPatch +from auth import create_password +from git import is_git_patch from datetime import datetime -from cache import getPersonFromCache -from bookmarks import bookmarkedByPerson -from like import likedByPerson -from like import noOfLikes -from follow import isFollowingActor -from posts import postIsMuted -from posts import getPersonBox -from posts import downloadAnnounce -from posts import populateRepliesJson -from utils import getActorLanguagesList -from utils import getBaseContentFromPost -from utils import getContentFromPost -from utils import hasObjectDict -from utils import updateAnnounceCollection -from utils import isPGPEncrypted -from utils import isDM -from utils import rejectPostId -from utils import isRecentPost -from utils import getConfigParam -from utils import getFullDomain -from utils import isEditor -from utils import locatePost -from utils import loadJson -from utils import getCachedPostDirectory -from utils import getCachedPostFilename -from utils import getProtocolPrefixes -from utils import isNewsPost -from utils import isBlogPost -from utils import getDisplayName -from utils import isPublicPost -from utils import updateRecentPostsCache -from utils import removeIdEnding -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import acctDir -from utils import localActorUrl -from content import limitRepeatedWords -from content import replaceEmojiFromTags -from content import htmlReplaceQuoteMarks -from content import htmlReplaceEmailQuote -from content import removeTextFormatting -from content import removeLongWords -from content import getMentionsFromHtml -from content import switchWords -from person import isPersonSnoozed -from person import getPersonAvatarUrl -from announce import announcedByPerson -from webapp_utils import getAvatarImageUrl -from webapp_utils import updateAvatarImageCache -from webapp_utils import loadIndividualPostAsHtmlFromCache -from webapp_utils import addEmojiToDisplayName -from webapp_utils import postContainsPublic -from webapp_utils import getContentWarningButton -from webapp_utils import getPostAttachmentsAsHtml -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlFooter -from webapp_utils import getBrokenLinkSubstitute -from webapp_media import addEmbeddedElements -from webapp_question import insertQuestion -from devices import E2EEdecryptMessageFromDevice -from webfinger import webfingerHandle -from speaker import updateSpeaker -from languages import autoTranslatePost -from blocking import isBlocked +from cache import get_person_from_cache +from bookmarks import bookmarked_by_person +from announce import announced_by_person +from announce import no_of_announces +from like import liked_by_person +from like import no_of_likes +from follow import is_following_actor +from posts import post_is_muted +from posts import get_person_box +from posts import download_announce +from posts import populate_replies_json +from utils import remove_eol +from utils import disallow_announce +from utils import disallow_reply +from utils import convert_published_to_local_timezone +from utils import remove_hash_from_post_id +from utils import remove_html +from utils import get_actor_languages_list +from utils import get_base_content_from_post +from utils import get_content_from_post +from utils import get_summary_from_post +from utils import has_object_dict +from utils import update_announce_collection +from utils import is_pgp_encrypted +from utils import is_dm +from utils import is_chat_message +from utils import reject_post_id +from utils import is_recent_post +from utils import get_config_param +from utils import get_full_domain +from utils import is_editor +from utils import locate_post +from utils import load_json +from utils import get_cached_post_directory +from utils import get_cached_post_filename +from utils import get_protocol_prefixes +from utils import is_news_post +from utils import is_blog_post +from utils import get_display_name +from utils import display_name_is_emoji +from utils import is_public_post +from utils import update_recent_posts_cache +from utils import remove_id_ending +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import acct_dir +from utils import local_actor_url +from utils import is_unlisted_post +from content import detect_dogwhistles +from content import create_edits_html +from content import bold_reading_string +from content import limit_repeated_words +from content import replace_emoji_from_tags +from content import html_replace_quote_marks +from content import html_replace_email_quote +from content import remove_text_formatting +from content import remove_long_words +from content import get_mentions_from_html +from content import switch_words +from person import is_person_snoozed +from person import get_person_avatar_url +from webapp_utils import get_banner_file +from webapp_utils import get_avatar_image_url +from webapp_utils import update_avatar_image_cache +from webapp_utils import load_individual_post_as_html_from_cache +from webapp_utils import add_emoji_to_display_name +from webapp_utils import post_contains_public +from webapp_utils import get_content_warning_button +from webapp_utils import get_post_attachments_as_html +from webapp_utils import html_header_with_external_style +from webapp_utils import html_footer +from webapp_utils import get_broken_link_substitute +from webapp_media import add_embedded_elements +from webapp_question import insert_question +from devices import e2e_edecrypt_message_from_device +from webfinger import webfinger_handle +from speaker import update_speaker +from languages import auto_translate_post +from blocking import is_blocked +from blocking import add_cw_from_lists +from reaction import html_emoji_reactions +from maps import html_open_street_map +from maps import set_map_preferences_coords +from maps import set_map_preferences_url +from maps import geocoords_from_map_link +from maps import get_location_from_tags -def _logPostTiming(enableTimingLog: bool, postStartTime, debugId: str) -> None: +def _html_post_metadata_open_graph(domain: str, post_json_object: {}, + system_language: str) -> str: + """Returns html OpenGraph metadata for a post + """ + metadata = \ + " \n" + metadata += \ + " \n" + obj_json = post_json_object + if has_object_dict(post_json_object): + obj_json = post_json_object['object'] + if obj_json.get('attributedTo'): + if isinstance(obj_json['attributedTo'], str): + attrib = obj_json['attributedTo'] + actor_nick = get_nickname_from_actor(attrib) + if actor_nick: + actor_domain, _ = get_domain_from_actor(attrib) + actor_handle = actor_nick + '@' + actor_domain + metadata += \ + " \n" + if obj_json.get('url'): + metadata += \ + " \n" + if obj_json.get('published'): + metadata += \ + " \n" + if not obj_json.get('attachment') or obj_json.get('sensitive'): + if obj_json.get('content') and not obj_json.get('sensitive'): + obj_content = obj_json['content'] + if obj_json.get('contentMap'): + if obj_json['contentMap'].get(system_language): + obj_content = obj_json['contentMap'][system_language] + description = remove_html(obj_content) + metadata += \ + " \n" + metadata += \ + " \n" + return metadata + + # metadata for attachment + for attach_json in obj_json['attachment']: + if not isinstance(attach_json, dict): + continue + if not attach_json.get('mediaType'): + continue + if not attach_json.get('url'): + continue + if not attach_json.get('name'): + continue + description = None + if attach_json['mediaType'].startswith('image/'): + description = 'Attached: 1 image' + elif attach_json['mediaType'].startswith('video/'): + description = 'Attached: 1 video' + elif attach_json['mediaType'].startswith('audio/'): + description = 'Attached: 1 audio' + if description: + if obj_json.get('content') and not obj_json.get('sensitive'): + obj_content = obj_json['content'] + if obj_json.get('contentMap'): + if obj_json['contentMap'].get(system_language): + obj_content = obj_json['contentMap'][system_language] + description += '\n\n' + remove_html(obj_content) + metadata += \ + " \n" + metadata += \ + " \n" + metadata += \ + " \n" + metadata += \ + " \n" + if attach_json.get('width'): + metadata += \ + " \n" + if attach_json.get('height'): + metadata += \ + " \n" + metadata += \ + " \n" + if attach_json['mediaType'].startswith('image/'): + metadata += \ + " \n" + return metadata + + +def _log_post_timing(enable_timing_log: bool, post_start_time, + debug_id: str) -> None: """Create a log of timings for performance tuning """ - if not enableTimingLog: + if not enable_timing_log: return - timeDiff = int((time.time() - postStartTime) * 1000) - if timeDiff > 100: - print('TIMING INDIV ' + debugId + ' = ' + str(timeDiff)) + time_diff = int((time.time() - post_start_time) * 1000) + if time_diff > 100: + print('TIMING INDIV ' + debug_id + ' = ' + str(time_diff)) -def prepareHtmlPostNickname(nickname: str, postHtml: str) -> str: +def prepare_html_post_nickname(nickname: str, post_html: str) -> str: """html posts stored in memory are for all accounts on the instance and they're indexed by id. However, some incoming posts may be destined for multiple accounts (followers). This creates a problem @@ -99,1901 +221,2488 @@ def prepareHtmlPostNickname(nickname: str, postHtml: str) -> str: This function changes the nicknames for the icon links. """ # replace the nickname - usersStr = ' href="/users/' - if usersStr not in postHtml: - return postHtml + users_str = ' href="/users/' + if users_str not in post_html: + return post_html - userFound = True - postStr = postHtml - newPostStr = '' - while userFound: - if usersStr not in postStr: - newPostStr += postStr + user_found = True + post_str = post_html + new_post_str = '' + while user_found: + if users_str not in post_str: + new_post_str += post_str break # the next part, after href="/users/nickname? - nextStr = postStr.split(usersStr, 1)[1] - if '?' in nextStr: - nextStr = nextStr.split('?', 1)[1] + next_str = post_str.split(users_str, 1)[1] + if '?' in next_str: + next_str = next_str.split('?', 1)[1] else: - newPostStr += postStr + new_post_str += post_str break # append the previous text to the result - newPostStr += postStr.split(usersStr)[0] - newPostStr += usersStr + nickname + '?' + new_post_str += post_str.split(users_str)[0] + new_post_str += users_str + nickname + '?' # post is now the next part - postStr = nextStr - return newPostStr + post_str = next_str + return new_post_str -def preparePostFromHtmlCache(nickname: str, postHtml: str, boxName: str, - pageNumber: int) -> str: +def prepare_post_from_html_cache(nickname: str, post_html: str, box_name: str, + page_number: int) -> str: """Sets the page number on a cached html post """ # if on the bookmarks timeline then remain there - if boxName == 'tlbookmarks' or boxName == 'bookmarks': - postHtml = postHtml.replace('?tl=inbox', '?tl=tlbookmarks') - if '?page=' in postHtml: - pageNumberStr = postHtml.split('?page=')[1] - if '?' in pageNumberStr: - pageNumberStr = pageNumberStr.split('?')[0] - postHtml = postHtml.replace('?page=' + pageNumberStr, '?page=-999') + if box_name in ('tlbookmarks', 'bookmarks'): + post_html = post_html.replace('?tl=inbox', '?tl=tlbookmarks') + if '?page=' in post_html: + page_number_str = post_html.split('?page=')[1] + if '?' in page_number_str: + page_number_str = page_number_str.split('?')[0] + post_html = \ + post_html.replace('?page=' + page_number_str, '?page=-999') - withPageNumber = postHtml.replace(';-999;', ';' + str(pageNumber) + ';') - withPageNumber = withPageNumber.replace('?page=-999', - '?page=' + str(pageNumber)) - return prepareHtmlPostNickname(nickname, withPageNumber) + with_page_number = \ + post_html.replace(';-999;', ';' + str(page_number) + ';') + with_page_number = \ + with_page_number.replace('?page=-999', '?page=' + str(page_number)) + return prepare_html_post_nickname(nickname, with_page_number) -def _saveIndividualPostAsHtmlToCache(baseDir: str, - nickname: str, domain: str, - postJsonObject: {}, - postHtml: str) -> bool: +def _save_individual_post_as_html_to_cache(base_dir: str, + nickname: str, domain: str, + post_json_object: {}, + post_html: str) -> bool: """Saves the given html for a post to a cache file This is so that it can be quickly reloaded on subsequent refresh of the timeline """ - htmlPostCacheDir = \ - getCachedPostDirectory(baseDir, nickname, domain) - cachedPostFilename = \ - getCachedPostFilename(baseDir, nickname, domain, postJsonObject) + html_post_cache_dir = \ + get_cached_post_directory(base_dir, nickname, domain) + cached_post_filename = \ + get_cached_post_filename(base_dir, nickname, domain, post_json_object) # create the cache directory if needed - if not os.path.isdir(htmlPostCacheDir): - os.mkdir(htmlPostCacheDir) + if not os.path.isdir(html_post_cache_dir): + os.mkdir(html_post_cache_dir) try: - with open(cachedPostFilename, 'w+') as fp: - fp.write(postHtml) + with open(cached_post_filename, 'w+', encoding='utf-8') as fp_cache: + fp_cache.write(post_html) return True - except Exception as e: - print('ERROR: saving post to cache, ' + str(e)) + except Exception as ex: + print('ERROR: saving post to cache, ' + str(ex)) return False -def _getPostFromRecentCache(session, - baseDir: str, - httpPrefix: str, - nickname: str, domain: str, - postJsonObject: {}, - postActor: str, - personCache: {}, - allowDownloads: bool, - showPublicOnly: bool, - storeToCache: bool, - boxName: str, - avatarUrl: str, - enableTimingLog: bool, - postStartTime, - pageNumber: int, - recentPostsCache: {}, - maxRecentPosts: int, - signingPrivateKeyPem: str) -> str: +def _get_post_from_recent_cache(session, + base_dir: str, + http_prefix: str, + nickname: str, domain: str, + post_json_object: {}, + post_actor: str, + person_cache: {}, + allow_downloads: bool, + show_public_only: bool, + store_to_cache: bool, + box_name: str, + avatar_url: str, + enable_timing_log: bool, + post_start_time, + page_number: int, + recent_posts_cache: {}, + max_recent_posts: int, + signing_priv_key_pem: str) -> str: """Attempts to get the html post from the recent posts cache in memory """ - if boxName == 'tlmedia': + if box_name == 'tlmedia': return None - if showPublicOnly: + if show_public_only: return None - tryCache = False - bmTimeline = boxName == 'bookmarks' or boxName == 'tlbookmarks' - if storeToCache or bmTimeline: - tryCache = True + try_cache = False + bm_timeline = box_name in ('bookmarks', 'tlbookmarks') + if store_to_cache or bm_timeline: + try_cache = True - if not tryCache: + if not try_cache: return None # update avatar if needed - if not avatarUrl: - avatarUrl = \ - getPersonAvatarUrl(baseDir, postActor, personCache, - allowDownloads) + if not avatar_url: + avatar_url = \ + get_person_avatar_url(base_dir, post_actor, person_cache) - _logPostTiming(enableTimingLog, postStartTime, '2.1') + _log_post_timing(enable_timing_log, post_start_time, '2.1') - updateAvatarImageCache(signingPrivateKeyPem, - session, baseDir, httpPrefix, - postActor, avatarUrl, personCache, - allowDownloads) + update_avatar_image_cache(signing_priv_key_pem, + session, base_dir, http_prefix, + post_actor, avatar_url, person_cache, + allow_downloads) - _logPostTiming(enableTimingLog, postStartTime, '2.2') + _log_post_timing(enable_timing_log, post_start_time, '2.2') - postHtml = \ - loadIndividualPostAsHtmlFromCache(baseDir, nickname, domain, - postJsonObject) - if not postHtml: + post_html = \ + load_individual_post_as_html_from_cache(base_dir, nickname, domain, + post_json_object) + if not post_html: return None - postHtml = \ - preparePostFromHtmlCache(nickname, postHtml, boxName, pageNumber) - updateRecentPostsCache(recentPostsCache, maxRecentPosts, - postJsonObject, postHtml) - _logPostTiming(enableTimingLog, postStartTime, '3') - return postHtml + post_html = \ + prepare_post_from_html_cache(nickname, post_html, + box_name, page_number) + update_recent_posts_cache(recent_posts_cache, max_recent_posts, + post_json_object, post_html) + _log_post_timing(enable_timing_log, post_start_time, '3') + return post_html -def _getAvatarImageHtml(showAvatarOptions: bool, - nickname: str, domainFull: str, - avatarUrl: str, postActor: str, - translate: {}, avatarPosition: str, - pageNumber: int, messageIdStr: str) -> str: +def _get_avatar_image_html(show_avatar_options: bool, + nickname: str, domain_full: str, + avatar_url: str, post_actor: str, + translate: {}, avatar_position: str, + page_number: int, message_id_str: str) -> str: """Get html for the avatar image """ - avatarLink = '' - if '/users/news/' not in avatarUrl: - avatarLink = ' ' - showProfileStr = 'Show profile' - if translate.get(showProfileStr): - showProfileStr = translate[showProfileStr] - avatarLink += \ - ' \n' + # don't use svg images + if avatar_url.endswith('.svg'): + avatar_url = '/icons/avatar_default.png' - if showAvatarOptions and \ - domainFull + '/users/' + nickname not in postActor: - showOptionsForThisPersonStr = 'Show options for this person' - if translate.get(showOptionsForThisPersonStr): - showOptionsForThisPersonStr = \ - translate[showOptionsForThisPersonStr] - if '/users/news/' not in avatarUrl: - avatarLink = \ + avatar_link = '' + if '/users/news/' not in avatar_url: + avatar_link = \ + ' ' + show_profile_str = 'Show profile' + if translate.get(show_profile_str): + show_profile_str = translate[show_profile_str] + avatar_link += \ + ' \n' + + if show_avatar_options and \ + domain_full + '/users/' + nickname not in post_actor: + show_options_for_this_person_str = 'Show options for this person' + if translate.get(show_options_for_this_person_str): + show_options_for_this_person_str = \ + translate[show_options_for_this_person_str] + if '/users/news/' not in avatar_url: + avatar_link = \ ' \n' - avatarLink += \ - ' \n' + avatar_link += \ + ' \n' + show_options_for_this_person_str + '" ' + \ + 'src="' + avatar_url + '" ' + avatar_position + \ + get_broken_link_substitute() + '/>\n' else: # don't link to the person options for the news account - avatarLink += \ - ' \n' - return avatarLink.strip() + show_options_for_this_person_str + '" ' + \ + 'src="' + avatar_url + '" ' + avatar_position + \ + get_broken_link_substitute() + '/>\n' + return avatar_link.strip() -def _getReplyIconHtml(baseDir: str, nickname: str, domain: str, - isPublicRepeat: bool, - showIcons: bool, commentsEnabled: bool, - postJsonObject: {}, pageNumberParam: str, - translate: {}, systemLanguage: str, - conversationId: str) -> str: +def _get_reply_icon_html(base_dir: str, nickname: str, domain: str, + is_public_reply: bool, is_unlisted_reply: bool, + show_icons: bool, comments_enabled: bool, + post_json_object: {}, page_number_param: str, + translate: {}, system_language: str, + conversation_id: str) -> str: """Returns html for the reply icon/button """ - replyStr = '' - if not (showIcons and commentsEnabled): - return replyStr + reply_str = '' + if not (show_icons and comments_enabled): + return reply_str # reply is permitted - create reply icon - replyToLink = removeIdEnding(postJsonObject['object']['id']) + reply_to_link = remove_hash_from_post_id(post_json_object['object']['id']) + reply_to_link = remove_id_ending(reply_to_link) # see Mike MacGirvin's replyTo suggestion - if postJsonObject['object'].get('replyTo'): + if post_json_object['object'].get('replyTo'): # check that the alternative replyTo url is not blocked - blockNickname = \ - getNicknameFromActor(postJsonObject['object']['replyTo']) - blockDomain, _ = \ - getDomainFromActor(postJsonObject['object']['replyTo']) - if not isBlocked(baseDir, nickname, domain, - blockNickname, blockDomain, {}): - replyToLink = postJsonObject['object']['replyTo'] + block_nickname = \ + get_nickname_from_actor(post_json_object['object']['replyTo']) + if not block_nickname: + return reply_str + block_domain, _ = \ + get_domain_from_actor(post_json_object['object']['replyTo']) + if not is_blocked(base_dir, nickname, domain, + block_nickname, block_domain, {}): + reply_to_link = post_json_object['object']['replyTo'] - if postJsonObject['object'].get('attributedTo'): - if isinstance(postJsonObject['object']['attributedTo'], str): - replyToLink += \ - '?mention=' + postJsonObject['object']['attributedTo'] - content = getBaseContentFromPost(postJsonObject, systemLanguage) + if post_json_object['object'].get('attributedTo'): + if isinstance(post_json_object['object']['attributedTo'], str): + reply_to_link += \ + '?mention=' + post_json_object['object']['attributedTo'] + content = get_base_content_from_post(post_json_object, system_language) if content: - mentionedActors = getMentionsFromHtml(content) - if mentionedActors: - for actorUrl in mentionedActors: - if '?mention=' + actorUrl not in replyToLink: - replyToLink += '?mention=' + actorUrl - if len(replyToLink) > 500: + mentioned_actors = \ + get_mentions_from_html(content, + "\n' + nickname + '?replyto=' + reply_to_link + \ + '?actor=' + post_json_object['actor'] + \ + conversation_str + \ + '" title="' + reply_to_this_post_str + '" tabindex="10">\n' + elif is_unlisted_reply: + reply_str += \ + ' \n' else: - if isDM(postJsonObject): - replyStr += \ + if is_dm(post_json_object): + reply_type = 'replydm' + if is_chat_message(post_json_object): + reply_type = 'replychat' + reply_str += \ ' ' + \ '\n' + '?' + reply_type + '=' + reply_to_link + \ + '?actor=' + post_json_object['actor'] + \ + conversation_str + \ + '" title="' + reply_to_this_post_str + '" tabindex="10">\n' else: - replyStr += \ + reply_str += \ ' ' + \ '\n' + '?replyfollowers=' + reply_to_link + \ + '?actor=' + post_json_object['actor'] + \ + conversation_str + \ + '" title="' + reply_to_this_post_str + '" tabindex="10">\n' - replyStr += \ + reply_str += \ ' ' + \ - '' + replyToThisPostStr + \
+        '<img loading=\n' - return replyStr + return reply_str -def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str, - postJsonObject: {}, actorNickname: str, - translate: {}, isEvent: bool) -> str: +def _get_edit_icon_html(base_dir: str, nickname: str, domain_full: str, + post_json_object: {}, actor_nickname: str, + translate: {}, is_event: bool) -> str: """Returns html for the edit icon/button """ - editStr = '' - actor = postJsonObject['actor'] + edit_str = '' + actor = post_json_object['actor'] # This should either be a post which you created, # or it could be generated from the newswire (see - # _addBlogsToNewswire) in which case anyone with + # _add_blogs_to_newswire) in which case anyone with # editor status should be able to alter it - if (actor.endswith('/' + domainFull + '/users/' + nickname) or - (isEditor(baseDir, nickname) and - actor.endswith('/' + domainFull + '/users/news'))): + if (actor.endswith('/' + domain_full + '/users/' + nickname) or + (is_editor(base_dir, nickname) and + actor.endswith('/' + domain_full + '/users/news'))): - postId = removeIdEnding(postJsonObject['object']['id']) + post_id = remove_id_ending(post_json_object['object']['id']) - if '/statuses/' not in postId: - return editStr + if '/statuses/' not in post_id: + return edit_str - if isBlogPost(postJsonObject): - editBlogPostStr = 'Edit blog post' - if translate.get(editBlogPostStr): - editBlogPostStr = translate[editBlogPostStr] - if not isNewsPost(postJsonObject): - editStr += \ + if is_blog_post(post_json_object): + edit_blog_post_str = 'Edit blog post' + if translate.get(edit_blog_post_str): + edit_blog_post_str = translate[edit_blog_post_str] + if not is_news_post(post_json_object): + edit_str += \ ' ' + \ '' + \ - '' + editBlogPostStr + \
+                    post_id.split('/statuses/')[1] + \
+                    ';actor=' + actor_nickname + \
+                    '' + \ + '' + edit_blog_post_str + \
                     ' |\n' else: - editStr += \ + edit_str += \ ' ' + \ '' + \ - '' + editBlogPostStr + \
+                    post_id.split('/statuses/')[1] + \
+                    '?actor=' + actor_nickname + \
+                    '' + \ + '' + edit_blog_post_str + \
                     ' |\n' - elif isEvent: - editEventStr = 'Edit event' - if translate.get(editEventStr): - editEventStr = translate[editEventStr] - editStr += \ + elif is_event: + edit_event_str = 'Edit event' + if translate.get(edit_event_str): + edit_event_str = translate[edit_event_str] + edit_str += \ ' ' + \ '' + \ - '' + editEventStr + \
+                post_id.split('/statuses/')[1] + \
+                '?actor=' + actor_nickname + \
+                '' + \ + '' + edit_event_str + \
                 ' |\n' - return editStr + return edit_str -def _getAnnounceIconHtml(isAnnounced: bool, - postActor: str, - nickname: str, domainFull: str, - announceJsonObject: {}, - postJsonObject: {}, - isPublicRepeat: bool, - isModerationPost: bool, - showRepeatIcon: bool, - translate: {}, - pageNumberParam: str, - timelinePostBookmark: str, - boxName: str) -> str: - """Returns html for announce icon/button +def _get_announce_icon_html(is_announced: bool, + post_actor: str, + nickname: str, domain_full: str, + announce_json_object: {}, + post_json_object: {}, + is_public_repeat: bool, + is_moderation_post: bool, + show_repeat_icon: bool, + translate: {}, + page_number_param: str, + timeline_post_bookmark: str, + box_name: str, + max_announce_count: int) -> str: + """Returns html for announce icon/button at the bottom of the post """ - announceStr = '' + announce_str = '' - if not showRepeatIcon: - return announceStr + if not show_repeat_icon: + return announce_str - if isModerationPost: - return announceStr + if is_moderation_post: + return announce_str # don't allow announce/repeat of your own posts - announceIcon = 'repeat_inactive.png' - announceLink = 'repeat' - announceEmoji = '' - if not isPublicRepeat: - announceLink = 'repeatprivate' - repeatThisPostStr = 'Repeat this post' - if translate.get(repeatThisPostStr): - repeatThisPostStr = translate[repeatThisPostStr] - announceTitle = repeatThisPostStr - unannounceLinkStr = '' + announce_icon = 'repeat_inactive.png' + announce_link = 'repeat' + announce_emoji = '' + if not is_public_repeat: + announce_link = 'repeatprivate' + repeat_this_post_str = 'Repeat this post' + if translate.get(repeat_this_post_str): + repeat_this_post_str = translate[repeat_this_post_str] + announce_title = repeat_this_post_str + unannounce_link_str = '' + announce_count = no_of_announces(post_json_object) - if announcedByPerson(isAnnounced, - postActor, nickname, domainFull): - announceIcon = 'repeat.png' - announceEmoji = '🔁 ' - announceLink = 'unrepeat' - if not isPublicRepeat: - announceLink = 'unrepeatprivate' - undoTheRepeatStr = 'Undo the repeat' - if translate.get(undoTheRepeatStr): - undoTheRepeatStr = translate[undoTheRepeatStr] - announceTitle = undoTheRepeatStr - if announceJsonObject: - unannounceLinkStr = '?unannounce=' + \ - removeIdEnding(announceJsonObject['id']) + announce_count_str = '' + if announce_count > 0: + if announce_count <= max_announce_count: + announce_count_str = ' (' + str(announce_count) + ')' + else: + announce_count_str = ' (' + str(max_announce_count) + '+)' + if announced_by_person(is_announced, + post_actor, nickname, domain_full): + if announce_count == 1: + # announced by the reader only + announce_count_str = '' + announce_icon = 'repeat.png' + announce_emoji = '🔁 ' + announce_link = 'unrepeat' + if not is_public_repeat: + announce_link = 'unrepeatprivate' + undo_the_repeat_str = 'Undo the repeat' + if translate.get(undo_the_repeat_str): + undo_the_repeat_str = translate[undo_the_repeat_str] + announce_title = undo_the_repeat_str + if announce_json_object: + unannounce_link_str = '?unannounce=' + \ + remove_id_ending(announce_json_object['id']) - announcePostId = removeIdEnding(postJsonObject['object']['id']) - announceLinkStr = '?' + \ - announceLink + '=' + announcePostId + pageNumberParam - announceStr = \ + announce_post_id = \ + remove_hash_from_post_id(post_json_object['object']['id']) + announce_post_id = remove_id_ending(announce_post_id) + + announce_str = '' + if announce_count_str: + announcers_post_id = announce_post_id.replace('/', '--') + announcers_screen_link = \ + '/users/' + nickname + '?announcers=' + announcers_post_id + + # show the number of announces next to icon + announce_str += '\n' + + announce_link_str = '?' + \ + announce_link + '=' + announce_post_id + page_number_param + announce_str += \ ' \n' + nickname + announce_link_str + unannounce_link_str + \ + '?actor=' + post_json_object['actor'] + \ + '?bm=' + timeline_post_bookmark + \ + '?tl=' + box_name + '" title="' + announce_title + '" tabindex="10">\n' - announceStr += \ + announce_str += \ ' ' + \ - '' + announceEmoji + announceTitle + \
-        ' |\n' - return announceStr + '' + announce_emoji + announce_title + \
+        ' |\n' + return announce_str -def _getLikeIconHtml(nickname: str, domainFull: str, - isModerationPost: bool, - showLikeButton: bool, - postJsonObject: {}, - enableTimingLog: bool, - postStartTime, - translate: {}, pageNumberParam: str, - timelinePostBookmark: str, - boxName: str, - maxLikeCount: int) -> str: +def _get_like_icon_html(nickname: str, domain_full: str, + is_moderation_post: bool, + show_like_button: bool, + post_json_object: {}, + enable_timing_log: bool, + post_start_time, + translate: {}, page_number_param: str, + timeline_post_bookmark: str, + box_name: str, + max_like_count: int) -> str: """Returns html for like icon/button """ - likeStr = '' - if not isModerationPost and showLikeButton: - likeIcon = 'like_inactive.png' - likeLink = 'like' - likeTitle = 'Like this post' - if translate.get(likeTitle): - likeTitle = translate[likeTitle] - likeEmoji = '' - likeCount = noOfLikes(postJsonObject) + if not show_like_button or is_moderation_post: + return '' + like_str = '' + like_icon = 'like_inactive.png' + like_link = 'like' + like_title = 'Like this post' + if translate.get(like_title): + like_title = translate[like_title] + like_emoji = '' + like_count = no_of_likes(post_json_object) - _logPostTiming(enableTimingLog, postStartTime, '12.1') + _log_post_timing(enable_timing_log, post_start_time, '12.1') - likeCountStr = '' - if likeCount > 0: - if likeCount <= maxLikeCount: - likeCountStr = ' (' + str(likeCount) + ')' - else: - likeCountStr = ' (' + str(maxLikeCount) + '+)' - if likedByPerson(postJsonObject, nickname, domainFull): - if likeCount == 1: - # liked by the reader only - likeCountStr = '' - likeIcon = 'like.png' - likeLink = 'unlike' - likeTitle = 'Undo the like' - if translate.get(likeTitle): - likeTitle = translate[likeTitle] - likeEmoji = '👍 ' + like_count_str = '' + if like_count > 0: + if like_count <= max_like_count: + like_count_str = ' (' + str(like_count) + ')' + else: + like_count_str = ' (' + str(max_like_count) + '+)' + if liked_by_person(post_json_object, nickname, domain_full): + if like_count == 1: + # liked by the reader only + like_count_str = '' + like_icon = 'like.png' + like_link = 'unlike' + like_title = 'Undo the like' + if translate.get(like_title): + like_title = translate[like_title] + like_emoji = '👍 ' - _logPostTiming(enableTimingLog, postStartTime, '12.2') + _log_post_timing(enable_timing_log, post_start_time, '12.2') - likeStr = '' - if likeCountStr: - # show the number of likes next to icon - likeStr += '\n' - likePostId = removeIdEnding(postJsonObject['object']['id']) - likeStr += \ - ' \n' - likeStr += \ - ' ' + \ - '' + likeEmoji + likeTitle + \
-            ' |\n' - return likeStr + like_post_id = remove_hash_from_post_id(post_json_object['id']) + like_post_id = remove_id_ending(like_post_id) + + like_str = '' + if like_count_str: + likers_post_id = like_post_id.replace('/', '--') + likers_screen_link = \ + '/users/' + nickname + '?likers=' + likers_post_id + + # show the number of likes next to icon + like_str += '\n' + + like_str += \ + ' \n' + like_str += \ + ' ' + \ + '' + like_emoji + like_title + \
+        ' |\n' + return like_str -def _getBookmarkIconHtml(nickname: str, domainFull: str, - postJsonObject: {}, - isModerationPost: bool, - translate: {}, - enableTimingLog: bool, - postStartTime, boxName: str, - pageNumberParam: str, - timelinePostBookmark: str) -> str: +def _get_bookmark_icon_html(nickname: str, domain_full: str, + post_json_object: {}, + is_moderation_post: bool, + translate: {}, + enable_timing_log: bool, + post_start_time, box_name: str, + page_number_param: str, + timeline_post_bookmark: str) -> str: """Returns html for bookmark icon/button """ - bookmarkStr = '' + bookmark_str = '' - if isModerationPost: - return bookmarkStr + if is_moderation_post: + return bookmark_str - bookmarkIcon = 'bookmark_inactive.png' - bookmarkLink = 'bookmark' - bookmarkEmoji = '' - bookmarkTitle = 'Bookmark this post' - if translate.get(bookmarkTitle): - bookmarkTitle = translate[bookmarkTitle] - if bookmarkedByPerson(postJsonObject, nickname, domainFull): - bookmarkIcon = 'bookmark.png' - bookmarkLink = 'unbookmark' - bookmarkEmoji = '🔖 ' - bookmarkTitle = 'Undo the bookmark' - if translate.get(bookmarkTitle): - bookmarkTitle = translate[bookmarkTitle] - _logPostTiming(enableTimingLog, postStartTime, '12.6') - bookmarkPostId = removeIdEnding(postJsonObject['object']['id']) - bookmarkStr = \ + bookmark_icon = 'bookmark_inactive.png' + bookmark_link = 'bookmark' + bookmark_emoji = '' + bookmark_title = 'Bookmark this post' + if translate.get(bookmark_title): + bookmark_title = translate[bookmark_title] + if bookmarked_by_person(post_json_object, nickname, domain_full): + bookmark_icon = 'bookmark.png' + bookmark_link = 'unbookmark' + bookmark_emoji = '🔖 ' + bookmark_title = 'Undo the bookmark' + if translate.get(bookmark_title): + bookmark_title = translate[bookmark_title] + _log_post_timing(enable_timing_log, post_start_time, '12.6') + bookmark_post_id = \ + remove_hash_from_post_id(post_json_object['object']['id']) + bookmark_post_id = remove_id_ending(bookmark_post_id) + bookmark_str = \ ' \n' - bookmarkStr += \ + bookmark_link + '=' + bookmark_post_id + \ + page_number_param + \ + '?actor=' + post_json_object['actor'] + \ + '?bm=' + timeline_post_bookmark + \ + '?tl=' + box_name + '" title="' + bookmark_title + \ + '" tabindex="10">\n' + bookmark_str += \ ' ' + \ - '' + \
-        bookmarkEmoji + bookmarkTitle + ' |\n' - return bookmarkStr + '' + \
+        bookmark_emoji + bookmark_title + ' |\n' + return bookmark_str -def _getMuteIconHtml(isMuted: bool, - postActor: str, - messageId: str, - nickname: str, domainFull: str, - allowDeletion: bool, - pageNumberParam: str, - boxName: str, - timelinePostBookmark: str, - translate: {}) -> str: +def _get_reaction_icon_html(nickname: str, post_json_object: {}, + is_moderation_post: bool, + show_reaction_button: bool, + translate: {}, + enable_timing_log: bool, + post_start_time, box_name: str, + page_number_param: str, + timeline_post_reaction: str) -> str: + """Returns html for reaction icon/button + """ + reaction_str = '' + + if not show_reaction_button or is_moderation_post: + return reaction_str + + reaction_icon = 'reaction.png' + reaction_title = 'Select reaction' + if translate.get(reaction_title): + reaction_title = translate[reaction_title] + _log_post_timing(enable_timing_log, post_start_time, '12.65') + reaction_post_id = \ + remove_hash_from_post_id(post_json_object['object']['id']) + reaction_post_id = remove_id_ending(reaction_post_id) + reaction_str = \ + ' \n' + reaction_str += \ + ' ' + \ + '' + \
+        reaction_title + ' |\n' + return reaction_str + + +def _get_mute_icon_html(is_muted: bool, + post_actor: str, + message_id: str, + nickname: str, domain_full: str, + allow_deletion: bool, + page_number_param: str, + box_name: str, + timeline_post_bookmark: str, + translate: {}) -> str: """Returns html for mute icon/button """ - muteStr = '' - if (allowDeletion or - ('/' + domainFull + '/' in postActor and - messageId.startswith(postActor))): - return muteStr + mute_str = '' + if (allow_deletion or + ('/' + domain_full + '/' in post_actor and + message_id.startswith(post_actor))): + return mute_str - if not isMuted: - muteThisPostStr = 'Mute this post' + if not is_muted: + mute_this_post_str = 'Mute this post' if translate.get('Mute this post'): - muteThisPostStr = translate[muteThisPostStr] - muteStr = \ + mute_this_post_str = translate[mute_this_post_str] + mute_str = \ ' \n' - muteStr += \ + '?mute=' + message_id + page_number_param + '?tl=' + box_name + \ + '?bm=' + timeline_post_bookmark + \ + '" title="' + mute_this_post_str + '" tabindex="10">\n' + mute_str += \ ' ' + \ - '' + \
-            muteThisPostStr + \
-            ' |\n' else: - undoMuteStr = 'Undo mute' - if translate.get(undoMuteStr): - undoMuteStr = translate[undoMuteStr] - muteStr = \ + undo_mute_str = 'Undo mute' + if translate.get(undo_mute_str): + undo_mute_str = translate[undo_mute_str] + mute_str = \ ' \n' - muteStr += \ + nickname + '?unmute=' + message_id + \ + page_number_param + '?tl=' + box_name + '?bm=' + \ + timeline_post_bookmark + '" title="' + undo_mute_str + \ + '" tabindex="10">\n' + mute_str += \ ' ' + \ - '🔇 ' + undoMuteStr + \
-            ' |\n' - return muteStr + return mute_str -def _getDeleteIconHtml(nickname: str, domainFull: str, - allowDeletion: bool, - postActor: str, - messageId: str, - postJsonObject: {}, - pageNumberParam: str, - translate: {}) -> str: +def _get_delete_icon_html(nickname: str, domain_full: str, + allow_deletion: bool, + post_actor: str, + message_id: str, + post_json_object: {}, + page_number_param: str, + translate: {}) -> str: """Returns html for delete icon/button """ - deleteStr = '' - if (allowDeletion or - ('/' + domainFull + '/' in postActor and - messageId.startswith(postActor))): - if '/users/' + nickname + '/' in messageId: - if not isNewsPost(postJsonObject): - deleteThisPostStr = 'Delete this post' - if translate.get(deleteThisPostStr): - deleteThisPostStr = translate[deleteThisPostStr] - deleteStr = \ + delete_str = '' + if (allow_deletion or + ('/' + domain_full + '/' in post_actor and + message_id.startswith(post_actor))): + if '/users/' + nickname + '/' in message_id: + if not is_news_post(post_json_object): + delete_this_post_str = 'Delete this post' + if translate.get(delete_this_post_str): + delete_this_post_str = translate[delete_this_post_str] + delete_str = \ ' \n' - deleteStr += \ + '?delete=' + message_id + page_number_param + \ + '" title="' + delete_this_post_str + '" tabindex="10">\n' + delete_str += \ ' ' + \ - '' + \
-                    deleteThisPostStr + \
-                    ' |\n' - return deleteStr + return delete_str -def _getPublishedDateStr(postJsonObject: {}, - showPublishedDateOnly: bool) -> str: +def _get_published_date_str(post_json_object: {}, + show_published_date_only: bool, + timezone: str) -> str: """Return the html for the published date on a post """ - publishedStr = '' + published_str = '' - if not postJsonObject['object'].get('published'): - return publishedStr + if not post_json_object['object'].get('published'): + return published_str - publishedStr = postJsonObject['object']['published'] - if '.' not in publishedStr: - if '+' not in publishedStr: - datetimeObject = \ - datetime.strptime(publishedStr, "%Y-%m-%dT%H:%M:%SZ") + published_str = post_json_object['object']['published'] + if '.' not in published_str: + if '+' not in published_str: + datetime_object = \ + datetime.strptime(published_str, "%Y-%m-%dT%H:%M:%SZ") else: - datetimeObject = \ - datetime.strptime(publishedStr.split('+')[0] + 'Z', + datetime_object = \ + datetime.strptime(published_str.split('+')[0] + 'Z', "%Y-%m-%dT%H:%M:%SZ") else: - publishedStr = \ - publishedStr.replace('T', ' ').split('.')[0] - datetimeObject = parse(publishedStr) - if not showPublishedDateOnly: - publishedStr = datetimeObject.strftime("%a %b %d, %H:%M") + published_str = \ + published_str.replace('T', ' ').split('.')[0] + datetime_object = parse(published_str) + + # convert to local time + datetime_object = \ + convert_published_to_local_timezone(datetime_object, timezone) + + if not show_published_date_only: + published_str = datetime_object.strftime("%a %b %d, %H:%M") else: - publishedStr = datetimeObject.strftime("%a %b %d") + published_str = datetime_object.strftime("%a %b %d") # if the post has replies then append a symbol to indicate this - if postJsonObject.get('hasReplies'): - if postJsonObject['hasReplies'] is True: - publishedStr = '[' + publishedStr + ']' - return publishedStr + if post_json_object.get('hasReplies'): + if post_json_object['hasReplies'] is True: + published_str = '[' + published_str + ']' + return published_str -def _getBlogCitationsHtml(boxName: str, - postJsonObject: {}, - translate: {}) -> str: +def _get_blog_citations_html(box_name: str, + post_json_object: {}, + translate: {}) -> str: """Returns blog citations as html """ # show blog citations - citationsStr = '' - if not (boxName == 'tlblogs' or boxName == 'tlfeatures'): - return citationsStr + citations_str = '' + if box_name not in ('tlblogs', 'tlfeatures'): + return citations_str - if not postJsonObject['object'].get('tag'): - return citationsStr + if not post_json_object['object'].get('tag'): + return citations_str - for tagJson in postJsonObject['object']['tag']: - if not isinstance(tagJson, dict): + for tag_json in post_json_object['object']['tag']: + if not isinstance(tag_json, dict): continue - if not tagJson.get('type'): + if not tag_json.get('type'): continue - if tagJson['type'] != 'Article': + if tag_json['type'] != 'Article': continue - if not tagJson.get('name'): + if not tag_json.get('name'): continue - if not tagJson.get('url'): + if not tag_json.get('url'): continue - citationsStr += \ - '
  • ' + \ - '' + tagJson['name'] + '
  • \n' + citations_str += \ + '
  • ' + \ + '' + tag_json['name'] + '
  • \n' - if citationsStr: - translatedCitationsStr = 'Citations' - if translate.get(translatedCitationsStr): - translatedCitationsStr = translate[translatedCitationsStr] - citationsStr = '

    ' + translatedCitationsStr + ':

    ' + \ - '
      \n' + citationsStr + '
    \n' - return citationsStr + if citations_str: + translated_citations_str = 'Citations' + if translate.get(translated_citations_str): + translated_citations_str = translate[translated_citations_str] + citations_str = '

    ' + translated_citations_str + ':

    ' + \ + '\n' + citations_str + '\n' + return citations_str -def _boostOwnPostHtml(translate: {}) -> str: +def _boost_own_post_html(translate: {}) -> str: """The html title for announcing your own post """ - announcesStr = 'announces' - if translate.get(announcesStr): - announcesStr = translate[announcesStr] - return ' ' + announcesStr + \
+    announces_str = 'announces'
+    if translate.get(announces_str):
+        announces_str = translate[announces_str]
+    return '        <img loading=\n' -def _announceUnattributedHtml(translate: {}, - postJsonObject: {}) -> str: +def _announce_unattributed_html(translate: {}, + post_json_object: {}) -> str: """Returns the html for an announce title where there is no attribution on the announced post """ - announcesStr = 'announces' - if translate.get(announcesStr): - announcesStr = translate[announcesStr] - postId = removeIdEnding(postJsonObject['object']['id']) - return ' ' + \
-        announcesStr + '\n' + \ - ' @unattributed\n' + ' @unattributed\n' -def _announceWithDisplayNameHtml(translate: {}, - postJsonObject: {}, - announceDisplayName: str) -> str: +def _announce_with_display_name_html(translate: {}, + post_json_object: {}, + announce_display_name: str) -> str: """Returns html for an announce having a display name """ - announcesStr = 'announces' - if translate.get(announcesStr): - announcesStr = translate[announcesStr] - postId = removeIdEnding(postJsonObject['object']['id']) - return ' ' + \
-        announcesStr + '\n' + \ - ' ' + announceDisplayName + '\n' + ' ' + \ + '\n' -def _getPostTitleAnnounceHtml(baseDir: str, - httpPrefix: str, - nickname: str, domain: str, - showRepeatIcon: bool, - isAnnounced: bool, - postJsonObject: {}, - postActor: str, - translate: {}, - enableTimingLog: bool, - postStartTime, - boxName: str, - personCache: {}, - allowDownloads: bool, - avatarPosition: str, - pageNumber: int, - messageIdStr: str, - containerClassIcons: str, - containerClass: str) -> (str, str, str, str): +def _get_post_title_announce_html(base_dir: str, + http_prefix: str, + nickname: str, domain: str, + show_repeat_icon: bool, + is_announced: bool, + post_json_object: {}, + post_actor: str, + translate: {}, + enable_timing_log: bool, + post_start_time, + box_name: str, + person_cache: {}, + allow_downloads: bool, + avatar_position: str, + page_number: int, + message_id_str: str, + container_class_icons: str, + container_class: str, + mitm: bool) -> (str, str, str, str): """Returns the announce title of a post containing names of participants x announces y """ - titleStr = '' - replyAvatarImageInPost = '' - objJson = postJsonObject['object'] + title_str = '' + reply_avatar_image_in_post = '' + obj_json = post_json_object['object'] # has no attribution - if not objJson.get('attributedTo'): - titleStr += _announceUnattributedHtml(translate, postJsonObject) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + if not obj_json.get('attributedTo'): + title_str += _announce_unattributed_html(translate, post_json_object) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - attributedTo = '' - if isinstance(objJson['attributedTo'], str): - attributedTo = objJson['attributedTo'] + attributed_to = '' + if isinstance(obj_json['attributedTo'], str): + attributed_to = obj_json['attributedTo'] # boosting your own post - if attributedTo.startswith(postActor): - titleStr += _boostOwnPostHtml(translate) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + if attributed_to.startswith(post_actor): + title_str += _boost_own_post_html(translate) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) # boosting another person's post - _logPostTiming(enableTimingLog, postStartTime, '13.2') - announceNickname = None - if attributedTo: - announceNickname = getNicknameFromActor(attributedTo) - if not announceNickname: - titleStr += _announceUnattributedHtml(translate, postJsonObject) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + _log_post_timing(enable_timing_log, post_start_time, '13.2') + announce_nickname = None + if attributed_to: + announce_nickname = get_nickname_from_actor(attributed_to) + if not announce_nickname: + title_str += _announce_unattributed_html(translate, post_json_object) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - announceDomain, announcePort = getDomainFromActor(attributedTo) - getPersonFromCache(baseDir, attributedTo, personCache, allowDownloads) - announceDisplayName = getDisplayName(baseDir, attributedTo, personCache) - if not announceDisplayName: - announceDisplayName = announceNickname + '@' + announceDomain + announce_domain, _ = get_domain_from_actor(attributed_to) + get_person_from_cache(base_dir, attributed_to, person_cache) + announce_display_name = \ + get_display_name(base_dir, attributed_to, person_cache) + if announce_display_name: + if len(announce_display_name) < 2 or \ + display_name_is_emoji(announce_display_name): + announce_display_name = None + if not announce_display_name: + announce_display_name = announce_nickname + '@' + announce_domain - _logPostTiming(enableTimingLog, postStartTime, '13.3') + _log_post_timing(enable_timing_log, post_start_time, '13.3') # add any emoji to the display name - if ':' in announceDisplayName: - announceDisplayName = \ - addEmojiToDisplayName(baseDir, httpPrefix, nickname, domain, - announceDisplayName, False) - _logPostTiming(enableTimingLog, postStartTime, '13.3.1') - titleStr += \ - _announceWithDisplayNameHtml(translate, postJsonObject, - announceDisplayName) + if ':' in announce_display_name: + announce_display_name = \ + add_emoji_to_display_name(None, base_dir, http_prefix, + nickname, domain, + announce_display_name, False, + translate) + _log_post_timing(enable_timing_log, post_start_time, '13.3.1') + title_str += \ + _announce_with_display_name_html(translate, post_json_object, + announce_display_name) + + if mitm: + title_str += _mitm_warning_html(translate) + # show avatar of person replied to - announceActor = attributedTo - announceAvatarUrl = \ - getPersonAvatarUrl(baseDir, announceActor, - personCache, allowDownloads) + announce_actor = attributed_to + announce_avatar_url = \ + get_person_avatar_url(base_dir, announce_actor, person_cache) - _logPostTiming(enableTimingLog, postStartTime, '13.4') + _log_post_timing(enable_timing_log, post_start_time, '13.4') - if not announceAvatarUrl: - announceAvatarUrl = '' + if not announce_avatar_url: + announce_avatar_url = '' idx = 'Show options for this person' - if '/users/news/' not in announceAvatarUrl: - showOptionsForThisPersonStr = idx + if '/users/news/' not in announce_avatar_url: + show_options_for_this_person_str = idx if translate.get(idx): - showOptionsForThisPersonStr = translate[idx] - replyAvatarImageInPost = \ + show_options_for_this_person_str = translate[idx] + reply_avatar_image_in_post = \ '
    \n' \ ' ' \ - ' \n
    \n' + announce_actor + ';' + str(page_number) + \ + ';' + announce_avatar_url + message_id_str + \ + '" tabindex="10">' \ + ' \n
    \n' - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) -def _replyToYourselfHtml(translate: {}) -> str: +def _reply_to_yourself_html(translate: {}) -> str: """Returns html for a title which is a reply to yourself """ - replyingToThemselvesStr = 'replying to themselves' - if translate.get(replyingToThemselvesStr): - replyingToThemselvesStr = translate[replyingToThemselvesStr] - return ' ' + replyingToThemselvesStr + \
+    replying_to_themselves_str = 'replying to themselves'
+    if translate.get(replying_to_themselves_str):
+        replying_to_themselves_str = translate[replying_to_themselves_str]
+    title_str = \
+        '    <img loading=\n' + return title_str -def _replyToUnknownHtml(translate: {}, - postJsonObject: {}) -> str: +def _reply_to_unknown_html(translate: {}, + post_json_object: {}) -> str: """Returns the html title for a reply to an unknown handle """ - replyingToStr = 'replying to' - if translate.get(replyingToStr): - replyingToStr = translate[replyingToStr] - return ' ' + \
-        replyingToStr + '\n' + \ ' @unknown\n' + post_json_object['object']['inReplyTo'] + \ + '" class="announceOrReply" tabindex="10">@unknown\n' -def _replyWithUnknownPathHtml(translate: {}, - postJsonObject: {}, - postDomain: str) -> str: +def _mitm_warning_html(translate: {}) -> str: + """Returns the html title for a reply to an unknown handle + """ + mitm_warning_str = translate['mitm'] + return ' ' + \
+        mitm_warning_str + '\n' + + +def _reply_with_unknown_path_html(translate: {}, + post_json_object: {}, + post_domain: str) -> str: """Returns html title for a reply with an unknown path eg. does not contain /statuses/ """ - replyingToStr = 'replying to' - if translate.get(replyingToStr): - replyingToStr = translate[replyingToStr] - return ' ' + replyingToStr + \
+    replying_to_str = 'replying to'
+    if translate.get(replying_to_str):
+        replying_to_str = translate[replying_to_str]
+    return '        <img loading=\n' + \ ' ' + \ - postDomain + '\n' + post_json_object['object']['inReplyTo'] + \ + '" class="announceOrReply" tabindex="10">' + \ + post_domain + '\n' -def _getReplyHtml(translate: {}, - inReplyTo: str, replyDisplayName: str) -> str: +def _get_reply_html(translate: {}, + in_reply_to: str, reply_display_name: str) -> str: """Returns html title for a reply """ - replyingToStr = 'replying to' - if translate.get(replyingToStr): - replyingToStr = translate[replyingToStr] + replying_to_str = 'replying to' + if translate.get(replying_to_str): + replying_to_str = translate[replying_to_str] return ' ' + \ - '' + \
-        replyingToStr + '\n' + \ - ' ' + \ - replyDisplayName + '\n' + ' ' + \ + '' + \ + reply_display_name + '\n' -def _getPostTitleReplyHtml(baseDir: str, - httpPrefix: str, - nickname: str, domain: str, - showRepeatIcon: bool, - isAnnounced: bool, - postJsonObject: {}, - postActor: str, - translate: {}, - enableTimingLog: bool, - postStartTime, - boxName: str, - personCache: {}, - allowDownloads: bool, - avatarPosition: str, - pageNumber: int, - messageIdStr: str, - containerClassIcons: str, - containerClass: str) -> (str, str, str, str): +def _get_post_title_reply_html(base_dir: str, + http_prefix: str, + nickname: str, domain: str, + show_repeat_icon: bool, + is_announced: bool, + post_json_object: {}, + post_actor: str, + translate: {}, + enable_timing_log: bool, + post_start_time, + box_name: str, + person_cache: {}, + allow_downloads: bool, + avatar_position: str, + page_number: int, + message_id_str: str, + container_class_icons: str, + container_class: str, + mitm: bool) -> (str, str, str, str): """Returns the reply title of a post containing names of participants x replies to y """ - titleStr = '' - replyAvatarImageInPost = '' - objJson = postJsonObject['object'] + title_str = '' + reply_avatar_image_in_post = '' + obj_json = post_json_object['object'] # not a reply - if not objJson.get('inReplyTo'): - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + if not obj_json.get('inReplyTo'): + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - containerClassIcons = 'containericons darker' - containerClass = 'container darker' + container_class_icons = 'containericons darker' + container_class = 'container darker' # reply to self - if objJson['inReplyTo'].startswith(postActor): - titleStr += _replyToYourselfHtml(translate) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + if obj_json['inReplyTo'].startswith(post_actor): + title_str += _reply_to_yourself_html(translate) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) # has a reply - if '/statuses/' not in objJson['inReplyTo']: - postDomain = objJson['inReplyTo'] - prefixes = getProtocolPrefixes() + if '/statuses/' not in obj_json['inReplyTo']: + post_domain = obj_json['inReplyTo'] + prefixes = get_protocol_prefixes() for prefix in prefixes: - postDomain = postDomain.replace(prefix, '') - if '/' in postDomain: - postDomain = postDomain.split('/', 1)[0] - if postDomain: - titleStr += \ - _replyWithUnknownPathHtml(translate, - postJsonObject, postDomain) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + post_domain = post_domain.replace(prefix, '') + if '/' in post_domain: + post_domain = post_domain.split('/', 1)[0] + if post_domain: + title_str += \ + _reply_with_unknown_path_html(translate, + post_json_object, post_domain) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - inReplyTo = objJson['inReplyTo'] - replyActor = inReplyTo.split('/statuses/')[0] - replyNickname = getNicknameFromActor(replyActor) - if not replyNickname: - titleStr += _replyToUnknownHtml(translate, postJsonObject) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + in_reply_to = obj_json['inReplyTo'] + reply_actor = in_reply_to.split('/statuses/')[0] + reply_nickname = get_nickname_from_actor(reply_actor) + if not reply_nickname: + title_str += _reply_to_unknown_html(translate, post_json_object) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - replyDomain, replyPort = getDomainFromActor(replyActor) - if not (replyNickname and replyDomain): - titleStr += _replyToUnknownHtml(translate, postJsonObject) - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + reply_domain, _ = get_domain_from_actor(reply_actor) + if not (reply_nickname and reply_domain): + title_str += _reply_to_unknown_html(translate, post_json_object) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) - getPersonFromCache(baseDir, replyActor, personCache, allowDownloads) - replyDisplayName = getDisplayName(baseDir, replyActor, personCache) - if not replyDisplayName: - replyDisplayName = replyNickname + '@' + replyDomain + get_person_from_cache(base_dir, reply_actor, person_cache) + reply_display_name = \ + get_display_name(base_dir, reply_actor, person_cache) + if reply_display_name: + if len(reply_display_name) < 2 or \ + display_name_is_emoji(reply_display_name): + reply_display_name = None + if not reply_display_name: + reply_display_name = reply_nickname + '@' + reply_domain # add emoji to the display name - if ':' in replyDisplayName: - _logPostTiming(enableTimingLog, postStartTime, '13.5') + if ':' in reply_display_name: + _log_post_timing(enable_timing_log, post_start_time, '13.5') - replyDisplayName = \ - addEmojiToDisplayName(baseDir, httpPrefix, nickname, domain, - replyDisplayName, False) - _logPostTiming(enableTimingLog, postStartTime, '13.6') + reply_display_name = \ + add_emoji_to_display_name(None, base_dir, http_prefix, + nickname, domain, + reply_display_name, False, translate) + _log_post_timing(enable_timing_log, post_start_time, '13.6') - titleStr += _getReplyHtml(translate, inReplyTo, replyDisplayName) + title_str += _get_reply_html(translate, in_reply_to, reply_display_name) - _logPostTiming(enableTimingLog, postStartTime, '13.7') + if mitm: + title_str += _mitm_warning_html(translate) + + _log_post_timing(enable_timing_log, post_start_time, '13.7') # show avatar of person replied to - replyAvatarUrl = \ - getPersonAvatarUrl(baseDir, replyActor, personCache, allowDownloads) + reply_avatar_url = \ + get_person_avatar_url(base_dir, reply_actor, person_cache) - _logPostTiming(enableTimingLog, postStartTime, '13.8') + _log_post_timing(enable_timing_log, post_start_time, '13.8') - if replyAvatarUrl: - showProfileStr = 'Show profile' - if translate.get(showProfileStr): - showProfileStr = translate[showProfileStr] - replyAvatarImageInPost = \ + if reply_avatar_url: + show_profile_str = 'Show profile' + if translate.get(show_profile_str): + show_profile_str = translate[show_profile_str] + reply_avatar_image_in_post = \ ' \n' - return (titleStr, replyAvatarImageInPost, - containerClassIcons, containerClass) + return (title_str, reply_avatar_image_in_post, + container_class_icons, container_class) -def _getPostTitleHtml(baseDir: str, - httpPrefix: str, - nickname: str, domain: str, - showRepeatIcon: bool, - isAnnounced: bool, - postJsonObject: {}, - postActor: str, - translate: {}, - enableTimingLog: bool, - postStartTime, - boxName: str, - personCache: {}, - allowDownloads: bool, - avatarPosition: str, - pageNumber: int, - messageIdStr: str, - containerClassIcons: str, - containerClass: str) -> (str, str, str, str): +def _get_post_title_html(base_dir: str, + http_prefix: str, + nickname: str, domain: str, + show_repeat_icon: bool, + is_announced: bool, + post_json_object: {}, + post_actor: str, + translate: {}, + enable_timing_log: bool, + post_start_time, + box_name: str, + person_cache: {}, + allow_downloads: bool, + avatar_position: str, + page_number: int, + message_id_str: str, + container_class_icons: str, + container_class: str, + mitm: bool) -> (str, str, str, str): """Returns the title of a post containing names of participants x replies to y, x announces y, etc """ - if not isAnnounced and boxName == 'search' and \ - postJsonObject.get('object'): - if postJsonObject['object'].get('attributedTo'): - if postJsonObject['object']['attributedTo'] != postActor: - isAnnounced = True + if not is_announced and box_name == 'search' and \ + post_json_object.get('object'): + if post_json_object['object'].get('attributedTo'): + if post_json_object['object']['attributedTo'] != post_actor: + is_announced = True - if isAnnounced: - return _getPostTitleAnnounceHtml(baseDir, - httpPrefix, - nickname, domain, - showRepeatIcon, - isAnnounced, - postJsonObject, - postActor, - translate, - enableTimingLog, - postStartTime, - boxName, - personCache, - allowDownloads, - avatarPosition, - pageNumber, - messageIdStr, - containerClassIcons, - containerClass) + if is_announced: + return _get_post_title_announce_html(base_dir, + http_prefix, + nickname, domain, + show_repeat_icon, + is_announced, + post_json_object, + post_actor, + translate, + enable_timing_log, + post_start_time, + box_name, + person_cache, + allow_downloads, + avatar_position, + page_number, + message_id_str, + container_class_icons, + container_class, mitm) - return _getPostTitleReplyHtml(baseDir, - httpPrefix, - nickname, domain, - showRepeatIcon, - isAnnounced, - postJsonObject, - postActor, - translate, - enableTimingLog, - postStartTime, - boxName, - personCache, - allowDownloads, - avatarPosition, - pageNumber, - messageIdStr, - containerClassIcons, - containerClass) + return _get_post_title_reply_html(base_dir, + http_prefix, + nickname, domain, + show_repeat_icon, + is_announced, + post_json_object, + post_actor, + translate, + enable_timing_log, + post_start_time, + box_name, + person_cache, + allow_downloads, + avatar_position, + page_number, + message_id_str, + container_class_icons, + container_class, mitm) -def _getFooterWithIcons(showIcons: bool, - containerClassIcons: str, - replyStr: str, announceStr: str, - likeStr: str, bookmarkStr: str, - deleteStr: str, muteStr: str, editStr: str, - postJsonObject: {}, publishedLink: str, - timeClass: str, publishedStr: str) -> str: +def _get_footer_with_icons(show_icons: bool, + container_class_icons: str, + reply_str: str, announce_str: str, + like_str: str, reaction_str: str, + bookmark_str: str, + delete_str: str, mute_str: str, edit_str: str, + post_json_object: {}, published_link: str, + time_class: str, published_str: str) -> str: """Returns the html for a post footer containing icons """ - if not showIcons: + if not show_icons: return None - footerStr = '\n \n' + return footer_str -def individualPostAsHtml(signingPrivateKeyPem: str, - allowDownloads: bool, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, - pageNumber: int, baseDir: str, - session, cachedWebfingers: {}, personCache: {}, - nickname: str, domain: str, port: int, - postJsonObject: {}, - avatarUrl: str, showAvatarOptions: bool, - allowDeletion: bool, - httpPrefix: str, projectVersion: str, - boxName: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, - showRepeats: bool, - showIcons: bool, - manuallyApprovesFollowers: bool, - showPublicOnly: bool, - storeToCache: bool, - useCacheOnly: bool) -> str: +def _substitute_onion_domains(base_dir: str, content: str) -> str: + """Replace clearnet domains with onion domains + """ + # any common sites which have onion equivalents + bbc_onion = \ + 'bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion' + ddg_onion = \ + 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion' + guardian_onion = \ + 'guardian2zotagl6tmjucg3lrhxdk4dw3lhbqnkvvkywawy3oqfoprid.onion' + propublica_onion = \ + 'p53lf57qovyuvwsc6xnrppyply3vtqm7l6pcobkmyqsiofyeznfu5uqd.onion' + # woe betide anyone following a facebook link, but if you must + # then do it safely + facebook_onion = \ + 'facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion' + protonmail_onion = \ + 'protonmailrmez3lotccipshtkleegetolb73fuirgj7r4o4vfu7ozyd.onion' + riseup_onion = \ + 'vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion' + keybase_onion = \ + 'keybase5wmilwokqirssclfnsqrjdsi7jdir5wy7y7iu3tanwmtp6oid.onion' + zerobin_onion = \ + 'zerobinftagjpeeebbvyzjcqyjpmjvynj5qlexwyxe7l3vqejxnqv5qd.onion' + securedrop_onion = \ + 'sdolvtfhatvsysc6l34d65ymdwxcujausv7k5jk4cy5ttzhjoi6fzvyd.onion' + # the hell site 🔥 + twitter_onion = \ + 'twitter3e4tixl4xyajtrzo62zg5vztmjuricljdp2c5kshju4avyoid.onion' + onion_domains = { + "bbc.com": bbc_onion, + "bbc.co.uk": bbc_onion, + "theguardian.com": guardian_onion, + "theguardian.co.uk": guardian_onion, + "duckduckgo.com": ddg_onion, + "propublica.org": propublica_onion, + "facebook.com": facebook_onion, + "protonmail.ch": protonmail_onion, + "proton.me": protonmail_onion, + "riseup.net": riseup_onion, + "keybase.io": keybase_onion, + "zerobin.net": zerobin_onion, + "securedrop.org": securedrop_onion, + "twitter.com": twitter_onion + } + + onion_domains_filename = base_dir + '/accounts/onion_domains.txt' + if os.path.isfile(onion_domains_filename): + onion_domains_list = [] + try: + with open(onion_domains_filename, 'r', + encoding='utf-8') as fp_onions: + onion_domains_list = fp_onions.readlines() + except OSError: + print('EX: unable to load onion domains file ' + + onion_domains_filename) + if onion_domains_list: + onion_domains = {} + separators = (' ', ',', '->') + for line in onion_domains_list: + line = line.strip() + if line.startswith('#'): + continue + for sep in separators: + if sep not in line: + continue + clearnet = line.split(sep, 1)[0].strip() + onion1 = line.split(sep, 1)[1].strip() + onion = remove_eol(onion1) + if clearnet and onion: + onion_domains[clearnet] = onion + break + + for clearnet, onion in onion_domains.items(): + if clearnet in content: + content = content.replace(clearnet, onion) + return content + + +def _add_dogwhistle_warnings(summary: str, content: str, + dogwhistles: {}, translate: {}) -> {}: + """Adds dogwhistle warnings for the given content + """ + if not dogwhistles: + return summary + content_str = str(summary) + ' ' + content + detected = detect_dogwhistles(content_str, dogwhistles) + if not detected: + return summary + + for _, item in detected.items(): + if not item.get('category'): + continue + whistle_str = item['category'] + if translate.get(whistle_str): + whistle_str = translate[whistle_str] + if summary: + if whistle_str not in summary: + summary += ', ' + whistle_str + else: + summary = whistle_str + return summary + + +def individual_post_as_html(signing_priv_key_pem: str, + allow_downloads: bool, + recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + page_number: int, base_dir: str, + session, cached_webfingers: {}, person_cache: {}, + nickname: str, domain: str, port: int, + post_json_object: {}, + avatar_url: str, show_avatar_options: bool, + allow_deletion: bool, + http_prefix: str, project_version: str, + box_name: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, + show_repeats: bool, + show_icons: bool, + manually_approves_followers: bool, + show_public_only: bool, + store_to_cache: bool, + use_cache_only: bool, + cw_lists: {}, + lists_enabled: str, + timezone: str, + mitm: bool, bold_reading: bool, + dogwhistles: {}) -> str: """ Shows a single post as html """ - if not postJsonObject: + if not post_json_object: return '' - # benchmark - postStartTime = time.time() + # maximum number of different emoji reactions which can be added to a post + max_reaction_types = 5 - postActor = postJsonObject['actor'] + # benchmark + post_start_time = time.time() + + post_actor = post_json_object['actor'] # ZZZzzz - if isPersonSnoozed(baseDir, nickname, domain, postActor): + if is_person_snoozed(base_dir, nickname, domain, post_actor): return '' # if downloads of avatar images aren't enabled then we can do more # accurate timing of different parts of the code - enableTimingLog = not allowDownloads + enable_timing_log = not allow_downloads - _logPostTiming(enableTimingLog, postStartTime, '1') + _log_post_timing(enable_timing_log, post_start_time, '1') - avatarPosition = '' - messageId = '' - if postJsonObject.get('id'): - messageId = removeIdEnding(postJsonObject['id']) + avatar_position = '' + message_id = '' + if post_json_object.get('id'): + message_id = remove_hash_from_post_id(post_json_object['id']) + message_id = remove_id_ending(message_id) - _logPostTiming(enableTimingLog, postStartTime, '2') + _log_post_timing(enable_timing_log, post_start_time, '2') - messageIdStr = '' - if messageId: - messageIdStr = ';' + messageId + # does this post have edits? + edits_post_url = \ + remove_id_ending(message_id.strip()).replace('/', '#') + '.edits' + account_dir = acct_dir(base_dir, nickname, domain) + '/' + edits_filename = account_dir + box_name + '/' + edits_post_url + edits_str = '' + if os.path.isfile(edits_filename): + edits_json = load_json(edits_filename, 0, 1) + if edits_json: + edits_str = create_edits_html(edits_json, post_json_object, + translate, timezone, system_language) - domainFull = getFullDomain(domain, port) + message_id_str = '' + if message_id: + message_id_str = ';' + message_id - pageNumberParam = '' - if pageNumber: - pageNumberParam = '?page=' + str(pageNumber) + domain_full = get_full_domain(domain, port) + + page_number_param = '' + if page_number: + page_number_param = '?page=' + str(page_number) # get the html post from the recent posts cache if it exists there - postHtml = \ - _getPostFromRecentCache(session, baseDir, - httpPrefix, nickname, domain, - postJsonObject, - postActor, - personCache, - allowDownloads, - showPublicOnly, - storeToCache, - boxName, - avatarUrl, - enableTimingLog, - postStartTime, - pageNumber, - recentPostsCache, - maxRecentPosts, - signingPrivateKeyPem) - if postHtml: - return postHtml - if useCacheOnly and postJsonObject['type'] != 'Announce': + post_html = \ + _get_post_from_recent_cache(session, base_dir, + http_prefix, nickname, domain, + post_json_object, + post_actor, + person_cache, + allow_downloads, + show_public_only, + store_to_cache, + box_name, + avatar_url, + enable_timing_log, + post_start_time, + page_number, + recent_posts_cache, + max_recent_posts, + signing_priv_key_pem) + if post_html: + return post_html + if use_cache_only and post_json_object['type'] != 'Announce': return '' - _logPostTiming(enableTimingLog, postStartTime, '4') + _log_post_timing(enable_timing_log, post_start_time, '4') - avatarUrl = \ - getAvatarImageUrl(session, - baseDir, httpPrefix, - postActor, personCache, - avatarUrl, allowDownloads, - signingPrivateKeyPem) + avatar_url = \ + get_avatar_image_url(session, + base_dir, http_prefix, + post_actor, person_cache, + avatar_url, allow_downloads, + signing_priv_key_pem) - _logPostTiming(enableTimingLog, postStartTime, '5') + _log_post_timing(enable_timing_log, post_start_time, '5') # get the display name - if domainFull not in postActor: - # lookup the correct webfinger for the postActor - postActorNickname = getNicknameFromActor(postActor) - postActorDomain, postActorPort = getDomainFromActor(postActor) - postActorDomainFull = getFullDomain(postActorDomain, postActorPort) - postActorHandle = postActorNickname + '@' + postActorDomainFull - postActorWf = \ - webfingerHandle(session, postActorHandle, httpPrefix, - cachedWebfingers, - domain, __version__, False, False, - signingPrivateKeyPem) + if domain_full not in post_actor: + # lookup the correct webfinger for the post_actor + post_actor_nickname = get_nickname_from_actor(post_actor) + if not post_actor_nickname: + return '' + post_actor_domain, post_actor_port = get_domain_from_actor(post_actor) + post_actor_domain_full = \ + get_full_domain(post_actor_domain, post_actor_port) + post_actor_handle = post_actor_nickname + '@' + post_actor_domain_full + post_actor_wf = \ + webfinger_handle(session, post_actor_handle, http_prefix, + cached_webfingers, + domain, __version__, False, False, + signing_priv_key_pem) - avatarUrl2 = None - displayName = None - if postActorWf: - originDomain = domain - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl2, - displayName, _) = getPersonBox(signingPrivateKeyPem, - originDomain, - baseDir, session, - postActorWf, - personCache, - projectVersion, - httpPrefix, - nickname, domain, - 'outbox', 72367) + avatar_url2 = None + display_name = None + if post_actor_wf: + origin_domain = domain + (_, _, _, _, _, avatar_url2, + display_name, _) = get_person_box(signing_priv_key_pem, + origin_domain, + base_dir, session, + post_actor_wf, + person_cache, + project_version, + http_prefix, + nickname, domain, + 'outbox', 72367) - _logPostTiming(enableTimingLog, postStartTime, '6') + _log_post_timing(enable_timing_log, post_start_time, '6') - if avatarUrl2: - avatarUrl = avatarUrl2 - if displayName: + if avatar_url2: + avatar_url = avatar_url2 + if display_name: # add any emoji to the display name - if ':' in displayName: - displayName = \ - addEmojiToDisplayName(baseDir, httpPrefix, - nickname, domain, - displayName, False) + if ':' in display_name: + display_name = \ + add_emoji_to_display_name(session, base_dir, http_prefix, + nickname, domain, + display_name, False, translate) - _logPostTiming(enableTimingLog, postStartTime, '7') + _log_post_timing(enable_timing_log, post_start_time, '7') - avatarLink = \ - _getAvatarImageHtml(showAvatarOptions, - nickname, domainFull, - avatarUrl, postActor, - translate, avatarPosition, - pageNumber, messageIdStr) + avatar_link = \ + _get_avatar_image_html(show_avatar_options, + nickname, domain_full, + avatar_url, post_actor, + translate, avatar_position, + page_number, message_id_str) - avatarImageInPost = \ - '
    ' + avatarLink + '
    \n' + avatar_image_in_post = \ + '
    ' + avatar_link + '
    \n' - timelinePostBookmark = removeIdEnding(postJsonObject['id']) - timelinePostBookmark = timelinePostBookmark.replace('://', '-') - timelinePostBookmark = timelinePostBookmark.replace('/', '-') + timeline_post_bookmark = remove_id_ending(post_json_object['id']) + timeline_post_bookmark = timeline_post_bookmark.replace('://', '-') + timeline_post_bookmark = timeline_post_bookmark.replace('/', '-') # If this is the inbox timeline then don't show the repeat icon on any DMs - showRepeatIcon = showRepeats - isPublicRepeat = False - postIsDM = isDM(postJsonObject) - if showRepeats: - if postIsDM: - showRepeatIcon = False + show_repeat_icon = show_repeats + is_public_repeat = False + post_is_dm = is_dm(post_json_object) + if show_repeats: + if post_is_dm: + show_repeat_icon = False else: - if not isPublicPost(postJsonObject): - isPublicRepeat = True + if not is_public_post(post_json_object): + is_public_repeat = True - titleStr = '' - galleryStr = '' - isAnnounced = False - announceJsonObject = None - if postJsonObject['type'] == 'Announce': - announceJsonObject = postJsonObject.copy() - blockedCache = {} - postJsonAnnounce = \ - downloadAnnounce(session, baseDir, httpPrefix, - nickname, domain, postJsonObject, - projectVersion, translate, - YTReplacementDomain, - twitterReplacementDomain, - allowLocalNetworkAccess, - recentPostsCache, False, - systemLanguage, - domainFull, personCache, - signingPrivateKeyPem, - blockedCache) - if not postJsonAnnounce: + title_str = '' + gallery_str = '' + is_announced = False + announce_json_object = None + if post_json_object['type'] == 'Announce': + announce_json_object = post_json_object.copy() + blocked_cache = {} + post_json_announce = \ + download_announce(session, base_dir, http_prefix, + nickname, domain, post_json_object, + project_version, + yt_replace_domain, + twitter_replacement_domain, + allow_local_network_access, + recent_posts_cache, False, + system_language, + domain_full, person_cache, + signing_priv_key_pem, + blocked_cache, bold_reading) + if not post_json_announce: # if the announce could not be downloaded then mark it as rejected - announcedPostId = removeIdEnding(postJsonObject['id']) - rejectPostId(baseDir, nickname, domain, announcedPostId, - recentPostsCache) + announced_post_id = remove_id_ending(post_json_object['id']) + reject_post_id(base_dir, nickname, domain, announced_post_id, + recent_posts_cache) return '' - postJsonObject = postJsonAnnounce + post_json_object = post_json_announce # is the announced post in the html cache? - postHtml = \ - _getPostFromRecentCache(session, baseDir, - httpPrefix, nickname, domain, - postJsonObject, - postActor, - personCache, - allowDownloads, - showPublicOnly, - storeToCache, - boxName, - avatarUrl, - enableTimingLog, - postStartTime, - pageNumber, - recentPostsCache, - maxRecentPosts, - signingPrivateKeyPem) - if postHtml: - return postHtml + post_html = \ + _get_post_from_recent_cache(session, base_dir, + http_prefix, nickname, domain, + post_json_object, + post_actor, + person_cache, + allow_downloads, + show_public_only, + store_to_cache, + box_name, + avatar_url, + enable_timing_log, + post_start_time, + page_number, + recent_posts_cache, + max_recent_posts, + signing_priv_key_pem) + if post_html: + return post_html - announceFilename = \ - locatePost(baseDir, nickname, domain, postJsonObject['id']) - if announceFilename: - updateAnnounceCollection(recentPostsCache, - baseDir, announceFilename, - postActor, nickname, domainFull, False) + announce_filename = \ + locate_post(base_dir, nickname, domain, post_json_object['id']) + if announce_filename: + update_announce_collection(recent_posts_cache, + base_dir, announce_filename, + post_actor, nickname, + domain_full, False) # create a file for use by text-to-speech - if isRecentPost(postJsonObject): - if postJsonObject.get('actor'): - if not os.path.isfile(announceFilename + '.tts'): - updateSpeaker(baseDir, httpPrefix, - nickname, domain, domainFull, - postJsonObject, personCache, - translate, postJsonObject['actor'], - themeName) - with open(announceFilename + '.tts', 'w+') as ttsFile: - ttsFile.write('\n') + if is_recent_post(post_json_object, 3): + if post_json_object.get('actor'): + if not os.path.isfile(announce_filename + '.tts'): + update_speaker(base_dir, http_prefix, + nickname, domain, domain_full, + post_json_object, person_cache, + translate, post_json_object['actor'], + theme_name, system_language, + box_name) + with open(announce_filename + '.tts', 'w+', + encoding='utf-8') as ttsfile: + ttsfile.write('\n') - isAnnounced = True + is_announced = True - _logPostTiming(enableTimingLog, postStartTime, '8') + _log_post_timing(enable_timing_log, post_start_time, '8') - if not hasObjectDict(postJsonObject): + if not has_object_dict(post_json_object): return '' # if this post should be public then check its recipients - if showPublicOnly: - if not postContainsPublic(postJsonObject): + if show_public_only: + if not post_contains_public(post_json_object): return '' - isModerationPost = False - if postJsonObject['object'].get('moderationStatus'): - isModerationPost = True - containerClass = 'container' - containerClassIcons = 'containericons' - timeClass = 'time-right' - actorNickname = getNicknameFromActor(postActor) - if not actorNickname: + is_moderation_post = False + if post_json_object['object'].get('moderationStatus'): + is_moderation_post = True + container_class = 'container' + container_class_icons = 'containericons' + time_class = 'time-right' + actor_nickname = get_nickname_from_actor(post_actor) + if not actor_nickname: # single user instance - actorNickname = 'dev' - actorDomain, actorPort = getDomainFromActor(postActor) + actor_nickname = 'dev' + actor_domain, _ = get_domain_from_actor(post_actor) - displayName = getDisplayName(baseDir, postActor, personCache) - if displayName: - if ':' in displayName: - displayName = \ - addEmojiToDisplayName(baseDir, httpPrefix, - nickname, domain, - displayName, False) - titleStr += \ + display_name = get_display_name(base_dir, post_actor, person_cache) + if display_name: + if len(display_name) < 2 or \ + display_name_is_emoji(display_name): + display_name = None + if display_name: + if ':' in display_name: + display_name = \ + add_emoji_to_display_name(session, base_dir, http_prefix, + nickname, domain, + display_name, False, translate) + title_str += \ ' ' + displayName + '\n' + nickname + '?options=' + post_actor + \ + ';' + str(page_number) + ';' + avatar_url + message_id_str + \ + '" tabindex="10">' + \ + '' + \ + '\n' else: - if not messageId: - # pprint(postJsonObject) - print('ERROR: no messageId') - if not actorNickname: - # pprint(postJsonObject) - print('ERROR: no actorNickname') - if not actorDomain: - # pprint(postJsonObject) - print('ERROR: no actorDomain') - titleStr += \ + if not message_id: + # pprint(post_json_object) + print('ERROR: no message_id') + if not actor_nickname: + # pprint(post_json_object) + print('ERROR: no actor_nickname') + if not actor_domain: + # pprint(post_json_object) + print('ERROR: no actor_domain') + actor_handle = actor_nickname + '@' + actor_domain + title_str += \ ' @' + actorNickname + '@' + actorDomain + '\n' + nickname + '?options=' + post_actor + \ + ';' + str(page_number) + ';' + avatar_url + message_id_str + \ + '" tabindex="10">' + \ + '@\n' # benchmark 9 - _logPostTiming(enableTimingLog, postStartTime, '9') + _log_post_timing(enable_timing_log, post_start_time, '9') # Show a DM icon for DMs in the inbox timeline - if postIsDM: - titleStr = \ - titleStr + ' \n' # check if replying is permitted - commentsEnabled = True - if isinstance(postJsonObject['object'], dict) and \ - 'commentsEnabled' in postJsonObject['object']: - if postJsonObject['object']['commentsEnabled'] is False: - commentsEnabled = False - elif 'rejectReplies' in postJsonObject['object']: - if postJsonObject['object']['rejectReplies']: - commentsEnabled = False + comments_enabled = True + if isinstance(post_json_object['object'], dict) and \ + 'commentsEnabled' in post_json_object['object']: + if post_json_object['object']['commentsEnabled'] is False: + comments_enabled = False + elif 'rejectReplies' in post_json_object['object']: + if post_json_object['object']['rejectReplies']: + comments_enabled = False - conversationId = None - if isinstance(postJsonObject['object'], dict) and \ - 'conversation' in postJsonObject['object']: - if postJsonObject['object']['conversation']: - conversationId = postJsonObject['object']['conversation'] + conversation_id = None + if isinstance(post_json_object['object'], dict) and \ + 'conversation' in post_json_object['object']: + if post_json_object['object']['conversation']: + conversation_id = post_json_object['object']['conversation'] - replyStr = _getReplyIconHtml(baseDir, nickname, domain, - isPublicRepeat, - showIcons, commentsEnabled, - postJsonObject, pageNumberParam, - translate, systemLanguage, - conversationId) + public_reply = False + unlisted_reply = False + if is_public_post(post_json_object): + public_reply = True + if is_unlisted_post(post_json_object): + public_reply = False + unlisted_reply = True + reply_str = _get_reply_icon_html(base_dir, nickname, domain, + public_reply, unlisted_reply, + show_icons, comments_enabled, + post_json_object, page_number_param, + translate, system_language, + conversation_id) - _logPostTiming(enableTimingLog, postStartTime, '10') + _log_post_timing(enable_timing_log, post_start_time, '10') - editStr = _getEditIconHtml(baseDir, nickname, domainFull, - postJsonObject, actorNickname, - translate, False) + edit_str = _get_edit_icon_html(base_dir, nickname, domain_full, + post_json_object, actor_nickname, + translate, False) - _logPostTiming(enableTimingLog, postStartTime, '11') + _log_post_timing(enable_timing_log, post_start_time, '11') - announceStr = \ - _getAnnounceIconHtml(isAnnounced, - postActor, - nickname, domainFull, - announceJsonObject, - postJsonObject, - isPublicRepeat, - isModerationPost, - showRepeatIcon, - translate, - pageNumberParam, - timelinePostBookmark, - boxName) + announce_str = \ + _get_announce_icon_html(is_announced, + post_actor, + nickname, domain_full, + announce_json_object, + post_json_object, + is_public_repeat, + is_moderation_post, + show_repeat_icon, + translate, + page_number_param, + timeline_post_bookmark, + box_name, max_like_count) - _logPostTiming(enableTimingLog, postStartTime, '12') + _log_post_timing(enable_timing_log, post_start_time, '12') # whether to show a like button - hideLikeButtonFile = \ - acctDir(baseDir, nickname, domain) + '/.hideLikeButton' - showLikeButton = True - if os.path.isfile(hideLikeButtonFile): - showLikeButton = False + hide_like_button_file = \ + acct_dir(base_dir, nickname, domain) + '/.hideLikeButton' + show_like_button = True + if os.path.isfile(hide_like_button_file): + show_like_button = False - likeStr = _getLikeIconHtml(nickname, domainFull, - isModerationPost, - showLikeButton, - postJsonObject, - enableTimingLog, - postStartTime, - translate, pageNumberParam, - timelinePostBookmark, - boxName, maxLikeCount) + # whether to show a reaction button + hide_reaction_button_file = \ + acct_dir(base_dir, nickname, domain) + '/.hideReactionButton' + show_reaction_button = True + if os.path.isfile(hide_reaction_button_file): + show_reaction_button = False - _logPostTiming(enableTimingLog, postStartTime, '12.5') + like_json_object = post_json_object + if announce_json_object: + like_json_object = announce_json_object + like_str = _get_like_icon_html(nickname, domain_full, + is_moderation_post, + show_like_button, + like_json_object, + enable_timing_log, + post_start_time, + translate, page_number_param, + timeline_post_bookmark, + box_name, max_like_count) - bookmarkStr = \ - _getBookmarkIconHtml(nickname, domainFull, - postJsonObject, - isModerationPost, - translate, - enableTimingLog, - postStartTime, boxName, - pageNumberParam, - timelinePostBookmark) + _log_post_timing(enable_timing_log, post_start_time, '12.5') - _logPostTiming(enableTimingLog, postStartTime, '12.9') + bookmark_str = \ + _get_bookmark_icon_html(nickname, domain_full, + post_json_object, + is_moderation_post, + translate, + enable_timing_log, + post_start_time, box_name, + page_number_param, + timeline_post_bookmark) - isMuted = postIsMuted(baseDir, nickname, domain, postJsonObject, messageId) + _log_post_timing(enable_timing_log, post_start_time, '12.9') - _logPostTiming(enableTimingLog, postStartTime, '13') + reaction_str = \ + _get_reaction_icon_html(nickname, post_json_object, + is_moderation_post, + show_reaction_button, + translate, + enable_timing_log, + post_start_time, box_name, + page_number_param, + timeline_post_bookmark) - muteStr = \ - _getMuteIconHtml(isMuted, - postActor, - messageId, - nickname, domainFull, - allowDeletion, - pageNumberParam, - boxName, - timelinePostBookmark, - translate) + _log_post_timing(enable_timing_log, post_start_time, '12.10') - deleteStr = \ - _getDeleteIconHtml(nickname, domainFull, - allowDeletion, - postActor, - messageId, - postJsonObject, - pageNumberParam, - translate) + is_muted = post_is_muted(base_dir, nickname, domain, + post_json_object, message_id) - _logPostTiming(enableTimingLog, postStartTime, '13.1') + _log_post_timing(enable_timing_log, post_start_time, '13') + + mute_str = \ + _get_mute_icon_html(is_muted, + post_actor, + message_id, + nickname, domain_full, + allow_deletion, + page_number_param, + box_name, + timeline_post_bookmark, + translate) + + delete_str = \ + _get_delete_icon_html(nickname, domain_full, + allow_deletion, + post_actor, + message_id, + post_json_object, + page_number_param, + translate) + + _log_post_timing(enable_timing_log, post_start_time, '13.1') # get the title: x replies to y, x announces y, etc - (titleStr2, - replyAvatarImageInPost, - containerClassIcons, - containerClass) = _getPostTitleHtml(baseDir, - httpPrefix, - nickname, domain, - showRepeatIcon, - isAnnounced, - postJsonObject, - postActor, - translate, - enableTimingLog, - postStartTime, - boxName, - personCache, - allowDownloads, - avatarPosition, - pageNumber, - messageIdStr, - containerClassIcons, - containerClass) - titleStr += titleStr2 + (title_str2, + reply_avatar_image_in_post, + container_class_icons, + container_class) = _get_post_title_html(base_dir, + http_prefix, + nickname, domain, + show_repeat_icon, + is_announced, + post_json_object, + post_actor, + translate, + enable_timing_log, + post_start_time, + box_name, + person_cache, + allow_downloads, + avatar_position, + page_number, + message_id_str, + container_class_icons, + container_class, mitm) + title_str += title_str2 - _logPostTiming(enableTimingLog, postStartTime, '14') + _log_post_timing(enable_timing_log, post_start_time, '14') - attachmentStr, galleryStr = \ - getPostAttachmentsAsHtml(postJsonObject, boxName, translate, - isMuted, avatarLink, - replyStr, announceStr, likeStr, - bookmarkStr, deleteStr, muteStr) + person_url = local_actor_url(http_prefix, nickname, domain_full) + actor_json = \ + get_person_from_cache(base_dir, person_url, person_cache) + languages_understood = [] + if actor_json: + languages_understood = get_actor_languages_list(actor_json) + content_str = get_content_from_post(post_json_object, system_language, + languages_understood) - publishedStr = \ - _getPublishedDateStr(postJsonObject, showPublishedDateOnly) + attachment_str, gallery_str = \ + get_post_attachments_as_html(base_dir, nickname, domain, + domain_full, + post_json_object, + box_name, translate, + is_muted, avatar_link, + reply_str, announce_str, like_str, + bookmark_str, delete_str, mute_str, + content_str) - _logPostTiming(enableTimingLog, postStartTime, '15') + published_str = \ + _get_published_date_str(post_json_object, show_published_date_only, + timezone) - publishedLink = messageId + _log_post_timing(enable_timing_log, post_start_time, '15') + + published_link = message_id # blog posts should have no /statuses/ in their link - if isBlogPost(postJsonObject): + post_is_blog = False + if is_blog_post(post_json_object): + post_is_blog = True # is this a post to the local domain? - if '://' + domain in messageId: - publishedLink = messageId.replace('/statuses/', '/') + if '://' + domain in message_id: + published_link = message_id.replace('/statuses/', '/') # if this is a local link then make it relative so that it works # on clearnet or onion address - if domain + '/users/' in publishedLink or \ - domain + ':' + str(port) + '/users/' in publishedLink: - publishedLink = '/users/' + publishedLink.split('/users/')[1] + if domain + '/users/' in published_link or \ + domain + ':' + str(port) + '/users/' in published_link: + published_link = '/users/' + published_link.split('/users/')[1] - if not isNewsPost(postJsonObject): - footerStr = '' + publishedStr + '\n' + if not is_news_post(post_json_object): + footer_str = '' + \ + published_str + '\n' else: - footerStr = '' + publishedStr + '\n' + footer_str = '' + \ + published_str + '\n' # change the background color for DMs in inbox timeline - if postIsDM: - containerClassIcons = 'containericons dm' - containerClass = 'container dm' + if post_is_dm: + container_class_icons = 'containericons dm' + container_class = 'container dm' - newFooterStr = _getFooterWithIcons(showIcons, - containerClassIcons, - replyStr, announceStr, - likeStr, bookmarkStr, - deleteStr, muteStr, editStr, - postJsonObject, publishedLink, - timeClass, publishedStr) - if newFooterStr: - footerStr = newFooterStr + # add any content warning from the cwlists directory + add_cw_from_lists(post_json_object, cw_lists, translate, lists_enabled, + system_language) - postIsSensitive = False - if postJsonObject['object'].get('sensitive'): + post_is_sensitive = False + if post_json_object['object'].get('sensitive'): # sensitive posts should have a summary - if postJsonObject['object'].get('summary'): - postIsSensitive = postJsonObject['object']['sensitive'] + if post_json_object['object'].get('summary'): + post_is_sensitive = post_json_object['object']['sensitive'] else: # add a generic summary if none is provided - sensitiveStr = 'Sensitive' - if translate.get(sensitiveStr): - sensitiveStr = translate[sensitiveStr] - postJsonObject['object']['summary'] = sensitiveStr + sensitive_str = 'Sensitive' + if translate.get(sensitive_str): + sensitive_str = translate[sensitive_str] + post_json_object['object']['summary'] = sensitive_str + post_json_object['object']['summaryMap'] = { + system_language: sensitive_str + } + + if not post_json_object['object'].get('summary'): + post_json_object['object']['summary'] = '' + post_json_object['object']['summaryMap'] = { + system_language: '' + } + + displaying_ciphertext = False + if post_json_object['object'].get('cipherText'): + displaying_ciphertext = True + post_json_object['object']['content'] = \ + e2e_edecrypt_message_from_device(post_json_object['object']) + post_json_object['object']['contentMap'][system_language] = \ + post_json_object['object']['content'] + + domain_full = get_full_domain(domain, port) + if not content_str: + content_str = get_content_from_post(post_json_object, system_language, + languages_understood) + if not content_str: + content_str = \ + auto_translate_post(base_dir, post_json_object, + system_language, translate) + if not content_str: + return '' + + summary_str = '' + if content_str: + summary_str = get_summary_from_post(post_json_object, system_language, + languages_understood) + # add dogwhistle warnings to summary + summary_str = _add_dogwhistle_warnings(summary_str, content_str, + dogwhistles, translate) + + content_all_str = str(summary_str) + ' ' + content_str + # does an emoji indicate a no boost preference? + # if so then don't show the repeat/announce icon + if disallow_announce(content_all_str): + announce_str = '' + # does an emoji indicate a no replies preference? + # if so then don't show the reply icon + if disallow_reply(content_all_str): + reply_str = '' + + new_footer_str = \ + _get_footer_with_icons(show_icons, + container_class_icons, + reply_str, announce_str, + like_str, reaction_str, bookmark_str, + delete_str, mute_str, edit_str, + post_json_object, published_link, + time_class, published_str) + if new_footer_str: + footer_str = new_footer_str # add an extra line if there is a content warning, # for better vertical spacing on mobile - if postIsSensitive: - footerStr = '
    ' + footerStr + if post_is_sensitive: + footer_str = '
    ' + footer_str - if not postJsonObject['object'].get('summary'): - postJsonObject['object']['summary'] = '' + if not summary_str: + summary_str = get_summary_from_post(post_json_object, system_language, + languages_understood) + is_patch = is_git_patch(base_dir, nickname, domain, + post_json_object['object']['type'], + summary_str, content_str) - if postJsonObject['object'].get('cipherText'): - postJsonObject['object']['content'] = \ - E2EEdecryptMessageFromDevice(postJsonObject['object']) - postJsonObject['object']['contentMap'][systemLanguage] = \ - postJsonObject['object']['content'] + _log_post_timing(enable_timing_log, post_start_time, '16') - domainFull = getFullDomain(domain, port) - personUrl = localActorUrl(httpPrefix, nickname, domainFull) - actorJson = \ - getPersonFromCache(baseDir, personUrl, personCache, False) - languagesUnderstood = [] - if actorJson: - languagesUnderstood = getActorLanguagesList(actorJson) - contentStr = getContentFromPost(postJsonObject, systemLanguage, - languagesUnderstood) - if not contentStr: - contentStr = \ - autoTranslatePost(baseDir, postJsonObject, - systemLanguage, translate) - if not contentStr: - return '' + if not is_pgp_encrypted(content_str): + # if we are on an onion instance then substitute any common clearnet + # domains with their onion version + if '.onion' in domain and '://' in content_str: + content_str = \ + _substitute_onion_domains(base_dir, content_str) + if not is_patch: + # remove any tabs + content_str = \ + content_str.replace('\t', '').replace('\r', '') + # Add bold text + if bold_reading and \ + not displaying_ciphertext and \ + not post_is_blog: + content_str = bold_reading_string(content_str) - isPatch = isGitPatch(baseDir, nickname, domain, - postJsonObject['object']['type'], - postJsonObject['object']['summary'], - contentStr) - - _logPostTiming(enableTimingLog, postStartTime, '16') - - if not isPGPEncrypted(contentStr): - if not isPatch: - objectContent = \ - removeLongWords(contentStr, 40, []) - objectContent = removeTextFormatting(objectContent) - objectContent = limitRepeatedWords(objectContent, 6) - objectContent = \ - switchWords(baseDir, nickname, domain, objectContent) - objectContent = htmlReplaceEmailQuote(objectContent) - objectContent = htmlReplaceQuoteMarks(objectContent) + object_content = \ + remove_long_words(content_str, 40, []) + object_content = \ + remove_text_formatting(object_content, bold_reading) + object_content = limit_repeated_words(object_content, 6) + object_content = \ + switch_words(base_dir, nickname, domain, object_content) + object_content = html_replace_email_quote(object_content) + object_content = html_replace_quote_marks(object_content) + # append any edits + object_content += edits_str else: - objectContent = contentStr + object_content = content_str else: - encryptedStr = 'Encrypted' - if translate.get(encryptedStr): - encryptedStr = translate[encryptedStr] - objectContent = '🔒 ' + encryptedStr + encrypted_str = 'Encrypted' + if translate.get(encrypted_str): + encrypted_str = translate[encrypted_str] + object_content = '🔒 ' + encrypted_str - objectContent = '
    ' + objectContent + '
    ' + object_content = \ + '
    ' + \ + object_content + '
    ' - if not postIsSensitive: - contentStr = objectContent + attachmentStr - contentStr = addEmbeddedElements(translate, contentStr, - peertubeInstances) - contentStr = insertQuestion(baseDir, translate, - nickname, domain, port, - contentStr, postJsonObject, - pageNumber) - else: - postID = 'post' + str(createPassword(8)) - contentStr = '' - if postJsonObject['object'].get('summary'): - cwStr = str(postJsonObject['object']['summary']) - cwStr = \ - addEmojiToDisplayName(baseDir, httpPrefix, + if not post_is_sensitive: + content_str = object_content + attachment_str + content_str = add_embedded_elements(translate, content_str, + peertube_instances) + content_str = insert_question(base_dir, translate, nickname, domain, - cwStr, False) - contentStr += \ - '\n ' - if isModerationPost: - containerClass = 'container report' - # get the content warning text - cwContentStr = objectContent + attachmentStr - if not isPatch: - cwContentStr = addEmbeddedElements(translate, cwContentStr, - peertubeInstances) - cwContentStr = \ - insertQuestion(baseDir, translate, nickname, domain, port, - cwContentStr, postJsonObject, pageNumber) - cwContentStr = \ - switchWords(baseDir, nickname, domain, cwContentStr) - if not isBlogPost(postJsonObject): - # get the content warning button - contentStr += \ - getContentWarningButton(postID, translate, cwContentStr) - else: - contentStr += cwContentStr - - _logPostTiming(enableTimingLog, postStartTime, '17') - - if postJsonObject['object'].get('tag') and not isPatch: - contentStr = \ - replaceEmojiFromTags(contentStr, - postJsonObject['object']['tag'], - 'content') - - if isMuted: - contentStr = '' + content_str, post_json_object, + page_number) else: - if not isPatch: - contentStr = '
    ' + \ - contentStr + \ + post_id = 'post' + str(create_password(8)) + content_str = '' + if summary_str: + cw_str = \ + add_emoji_to_display_name(session, base_dir, http_prefix, + nickname, domain, + summary_str, False, translate) + content_str += \ + '\n' + if is_moderation_post: + container_class = 'container report' + # get the content warning text + cw_content_str = object_content + attachment_str + if not is_patch: + cw_content_str = add_embedded_elements(translate, cw_content_str, + peertube_instances) + cw_content_str = \ + insert_question(base_dir, translate, nickname, domain, + cw_content_str, post_json_object, page_number) + cw_content_str = \ + switch_words(base_dir, nickname, domain, cw_content_str) + if not is_blog_post(post_json_object): + # get the content warning button + content_str += \ + get_content_warning_button(post_id, translate, cw_content_str) + else: + content_str += cw_content_str + + _log_post_timing(enable_timing_log, post_start_time, '17') + + map_str = '' + if post_json_object['object'].get('tag'): + if not is_patch: + content_str = \ + replace_emoji_from_tags(session, base_dir, content_str, + post_json_object['object']['tag'], + 'content', False, True) + + # show embedded map if the location contains a map url + location_str = \ + get_location_from_tags(post_json_object['object']['tag']) + if location_str: + if '://' in location_str and '.' in location_str: + bounding_box_degrees = 0.001 + map_str = \ + html_open_street_map(location_str, + bounding_box_degrees, + translate) + if map_str: + map_str = '
    \n' + map_str + '
    \n' + if map_str and post_json_object['object'].get('attributedTo'): + attrib = post_json_object['object']['attributedTo'] + # is this being sent by the author? + if '://' + domain_full + '/users/' + nickname in attrib: + location_domain = location_str + if '://' in location_str: + location_domain = location_str.split('://')[1] + if '/' in location_domain: + location_domain = location_domain.split('/')[0] + location_domain = \ + location_str.split('://')[0] + '://' + location_domain + else: + if '/' in location_domain: + location_domain = location_domain.split('/')[0] + location_domain = 'https://' + location_domain + # remember the map site used + set_map_preferences_url(base_dir, nickname, domain, + location_domain) + # remember the coordinates + map_zoom, map_latitude, map_longitude = \ + geocoords_from_map_link(location_str) + if map_zoom and map_latitude and map_longitude: + set_map_preferences_coords(base_dir, nickname, domain, + map_latitude, map_longitude, + map_zoom) + + if is_muted: + content_str = '' + else: + if not is_patch: + content_str = '
    ' + \ + content_str + \ '
    \n' else: - contentStr = \ - '
    ' + contentStr + \
    +            content_str = \
    +                '
    ' + content_str + \
                     '
    \n' # show blog citations - citationsStr = \ - _getBlogCitationsHtml(boxName, postJsonObject, translate) + citations_str = \ + _get_blog_citations_html(box_name, post_json_object, translate) - postHtml = '' - if boxName != 'tlmedia': - postHtml = '
    \n' - postHtml += avatarImageInPost - postHtml += '
    \n' + \ - ' ' + titleStr + \ - replyAvatarImageInPost + '
    \n' - postHtml += contentStr + citationsStr + footerStr + '\n' - postHtml += '
    \n' + post_html = '' + if box_name != 'tlmedia': + reaction_str = '' + if show_icons: + reaction_str = \ + html_emoji_reactions(post_json_object, True, person_url, + max_reaction_types, + box_name, page_number) + if post_is_sensitive and reaction_str: + reaction_str = '
    ' + reaction_str + post_html = '
    \n' + post_html += avatar_image_in_post + post_html += '
    \n' + \ + ' ' + title_str + \ + reply_avatar_image_in_post + '
    \n' + post_html += \ + content_str + citations_str + map_str + \ + reaction_str + footer_str + '\n' + post_html += '
    \n' else: - postHtml = galleryStr + post_html = gallery_str - _logPostTiming(enableTimingLog, postStartTime, '18') + _log_post_timing(enable_timing_log, post_start_time, '18') # save the created html to the recent posts cache - if not showPublicOnly and storeToCache and \ - boxName != 'tlmedia' and boxName != 'tlbookmarks' and \ - boxName != 'bookmarks': - _saveIndividualPostAsHtmlToCache(baseDir, nickname, domain, - postJsonObject, postHtml) - updateRecentPostsCache(recentPostsCache, maxRecentPosts, - postJsonObject, postHtml) + if not show_public_only and store_to_cache and \ + box_name != 'tlmedia' and box_name != 'tlbookmarks' and \ + box_name != 'bookmarks': + _save_individual_post_as_html_to_cache(base_dir, nickname, domain, + post_json_object, post_html) + update_recent_posts_cache(recent_posts_cache, max_recent_posts, + post_json_object, post_html) - _logPostTiming(enableTimingLog, postStartTime, '19') + _log_post_timing(enable_timing_log, post_start_time, '19') - return postHtml + return post_html -def htmlIndividualPost(cssCache: {}, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, - baseDir: str, session, cachedWebfingers: {}, - personCache: {}, - nickname: str, domain: str, port: int, authorized: bool, - postJsonObject: {}, httpPrefix: str, - projectVersion: str, likedBy: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, signingPrivateKeyPem: str) -> str: +def html_individual_post(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + base_dir: str, session, cached_webfingers: {}, + person_cache: {}, + nickname: str, domain: str, port: int, + authorized: bool, + post_json_object: {}, http_prefix: str, + project_version: str, liked_by: str, + react_by: str, react_emoji: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + timezone: str, mitm: bool, + bold_reading: bool, dogwhistles: {}) -> str: """Show an individual post as html """ - postStr = '' - if likedBy: - likedByNickname = getNicknameFromActor(likedBy) - likedByDomain, likedByPort = getDomainFromActor(likedBy) - likedByDomain = getFullDomain(likedByDomain, likedByPort) - likedByHandle = likedByNickname + '@' + likedByDomain - likedByStr = 'Liked by' - if translate.get(likedByStr): - likedByStr = translate[likedByStr] - postStr += \ - '

    ' + likedByStr + ' @' + \ - likedByHandle + '\n' + original_post_json = post_json_object + post_str = '' + by_str = '' + by_text = '' + by_text_extra = '' + if liked_by: + by_str = liked_by + by_text = 'Liked by' + elif react_by and react_emoji: + by_str = react_by + by_text = 'Reaction by' + by_text_extra = ' ' + react_emoji - domainFull = getFullDomain(domain, port) + if by_str: + by_str_nickname = get_nickname_from_actor(by_str) + if not by_str_nickname: + return '' + by_str_domain, by_str_port = get_domain_from_actor(by_str) + by_str_domain = get_full_domain(by_str_domain, by_str_port) + by_str_handle = by_str_nickname + '@' + by_str_domain + if translate.get(by_text): + by_text = translate[by_text] + post_str += \ + '

    ' + by_text + ' @' + \ + by_str_handle + '' + by_text_extra + '\n' + + domain_full = get_full_domain(domain, port) actor = '/users/' + nickname - followStr = '

    \n' - followStr += \ + follow_str += \ ' \n' - followStr += \ + follow_str += \ ' \n' - if not isFollowingActor(baseDir, nickname, domainFull, likedBy): - translateFollowStr = 'Follow' - if translate.get(translateFollowStr): - translateFollowStr = translate[translateFollowStr] - followStr += ' \n' - goBackStr = 'Go Back' - if translate.get(goBackStr): - goBackStr = translate[goBackStr] - followStr += ' \n' - followStr += '
    \n' - postStr += followStr + '

    \n' + by_str_handle + '">\n' + if not is_following_actor(base_dir, nickname, domain_full, by_str): + translate_follow_str = 'Follow' + if translate.get(translate_follow_str): + translate_follow_str = translate[translate_follow_str] + follow_str += ' \n' + go_back_str = 'Go Back' + if translate.get(go_back_str): + go_back_str = translate[go_back_str] + follow_str += ' \n' + follow_str += ' \n' + post_str += follow_str + '

    \n' - postStr += \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, maxRecentPosts, - translate, None, - baseDir, session, cachedWebfingers, personCache, - nickname, domain, port, postJsonObject, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, themeName, - systemLanguage, maxLikeCount, - False, authorized, False, False, False, False) - messageId = removeIdEnding(postJsonObject['id']) + post_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, person_cache, + nickname, domain, port, post_json_object, + None, True, False, + http_prefix, project_version, 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, theme_name, + system_language, max_like_count, + False, authorized, False, False, False, False, + cw_lists, lists_enabled, timezone, mitm, + bold_reading, dogwhistles) + message_id = remove_id_ending(post_json_object['id']) # show the previous posts - if hasObjectDict(postJsonObject): - while postJsonObject['object'].get('inReplyTo'): - postFilename = \ - locatePost(baseDir, nickname, domain, - postJsonObject['object']['inReplyTo']) - if not postFilename: + if has_object_dict(post_json_object): + while post_json_object['object'].get('inReplyTo'): + post_filename = \ + locate_post(base_dir, nickname, domain, + post_json_object['object']['inReplyTo']) + if not post_filename: break - postJsonObject = loadJson(postFilename) - if postJsonObject: - postStr = \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, - maxRecentPosts, - translate, None, - baseDir, session, cachedWebfingers, - personCache, - nickname, domain, port, - postJsonObject, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, - maxLikeCount, - False, authorized, - False, False, False, False) + postStr + post_json_object = load_json(post_filename) + if post_json_object: + mitm = False + if os.path.isfile(post_filename.replace('.json', '') + + '.mitm'): + mitm = True + post_str = \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, + None, True, False, + http_prefix, project_version, + 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, authorized, + False, False, False, False, + cw_lists, lists_enabled, + timezone, mitm, + bold_reading, + dogwhistles) + post_str # show the following posts - postFilename = locatePost(baseDir, nickname, domain, messageId) - if postFilename: + post_filename = locate_post(base_dir, nickname, domain, message_id) + if post_filename: # is there a replies file for this post? - repliesFilename = postFilename.replace('.json', '.replies') - if os.path.isfile(repliesFilename): + replies_filename = post_filename.replace('.json', '.replies') + if os.path.isfile(replies_filename): # get items from the replies file - repliesJson = { + replies_json = { 'orderedItems': [] } - populateRepliesJson(baseDir, nickname, domain, - repliesFilename, authorized, repliesJson) + populate_replies_json(base_dir, nickname, domain, + replies_filename, authorized, replies_json) # add items to the html output - for item in repliesJson['orderedItems']: - postStr += \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, - maxRecentPosts, - translate, None, - baseDir, session, cachedWebfingers, - personCache, - nickname, domain, port, item, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, - maxLikeCount, - False, authorized, - False, False, False, False) - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + for item in replies_json['orderedItems']: + post_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, item, + None, True, False, + http_prefix, project_version, + 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, authorized, + False, False, False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - return htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + \ - postStr + htmlFooter() + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + metadata_str = _html_post_metadata_open_graph(domain, original_post_json, + system_language) + header_str = html_header_with_external_style(css_filename, + instance_title, metadata_str) + return header_str + post_str + html_footer() -def htmlPostReplies(cssCache: {}, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, baseDir: str, - session, cachedWebfingers: {}, personCache: {}, - nickname: str, domain: str, port: int, repliesJson: {}, - httpPrefix: str, projectVersion: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, - signingPrivateKeyPem: str) -> str: +def html_post_replies(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, base_dir: str, + session, cached_webfingers: {}, person_cache: {}, + nickname: str, domain: str, port: int, replies_json: {}, + http_prefix: str, project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, + signing_priv_key_pem: str, cw_lists: {}, + lists_enabled: str, + timezone: str, bold_reading: bool, + dogwhistles: {}) -> str: """Show the replies to an individual post as html """ - repliesStr = '' - if repliesJson.get('orderedItems'): - for item in repliesJson['orderedItems']: - repliesStr += \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, - maxRecentPosts, - translate, None, - baseDir, session, cachedWebfingers, - personCache, - nickname, domain, port, item, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, - maxLikeCount, - False, False, False, False, False, False) + replies_str = '' + if replies_json.get('orderedItems'): + for item in replies_json['orderedItems']: + replies_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, cached_webfingers, + person_cache, + nickname, domain, port, item, + None, True, False, + http_prefix, project_version, 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, False, False, False, + False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - return htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + \ - repliesStr + htmlFooter() + instance_title = get_config_param(base_dir, 'instanceTitle') + metadata = '' + header_str = \ + html_header_with_external_style(css_filename, instance_title, metadata) + return header_str + replies_str + html_footer() + + +def html_emoji_reaction_picker(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + base_dir: str, session, cached_webfingers: {}, + person_cache: {}, + nickname: str, domain: str, port: int, + post_json_object: {}, http_prefix: str, + project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + box_name: str, page_number: int, + timezone: str, bold_reading: bool, + dogwhistles: {}) -> str: + """Returns the emoji picker screen + """ + reacted_to_post_str = \ + '
    \n' + \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, cached_webfingers, + person_cache, + nickname, domain, port, post_json_object, + None, True, False, + http_prefix, project_version, 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, False, False, False, False, False, + cw_lists, lists_enabled, timezone, False, + bold_reading, dogwhistles) + + reactions_filename = base_dir + '/emoji/reactions.json' + if not os.path.isfile(reactions_filename): + reactions_filename = base_dir + '/emoji/default_reactions.json' + reactions_json = load_json(reactions_filename) + emoji_picks_str = '' + base_url = '/users/' + nickname + post_id = remove_id_ending(post_json_object['id']) + for _, item in reactions_json.items(): + emoji_picks_str += '
    \n' + for emoji_content in item: + emoji_content_encoded = urllib.parse.quote_plus(emoji_content) + emoji_url = \ + base_url + '?react=' + post_id + \ + '?actor=' + post_json_object['actor'] + \ + '?tl=' + box_name + \ + '?page=' + str(page_number) + \ + '?emojreact=' + emoji_content_encoded + emoji_label = '' + emoji_picks_str += \ + ' ' + \ + emoji_label + '\n' + emoji_picks_str += '
    \n' + + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + + # filename of the banner shown at the top + banner_file, _ = \ + get_banner_file(base_dir, nickname, domain, theme_name) + + instance_title = get_config_param(base_dir, 'instanceTitle') + metadata = '' + header_str = \ + html_header_with_external_style(css_filename, instance_title, metadata) + + # banner + header_str += \ + '
    \n' + \ + '\n' + header_str += '\n' + \ + '
    \n' + + return header_str + reacted_to_post_str + emoji_picks_str + html_footer() diff --git a/webapp_profile.py b/webapp_profile.py index 1a1cf91ec..cfc0370d7 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1,7 +1,7 @@ __filename__ = "webapp_profile.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" -__version__ = "1.2.0" +__version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" @@ -9,1662 +9,1914 @@ __module_group__ = "Web Interface" import os from pprint import pprint -from utils import isGroupAccount -from utils import hasObjectDict -from utils import getOccupationName -from utils import getLockedAccount -from utils import getFullDomain -from utils import isArtist -from utils import isDormant -from utils import getNicknameFromActor -from utils import getDomainFromActor -from utils import isSystemAccount -from utils import removeHtml -from utils import loadJson -from utils import getConfigParam -from utils import getImageFormats -from utils import acctDir -from utils import getSupportedLanguages -from utils import localActorUrl -from utils import getReplyIntervalHours -from languages import getActorLanguages -from skills import getSkills -from theme import getThemesList -from person import personBoxJson -from person import getActorJson -from person import getPersonAvatarUrl -from webfinger import webfingerHandle -from posts import parseUserFeed -from posts import getPersonBox -from posts import isCreateInsideAnnounce -from donate import getDonationUrl -from donate import getWebsite -from xmpp import getXmppAddress -from matrix import getMatrixAddress -from ssb import getSSBAddress -from pgp import getEmailAddress -from pgp import getPGPfingerprint -from pgp import getPGPpubKey -from tox import getToxAddress -from briar import getBriarAddress -from jami import getJamiAddress -from cwtch import getCwtchAddress -from filters import isFiltered -from follow import isFollowerOfPerson -from webapp_frontscreen import htmlFrontScreen -from webapp_utils import htmlKeyboardNavigation -from webapp_utils import htmlHideFromScreenReader -from webapp_utils import scheduledPostsExist -from webapp_utils import htmlHeaderWithExternalStyle -from webapp_utils import htmlHeaderWithPersonMarkup -from webapp_utils import htmlFooter -from webapp_utils import addEmojiToDisplayName -from webapp_utils import getBannerFile -from webapp_utils import htmlPostSeparator -from webapp_utils import editCheckBox -from webapp_utils import editTextField -from webapp_utils import editTextArea -from webapp_utils import beginEditSection -from webapp_utils import endEditSection -from blog import getBlogAddress -from webapp_post import individualPostAsHtml -from webapp_timeline import htmlIndividualShare +from webfinger import webfinger_handle +from utils import standardize_text +from utils import get_display_name +from utils import is_group_account +from utils import has_object_dict +from utils import get_occupation_name +from utils import get_locked_account +from utils import get_full_domain +from utils import is_artist +from utils import is_dormant +from utils import get_nickname_from_actor +from utils import get_domain_from_actor +from utils import is_system_account +from utils import remove_html +from utils import load_json +from utils import get_config_param +from utils import get_image_formats +from utils import acct_dir +from utils import get_supported_languages +from utils import local_actor_url +from utils import get_reply_interval_hours +from utils import get_account_timezone +from utils import remove_eol +from languages import get_actor_languages +from skills import get_skills +from theme import get_themes_list +from person import person_box_json +from person import get_actor_json +from person import get_person_avatar_url +from posts import get_post_expiry_keep_dms +from posts import get_post_expiry_days +from posts import get_person_box +from posts import is_moderator +from posts import parse_user_feed +from posts import is_create_inside_announce +from donate import get_donation_url +from donate import get_website +from xmpp import get_xmpp_address +from matrix import get_matrix_address +from ssb import get_ssb_address +from pgp import get_email_address +from pgp import get_pgp_fingerprint +from pgp import get_pgp_pub_key +from enigma import get_enigma_pub_key +from tox import get_tox_address +from briar import get_briar_address +from cwtch import get_cwtch_address +from filters import is_filtered +from follow import is_follower_of_person +from follow import get_follower_domains +from webapp_frontscreen import html_front_screen +from webapp_utils import edit_number_field +from webapp_utils import html_keyboard_navigation +from webapp_utils import html_hide_from_screen_reader +from webapp_utils import scheduled_posts_exist +from webapp_utils import html_header_with_external_style +from webapp_utils import html_header_with_person_markup +from webapp_utils import html_footer +from webapp_utils import add_emoji_to_display_name +from webapp_utils import get_profile_background_file +from webapp_utils import html_post_separator +from webapp_utils import edit_check_box +from webapp_utils import edit_text_field +from webapp_utils import edit_text_area +from webapp_utils import begin_edit_section +from webapp_utils import end_edit_section +from blog import get_blog_address +from webapp_post import individual_post_as_html +from webapp_timeline import html_individual_share +from webapp_timeline import page_number_buttons +from blocking import get_cw_list_variable +from blocking import is_blocked +from content import bold_reading_string +from roles import is_devops + +THEME_FORMATS = '.zip, .gz' -def htmlProfileAfterSearch(cssCache: {}, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, - baseDir: str, path: str, httpPrefix: str, - nickname: str, domain: str, port: int, - profileHandle: str, - session, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - defaultTimeline: str, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, - accessKeys: {}, - systemLanguage: str, - maxLikeCount: int, - signingPrivateKeyPem: str) -> str: +def _valid_profile_preview_post(post_json_object: {}, + person_url: str) -> (bool, {}): + """Returns true if the given post should appear on a person/group profile + after searching for a handle + """ + is_announced_feed_item = False + if is_create_inside_announce(post_json_object): + is_announced_feed_item = True + post_json_object = post_json_object['object'] + if not post_json_object.get('type'): + return False, None + if post_json_object['type'] == 'Create': + if not has_object_dict(post_json_object): + return False, None + if post_json_object['type'] != 'Create' and \ + post_json_object['type'] != 'Announce': + if post_json_object['type'] != 'Note' and \ + post_json_object['type'] != 'Page': + return False, None + if not post_json_object.get('to'): + return False, None + if not post_json_object.get('id'): + return False, None + # wrap in create + cc_list = [] + if post_json_object.get('cc'): + cc_list = post_json_object['cc'] + new_post_json_object = { + 'object': post_json_object, + 'to': post_json_object['to'], + 'cc': cc_list, + 'id': post_json_object['id'], + 'actor': person_url, + 'type': 'Create' + } + post_json_object = new_post_json_object + if not post_json_object.get('actor'): + return False, None + if not is_announced_feed_item: + if has_object_dict(post_json_object): + if post_json_object['actor'] != person_url and \ + post_json_object['object']['type'] != 'Page': + return False, None + return True, post_json_object + + +def html_profile_after_search(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + base_dir: str, path: str, http_prefix: str, + nickname: str, domain: str, port: int, + profile_handle: str, + session, cached_webfingers: {}, person_cache: {}, + debug: bool, project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + default_timeline: str, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, + access_keys: {}, + system_language: str, + max_like_count: int, + signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + timezone: str, + onion_domain: str, i2p_domain: str, + bold_reading: bool, dogwhistles: {}) -> str: """Show a profile page after a search for a fediverse address """ http = False gnunet = False - if httpPrefix == 'http': + ipfs = False + ipns = False + if http_prefix == 'http': http = True - elif httpPrefix == 'gnunet': + elif http_prefix == 'gnunet': gnunet = True - profileJson, asHeader = \ - getActorJson(domain, profileHandle, http, gnunet, debug, False, - signingPrivateKeyPem) - if not profileJson: + elif http_prefix == 'ipfs': + ipfs = True + elif http_prefix == 'ipns': + ipns = True + from_domain = domain + if onion_domain: + if '.onion/' in profile_handle or profile_handle.endswith('.onion'): + from_domain = onion_domain + http = True + if i2p_domain: + if '.i2p/' in profile_handle or profile_handle.endswith('.i2p'): + from_domain = i2p_domain + http = True + profile_json, as_header = \ + get_actor_json(from_domain, profile_handle, http, + gnunet, ipfs, ipns, debug, False, + signing_priv_key_pem, session) + if not profile_json: + return None + if not profile_json.get('id'): return None - personUrl = profileJson['id'] - searchDomain, searchPort = getDomainFromActor(personUrl) - if not searchDomain: + person_url = profile_json['id'] + search_domain, search_port = get_domain_from_actor(person_url) + if not search_domain: return None - searchNickname = getNicknameFromActor(personUrl) - if not searchNickname: + search_nickname = get_nickname_from_actor(person_url) + if not search_nickname: return None - searchDomainFull = getFullDomain(searchDomain, searchPort) + search_domain_full = get_full_domain(search_domain, search_port) - profileStr = '' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + profile_str = '' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - isGroup = False - if profileJson.get('type'): - if profileJson['type'] == 'Group': - isGroup = True + is_group = False + if profile_json.get('type'): + if profile_json['type'] == 'Group': + is_group = True - avatarUrl = '' - if profileJson.get('icon'): - if profileJson['icon'].get('url'): - avatarUrl = profileJson['icon']['url'] - if not avatarUrl: - avatarUrl = getPersonAvatarUrl(baseDir, personUrl, - personCache, True) - displayName = searchNickname - if profileJson.get('name'): - displayName = profileJson['name'] + avatar_url = '' + if profile_json.get('icon'): + if profile_json['icon'].get('url'): + avatar_url = profile_json['icon']['url'] + if not avatar_url: + avatar_url = get_person_avatar_url(base_dir, person_url, person_cache) + display_name = search_nickname + if profile_json.get('name'): + display_name = profile_json['name'] - lockedAccount = getLockedAccount(profileJson) - if lockedAccount: - displayName += '🔒' - movedTo = '' - if profileJson.get('movedTo'): - movedTo = profileJson['movedTo'] - if '"' in movedTo: - movedTo = movedTo.split('"')[1] - displayName += ' ⌂' + locked_account = get_locked_account(profile_json) + if locked_account: + display_name += '🔒' + moved_to = '' + if profile_json.get('movedTo'): + moved_to = profile_json['movedTo'] + if '"' in moved_to: + moved_to = moved_to.split('"')[1] + display_name += ' ⌂' - followsYou = \ - isFollowerOfPerson(baseDir, - nickname, domain, - searchNickname, - searchDomainFull) + follows_you = \ + is_follower_of_person(base_dir, + nickname, domain, + search_nickname, + search_domain_full) - profileDescription = '' - if profileJson.get('summary'): - profileDescription = profileJson['summary'] - outboxUrl = None - if not profileJson.get('outbox'): + profile_description = '' + if profile_json.get('summary'): + profile_description = profile_json['summary'] + outbox_url = None + if not profile_json.get('outbox'): if debug: - pprint(profileJson) + pprint(profile_json) print('DEBUG: No outbox found') return None - outboxUrl = profileJson['outbox'] + outbox_url = profile_json['outbox'] # profileBackgroundImage = '' - # if profileJson.get('image'): - # if profileJson['image'].get('url'): - # profileBackgroundImage = profileJson['image']['url'] + # if profile_json.get('image'): + # if profile_json['image'].get('url'): + # profileBackgroundImage = profile_json['image']['url'] # url to return to - backUrl = path - if not backUrl.endswith('/inbox'): - backUrl += '/inbox' + back_url = path + if not back_url.endswith('/inbox'): + back_url += '/inbox' - profileDescriptionShort = profileDescription - if '\n' in profileDescription: - if len(profileDescription.split('\n')) > 2: - profileDescriptionShort = '' + profile_description_short = profile_description + if '\n' in profile_description: + if len(profile_description.split('\n')) > 2: + profile_description_short = '' else: - if '
    ' in profileDescription: - if len(profileDescription.split('
    ')) > 2: - profileDescriptionShort = '' + if '
    ' in profile_description: + if len(profile_description.split('
    ')) > 2: + profile_description_short = '' # keep the profile description short - if len(profileDescriptionShort) > 256: - profileDescriptionShort = '' + if len(profile_description_short) > 2048: + profile_description_short = '' # remove formatting from profile description used on title - avatarDescription = '' - if profileJson.get('summary'): - if isinstance(profileJson['summary'], str): - avatarDescription = \ - profileJson['summary'].replace('
    ', '\n') - avatarDescription = avatarDescription.replace('

    ', '') - avatarDescription = avatarDescription.replace('

    ', '') - if '<' in avatarDescription: - avatarDescription = removeHtml(avatarDescription) + avatar_description = '' + if profile_json.get('summary'): + if isinstance(profile_json['summary'], str): + avatar_description = \ + profile_json['summary'].replace('
    ', '\n') + avatar_description = avatar_description.replace('

    ', '') + avatar_description = avatar_description.replace('

    ', '') + if '<' in avatar_description: + avatar_description = remove_html(avatar_description) - imageUrl = '' - if profileJson.get('image'): - if profileJson['image'].get('url'): - imageUrl = profileJson['image']['url'] + image_url = '' + if profile_json.get('image'): + if profile_json['image'].get('url'): + image_url = profile_json['image']['url'] - alsoKnownAs = None - if profileJson.get('alsoKnownAs'): - alsoKnownAs = profileJson['alsoKnownAs'] + also_known_as = None + if profile_json.get('alsoKnownAs'): + also_known_as = profile_json['alsoKnownAs'] - joinedDate = None - if profileJson.get('published'): - if 'T' in profileJson['published']: - joinedDate = profileJson['published'] + joined_date = None + if profile_json.get('published'): + if 'T' in profile_json['published']: + joined_date = profile_json['published'] - profileStr = \ - _getProfileHeaderAfterSearch(baseDir, - nickname, defaultTimeline, - searchNickname, - searchDomainFull, - translate, - displayName, followsYou, - profileDescriptionShort, - avatarUrl, imageUrl, - movedTo, profileJson['id'], - alsoKnownAs, accessKeys, - joinedDate) + profile_str = \ + _get_profile_header_after_search(nickname, default_timeline, + search_nickname, + search_domain_full, + translate, + display_name, follows_you, + profile_description_short, + avatar_url, image_url, + moved_to, profile_json['id'], + also_known_as, access_keys, + joined_date) - domainFull = getFullDomain(domain, port) + domain_full = get_full_domain(domain, port) - followIsPermitted = True - if not profileJson.get('followers'): + follow_is_permitted = True + if not profile_json.get('followers'): # no followers collection specified within actor - followIsPermitted = False - elif searchNickname == 'news' and searchDomainFull == domainFull: + follow_is_permitted = False + elif search_nickname == 'news' and search_domain_full == domain_full: # currently the news actor is not something you can follow - followIsPermitted = False - elif searchNickname == nickname and searchDomainFull == domainFull: + follow_is_permitted = False + elif search_nickname == nickname and search_domain_full == domain_full: # don't follow yourself! - followIsPermitted = False + follow_is_permitted = False - if followIsPermitted: - followStr = 'Follow' - if isGroup: - followStr = 'Join' + blocked = \ + is_blocked(base_dir, nickname, domain, search_nickname, search_domain) - profileStr += \ + if follow_is_permitted: + follow_str = 'Follow' + if is_group: + follow_str = 'Join' + + profile_str += \ '
    \n' + \ '
    \n' + \ - '
    \n' + \ + back_url + '/followconfirm">\n' + \ + '
    \n' + profile_str += \ ' \n' + \ + person_url + '">\n' + \ ' \n' + \ + 'accesskey="' + access_keys['followButton'] + '">' + \ + translate[follow_str] + '\n' + profile_str += \ ' \n' + \ + 'accesskey="' + access_keys['viewButton'] + '">' + \ + translate['View'] + '\n' + if blocked: + profile_str += \ + ' \n' + profile_str += \ '
    \n' + \ ' \n' + \ '
    \n' else: - profileStr += \ + profile_str += \ '
    \n' + \ '
    \n' + \ + back_url + '/followconfirm">\n' + \ '
    \n' + \ ' \n' + \ + person_url + '">\n' + \ ' \n' + \ '
    \n' + \ '
    \n' + \ '
    \n' - userFeed = \ - parseUserFeed(signingPrivateKeyPem, - session, outboxUrl, asHeader, projectVersion, - httpPrefix, domain, debug) - if userFeed: + user_feed = \ + parse_user_feed(signing_priv_key_pem, + session, outbox_url, as_header, project_version, + http_prefix, from_domain, debug) + if user_feed: i = 0 - for item in userFeed: - isAnnouncedFeedItem = False - if isCreateInsideAnnounce(item): - isAnnouncedFeedItem = True - item = item['object'] - if not item.get('actor'): - continue - if not isAnnouncedFeedItem and item['actor'] != personUrl: - continue - if not item.get('type'): - continue - if item['type'] == 'Create': - if not hasObjectDict(item): - continue - if item['type'] != 'Create' and item['type'] != 'Announce': + for item in user_feed: + show_item, post_json_object = \ + _valid_profile_preview_post(item, person_url) + if not show_item: continue - profileStr += \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, maxRecentPosts, - translate, None, baseDir, - session, cachedWebfingers, personCache, - nickname, domain, port, - item, avatarUrl, False, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, maxLikeCount, - False, False, False, False, False, False) + profile_str += \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, base_dir, + session, cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, avatar_url, + False, False, + http_prefix, project_version, 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, False, False, + False, False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) i += 1 if i >= 8: break - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - return htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + \ - profileStr + htmlFooter() + instance_title = get_config_param(base_dir, 'instanceTitle') + return html_header_with_external_style(css_filename, + instance_title, None) + \ + profile_str + html_footer() -def _getProfileHeader(baseDir: str, httpPrefix: str, - nickname: str, domain: str, - domainFull: str, translate: {}, - defaultTimeline: str, - displayName: str, - avatarDescription: str, - profileDescriptionShort: str, - loginButton: str, avatarUrl: str, - theme: str, movedTo: str, - alsoKnownAs: [], - pinnedContent: str, - accessKeys: {}, - joinedDate: str, - occupationName: str) -> str: +def _get_profile_header(base_dir: str, http_prefix: str, nickname: str, + domain: str, domain_full: str, translate: {}, + default_timeline: str, + display_name: str, + profile_description_short: str, + login_button: str, avatar_url: str, + theme: str, moved_to: str, + also_known_as: [], + pinned_content: str, + access_keys: {}, + joined_date: str, + occupation_name: str) -> str: """The header of the profile screen, containing background image and avatar """ - htmlStr = \ + banner_file, _ = \ + get_profile_background_file(base_dir, nickname, domain, theme) + html_str = \ '\n\n
    \n' + \ ' \n' + \ + nickname + '/' + default_timeline + '" title="' + \ + translate['Switch to timeline view'] + '" tabindex="1" ' + \ + 'accesskey="' + access_keys['menuTimeline'] + '">\n' + \ ' \n' + \ + 'src="/users/' + nickname + '/' + banner_file + '" />\n' + \ '
    \n' + \ ' \n' + \ - ' \n' + ' \n' - occupationStr = '' - if occupationName: - occupationStr += \ - ' ' + occupationName + '
    \n' + occupation_str = '' + if occupation_name: + occupation_str += \ + ' ' + occupation_name + '
    \n' - htmlStr += '

    ' + displayName + '

    \n' + occupationStr + html_str += '

    ' + display_name + '

    \n' + occupation_str - htmlStr += \ - '

    @' + nickname + '@' + domainFull + '
    \n' - if joinedDate: - htmlStr += \ + html_str += \ + '

    @' + nickname + '@' + domain_full + '
    \n' + if joined_date: + html_str += \ '

    ' + translate['Joined'] + ' ' + \ - joinedDate.split('T')[0] + '
    \n' - if movedTo: - newNickname = getNicknameFromActor(movedTo) - newDomain, newPort = getDomainFromActor(movedTo) - newDomainFull = getFullDomain(newDomain, newPort) - if newNickname and newDomain: - htmlStr += \ + joined_date.split('T')[0] + '
    \n' + if moved_to: + new_nickname = get_nickname_from_actor(moved_to) + new_domain, new_port = get_domain_from_actor(moved_to) + new_domain_full = get_full_domain(new_domain, new_port) + if new_nickname and new_domain: + html_str += \ '

    ' + translate['New account'] + ': ' + \ - '@' + \ - newNickname + '@' + newDomainFull + '
    \n' - elif alsoKnownAs: - otherAccountsHtml = \ + '@' + \ + new_nickname + '@' + new_domain_full + '
    \n' + elif also_known_as: + other_accounts_html = \ '

    ' + translate['Other accounts'] + ': ' - actor = localActorUrl(httpPrefix, nickname, domainFull) + actor = local_actor_url(http_prefix, nickname, domain_full) ctr = 0 - if isinstance(alsoKnownAs, list): - for altActor in alsoKnownAs: - if altActor == actor: + if isinstance(also_known_as, list): + for alt_actor in also_known_as: + if alt_actor == actor: continue if ctr > 0: - otherAccountsHtml += ' ' + other_accounts_html += ' ' ctr += 1 - altDomain, altPort = getDomainFromActor(altActor) - otherAccountsHtml += \ - '' + altDomain + '' - elif isinstance(alsoKnownAs, str): - if alsoKnownAs != actor: + alt_domain, _ = get_domain_from_actor(alt_actor) + other_accounts_html += \ + '' + alt_domain + '' + elif isinstance(also_known_as, str): + if also_known_as != actor: ctr += 1 - altDomain, altPort = getDomainFromActor(alsoKnownAs) - otherAccountsHtml += \ - '' + altDomain + '' - otherAccountsHtml += '

    \n' + alt_domain, _ = get_domain_from_actor(also_known_as) + other_accounts_html += \ + '' + alt_domain + '' + other_accounts_html += '

    \n' if ctr > 0: - htmlStr += otherAccountsHtml - htmlStr += \ + html_str += other_accounts_html + html_str += \ ' ' + \ + translate['QR Code'] + '" tabindex="1">' + \ '' + translate['QR Code'] + \
         '

    \n' + \ - '

    ' + profileDescriptionShort + '

    \n' + loginButton - if pinnedContent: - htmlStr += pinnedContent.replace('

    ', '

    📎', 1) - htmlStr += \ + '

    ' + profile_description_short + '

    \n' + login_button + if pinned_content: + html_str += pinned_content.replace('

    ', '

    📎', 1) + + # show vcard download link + html_str += \ + ' ' + \ + 'vCard\n' + + html_str += \ '

    \n' + \ '
    \n\n' - return htmlStr + return html_str -def _getProfileHeaderAfterSearch(baseDir: str, - nickname: str, defaultTimeline: str, - searchNickname: str, - searchDomainFull: str, - translate: {}, - displayName: str, - followsYou: bool, - profileDescriptionShort: str, - avatarUrl: str, imageUrl: str, - movedTo: str, actor: str, - alsoKnownAs: [], - accessKeys: {}, - joinedDate: str) -> str: +def _get_profile_header_after_search(nickname: str, default_timeline: str, + search_nickname: str, + search_domain_full: str, + translate: {}, + display_name: str, + follows_you: bool, + profile_description_short: str, + avatar_url: str, image_url: str, + moved_to: str, actor: str, + also_known_as: [], + access_keys: {}, + joined_date: str) -> str: """The header of a searched for handle, containing background image and avatar """ - if not imageUrl: - imageUrl = '/defaultprofilebackground' - htmlStr = \ + if not image_url: + image_url = '/defaultprofilebackground' + html_str = \ '\n\n
    \n' + \ ' \n' + \ + 'accesskey="' + access_keys['menuTimeline'] + '" tabindex="1">\n' + \ ' \n' + \ + 'src="' + image_url + '" />\n' + \ '
    \n' - if avatarUrl: - htmlStr += \ + if avatar_url: + html_str += \ ' \n' + \ - ' \n' - if not displayName: - displayName = searchNickname - htmlStr += \ - '

    ' + displayName + '

    \n' + \ - '

    @' + searchNickname + '@' + searchDomainFull + '
    \n' - if joinedDate: - htmlStr += '

    ' + translate['Joined'] + ' ' + \ - joinedDate.split('T')[0] + '

    \n' - if followsYou: - htmlStr += '

    ' + translate['Follows you'] + '

    \n' - if movedTo: - newNickname = getNicknameFromActor(movedTo) - newDomain, newPort = getDomainFromActor(movedTo) - newDomainFull = getFullDomain(newDomain, newPort) - if newNickname and newDomain: - newHandle = newNickname + '@' + newDomainFull - htmlStr += '

    ' + translate['New account'] + \ - ': @' + newHandle + '

    \n' - elif alsoKnownAs: - otherAccountshtml = \ + ' \n' + if not display_name: + display_name = search_nickname + html_str += \ + '

    ' + display_name + '

    \n' + \ + '

    @' + search_nickname + '@' + search_domain_full + \ + '
    \n' + if joined_date: + html_str += '

    ' + translate['Joined'] + ' ' + \ + joined_date.split('T')[0] + '

    \n' + if follows_you: + html_str += '

    ' + translate['Follows you'] + '

    \n' + if moved_to: + new_nickname = get_nickname_from_actor(moved_to) + new_domain, new_port = get_domain_from_actor(moved_to) + new_domain_full = get_full_domain(new_domain, new_port) + if new_nickname and new_domain: + new_handle = new_nickname + '@' + new_domain_full + html_str += '

    ' + translate['New account'] + \ + ': @' + new_handle + '

    \n' + elif also_known_as: + other_accounts_html = \ '

    ' + translate['Other accounts'] + ': ' ctr = 0 - if isinstance(alsoKnownAs, list): - for altActor in alsoKnownAs: - if altActor == actor: + if isinstance(also_known_as, list): + for alt_actor in also_known_as: + if alt_actor == actor: continue if ctr > 0: - otherAccountshtml += ' ' + other_accounts_html += ' ' ctr += 1 - altDomain, altPort = getDomainFromActor(altActor) - otherAccountshtml += \ - '' + altDomain + '' - elif isinstance(alsoKnownAs, str): - if alsoKnownAs != actor: + alt_domain, _ = get_domain_from_actor(alt_actor) + other_accounts_html += \ + '' + alt_domain + '' + elif isinstance(also_known_as, str): + if also_known_as != actor: ctr += 1 - altDomain, altPort = getDomainFromActor(alsoKnownAs) - otherAccountshtml += \ - '' + altDomain + '' + alt_domain, _ = get_domain_from_actor(also_known_as) + other_accounts_html += \ + '' + alt_domain + '' - otherAccountshtml += '

    \n' + other_accounts_html += '

    \n' if ctr > 0: - htmlStr += otherAccountshtml + html_str += other_accounts_html - htmlStr += \ - '

    ' + profileDescriptionShort + '

    \n' + \ + html_str += \ + '

    ' + profile_description_short + '

    \n' + \ '
    \n' + \ '
    \n\n' - return htmlStr + return html_str -def htmlProfile(signingPrivateKeyPem: str, - rssIconAtTop: bool, - cssCache: {}, iconsAsButtons: bool, - defaultTimeline: str, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, projectVersion: str, - baseDir: str, httpPrefix: str, authorized: bool, - profileJson: {}, selected: str, - session, cachedWebfingers: {}, personCache: {}, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - newswire: {}, theme: str, dormantMonths: int, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - textModeBanner: str, - debug: bool, accessKeys: {}, city: str, - systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: [], - extraJson: {} = None, pageNumber: int = None, - maxItemsPerPage: int = None) -> str: +def html_profile(signing_priv_key_pem: str, + rss_icon_at_top: bool, + icons_as_buttons: bool, + default_timeline: str, + recent_posts_cache: {}, max_recent_posts: int, + translate: {}, project_version: str, + base_dir: str, http_prefix: str, authorized: bool, + profile_json: {}, selected: str, + session, cached_webfingers: {}, person_cache: {}, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + newswire: {}, theme: str, dormant_months: int, + peertube_instances: [], + allow_local_network_access: bool, + text_mode_banner: str, + debug: bool, access_keys: {}, city: str, + system_language: str, max_like_count: int, + shared_items_federated_domains: [], + extra_json: {}, page_number: int, + max_items_per_page: int, + cw_lists: {}, lists_enabled: str, + content_license_url: str, + timezone: str, bold_reading: bool) -> str: """Show the profile page as html """ - nickname = profileJson['preferredUsername'] + nickname = profile_json['preferredUsername'] if not nickname: return "" - if isSystemAccount(nickname): - return htmlFrontScreen(signingPrivateKeyPem, - rssIconAtTop, - cssCache, iconsAsButtons, - defaultTimeline, - recentPostsCache, maxRecentPosts, - translate, projectVersion, - baseDir, httpPrefix, authorized, - profileJson, selected, - session, cachedWebfingers, personCache, - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - newswire, theme, extraJson, - allowLocalNetworkAccess, accessKeys, - systemLanguage, maxLikeCount, - sharedItemsFederatedDomains, - pageNumber, maxItemsPerPage) + if is_system_account(nickname): + return html_front_screen(signing_priv_key_pem, + rss_icon_at_top, + icons_as_buttons, + default_timeline, + recent_posts_cache, max_recent_posts, + translate, project_version, + base_dir, http_prefix, authorized, + profile_json, selected, + session, cached_webfingers, person_cache, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + newswire, theme, extra_json, + allow_local_network_access, access_keys, + system_language, max_like_count, + shared_items_federated_domains, None, + page_number, max_items_per_page, cw_lists, + lists_enabled, {}) - domain, port = getDomainFromActor(profileJson['id']) + domain, port = get_domain_from_actor(profile_json['id']) if not domain: return "" - displayName = \ - addEmojiToDisplayName(baseDir, httpPrefix, - nickname, domain, - profileJson['name'], True) - domainFull = getFullDomain(domain, port) - profileDescription = \ - addEmojiToDisplayName(baseDir, httpPrefix, - nickname, domain, - profileJson['summary'], False) - postsButton = 'button' - followingButton = 'button' - followersButton = 'button' - rolesButton = 'button' - skillsButton = 'button' - sharesButton = 'button' - wantedButton = 'button' + display_name = \ + add_emoji_to_display_name(session, base_dir, http_prefix, + nickname, domain, + profile_json['name'], True, translate) + domain_full = get_full_domain(domain, port) + profile_description = \ + add_emoji_to_display_name(session, base_dir, http_prefix, + nickname, domain, + profile_json['summary'], False, translate) + if profile_description: + profile_description = standardize_text(profile_description) + posts_button = 'button' + following_button = 'button' + followers_button = 'button' + roles_button = 'button' + skills_button = 'button' +# shares_button = 'button' +# wanted_button = 'button' if selected == 'posts': - postsButton = 'buttonselected' + posts_button = 'buttonselected' elif selected == 'following': - followingButton = 'buttonselected' + following_button = 'buttonselected' elif selected == 'followers': - followersButton = 'buttonselected' + followers_button = 'buttonselected' elif selected == 'roles': - rolesButton = 'buttonselected' + roles_button = 'buttonselected' elif selected == 'skills': - skillsButton = 'buttonselected' - elif selected == 'shares': - sharesButton = 'buttonselected' - elif selected == 'wanted': - wantedButton = 'buttonselected' - loginButton = '' + skills_button = 'buttonselected' +# elif selected == 'shares': +# shares_button = 'buttonselected' +# elif selected == 'wanted': +# wanted_button = 'buttonselected' + login_button = '' - followApprovalsSection = '' - followApprovals = False - editProfileStr = '' - logoutStr = '' - actor = profileJson['id'] - usersPath = '/users/' + actor.split('/users/')[1] + follow_approvals_section = '' + follow_approvals = False + edit_profile_str = '' + logout_str = '' + actor = profile_json['id'] + users_path = '/users/' + actor.split('/users/')[1] - donateSection = '' - donateUrl = getDonationUrl(profileJson) - websiteUrl = getWebsite(profileJson, translate) - blogAddress = getBlogAddress(profileJson) - PGPpubKey = getPGPpubKey(profileJson) - PGPfingerprint = getPGPfingerprint(profileJson) - emailAddress = getEmailAddress(profileJson) - xmppAddress = getXmppAddress(profileJson) - matrixAddress = getMatrixAddress(profileJson) - ssbAddress = getSSBAddress(profileJson) - toxAddress = getToxAddress(profileJson) - briarAddress = getBriarAddress(profileJson) - jamiAddress = getJamiAddress(profileJson) - cwtchAddress = getCwtchAddress(profileJson) - if donateUrl or websiteUrl or xmppAddress or matrixAddress or \ - ssbAddress or toxAddress or briarAddress or \ - jamiAddress or cwtchAddress or PGPpubKey or \ - PGPfingerprint or emailAddress: - donateSection = '
    \n' - donateSection += '
    \n' - if donateUrl and not isSystemAccount(nickname): - donateSection += \ - '

    \n' - if websiteUrl: - donateSection += \ + if website_url: + donate_section += \ '

    ' + translate['Website'] + ': ' + websiteUrl + '

    \n' - if emailAddress: - donateSection += \ + website_url + '" tabindex="1">' + website_url + '

    \n' + if email_address: + donate_section += \ '

    ' + translate['Email'] + ': ' + emailAddress + '

    \n' - if blogAddress: - donateSection += \ + email_address + '" tabindex="1">' + \ + email_address + '

    \n' + if blog_address: + donate_section += \ '

    Blog: ' + blogAddress + '

    \n' - if xmppAddress: - donateSection += \ + blog_address + '" tabindex="1">' + blog_address + '

    \n' + if xmpp_address: + donate_section += \ '

    ' + translate['XMPP'] + ': ' + xmppAddress + '

    \n' - if matrixAddress: - donateSection += \ - '

    ' + translate['Matrix'] + ': ' + matrixAddress + '

    \n' - if ssbAddress: - donateSection += \ + xmpp_address + '" tabindex="1">' + xmpp_address + '

    \n' + if matrix_address: + donate_section += \ + '

    ' + translate['Matrix'] + ': ' + matrix_address + '

    \n' + if ssb_address: + donate_section += \ '

    SSB:

    \n' - if toxAddress: - donateSection += \ + ssb_address + '

    \n' + if tox_address: + donate_section += \ '

    Tox:

    \n' - if briarAddress: - if briarAddress.startswith('briar://'): - donateSection += \ + tox_address + '

    \n' + if briar_address: + if briar_address.startswith('briar://'): + donate_section += \ '

    \n' + briar_address + '

    \n' else: - donateSection += \ + donate_section += \ '

    briar://

    \n' - if jamiAddress: - donateSection += \ - '

    Jami:

    \n' - if cwtchAddress: - donateSection += \ + briar_address + '

    \n' + if cwtch_address: + donate_section += \ '

    Cwtch:

    \n' - if PGPfingerprint: - donateSection += \ + cwtch_address + '

    \n' + if enigma_pub_key: + donate_section += \ + '

    Enigma:

    \n' + if pgp_fingerprint: + donate_section += \ '

    PGP: ' + \ - PGPfingerprint.replace('\n', '
    ') + '

    \n' - if PGPpubKey: - donateSection += \ - '

    ' + PGPpubKey.replace('\n', '
    ') + '

    \n' - donateSection += '
    \n' - donateSection += '
    \n' + pgp_fingerprint.replace('\n', '
    ') + '

    \n' + if pgp_pub_key: + donate_section += \ + '

    ' + \ + pgp_pub_key.replace('\n', '
    ') + '

    \n' + donate_section += '

    \n' + donate_section += '\n' if authorized: - editProfileStr = \ - '' + \ - '' + \ + '| ' + translate['Edit'] + '\n' - logoutStr = \ - '' + \ - '' + \ + '| ' + translate['Logout'] + \
             '\n' # are there any follow requests? - followRequestsFilename = \ - acctDir(baseDir, nickname, domain) + '/followrequests.txt' - if os.path.isfile(followRequestsFilename): - with open(followRequestsFilename, 'r') as f: - for line in f: + follow_requests_filename = \ + acct_dir(base_dir, nickname, domain) + '/followrequests.txt' + if os.path.isfile(follow_requests_filename): + with open(follow_requests_filename, 'r', + encoding='utf-8') as foll_file: + for line in foll_file: if len(line) > 0: - followApprovals = True - followersButton = 'buttonhighlighted' + follow_approvals = True + followers_button = 'buttonhighlighted' if selected == 'followers': - followersButton = 'buttonselectedhighlighted' + followers_button = 'buttonselectedhighlighted' break if selected == 'followers': - if followApprovals: - with open(followRequestsFilename, 'r') as f: - for followerHandle in f: - if len(line) > 0: - if '://' in followerHandle: - followerActor = followerHandle + if follow_approvals: + curr_follower_domains = \ + get_follower_domains(base_dir, nickname, domain) + with open(follow_requests_filename, 'r', + encoding='utf-8') as req_file: + for follower_handle in req_file: + if len(follower_handle) > 0: + follower_handle = \ + remove_eol(follower_handle) + if '://' in follower_handle: + follower_actor = follower_handle else: - nick = followerHandle.split('@')[0] - dom = followerHandle.split('@')[1] - followerActor = \ - localActorUrl(httpPrefix, nick, dom) - basePath = '/users/' + nickname - followApprovalsSection += '' - profileDescriptionShort = profileDescription - if '\n' in profileDescription: - if len(profileDescription.split('\n')) > 2: - profileDescriptionShort = '' + profile_description_short = profile_description + if '\n' in profile_description: + if len(profile_description.split('\n')) > 2: + profile_description_short = '' else: - if '
    ' in profileDescription: - if len(profileDescription.split('
    ')) > 2: - profileDescriptionShort = '' - profileDescription = profileDescription.replace('
    ', '\n') + if '
    ' in profile_description: + if len(profile_description.split('
    ')) > 2: + profile_description_short = '' + profile_description = profile_description.replace('
    ', '\n') # keep the profile description short - if len(profileDescriptionShort) > 256: - profileDescriptionShort = '' + if len(profile_description_short) > 2048: + profile_description_short = '' # remove formatting from profile description used on title - avatarDescription = '' - if profileJson.get('summary'): - avatarDescription = profileJson['summary'].replace('
    ', '\n') - avatarDescription = avatarDescription.replace('

    ', '') - avatarDescription = avatarDescription.replace('

    ', '') + avatar_description = '' + if profile_json.get('summary'): + avatar_description = profile_json['summary'].replace('
    ', '\n') + avatar_description = avatar_description.replace('

    ', '') + avatar_description = avatar_description.replace('

    ', '') - movedTo = '' - if profileJson.get('movedTo'): - movedTo = profileJson['movedTo'] - if '"' in movedTo: - movedTo = movedTo.split('"')[1] + moved_to = '' + if profile_json.get('movedTo'): + moved_to = profile_json['movedTo'] + if '"' in moved_to: + moved_to = moved_to.split('"')[1] - alsoKnownAs = None - if profileJson.get('alsoKnownAs'): - alsoKnownAs = profileJson['alsoKnownAs'] + also_known_as = None + if profile_json.get('alsoKnownAs'): + also_known_as = profile_json['alsoKnownAs'] - joinedDate = None - if profileJson.get('published'): - if 'T' in profileJson['published']: - joinedDate = profileJson['published'] - occupationName = None - if profileJson.get('hasOccupation'): - occupationName = getOccupationName(profileJson) + joined_date = None + if profile_json.get('published'): + if 'T' in profile_json['published']: + joined_date = profile_json['published'] + occupation_name = None + if profile_json.get('hasOccupation'): + occupation_name = get_occupation_name(profile_json) - avatarUrl = profileJson['icon']['url'] + avatar_url = profile_json['icon']['url'] # use alternate path for local avatars to avoid any caching issues - if '://' + domainFull + '/accounts/avatars/' in avatarUrl: - avatarUrl = \ - avatarUrl.replace('://' + domainFull + '/accounts/avatars/', - '://' + domainFull + '/users/') + if '://' + domain_full + '/system/accounts/avatars/' in avatar_url: + avatar_url = \ + avatar_url.replace('://' + domain_full + + '/system/accounts/avatars/', + '://' + domain_full + '/users/') # get pinned post content - accountDir = acctDir(baseDir, nickname, domain) - pinnedFilename = accountDir + '/pinToProfile.txt' - pinnedContent = None - if os.path.isfile(pinnedFilename): - with open(pinnedFilename, 'r') as pinFile: - pinnedContent = pinFile.read() + account_dir = acct_dir(base_dir, nickname, domain) + pinned_filename = account_dir + '/pinToProfile.txt' + pinned_content = None + if os.path.isfile(pinned_filename): + with open(pinned_filename, 'r', encoding='utf-8') as pin_file: + pinned_content = pin_file.read() - profileHeaderStr = \ - _getProfileHeader(baseDir, httpPrefix, - nickname, domain, - domainFull, translate, - defaultTimeline, displayName, - avatarDescription, - profileDescriptionShort, - loginButton, avatarUrl, theme, - movedTo, alsoKnownAs, - pinnedContent, accessKeys, - joinedDate, occupationName) + profile_header_str = \ + _get_profile_header(base_dir, http_prefix, + nickname, + domain, domain_full, translate, + default_timeline, display_name, + profile_description_short, + login_button, avatar_url, theme, + moved_to, also_known_as, + pinned_content, access_keys, + joined_date, occupation_name) # keyboard navigation - userPathStr = '/users/' + nickname - deft = defaultTimeline - isGroup = False - followersStr = translate['Followers'] - if isGroupAccount(baseDir, nickname, domain): - isGroup = True - followersStr = translate['Members'] - menuTimeline = \ - htmlHideFromScreenReader('🏠') + ' ' + \ + user_path_str = '/users/' + nickname + deft = default_timeline + is_group = False + followers_str = translate['Followers'] + if is_group_account(base_dir, nickname, domain): + is_group = True + followers_str = translate['Members'] + menu_timeline = \ + html_hide_from_screen_reader('🏠') + ' ' + \ translate['Switch to timeline view'] - menuEdit = \ - htmlHideFromScreenReader('✍') + ' ' + translate['Edit'] - if not isGroup: - menuFollowing = \ - htmlHideFromScreenReader('👥') + ' ' + translate['Following'] - menuFollowers = \ - htmlHideFromScreenReader('👪') + ' ' + followersStr - if not isGroup: - menuRoles = \ - htmlHideFromScreenReader('🤚') + ' ' + translate['Roles'] - menuSkills = \ - htmlHideFromScreenReader('🛠') + ' ' + translate['Skills'] - menuLogout = \ - htmlHideFromScreenReader('❎') + ' ' + translate['Logout'] - navLinks = { - menuTimeline: userPathStr + '/' + deft, - menuEdit: userPathStr + '/editprofile', - menuFollowing: userPathStr + '/following#timeline', - menuFollowers: userPathStr + '/followers#timeline', - menuRoles: userPathStr + '/roles#timeline', - menuSkills: userPathStr + '/skills#timeline', - menuLogout: '/logout' + menu_edit = \ + html_hide_from_screen_reader('✍') + ' ' + translate['Edit'] + menu_followers = \ + html_hide_from_screen_reader('👪') + ' ' + followers_str + menu_logout = \ + html_hide_from_screen_reader('❎') + ' ' + translate['Logout'] + nav_links = { + menu_timeline: user_path_str + '/' + deft, + menu_edit: user_path_str + '/editprofile', + menu_followers: user_path_str + '/followers#timeline', + menu_logout: '/logout' } - navAccessKeys = {} - for variableName, key in accessKeys.items(): - if not locals().get(variableName): + if not is_group: + menu_following = \ + html_hide_from_screen_reader('👥') + ' ' + translate['Following'] + nav_links[menu_following] = user_path_str + '/following#timeline' + menu_roles = \ + html_hide_from_screen_reader('🤚') + ' ' + translate['Roles'] + nav_links[menu_roles] = user_path_str + '/roles#timeline' + menu_skills = \ + html_hide_from_screen_reader('🛠') + ' ' + translate['Skills'] + nav_links[menu_skills] = user_path_str + '/skills#timeline' + if is_artist(base_dir, nickname): + menu_theme_designer = \ + html_hide_from_screen_reader('🎨') + ' ' + \ + translate['Theme Designer'] + nav_links[menu_theme_designer] = user_path_str + '/themedesigner' + nav_access_keys = {} + for variable_name, key in access_keys.items(): + if not locals().get(variable_name): continue - navAccessKeys[locals()[variableName]] = key + nav_access_keys[locals()[variable_name]] = key - profileStr = htmlKeyboardNavigation(textModeBanner, - navLinks, navAccessKeys) + profile_str = html_keyboard_navigation(text_mode_banner, + nav_links, nav_access_keys) - profileStr += profileHeaderStr + donateSection - profileStr += '' + profile_str += logout_str + edit_profile_str + profile_str += ' ' + profile_str += '' # start of #timeline - profileStr += '
    \n' + profile_str += '
    \n' - profileStr += followApprovalsSection + profile_str += follow_approvals_section - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' - licenseStr = \ - '' + \ - '' + \
+    license_str = \
+        '<a href=' + \ + '' + \
         translate['Get the source code'] + '' if selected == 'posts': - profileStr += \ - _htmlProfilePosts(recentPostsCache, maxRecentPosts, - translate, - baseDir, httpPrefix, authorized, - nickname, domain, port, - session, cachedWebfingers, personCache, - projectVersion, - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - theme, systemLanguage, - maxLikeCount, - signingPrivateKeyPem) + licenseStr - if not isGroup: + profile_str += \ + _html_profile_posts(recent_posts_cache, max_recent_posts, + translate, + base_dir, http_prefix, authorized, + nickname, domain, port, + session, cached_webfingers, person_cache, + project_version, + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme, system_language, + max_like_count, + signing_priv_key_pem, + cw_lists, lists_enabled, + timezone, bold_reading, {}) + license_str + if not is_group: if selected == 'following': - profileStr += \ - _htmlProfileFollowing(translate, baseDir, httpPrefix, - authorized, nickname, - domain, port, session, - cachedWebfingers, personCache, extraJson, - projectVersion, ["unfollow"], selected, - usersPath, pageNumber, maxItemsPerPage, - dormantMonths, debug, - signingPrivateKeyPem) + profile_str += \ + _html_profile_following(translate, base_dir, http_prefix, + authorized, nickname, + domain, session, + cached_webfingers, + person_cache, extra_json, + project_version, ["unfollow"], + selected, + users_path, page_number, + max_items_per_page, + dormant_months, debug, + signing_priv_key_pem) if selected == 'followers': - profileStr += \ - _htmlProfileFollowing(translate, baseDir, httpPrefix, - authorized, nickname, - domain, port, session, - cachedWebfingers, personCache, extraJson, - projectVersion, ["block"], - selected, usersPath, pageNumber, - maxItemsPerPage, dormantMonths, debug, - signingPrivateKeyPem) - if not isGroup: + profile_str += \ + _html_profile_following(translate, base_dir, http_prefix, + authorized, nickname, + domain, session, + cached_webfingers, + person_cache, extra_json, + project_version, ["block"], + selected, users_path, page_number, + max_items_per_page, dormant_months, debug, + signing_priv_key_pem) + if not is_group: if selected == 'roles': - profileStr += \ - _htmlProfileRoles(translate, nickname, domainFull, - extraJson) + profile_str += \ + _html_profile_roles(translate, nickname, domain_full, + extra_json) elif selected == 'skills': - profileStr += \ - _htmlProfileSkills(translate, nickname, domainFull, extraJson) + profile_str += \ + _html_profile_skills(extra_json) # elif selected == 'shares': -# profileStr += \ -# _htmlProfileShares(actor, translate, -# nickname, domainFull, -# extraJson, 'shares') + licenseStr +# profile_str += \ +# _html_profile_shares(actor, translate, +# domain_full, +# extra_json, 'shares') + license_str # elif selected == 'wanted': -# profileStr += \ -# _htmlProfileShares(actor, translate, -# nickname, domainFull, -# extraJson, 'wanted') + licenseStr +# profile_str += \ +# _html_profile_shares(actor, translate, +# domain_full, +# extra_json, 'wanted') + license_str # end of #timeline - profileStr += '
    ' + profile_str += '
    ' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - profileStr = \ - htmlHeaderWithPersonMarkup(cssFilename, instanceTitle, - profileJson, city) + \ - profileStr + htmlFooter() - return profileStr + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + profile_str = \ + html_header_with_person_markup(css_filename, instance_title, + profile_json, city, + content_license_url) + \ + profile_str + html_footer() + return profile_str -def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, - translate: {}, - baseDir: str, httpPrefix: str, - authorized: bool, - nickname: str, domain: str, port: int, - session, cachedWebfingers: {}, personCache: {}, - projectVersion: str, - YTReplacementDomain: str, - twitterReplacementDomain: str, - showPublishedDateOnly: bool, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str, - maxLikeCount: int, - signingPrivateKeyPem: str) -> str: +def _html_profile_posts(recent_posts_cache: {}, max_recent_posts: int, + translate: {}, + base_dir: str, http_prefix: str, + authorized: bool, + nickname: str, domain: str, port: int, + session, cached_webfingers: {}, person_cache: {}, + project_version: str, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, system_language: str, + max_like_count: int, + signing_priv_key_pem: str, + cw_lists: {}, lists_enabled: str, + timezone: str, bold_reading: bool, + dogwhistles: {}) -> str: """Shows posts on the profile screen These should only be public posts """ - separatorStr = htmlPostSeparator(baseDir, None) - profileStr = '' - maxItems = 4 + separator_str = html_post_separator(base_dir, None) + profile_str = '' + max_items = 4 ctr = 0 - currPage = 1 - boxName = 'outbox' - while ctr < maxItems and currPage < 4: - outboxFeedPathStr = \ - '/users/' + nickname + '/' + boxName + '?page=' + \ - str(currPage) - outboxFeed = \ - personBoxJson({}, session, baseDir, domain, - port, - outboxFeedPathStr, - httpPrefix, - 10, boxName, - authorized, 0, False, 0) - if not outboxFeed: + curr_page = 1 + box_name = 'outbox' + while ctr < max_items and curr_page < 4: + outbox_feed_path_str = \ + '/users/' + nickname + '/' + box_name + '?page=' + \ + str(curr_page) + outbox_feed = \ + person_box_json({}, base_dir, domain, + port, + outbox_feed_path_str, + http_prefix, + 10, box_name, + authorized, 0, False, 0) + if not outbox_feed: break - if len(outboxFeed['orderedItems']) == 0: + if len(outbox_feed['orderedItems']) == 0: break - for item in outboxFeed['orderedItems']: + for item in outbox_feed['orderedItems']: if item['type'] == 'Create': - postStr = \ - individualPostAsHtml(signingPrivateKeyPem, - True, recentPostsCache, - maxRecentPosts, - translate, None, - baseDir, session, cachedWebfingers, - personCache, - nickname, domain, port, item, - None, True, False, - httpPrefix, projectVersion, 'inbox', - YTReplacementDomain, - twitterReplacementDomain, - showPublishedDateOnly, - peertubeInstances, - allowLocalNetworkAccess, - themeName, systemLanguage, - maxLikeCount, - False, False, False, - True, False, False) - if postStr: - profileStr += postStr + separatorStr + post_str = \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, + cached_webfingers, + person_cache, + nickname, domain, port, item, + None, True, False, + http_prefix, project_version, + 'inbox', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + False, False, False, + True, False, False, + cw_lists, lists_enabled, + timezone, False, + bold_reading, dogwhistles) + if post_str: + profile_str += post_str + separator_str ctr += 1 - if ctr >= maxItems: + if ctr >= max_items: break - currPage += 1 - return profileStr + curr_page += 1 + return profile_str -def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, - authorized: bool, - nickname: str, domain: str, port: int, - session, cachedWebfingers: {}, personCache: {}, - followingJson: {}, projectVersion: str, - buttons: [], - feedName: str, actor: str, - pageNumber: int, - maxItemsPerPage: int, - dormantMonths: int, debug: bool, - signingPrivateKeyPem: str) -> str: +def _html_profile_following(translate: {}, base_dir: str, http_prefix: str, + authorized: bool, nickname: str, domain: str, + session, cached_webfingers: {}, person_cache: {}, + following_json: {}, project_version: str, + buttons: [], + feed_name: str, actor: str, + page_number: int, + max_items_per_page: int, + dormant_months: int, debug: bool, + signing_priv_key_pem: str) -> str: """Shows following on the profile screen """ - profileStr = '' + profile_str = '' - if authorized and pageNumber: - if authorized and pageNumber > 1: + if authorized and page_number: + if authorized and page_number > 1: # page up arrow - profileStr += \ + profile_str += \ '
    \n' + \ - ' ' + \
                 translate['Page up'] + '\n' + \ '
    \n' - for followingActor in followingJson['orderedItems']: + for following_actor in following_json['orderedItems']: # is this a dormant followed account? dormant = False - if authorized and feedName == 'following': + if authorized and feed_name == 'following': dormant = \ - isDormant(baseDir, nickname, domain, followingActor, - dormantMonths) + is_dormant(base_dir, nickname, domain, following_actor, + dormant_months) - profileStr += \ - _individualFollowAsHtml(signingPrivateKeyPem, - translate, baseDir, session, - cachedWebfingers, personCache, - domain, followingActor, - authorized, nickname, - httpPrefix, projectVersion, dormant, - debug, buttons) + profile_str += \ + _individual_follow_as_html(signing_priv_key_pem, + translate, base_dir, session, + cached_webfingers, person_cache, + domain, following_actor, + authorized, nickname, + http_prefix, project_version, dormant, + debug, buttons) - if authorized and maxItemsPerPage and pageNumber: - if len(followingJson['orderedItems']) >= maxItemsPerPage: + if authorized and max_items_per_page and page_number: + if len(following_json['orderedItems']) >= max_items_per_page: # page down arrow - profileStr += \ + profile_str += \ '
    \n' + \ - ' ' + \
                 translate['Page down'] + '\n' + \ '
    \n' - return profileStr + # list of page numbers + profile_str += \ + page_number_buttons(actor, feed_name, page_number, + 'buttonheader') + # some vertical padding to allow "finger space" on mobile + profile_str += '
    ' + + return profile_str -def _htmlProfileRoles(translate: {}, nickname: str, domain: str, - rolesList: []) -> str: +def _html_profile_roles(translate: {}, nickname: str, domain: str, + roles_list: []) -> str: """Shows roles on the profile screen """ - profileStr = '' - profileStr += \ + profile_str = '' + profile_str += \ '
    \n
    \n' - for role in rolesList: + for role in roles_list: if translate.get(role): - profileStr += '

    ' + translate[role] + '

    \n' + profile_str += '

    ' + translate[role] + '

    \n' else: - profileStr += '

    ' + role + '

    \n' - profileStr += '
    \n' - if len(profileStr) == 0: - profileStr += \ + profile_str += '

    ' + role + '

    \n' + profile_str += '\n' + if len(profile_str) == 0: + profile_str += \ '

    @' + nickname + '@' + domain + ' has no roles assigned

    \n' else: - profileStr = '
    ' + profileStr + '
    \n' - return profileStr + profile_str = '
    ' + profile_str + '
    \n' + return profile_str -def _htmlProfileSkills(translate: {}, nickname: str, domain: str, - skillsJson: {}) -> str: +def _html_profile_skills(skills_json: {}) -> str: """Shows skills on the profile screen """ - profileStr = '' - for skill, level in skillsJson.items(): - profileStr += \ + profile_str = '' + for skill, level in skills_json.items(): + profile_str += \ '
    ' + skill + \ '
    \n
    \n' - if len(profileStr) > 0: - profileStr = '
    ' + \ - profileStr + '
    \n' - return profileStr + if len(profile_str) > 0: + profile_str = '
    ' + \ + profile_str + '
    \n' + return profile_str -def _htmlProfileShares(actor: str, translate: {}, - nickname: str, domain: str, sharesJson: {}, - sharesFileType: str) -> str: +def _html_profile_shares(actor: str, translate: {}, + domain: str, shares_json: {}, + shares_file_type: str) -> str: """Shows shares on the profile screen """ - profileStr = '' - for item in sharesJson['orderedItems']: - profileStr += htmlIndividualShare(domain, item['shareId'], - actor, item, translate, False, False, - sharesFileType) - if len(profileStr) > 0: - profileStr = '\n' - return profileStr + profile_str = '' + for item in shares_json['orderedItems']: + profile_str += html_individual_share(domain, item['shareId'], + actor, item, translate, + False, False, + shares_file_type) + if len(profile_str) > 0: + profile_str = '\n' + return profile_str -def _grayscaleEnabled(baseDir: str) -> bool: +def _grayscale_enabled(base_dir: str) -> bool: """Is grayscale UI enabled? """ - return os.path.isfile(baseDir + '/accounts/.grayscale') + return os.path.isfile(base_dir + '/accounts/.grayscale') -def _htmlThemesDropdown(baseDir: str, translate: {}) -> str: +def _html_themes_dropdown(base_dir: str, translate: {}) -> str: """Returns the html for theme selection dropdown """ # Themes section - themes = getThemesList(baseDir) - themesDropdown = '