1
0
Fork 0
mirror of https://github.com/YunoHost/apps.git synced 2024-09-03 20:06:07 +02:00

Merge pull request #1717 from YunoHost/app-store

New app store
This commit is contained in:
Alexandre Aubin 2023-09-25 17:43:41 +02:00 committed by GitHub
commit 997e89f44a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 3773 additions and 0 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ tools/autopatches/token
__pycache__
app_list_auto_update.log
venv

3
store/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.toml
.stars
messages.pot

0
store/.stars/.gitkeep Normal file
View file

49
store/README.md Normal file
View file

@ -0,0 +1,49 @@
# YunoHost app store
This is a Flask app interfacing with YunoHost's app catalog for a cool browsing of YunoHost's apps catalog, wishlist and being able to vote/star for apps
## Developement
```
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt
cp config.toml.example config.toml
# Tweak config.toml with appropriate values... (not everyting is needed for the base features to work)
nano config.toml
# You'll need to have a built version of the catalog
mkdir -p ../builds/default/v3/
curl https://app.yunohost.org/default/v3/apps.json > ../builds/default/v3/apps.json
# You will also want to run list_builder.py to initialize the .apps_cache (at least for a few apps, you can Ctrl+C after a while)
pushd ..
python3 list_builder.py
popd
```
And then start the dev server:
```
source venv/bin/activate
FLASK_APP=app.py FLASK_ENV=development flask run
```
## Translation
It's based on Flask-Babel : https://python-babel.github.io/
```
source venv/bin/activate
pybabel extract --ignore-dirs venv -F babel.cfg -o messages.pot .
# If working on a new locale : initialize it (in this example: fr)
pybabel init -i messages.pot -d translations -l fr
# Otherwise, update the existing .po:
pybabel update -i messages.pot -d translations
# ... translate stuff in translations/<lang>/LC_MESSAGES/messages.po
# then compile:
pybabel compile -d translations
```

0
store/__init__.py Normal file
View file

473
store/app.py Normal file
View file

@ -0,0 +1,473 @@
import time
import re
import toml
import base64
import hashlib
import hmac
import os
import string
import random
import urllib
import json
import sys
from slugify import slugify
from flask import (
Flask,
send_from_directory,
render_template,
session,
redirect,
request,
)
from flask_babel import Babel
from flask_babel import gettext as _
from github import Github, InputGitAuthor
from .utils import (
get_locale,
get_catalog,
get_wishlist,
get_stars,
get_app_md_and_screenshots,
)
app = Flask(__name__, static_url_path="/assets", static_folder="assets")
try:
config = toml.loads(open("config.toml").read())
except Exception as e:
print(
"You should create a config.toml with the appropriate key/values, cf config.toml.example"
)
sys.exit(1)
mandatory_config_keys = [
"DISCOURSE_SSO_SECRET",
"DISCOURSE_SSO_ENDPOINT",
"COOKIE_SECRET",
"CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE",
"GITHUB_LOGIN",
"GITHUB_TOKEN",
"GITHUB_EMAIL",
"APPS_CACHE",
]
for key in mandatory_config_keys:
if key not in config:
print(f"Missing key in config.toml: {key}")
sys.exit(1)
if config.get("DEBUG"):
app.debug = True
app.config["DEBUG"] = True
app.config["TEMPLATES_AUTO_RELOAD"] = True
# This is the secret key used for session signing
app.secret_key = config["COOKIE_SECRET"]
babel = Babel(app, locale_selector=get_locale)
@app.template_filter("localize")
def localize(d):
if not isinstance(d, dict):
return d
else:
locale = get_locale()
if locale in d:
return d[locale]
else:
return d["en"]
###############################################################################
@app.route("/favicon.ico")
def favicon():
return send_from_directory("assets", "favicon.png")
@app.route("/")
def index():
return render_template(
"index.html",
locale=get_locale(),
user=session.get("user", {}),
catalog=get_catalog(),
)
@app.route("/catalog")
def browse_catalog():
return render_template(
"catalog.html",
locale=get_locale(),
init_sort=request.args.get("sort"),
init_search=request.args.get("search"),
init_category=request.args.get("category"),
init_starsonly=request.args.get("starsonly"),
user=session.get("user", {}),
catalog=get_catalog(),
timestamp_now=int(time.time()),
stars=get_stars(),
)
@app.route("/popularity.json")
def popularity_json():
return {app: len(stars) for app, stars in get_stars().items()}
@app.route("/app/<app_id>")
def app_info(app_id):
infos = get_catalog()["apps"].get(app_id)
app_folder = os.path.join(config["APPS_CACHE"], app_id)
if not infos or not os.path.exists(app_folder):
return f"App {app_id} not found", 404
get_app_md_and_screenshots(app_folder, infos)
return render_template(
"app.html",
locale=get_locale(),
user=session.get("user", {}),
app_id=app_id,
infos=infos,
catalog=get_catalog(),
stars=get_stars(),
)
@app.route("/app/<app_id>/<action>")
def star_app(app_id, action):
assert action in ["star", "unstar"]
if app_id not in get_catalog()["apps"] and app_id not in get_wishlist():
return _("App %(app_id) not found", app_id=app_id), 404
if not session.get("user", {}):
return _("You must be logged in to be able to star an app"), 401
app_star_folder = os.path.join(".stars", app_id)
app_star_for_this_user = os.path.join(
".stars", app_id, session.get("user", {})["id"]
)
if not os.path.exists(app_star_folder):
os.mkdir(app_star_folder)
if action == "star":
open(app_star_for_this_user, "w").write("")
elif action == "unstar":
try:
os.remove(app_star_for_this_user)
except FileNotFoundError:
pass
if app_id in get_catalog()["apps"]:
return redirect(f"/app/{app_id}")
else:
return redirect("/wishlist")
@app.route("/wishlist")
def browse_wishlist():
return render_template(
"wishlist.html",
init_sort=request.args.get("sort"),
init_search=request.args.get("search"),
init_starsonly=request.args.get("starsonly"),
locale=get_locale(),
user=session.get("user", {}),
wishlist=get_wishlist(),
stars=get_stars(),
)
@app.route("/wishlist/add", methods=["GET", "POST"])
def add_to_wishlist():
if request.method == "POST":
user = session.get("user", {})
if not user:
errormsg = _("You must be logged in to submit an app to the wishlist")
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=None,
successmsg=None,
errormsg=errormsg,
)
csrf_token = request.form["csrf_token"]
if csrf_token != session.get("csrf_token"):
errormsg = _("Invalid CSRF token, please refresh the form and try again")
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=csrf_token,
successmsg=None,
errormsg=errormsg,
)
name = request.form["name"].strip().replace("\n", "")
description = request.form["description"].strip().replace("\n", "")
upstream = request.form["upstream"].strip().replace("\n", "")
website = request.form["website"].strip().replace("\n", "")
checks = [
(len(name) >= 3, _("App name should be at least 3 characters")),
(len(name) <= 30, _("App name should be less than 30 characters")),
(
len(description) >= 5,
_("App description should be at least 5 characters"),
),
(
len(description) <= 100,
_("App description should be less than 100 characters"),
),
(
len(upstream) >= 10,
_("Upstream code repo URL should be at least 10 characters"),
),
(
len(upstream) <= 150,
_("Upstream code repo URL should be less than 150 characters"),
),
(len(website) <= 150, _("Website URL should be less than 150 characters")),
(
re.match(r"^[\w\.\-\(\)\ ]+$", name),
_("App name contains special characters"),
),
]
for check, errormsg in checks:
if not check:
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=csrf_token,
successmsg=None,
errormsg=errormsg,
)
slug = slugify(name)
github = Github(config["GITHUB_TOKEN"])
author = InputGitAuthor(config["GITHUB_LOGIN"], config["GITHUB_EMAIL"])
repo = github.get_repo("Yunohost/apps")
current_wishlist_rawtoml = repo.get_contents(
"wishlist.toml", ref="app-store"
) # FIXME : ref=repo.default_branch)
current_wishlist_sha = current_wishlist_rawtoml.sha
current_wishlist_rawtoml = current_wishlist_rawtoml.decoded_content.decode()
new_wishlist = toml.loads(current_wishlist_rawtoml)
if slug in new_wishlist:
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=csrf_token,
successmsg=None,
errormsg=_(
"An entry with the name %(slug) already exists in the wishlist",
slug=slug,
),
)
new_wishlist[slug] = {
"name": name,
"description": description,
"upstream": upstream,
"website": website,
}
new_wishlist = dict(sorted(new_wishlist.items()))
new_wishlist_rawtoml = toml.dumps(new_wishlist)
new_branch = f"add-to-wishlist-{slug}"
try:
# Get the commit base for the new branch, and create it
commit_sha = repo.get_branch(
"app-store"
).commit.sha # FIXME app-store -> repo.default_branch
repo.create_git_ref(ref=f"refs/heads/{new_branch}", sha=commit_sha)
except exception as e:
print("... Failed to create branch ?")
print(e)
errormsg = _(
"Failed to create the pull request to add the app to the wishlist ... please report the issue to the yunohost team"
)
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=csrf_token,
successmsg=None,
errormsg=errormsg,
)
message = f"Add {name} to wishlist"
repo.update_file(
"wishlist.toml",
message=message,
content=new_wishlist_rawtoml,
sha=current_wishlist_sha,
branch=new_branch,
author=author,
)
# Wait a bit to preserve the API rate limit
time.sleep(1.5)
body = f"""
### Add {name} to wishlist
Proposed by **{session['user']['username']}**
- [ ] Confirm app is self-hostable and generally makes sense to possibly integrate in YunoHost
- [ ] Confirm app's license is opensource/free software (or not-totally-free, case by case TBD)
- [ ] Description describes concisely what the app is/does
"""
# Open the PR
pr = repo.create_pull(
title=message,
body=body,
head=new_branch,
base="app-store", # FIXME app-store -> repo.default_branch
)
url = f"https://github.com/YunoHost/apps/pull/{pr.number}"
successmsg = _(
"Your proposed app has succesfully been submitted. It must now be validated by the YunoHost team. You can track progress here: %(url)s",
url=url,
)
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
successmsg=successmsg,
)
else:
letters = string.ascii_lowercase + string.digits
csrf_token = ''.join(random.choice(letters) for i in range(16))
session["csrf_token"] = csrf_token
return render_template(
"wishlist_add.html",
locale=get_locale(),
user=session.get("user", {}),
csrf_token=csrf_token,
successmsg=None,
errormsg=None,
)
###############################################################################
# Session / SSO using Discourse #
###############################################################################
@app.route("/login_using_discourse")
def login_using_discourse():
"""
Send auth request to Discourse:
"""
(
nonce,
url,
uri_to_redirect_to_after_login,
) = create_nonce_and_build_url_to_login_on_discourse_sso()
session.clear()
session["nonce"] = nonce
if uri_to_redirect_to_after_login:
session["uri_to_redirect_to_after_login"] = uri_to_redirect_to_after_login
return redirect(url)
@app.route("/sso_login_callback")
def sso_login_callback():
response = base64.b64decode(request.args["sso"].encode()).decode()
user_data = urllib.parse.parse_qs(response)
if user_data["nonce"][0] != session.get("nonce"):
return "Invalid nonce", 401
uri_to_redirect_to_after_login = session.get("uri_to_redirect_to_after_login")
session.clear()
session["user"] = {
"id": user_data["external_id"][0],
"username": user_data["username"][0],
"avatar_url": user_data["avatar_url"][0],
}
if uri_to_redirect_to_after_login:
return redirect("/" + uri_to_redirect_to_after_login)
else:
return redirect("/")
@app.route("/logout")
def logout():
session.clear()
# Only use the current referer URI if it's on the same domain as the current route
# to avoid XSS or whatever...
referer = request.environ.get("HTTP_REFERER")
if referer:
if referer.startswith("http://"):
referer = referer[len("http://") :]
if referer.startswith("https://"):
referer = referer[len("https://") :]
if "/" not in referer:
referer = referer + "/"
domain, uri = referer.split("/", 1)
if domain == request.environ.get("HTTP_HOST"):
return redirect("/" + uri)
return redirect("/")
def create_nonce_and_build_url_to_login_on_discourse_sso():
"""
Redirect the user to DISCOURSE_ROOT_URL/session/sso_provider?sso=URL_ENCODED_PAYLOAD&sig=HEX_SIGNATURE
"""
nonce = "".join([str(random.randint(0, 9)) for i in range(99)])
# Only use the current referer URI if it's on the same domain as the current route
# to avoid XSS or whatever...
referer = request.environ.get("HTTP_REFERER")
uri_to_redirect_to_after_login = None
if referer:
if referer.startswith("http://"):
referer = referer[len("http://") :]
if referer.startswith("https://"):
referer = referer[len("https://") :]
if "/" not in referer:
referer = referer + "/"
domain, uri = referer.split("/", 1)
if domain == request.environ.get("HTTP_HOST"):
uri_to_redirect_to_after_login = uri
url_data = {
"nonce": nonce,
"return_sso_url": config["CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE"],
}
url_encoded = urllib.parse.urlencode(url_data)
payload = base64.b64encode(url_encoded.encode()).decode()
sig = hmac.new(
config["DISCOURSE_SSO_SECRET"].encode(),
msg=payload.encode(),
digestmod=hashlib.sha256,
).hexdigest()
data = {"sig": sig, "sso": payload}
url = f"{config['DISCOURSE_SSO_ENDPOINT']}?{urllib.parse.urlencode(data)}"
return nonce, url, uri_to_redirect_to_after_login

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
store/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

13
store/assets/fetch_assets Normal file
View file

@ -0,0 +1,13 @@
# Download standalone tailwind to compile what we need
wget https://github.com/tailwindlabs/tailwindcss/releases/download/v3.3.3/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
./tailwindcss-linux-x64 --input tailwind-local.css --output tailwind.css --minify
curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css > fork-awesome.min.css
sed -i 's@../fonts/@@g' ./fork-awesome.min.css
curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.woff2?v=1.2.0 > forkawesome-webfont.woff2
curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.woff?v=1.2.0 > forkawesome-webfont.woff
curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.ttf?v=1.2.0 > forkawesome-webfont.ttf
curl https://raw.githubusercontent.com/YunoHost/doc/master/images/logo_roundcorner.png > ynh_logo_roundcorner.png
curl https://raw.githubusercontent.com/YunoHost/doc/master/images/ynh_logo_black.svg > ynh_logo_black.svg

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="99.72818"
height="23.419621"
version="1.1"
viewBox="0 0 99.72818 23.419621"
xml:space="preserve"
id="svg40"
sodipodi:docname="install-with-yunohost.svg"
inkscape:export-filename="horizontal-yunohost.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
id="defs44" /><sodipodi:namedview
id="namedview42"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false" /><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><path
d="m 69.736754,14.789931 c -0.47499,0 -0.75098,-0.282492 -0.99297,-0.572285 -0.59699,-0.720682 -0.32399,-2.096848 0.09,-2.983126 0.40399,-0.857378 1.14297,-1.7733552 2.00495,-1.7733552 0.49698,0 1.02697,0.6065852 1.26096,0.9651762 0.39899,0.607784 0.47699,1.469063 0.207,2.359741 -0.257,0.838579 -0.79198,1.459663 -1.42997,1.704257 -0.51198,0.194095 -0.84497,0.299592 -1.13997,0.299592 z m 0.12,-9.5275612 c -0.89598,0 -2.78593,0.950376 -3.37092,1.695257 -0.76898,0.977376 -1.41496,3.4167152 -1.43996,5.4604642 -0.019,1.645259 0.052,1.894052 0.83898,3.041724 1.10897,1.619059 2.94493,3.054623 4.17889,3.268218 1.22697,0.212895 3.32592,-0.350291 4.0149,-0.997575 l 0.208,-0.186695 c 0.43099,-0.378891 1.23297,-1.083973 1.67096,-2.01955 0,0 0.61998,-1.231169 0.63898,-2.455739 0.02,-1.285567 -0.29399,-2.186645 -0.29399,-2.186645 -0.41399,-2.9699262 -0.71398,-3.3132172 -2.39594,-4.1053972 -2.79493,-1.317567 -3.73791,-1.514062 -4.0499,-1.514062 z m 8.75378,7.9233022 c -0.23099,0.216794 -0.40399,0.735081 -0.39599,1.683557 0.005,0.705783 0.159,1.492863 0.29399,1.725257 0.51999,0.903578 1.65296,2.125747 2.15195,2.41404 1.04397,0.607385 2.17595,0.103497 4.33489,-1.753856 0.69498,-0.597685 0.81698,-0.867178 0.93198,-2.055849 0.092,-0.925277 0.01,-1.825654 -0.23,-2.575635 l -0.72498,-1.766356 1.90495,-0.200395 c 0.256,-0.0207 0.46999,-0.0321 0.65799,-0.0321 0.41799,0 0.88598,0.0461 1.18997,0.38559 0.37699,0.421889 0.33299,1.090573 0.21599,2.214345 -0.21499,1.937451 0.098,3.058523 1.15597,4.149496 0.56799,0.587185 0.81698,0.766481 1.06198,0.766481 0.16399,0 0.38499,-0.0711 0.76498,-0.244194 0.87198,-0.39729 1.17697,-1.082773 0.81198,-4.302193 -0.157,-1.459763 -0.31999,-3.085123 -0.31999,-3.424914 0,-0.6988832 0.68498,-1.0230742 3.03792,-1.7738562 1.61396,-0.514787 3.15292,-1.17137 3.57791,-1.461663 0.69098,-0.473388 0.72998,-0.594885 0.68198,-1.22497 -0.04,-0.549186 -0.28999,-1.004674 -0.80898,-1.479263 -0.55598,-0.508587 -0.68798,-0.628884 -1.66595,-0.628884 -0.338,0 -0.76199,0.0153 -1.31197,0.0375 -0.55099,0.0473 -0.99498,0.0977 -1.33897,0.136697 -0.38899,0.0446 -0.65898,0.0735 -0.84098,0.0735 -0.59298,0 -0.70198,-0.0367 -0.70598,-0.354291 -0.001,-0.0899 -0.011,-0.448889 -0.001,-0.613285 0.01,-0.154696 0.032,-0.476988 0.005,-0.567986 l -0.026,-0.0996 c -0.037,-0.0691 -0.41999,-0.5761857 -1.09197,-1.2206696 C 91.075224,0.1718957 90.579234,0 90.313244,0 c -0.60499,0 -1.02298,1.081673 -1.20697,3.1281218 0,0 0,2.069848 -0.211,2.298343 -0.21099,0.228494 -2.52693,0.921477 -2.52693,0.921477 -1.69796,0.37889 -1.75996,0.0988 -2.01795,0.655783 -0.258,0.557486 -0.231,0.839479 -0.208,1.182071 0.023,0.342191 0.028,0.422189 0.028,0.422189 0,0 -0.83998,-0.40389 -1.15397,-0.611285 l -0.104,-0.0687 c -0.61098,-0.39929 -1.44696,-1.421864 -1.35796,-2.226144 0.04,-0.362491 0.25799,-0.635584 0.61398,-0.768781 0.171,-0.124997 0.78598,-0.201895 1.52196,-0.121497 0.282,0.0301 0.90198,0.358991 1.04598,-0.064 0.034,-0.234395 0.37999,-0.889078 0.001,-1.307768 -0.53299,-0.587885 -1.83096,-1.149571 -2.22295,-1.269168 -0.39199,-0.119897 -1.29196,-0.185095 -1.51596,-0.185095 -0.34899,0 -0.75398,0.177695 -1.66496,0.613584 -1.80195,0.860579 -2.24194,1.492563 -2.24194,3.22062 0,1.053573 0,1.094572 2.10895,3.17572 2.65193,2.6191352 3.53791,4.3549912 2.78393,5.2698692 -0.45299,0.547986 -0.91498,0.662483 -1.22297,0.662483 -0.69999,0 -0.98398,-1.219969 -1.40897,-1.549961 -0.42499,-0.341792 -0.71898,-0.192195 -0.74198,-0.192195 z M 53.024174,5.8197558 c -1.58596,0 -1.58596,0 -1.67896,6.0666482 -0.011,0.647284 -0.074,2.174946 -0.42999,2.493338 -0.27489,0.244094 -0.881475,0.552686 -1.364663,0.682383 -0.757881,0.203195 -1.330066,0.499987 -1.423864,0.602785 -0.117197,0.140596 -0.155396,0.439389 -0.102297,0.78118 0.0945,0.604285 0.428489,1.19727 0.813279,1.441364 l 0.0918,0.0281 c 1.412065,0.555886 1.700257,0.907478 2.755725,3.424215 0.279,0.525337 1.28397,1.246069 1.72896,1.540961 0.91198,0.613265 1.58996,0.713652 2.05795,0.246864 0.31599,-0.315612 -0.103,-1.339767 -0.51099,-3.537112 -0.21499,-1.17767 0.164,-1.331966 1.39697,-1.642459 0.68398,-0.173095 1.67296,-0.890677 2.01695,-0.890677 0.56798,0 1.21897,0.469988 1.78795,1.287868 0.37899,0.546886 1.11497,1.245268 1.68196,1.720657 0.41999,0.355891 1.18697,0.567585 1.42496,0.567585 0.235,-0.0305 0.41899,-0.186295 0.54799,-0.431589 0.202,-0.38599 0.216,-0.869978 0.035,-1.233169 -0.82498,-1.671858 -1.56396,-7.033824 -1.16497,-8.38149 1.40996,-4.7330822 0.35599,-5.8599542 -0.55899,-6.2963432 -0.49798,-0.237494 -0.93097,-0.358191 -1.28796,-0.358191 -0.81298,0 -1.90796,0.583585 -2.01195,5.629559 -0.069,3.4562142 -0.144,4.1155972 -2.12095,4.1248972 h -10e-4 c -0.39199,0 -0.92198,-0.0496 -1.24197,-0.484288 -0.33999,-0.481288 -0.33799,-1.251169 0.009,-2.487438 0.39899,-1.4406642 0.18799,-2.9417262 -0.55399,-3.9175022 -0.33899,-0.446089 -0.94297,-0.978175 -1.89595,-0.978175"
id="path8"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.0999975" /><path
d="m 42.372441,12.724983 c -0.332792,0 -0.645284,-0.172596 -0.928477,-0.514387 -0.556586,-0.670383 -0.38399,-1.952351 0.560186,-4.1573962 0.40739,-0.951876 0.752281,-1.59646 1.667858,-1.462864 0.691383,0.0997 1.022675,0.476189 1.178871,0.774681 0.38089,0.727282 0.206295,1.831954 -0.518287,3.2815182 -0.729682,1.456964 -1.315667,2.078448 -1.960151,2.078448 z M 43.528712,1.7112578 c -0.697283,0 -1.408965,0.256294 -2.174546,0.782781 -0.624984,0.430089 -1.034374,0.876978 -1.091072,0.982475 -0.0714,0.208595 -0.278493,0.692183 -0.751882,1.313567 -0.926976,1.21677 -1.803455,4.907778 -1.646458,6.9287272 0.197295,2.556636 1.008974,3.744107 3.472613,5.079173 0.256993,0.138297 0.853478,0.228595 1.519462,0.228595 1.039774,0 1.228469,-0.0239 1.792155,-0.298093 0.563286,-0.274593 1.883553,-1.417165 2.319042,-2.158146 0.0977,-0.169896 0.194195,-0.330092 0.287093,-0.484388 0.564486,-0.934277 0.937476,-1.552261 0.911377,-3.537012 -0.0864,-6.3849402 -0.924977,-6.9533262 -2.762131,-8.1985952 -0.632784,-0.430089 -1.246069,-0.639084 -1.875653,-0.639084 z m -16.876578,2.514838 c -0.216795,0.0023 -0.698383,0.189795 -1.019475,0.436289 -0.439889,0.338291 -0.475788,0.36599 -0.314892,1.348066 0.562086,3.396315 0.39649,7.4361142 -0.442889,10.8047302 -0.330491,1.312467 -0.143396,1.689858 1.177671,2.37814 0.546886,0.282393 0.933577,0.42109 1.18277,0.42109 0.42229,0 0.615985,-0.512187 1.106573,-2.091748 l 0.108197,-0.348091 c 0.596485,-1.927252 0.744981,-2.218345 1.132872,-2.218345 0.244494,0 0.39999,0.167596 0.975675,0.79178 0.292193,0.315693 0.645684,0.698483 1.007075,1.040274 1.425764,1.347167 2.938227,2.765131 3.59091,2.518238 0.666784,-0.253094 0.710483,-0.870279 0.586686,-8.252094 -0.0234,-1.3105672 0.0059,-3.7327072 0.0664,-5.3986652 0.0527,-1.540662 -0.0066,-2.648034 -0.0762,-2.937427 0.0606,-0.165296 -0.661284,-1.1273716 -0.736282,-1.2433687 -0.325392,-0.5046874 -0.757781,-0.9511762 -1.156171,-0.9530761 -0.39849,-0.002 -0.423889,0.1643959 -0.671883,0.6405839 -0.505887,0.9671759 -0.502687,1.6753579 -0.567186,5.3018679 -0.0766,4.4224892 -0.338291,4.5451862 -0.547986,4.6435842 l -0.219995,0.0488 c -0.472188,0 -0.77298,-0.40269 -2.128846,-3.4198142 -0.81638,-1.774156 -2.526037,-3.510812 -3.053024,-3.510812 z m -12.839679,0.853078 c -0.089,0.142197 -0.232394,0.454289 -0.342492,0.892178 -0.418389,1.659758 -0.444988,8.2505942 -0.0395,9.7950552 0.364091,1.395665 2.057349,3.490113 3.23592,4.0038 1.060873,0.460188 1.686657,0.452389 2.676433,-0.0484 1.623059,-0.823479 2.156946,-1.61056 2.720632,-4.0128 1.437064,-6.1389462 1.385965,-9.3177672 -0.171896,-10.6277342 -0.523787,-0.440589 -0.78938,-0.506587 -0.900678,-0.506587 -0.121497,0 -0.521087,0.171096 -0.569185,2.370641 -0.0144,0.810879 -0.176496,2.457738 -0.562486,3.6659082 -0.387491,1.21097 -0.601885,2.264843 -0.603885,2.427739 -0.0062,0.379591 -0.293693,0.954976 -0.496088,1.245269 -0.37689,0.515187 -0.982375,0.618685 -1.59136,0.276093 -0.639384,-0.367891 -0.704282,-0.982375 -0.753481,-3.954201 -0.0359,-2.1881452 -0.225395,-3.5260122 -0.341392,-3.7413062 -0.278493,-0.513588 -1.734356,-1.60346 -2.260543,-1.785856 z M 1.323767,0.6648844 c -0.268393,0 -0.538286,0.087498 -0.80198,0.2600935 C 0.099998,1.201571 0,1.2667693 0,2.5245378 c 0,0.78398 0.250394,2.611235 0.546387,3.9913 0.178995,0.836379 0.330891,1.60276 0.431289,2.107748 0.0613,0.311792 0.102297,0.519587 0.116397,0.574585 0.177295,0.42819 0.598785,0.8289802 1.296467,1.4921632 l 0.353092,0.337092 1.719857,1.427364 -0.0797,3.250319 c -0.0679,2.76433 0.0012,3.074123 0.577385,4.227194 l 0.0656,0.132097 c 0.332391,0.665183 1.023474,2.049948 1.436264,2.322142 0.872678,0.578885 1.072273,0.505847 1.269568,0.291002 0.197195,-0.214834 0.608985,-7.374815 0.747981,-9.862253 0.0285,-0.508987 0.653084,-1.277668 1.22067,-1.775656 0.542986,-0.476988 1.312467,-1.4085652 1.680458,-2.0339492 0.596485,-1.017175 0.589385,-1.130872 0.348391,-1.857753 -0.201095,-0.611785 -0.587085,-1.020675 -1.422564,-1.509763 -0.291793,-0.212494 -0.647684,-0.615184 -0.965976,-0.514487 -0.318392,0.101198 -2.384341,3.253819 -3.103523,3.482013 -0.147196,0.101898 -0.345691,0.167496 -0.537786,0.167496 -0.82778,0 -1.081673,-1.017875 -1.338667,-3.300618 C 4.177195,3.8323148 4.012799,3.1628318 3.343216,2.1687568 2.681932,1.1843814 1.98385,0.6648944 1.323766,0.6648944"
id="path10"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.0999975" /></svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.btn {
@apply text-sm font-medium rounded-md px-4 py-2 transition;
}
.btn-sm {
@apply text-xs font-medium rounded-md px-2 py-2 transition;
}
.btn-success {
@apply text-white bg-green-500 hover:bg-green-700;
}
.btn-primary {
@apply text-white bg-blue-500 hover:bg-blue-700;
}
.btn-link {
@apply bg-gray-100 hover:bg-gray-200;
}
.btn-primary-outline {
@apply border text-blue-600 border-blue-500 hover:text-blue-400;
}
.from-markdown p {
@apply mb-2;
}
.from-markdown h3 {
@apply text-xl mb-1 font-semibold;
}
.from-markdown ul {
padding: revert;
list-style: disc;
}
.from-markdown a {
@apply text-blue-600;
}
}

View file

@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['../templates/*.html'],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
safelist: [
'safelisted',
{
pattern: /^(text-[a-z]+-600|border-[a-z]+-400)$/,
},
]
}

2
store/babel.cfg Normal file
View file

@ -0,0 +1,2 @@
[python: **.py]
[jinja2: **/templates/**.html]

View file

@ -0,0 +1,9 @@
COOKIE_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890"
# This secret is configured in Discourse
DISCOURSE_SSO_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890"
DISCOURSE_SSO_ENDPOINT = "https://forum.yunohost.org/session/sso_provider"
CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE = "http://localhost:5000/sso_login_callback"
GITHUB_LOGIN = "yunohost-bot"
GITHUB_EMAIL = "yunohost [at] yunohost.org" # Replace the [at] by actual @
GITHUB_TOKEN = "superSecretToken"
APPS_CACHE = "../.apps_cache/"

13
store/gunicorn.py Normal file
View file

@ -0,0 +1,13 @@
import os
install_dir = os.path.dirname(__file__)
command = f'{install_dir}/venv/bin/gunicorn'
pythonpath = install_dir
workers = 4
user = 'appstore'
bind = f'unix:{install_dir}/sock'
pid = '/run/gunicorn/appstore-pid'
errorlog = '/var/log/appstore/error.log'
accesslog = '/var/log/appstore/access.log'
access_log_format = '%({X-Real-IP}i)s %({X-Forwarded-For}i)s %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
loglevel = 'warning'
capture_output = True

15
store/nginx.conf.example Normal file
View file

@ -0,0 +1,15 @@
location / {
try_files $uri @appstore;
}
location /assets {
alias __INSTALL_DIR__/assets/;
}
location @appstore {
proxy_pass http://unix:__INSTALL_DIR__/sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

9
store/requirements.txt Normal file
View file

@ -0,0 +1,9 @@
Flask==2.3.2
python-slugify
PyGithub
toml
pycmarkgfm
gunicorn
emoji
Babel
Flask-Babel

View file

@ -0,0 +1,53 @@
[Unit]
Description=appstore gunicorn daemon
After=network.target
[Service]
PIDFile=/run/gunicorn/appstore-pid
User=appstore
Group=appstore
WorkingDirectory=__INSTALL_DIR__
ExecStart=__INSTALL_DIR__/venv/bin/gunicorn -c __INSTALL_DIR__/gunicorn.py app:app
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
StandardOutput=append:/var/log/appstore/appstore.log
StandardError=inherit
# Sandboxing options to harden security
# Depending on specificities of your service/app, you may need to tweak these
# .. but this should be a good baseline
# Details for these options: https://www.freedesktop.org/software/systemd/man/systemd.exec.html
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictNamespaces=yes
RestrictRealtime=yes
DevicePolicy=closed
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProtectSystem=full
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap @cpu-emulation
# @privileged # (not sure why this need to be removed...)
# Denying access to capabilities that should not be relevant for webapps
# Doc: https://man7.org/linux/man-pages/man7/capabilities.7.html
CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD
CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE
CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT
CapabilityBoundingSet=~CAP_LEASE CAP_LINUX_IMMUTABLE CAP_IPC_LOCK
CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_WAKE_ALARM
CapabilityBoundingSet=~CAP_SYS_TTY_CONFIG
CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE
CapabilityBoundingSet=~CAP_NET_ADMIN CAP_NET_BROADCAST CAP_NET_RAW
CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SYSLOG
[Install]
WantedBy=multi-user.target

147
store/templates/app.html Normal file
View file

@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}
{{ infos['manifest']['name'] }}
{% endblock %}
{% block main %}
<div class="max-w-screen-md mx-auto pt-5 px-5 lg:px-0">
<span class="flex sm:flex-row sm:items-end mb-1" style="flex-wrap: wrap;">
<span class="flex flex-row items-end">
<img alt="{{ _('Logo for %(app)s',app=infos['manifest']['name']) }}"
{% if infos['logo_hash'] %}
src="https://app.yunohost.org/default/v3/logos/{{ infos['logo_hash'] }}.png"
{% else %}
src="{{ url_for('static', filename='app_logo_placeholder.png') }}"
{% endif %}
loading="lazy"
class="h-12 w-12 rounded-lg object-cover shadow-sm mt-1"
>
<h1 class="flex-0 pl-2 pt-3 {% if infos["manifest"]["name"]|length > 12 %}text-2xl{% else %}text-3xl{% endif %} font-bold text-gray-900">{{ infos["manifest"]["name"] }}</h1>
{% if infos['category'] %}
<span class="ml-2 mb-1 rounded-full px-2.5 py-0.5 text-[10px] border text-{{ catalog['categories'][infos['category']]['color'] }}-600 border-{{ catalog['categories'][infos['category']]['color'] }}-400 ">
{{ catalog['categories'][infos['category']]['title']|localize|lower }}
</span>
{% endif %}
{% if infos['level'] == "?" or infos["level"]|int <= 4 %}
<span class="ml-2 mb-1.5">
<i class="fa fa-exclamation-circle text-red-500 py-0.5"
title="{{ _('This app is currently flagged as broken because it failed our automatic tests.') }} {{ _('This is usually a temporary situation which requires packagers to fix something in the app.') }}"
aria-label="{{ _('This app is currently flagged as broken because it failed our automatic tests.') }} {{ _('This is usually a temporary situation which requires packagers to fix something in the app.') }}"
></i>
</span>
{% elif infos['level'] == 8 %}
<span class="ml-2 mb-1.5">
<i class="fa fa-diamond text-teal-500 py-0.5"
title="{{ _('This app has been good quality according to our automatic tests over at least one year.') }}"
aria-label="{{ _('This app has been good quality according to our automatic tests over at least one year.') }}"
></i>
</span>
{% endif %}
</span>
<span class="grow"></span>
<div class="h-9.5 flex flex-row items-end pt-1 my-3 sm:my-0 scale-90 sm:scale-100">
<span class="grow"></span>
{% set this_app_stars = stars.get(app_id, {})|length %}
{% if user %}
{% set user_starred_this_app = user['id'] in stars.get(app_id, {}) %}
{% else %}
{% set user_starred_this_app = False %}
{% endif %}
<a
href="{{ url_for('star_app', app_id=app_id, action="unstar" if user_starred_this_app else "star") }}"
role="button"
class="mr-1 inline-block group btn border text-violet-600 border-violet-500 {% if user %}hover:bg-violet-500 hover:text-white{% endif %}"
>
{% if not user_starred_this_app %}
<span class="inline-block {% if user %}group-hover:hidden{% endif %}">{{ this_app_stars }}</span>
<span class="hidden {% if user %}group-hover:inline-block{% endif %}">{{ this_app_stars+1 }}</span>
<i class="fa fa-star-o inline-block {% if user %}group-hover:hidden{% endif %}" aria-hidden="true"></i>
<i class="fa fa-star hidden {% if user %}group-hover:inline-block{% endif %}" aria-hidden="true"></i>
{% else %}
<span class="inline-block group-hover:hidden">{{ this_app_stars }}</span>
<span class="hidden group-hover:inline-block">{{ this_app_stars-1 }}</span>
<i class="fa fa-star inline-block group-hover:hidden" aria-hidden="true"></i>
<i class="fa fa-star-o hidden group-hover:inline-block" aria-hidden="true"></i>
{% endif %}
</a>
{% if infos["manifest"]["upstream"]["demo"] %}
<a
class="btn btn-success inline-block mr-1"
href="{{ infos["manifest"]["upstream"]["demo"] }}"
>
<i class="fa fa-external-link fa-fw" aria-hidden="true"></i>
<span class="hidden sm:inline">{{ _("Try the demo") }}</span>
<span class="inline sm:hidden">{{ _("Demo") }}</span>
</a>
{% endif %}
<a aria-label="{{ _('Install with YunoHost') }}" title="{{ _('Install with YunoHost') }}" class="h-9.5 inline-block rounded-md border p-0 bg-gray-900 text-white " href="https://install-app.yunohost.org/?app={{ app_id }}">
<span class="inline-block text-[11px] leading-3 text-center py-1.5 pl-2">Install<br/>with</span>
<span class="inline-block pr-2 pt-1"><img alt="YunoHost" src="{{ url_for('static', filename='horizontal-yunohost.svg') }}"></span>
</a>
</div>
</span>
<p class="text-sm text-slate-500">{{ _("Current version: %(version)s", version=infos["manifest"]["version"]) }}</p>
{% if infos["potential_alternative_to"] %}
<p class="text-sm text-slate-500">{{ _("Potential alternative to: %(alternatives)s", alternatives=infos["potential_alternative_to"]|join(', ')) }}</p>
{% endif %}
<div class="from-markdown my-4">{{ infos["full_description_html"]|safe }}</div>
{% if infos["screenshot"] %}
<img alt="{{ _("Screenshot for %(app)s",app=infos["manifest"]["name"]) }}" class="my-3 shadow-lg" src="{{ infos["screenshot"] }}">
{% endif %}
{% if infos["manifest"]["integration"]["architectures"] != "all" %}
<div class="my-3 rounded-md bg-yellow-100 text-yellow-800 px-5 py-2">
<i class="fa fa-exclamation-triangle fa-fw"></i> {{ _("This app is only compatible with these specific architectures: %(archs)s", archs=infos["manifest"]["integration"]["architectures"]|join(', ')) }}
</div>
{% endif %}
{% if infos["manifest"]["integration"]["ram"]["build_binary"] >= 500 * 1024 * 1024 %}
<div class="my-3 rounded-md bg-yellow-100 text-yellow-800 px-5 py-2">
<i class="fa fa-exclamation-triangle fa-fw"></i> {{ _("This app requires an unusual amount of RAM to install: %(ram)s", ram=infos["manifest"]["integration"]["ram"]["build"]) }}
</div>
{% endif %}
{% if infos["pre_install_html"] %}
<div class="my-3 rounded-md bg-blue-100 text-blue-800 px-5 py-2">
<h2 class="inline-block text-xl mb-2 font-semibold">{{ _("Important infos before installing") }}</h2>
<div class="from-markdown">{{ infos["pre_install_html"] | safe }}</div>
</div>
{% endif %}
{% if infos["antifeatures"] %}
<h2 class="inline-block text-xl mb-2 font-semibold">{{ _("Anti-features") }}</h2>
<p class="inline-block text-sm">{{ _("(This app has features you may not like)") }}</p>
<div class="my-3 rounded-md bg-red-100 text-red-800 px-5 py-2">
<ul>
{% for antifeature in infos["antifeatures"] %}
<li class="mb-1"><i class="fa fa-{{ catalog['antifeatures'][antifeature]['icon'] }} fa-fw" aria-hidden="true"></i> {{ catalog['antifeatures'][antifeature]['description']|localize }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<h2 class="text-xl mb-2 font-semibold">{{ _("Useful links") }}</h2>
<div>
{% set upstream = infos["manifest"]["upstream"] %}
<a class="block btn btn-link my-1" href="https://spdx.org/licenses/{{upstream.license}}"><i class="fa fa-institution fa-fw" aria-hidden="true"></i> {{ _("License: %(license)s", license=upstream.license) }}</a>
{% if upstream.website %}<a class="block btn btn-link my-1" href="{{ upstream.website }}"><i class="fa fa-globe fa-fw" aria-hidden="true"></i> {{ _(" Official website") }}</a>{% endif %}
{% if upstream.admindoc %}<a class="block btn btn-link my-1" href="{{ upstream.admindoc }}"><i class="fa fa-book fa-fw" aria-hidden="true"></i> {{ _("Official admin documentation") }}</a>{% endif %}
{% if upstream.userdoc %}<a class="block btn btn-link my-1" href="{{ upstream.userdoc }}"><i class="fa fa-book fa-fw" aria-hidden="true"></i> {{ _("Official user documentation") }}</a>{% endif %}
{% if upstream.code %}<a class="block btn btn-link my-1" href="{{ upstream.code }}"><i class="fa fa-code fa-fw" aria-hidden="true"></i> {{ _("Official code repository") }}</a>{% endif %}
<a class="block btn btn-link my-1" href="{{ infos["git"]["url"] }}"><i class="fa fa-code fa-fw" aria-hidden="true"></i> {{ _("YunoHost package repository") }}</a>
</div>
</div>
{% endblock %}

209
store/templates/base.html Normal file
View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="{{ locale }}">
<head>
<title>{{ _("YunoHost app store") }} | {% block title %}{% endblock %} </title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='fork-awesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='tailwind.css') }}">
</head>
<body>
<header class="pb-2 shadow-sm">
<div
class="flex h-12 items-center gap-8 pt-2 px-4 sm:px-6 lg:px-8"
>
<a class="block" href="/">
<span class="sr-only">{{ _("Home") }}</span>
<img alt="YunoHost Logo" src="{{ url_for('static', filename='ynh_logo_roundcorner.png') }}" style="height: 3em;">
</a>
<div class="flex flex-1 items-center justify-end md:justify-between">
<nav class="hidden md:block">
<ul class="flex items-center gap-6 text-sm">
<li>
<a class="text-gray-800 font-bold transition hover:text-gray-500/75" href="{{ url_for('browse_catalog') }}">
{{ _("Catalog") }}
</a>
</li>
<li>
<a class="text-gray-800 font-bold transition hover:text-gray-500/75" href="{{ url_for('browse_wishlist') }}">
{{ _("Wishlist") }}
</a>
</li>
</ul>
</nav>
<div class="flex items-center gap-4">
<div class="hidden md:flex sm:gap-4">
<a
class="btn btn-primary-outline inline-block"
href="https://yunohost.org/docs/"
>
<i class="fa fa-external-link fa-fw" aria-hidden="true"></i>
{{ _("YunoHost documentation") }}
</a>
{% if not user %}
<a
class="btn btn-primary inline-block"
href="{{ url_for('login_using_discourse') }}"
role="button"
>
{{ _("Login using YunoHost's forum") }}
</a>
{% else %}
<div class="relative">
<button
id="toggleUserMenu"
type="button"
class="group flex shrink-0 items-center rounded-md transition"
>
<img
alt="Avatar"
src="{{ user['avatar_url'] }}"
class="h-10 w-10 rounded-full object-cover"
>
<p class="ms-2 text-left text-xs inline-block">
<strong class="block font-medium">{{ user['username'] }}</strong>
</p>
<i class="fa fa-caret-down fa-fw" aria-hidden="true"></i>
</button>
<div
id="userMenu"
class="hidden absolute end-0 z-10 mt-2 w-56 rounded-md border border-gray-100 bg-white shadow-lg"
role="menu"
>
<div class="p-2">
<a
href="/logout"
class="block rounded-md px-4 py-2 text-sm text-gray-800 hover:bg-gray-50 hover:text-gray-700"
role="menuitem"
>
{{ _("Logout") }}
</a>
</div>
</div>
</div>
{% endif %}
</div>
<div class="relative">
<button
id="toggleMenu"
class="block rounded bg-gray-100 p-2.5 text-gray-600 transition hover:text-gray-600/75 md:hidden"
>
<span class="sr-only">{{ _("Toggle menu") }}</span>
<i class="fa fa-bars h-5 w-5" aria-hidden="true"></i>
</button>
<div
id="menu"
class="hidden absolute end-0 z-10 mt-2 p-2 w-64 rounded-md border border-gray-100 text-gray-800 bg-white shadow-lg"
role="menu"
>
<div class="px-2 py-0.5">
<a
href="/"
class="block rounded-md px-4 py-3 text-sm hover:bg-gray-100"
role="menuitem"
>
{{ _("Home") }}
</a>
</div>
<div class="px-2 py-0.5">
<a
href="{{ url_for('browse_catalog') }}"
class="block rounded-md px-4 py-3 text-sm hover:bg-gray-100"
role="menuitem"
>
{{ _("Catalog") }}
</a>
</div>
<div class="px-2 py-0.5">
<a
href="{{ url_for('browse_wishlist') }}"
class="block rounded-md px-4 py-3 text-sm hover:bg-gray-100"
role="menuitem"
>
{{ _("Wishlist") }}
</a>
</div>
<div class="px-2 py-1">
<a
href="https://yunohost.org/docs/"
class="block btn btn-primary-outline text-xs text-gray-500"
role="menuitem"
>
<i class="fa fa-external-link fa-fw" aria-hidden="true"></i>
{{ _("YunoHost documentation") }}
</a>
</div>
{% if not user %}
<div class="px-2 py-1">
<a
href="{{ url_for('login_using_discourse') }}"
class="block btn btn-primary rounded-md text-xs text-gray-500"
role="button"
>
{{ _("Login using YunoHost's forum") }}
</a>
</div>
{% else %}
<hr class="mt-3" />
<div class="px-2 py-0.5">
<span
class="block rounded-md px-4 py-2 text-sm text-gray-500"
role="menuitem"
>
<img
alt="Avatar"
src="{{ user['avatar_url'] }}"
class="h-10 w-10 rounded-full object-cover inline-block"
>
<p class="ms-2 inline-block text-left">
<strong class="font-medium">{{ user['username'] }}</strong>
</p>
</span>
</div>
<div class="px-2 py-0.5">
<a
href="/logout"
class="block rounded-md px-4 py-3 text-sm hover:bg-gray-100"
role="menuitem"
>
{{ _("Logout") }}
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</header>
<main>
{% block main %}
{% endblock %}
</main>
<footer class="h-5 my-5 text-center">
<p>
{{ _("Made with <i class='text-red-500 fa fa-heart-o' aria-label='love'></i> using <a class='text-blue-800' href='https://flask.palletsprojects.com'>Flask</a> and <a class='text-blue-800' href='https://tailwindcss.com/'>TailwindCSS</a> - <a class='text-blue-800' href='https://github.com/YunoHost/apps/tree/master/store'><i class='fa fa-code fa-fw' aria-hidden='true'></i> Source</a>") }}
</p>
</footer>
</body>
<script type="text/javascript">
{% if user %}
document.getElementById('toggleUserMenu').addEventListener('click', () => {
document.getElementById('userMenu').classList.toggle("hidden");
});
{% endif %}
document.getElementById('toggleMenu').addEventListener('click', () => {
document.getElementById('menu').classList.toggle("hidden");
});
</script>
</html>

View file

@ -0,0 +1,316 @@
{% macro appCard(app, infos, timestamp_now, catalog) -%}
{% set this_app_stars = stars.get(app, {})|length %}
{% if user %}
{% set user_starred_this_app = user['id'] in stars.get(app, {}) %}
{% else %}
{% set user_starred_this_app = False %}
{% endif %}
<div class="search-entry"
data-appid="{{ app }}"
data-stars="{{ this_app_stars }}"
data-starred="{{ user_starred_this_app }}"
data-addedincatalog="{{ ((timestamp_now - infos['added_in_catalog']) / 3600 / 24) | int }}"
data-category="{%- if infos['category'] -%}{{ infos['category'] }}{%- endif -%}"
>
<a
href="{{ url_for('app_info', app_id=app) }}"
class="relative block overflow-hidden rounded-lg py-2 px-4 hover:bg-gray-100 mx-2 md:mx-0"
>
<div class="flex justify-between gap-4">
<div class="shrink-0">
<img alt="{{ _('Logo for %(app)s',app=infos['manifest']['name']) }}"
{% if infos['logo_hash'] %}
src="https://app.yunohost.org/default/v3/logos/{{ infos['logo_hash'] }}.png"
{% else %}
src="{{ url_for('static', filename='app_logo_placeholder.png') }}"
{% endif %}
loading="lazy"
class="h-12 w-12 rounded-lg object-cover shadow-sm mt-1"
>
</div>
<div class="w-full">
<span class="flex">
<h3 class="grow text-md font-bold text-gray-900">
{{ infos['manifest']['name'] }}
</h3>
<span class="text-xs">
{% if infos['level'] == "?" or infos["level"]|int <= 4 %}
<i class="fa fa-exclamation-circle text-red-500 py-0.5"
aria-label="{{ _('This app is currently flagged as broken because it failed our automatic tests.') }} {{ _('This is usually a temporary situation which requires packagers to fix something in the app.') }}"
title="{{ _('This app is currently flagged as broken because it failed our automatic tests.') }} {{ _('This is usually a temporary situation which requires packagers to fix something in the app.') }}"
></i>
{% elif infos['level'] == 8 %}
<i class="fa fa-diamond text-teal-500 py-0.5"
aria-label="{{ _('This app has been good quality according to our automatic tests over at least one year.') }}"
title="{{ _('This app has been good quality according to our automatic tests over at least one year.') }}"
></i>
{% endif %}
<span class="inline-block group rounded-md text-xs text-violet-500 px-1 py-0.5">
<span class="inline-block">{{ this_app_stars }}</span>
<i class="fa {% if not user_starred_this_app %}fa-star-o{% else %}fa-star{% endif %} inline-block" aria-hidden="true"></i>
</span>
</span>
</span>
<p class="max-w-[40ch] text-xs text-gray-700">
{{ infos['manifest']['description']|localize }}
</p>
<div class="hidden">
{{ infos["potential_alternative_to"]|join(', ') }}
</div>
{% if infos['category'] %}
<span class="rounded-full px-2.5 py-0.5 text-[10px] border text-{{ catalog['categories'][infos['category']]['color'] }}-600 border-{{ catalog['categories'][infos['category']]['color'] }}-400 ">
{{ catalog['categories'][infos['category']]['title']|localize|lower }}
</span>
{% endif %}
</div>
</div>
</a>
</div>
{%- endmacro %}
{% extends "base.html" %}
{% block title %}
{{ _("Application Catalog") }}
{% endblock %}
{% block main %}
<div class="mt-5 text-center">
<h1 class="text-2xl font-bold text-gray-900">
{{ _("Application Catalog") }}
</h1>
</div>
<div class="max-w-screen-md mx-auto mt-3 mb-3">
<div class="flex flex-col md:flex-row items-center">
<div class="px-2 inline-block relative basis-2/3 text-gray-700">
<label for="search" class="sr-only"> {{ _("Search") }} </label>
<input
type="text"
id="search"
placeholder="{{ _('Search for...') }}"
{% if init_search %}value="{{ init_search }}"{% endif %}
class="w-full rounded-md border-gray-200 shadow-sm sm:text-sm py-2 pe-10"
>
<span class="absolute inset-y-0 end-0 grid w-10 place-content-center pr-4">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
</div>
<div class="basis-1/3 px-2 pt-2 md:pt-0 md:px-0">
<select
name="selectcategory"
id="selectcategory"
class="w-full rounded-md border-gray-200 shadow-sm sm:test-sm px-2 py-1.5"
>
<option value="">{{ _("All apps") }}</option>
{% for id, category in catalog['categories'].items() %}
<option {% if id == init_category %}selected{% endif %} value="{{ id }}">{{ category['title']|localize }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="flex flex-col md:flex-row justify-center items-center pt-2 text-center text-sm">
<div class="inline-block px-2">
{{ _("Sort by") }}
<select
name="selectsort"
id="selectsort"
class="inline-block rounded-md border-gray-200 text-sm ml-1 pl-1 pr-7 h-8 py-0"
>
<option {% if not init_sort %}selected{% endif %} value="">{{ _("Alphabetical") }}</option>
<option {% if init_sort == "newest" %}selected{% endif %} value="newest">{{ _("Newest") }}</option>
<option {% if init_sort == "popularity" %}selected{% endif %} value="popularity">{{ _("Popularity") }}</option>
</select>
</div>
<div class="inline-block flex items-center px-2 pt-2 md:pt-0 {% if not user %}text-gray-500{% endif %}" {% if not user %}title="{{ _('Requires to be logged-in') }}" aria-label="{{ _('Requires to be logged-in') }}"{% endif %}>
<label for="starsonly" class="inline-block relative mr-2 h-4 w-7 cursor-pointer">
<span class="sr-only">{{ _("Show only apps you starred") }}</span>
<input type="checkbox" id="starsonly" class="peer sr-only" {% if user and init_starsonly %}checked{% endif %} {% if not user%}disabled{% endif %} >
<span class="absolute inset-0 rounded-full bg-gray-300 transition peer-checked:bg-green-500">
</span>
<span class="absolute inset-y-0 start-0 m-1 h-2 w-2 rounded-full bg-white transition-all peer-checked:start-3">
</span>
</label>
{{ _("Show only apps you starred") }}
</div>
</div>
</div>
<div id="catalogGoodQuality" class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 max-w-screen-lg mx-auto pt-10">
{% for app, infos in catalog['apps'].items() %}
{% if infos['level'] and infos['level'] != "?" and infos['level'] > 4 %}
{{ appCard(app, infos, timestamp_now, catalog) }}
{% endif %}
{% endfor %}
</div>
<div id="noResultFound" class="text-center pt-5 hidden">
<p class="text-lg font-bold text-gray-900 mb-5">
{{ _("No results found.") }}
</p>
<p class="text-md text-gray-900">
{{ _("Not finding what you are looking for?") }}<br/>
{{ _("Checkout the wishlist!") }}
</p>
</div>
<div id="lowQualityAppTitle" class="text-center pt-10 mx-4 md:mx-0">
<h2 class="text-lg font-bold text-gray-900">
{{ _("Applications currently flagged as broken") }}
</h2>
<p class="text-sm">
{{ _("These are apps which failed our automatic tests.") }}<br/>
{{ _("This is usually a temporary situation which requires packagers to fix something in the app.") }}
</p>
</div>
<div id="catalogLowQuality" class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 max-w-screen-lg mx-auto pt-10">
{% for app, infos in catalog['apps'].items() %}
{% if not infos['level'] or infos["level"] == "?" or infos['level'] <= 4 %}
{{ appCard(app, infos, timestamp_now, catalog) }}
{% endif %}
{% endfor %}
</div>
<script type="text/javascript">
// A little delay
let typingTimer;
let typeInterval = 500; // Half a second
let searchInput = document.getElementById('search');
let selectCategory = document.getElementById('selectcategory');
let selectSort = document.getElementById('selectsort');
let toggleStarsonly = document.getElementById('starsonly');
function liveSearch() {
// Locate the card elements
let entries = document.querySelectorAll('.search-entry')
// Locate the search input
let search_query = searchInput.value.trim().toLowerCase();
let selectedCategory = selectCategory.value.trim();
let starsOnly = toggleStarsonly.checked;
let at_least_one_match = false;
// Loop through the entries
for (var i = 0; i < entries.length; i++) {
// If the text is within the card and the text matches the search query
if ((entries[i].textContent.toLowerCase().includes(search_query))
&& (! selectedCategory || (entries[i].dataset.category == selectedCategory))
&& (! starsOnly || (entries[i].dataset.starred == "True")))
{
// ...remove the `.is-hidden` class.
entries[i].classList.remove("hidden");
at_least_one_match = true;
}
else
{
// Otherwise, add the class.
entries[i].classList.add("hidden");
}
}
if (at_least_one_match === false)
{
document.getElementById('lowQualityAppTitle').classList.add("hidden");
document.getElementById('noResultFound').classList.remove("hidden");
}
else
{
document.getElementById('lowQualityAppTitle').classList.remove("hidden");
document.getElementById('noResultFound').classList.add("hidden");
}
updateQueryArgsInURL()
}
function liveSort(container_to_sort) {
let sortBy = selectSort.value.trim();
var toSort = document.getElementById(container_to_sort).children;
toSort = Array.prototype.slice.call(toSort, 0);
if (sortBy === "newest") {
toSort.sort(function(a, b) {
return a.dataset.addedincatalog - b.dataset.addedincatalog ? 1 : -1;
});
}
else if (sortBy === "popularity") {
toSort.sort(function(a, b) {
return a.dataset.stars < b.dataset.stars ? 1 : -1;
});
}
else if (sortBy === "") {
toSort.sort(function(a, b) {
return a.dataset.appid > b.dataset.appid ? 1 : -1;
});
}
var parent = document.getElementById(container_to_sort);
parent.innerHTML = "";
for(var i = 0, l = toSort.length; i < l; i++) {
parent.appendChild(toSort[i]);
}
updateQueryArgsInURL()
}
function updateQueryArgsInURL() {
let search_query = searchInput.value.trim();
let category = selectCategory.value.trim();
let sortBy = selectSort.value.trim();
let starsOnly = toggleStarsonly.checked;
if ('URLSearchParams' in window) {
var queryArgs = new URLSearchParams(window.location.search)
if (search_query) { queryArgs.set("search", search_query) } else { queryArgs.delete("search"); };
if (category) { queryArgs.set("category", category) } else { queryArgs.delete("category"); };
if (sortBy) { queryArgs.set("sort", sortBy) } else { queryArgs.delete("sort"); };
if (starsOnly) { queryArgs.set("starsonly", true) } else { queryArgs.delete("starsonly"); };
let newUrl;
if (queryArgs.toString())
{
newUrl = window.location.pathname + '?' + queryArgs.toString();
}
else
{
newUrl = window.location.pathname;
}
if (newUrl != window.location.pathname + window.location.search)
{
history.pushState(null, '', newUrl);
}
}
}
searchInput.addEventListener('keyup', () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(liveSearch, typeInterval);
});
selectCategory.addEventListener('change', () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(liveSearch, typeInterval);
});
selectSort.addEventListener('change', () => {
liveSort("catalogGoodQuality");
liveSort("catalogLowQuality");
});
toggleStarsonly.addEventListener('change', () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(liveSearch, typeInterval);
});
liveSearch();
liveSort("catalogGoodQuality");
liveSort("catalogLowQuality");
</script>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}
{{ _("Home") }}
{% endblock %}
{% block main %}
<div class="mx-auto w-full text-center p-8">
<img alt="YunoHost logo" src="{{ url_for('static', filename='ynh_logo_black.svg') }}" class="w-32 mx-auto">
<h1 class="text-2xl font-bold text-gray-900">
{{ _("Application Store") }}
</h1>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 max-w-screen-lg mx-auto pt-5">
<div class="text-center border rounded-lg h-32 mx-3 sm:mx-0">
<a
href="{{ url_for('browse_catalog') }}"
class="h-full relative block overflow-hidden hover:bg-gray-200 pt-12"
>
<h2 class="text-md font-bold text-gray-900">
{{ _("Browse all applications") }}
</h2>
</a>
</div>
{% for id, category in catalog['categories'].items() %}
<div class="text-center border rounded-lg h-32 mx-3 sm:mx-0">
<a
href="{{ url_for('browse_catalog', category=id) }}"
class="h-full relative block overflow-hidden hover:bg-gray-200 pt-10"
>
<h2 class="text-md font-bold text-gray-900">
<i class="fa fa-{{ category['icon'] }}" aria-hidden="true"></i>
{{ category['title']|localize }}
</h2>
<p class="mx-auto max-w-[40ch] text-xs text-gray-500">
{{ category['description']|localize }}
</p>
</a>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,280 @@
{% extends "base.html" %}
{% block title %}
{{ _("Application Wishlist") }}
{% endblock %}
{% block main %}
<div class="text-center max-w-screen-md mx-auto mt-5 mx-2">
<h1 class="text-2xl font-bold text-gray-900">
{{ _("Application Wishlist") }}
</h1>
<p class="text-sm text-gray-700 mx-10 mt-2">{{ _("The wishlist is the place where people can collectively suggest and vote for apps that they would like to see packaged and made available in YunoHost's official apps catalog. Nevertheless, the fact that apps are listed here should by no mean be interpreted as a fact that the YunoHost project plans to integrate it, and is merely a source of inspiration for packaging volunteers.") }}</p>
</div>
<div class="max-w-screen-md mx-auto mt-3 mb-3">
<div class="flex flex-col md:flex-row items-center">
<div class="px-2 inline-block relative basis-2/3 text-gray-700">
<label for="search" class="sr-only"> {{ _("Search") }} </label>
<input
type="text"
id="search"
placeholder="{{ _('Search for...') }}"
class="w-full rounded-md border-gray-200 shadow-sm sm:text-sm py-2 pe-10"
>
<span class="absolute inset-y-0 end-0 grid w-10 place-content-center pr-4">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
</div>
<div class="pb-1 mb-2 mt-3 md:my-0">
<a class="btn btn-primary-outline" href="{{ url_for('add_to_wishlist') }}">
<i class="fa fa-plus fa-fw" aria-hidden="true"></i>
{{ _("Suggest an app") }}
</a>
</div>
</div>
<div class="flex flex-col md:flex-row justify-center items-center pt-1 text-center text-sm">
<div class="inline-block px-2">
{{ _("Sort by") }}
<select
name="selectsort"
id="selectsort"
class="inline-block rounded-md border-gray-200 text-sm ml-1 pl-1 pr-7 h-8 py-0"
>
<option {% if not init_sort %}selected{% endif %} value="">{{ _("Alphabetical") }}</option>
<option {% if init_sort == "popularity" %}selected{% endif %} value="popularity">{{ _("Popularity") }}</option>
</select>
</div>
<div class="inline-block flex items-center px-2 pt-2 md:pt-0 {% if not user %}text-gray-500{% endif %}" {% if not user %}title="{{ _('Requires to be logged-in') }}" aria-label="{{ _('Requires to be logged-in') }}"{% endif %}>
<label for="starsonly" class="inline-block relative mr-2 h-4 w-7 cursor-pointer">
<span class="sr-only">{{ _("Show only apps you starred") }}</span>
<input type="checkbox" id="starsonly" class="peer sr-only" {% if user and init_starsonly %}checked{% endif %} {% if not user%}disabled{% endif %}>
<span class="absolute inset-0 rounded-full bg-gray-300 transition peer-checked:bg-green-500">
</span>
<span class="absolute inset-y-0 start-0 m-1 h-2 w-2 rounded-full bg-white transition-all peer-checked:start-3">
</span>
</label>
{{ _("Show only apps you starred") }}
</div>
</div>
</div>
<div class="overflow-x-auto max-w-screen-lg mx-auto pt-5">
<table class="min-w-full divide-y-2 divide-gray-200 bg-white text-sm">
<thead>
<tr>
<th class="hidden sm:table-cell whitespace-nowrap px-4 py-2 font-medium text-gray-900">
{{ _("Name") }}
</th>
<th class="hidden sm:table-cell whitespace-nowrap px-4 py-2 font-medium text-gray-900">
{{ _("Description") }}
</th>
<th class="hidden sm:table-cell py-2"></th>
<th class="hidden sm:table-cell py-2"></th>
<th class="hidden sm:table-cell whitespace-nowrap px-4 py-2 font-medium text-gray-900 max-w-[5em]">{{ _("Popularity") }}</th>
</tr>
</thead>
<tbody id="wishlist" class="divide-y divide-gray-200">
{% for app, infos in wishlist.items() %}
{% set this_app_stars = stars.get(app, {})|length %}
{% if user %}
{% set user_starred_this_app = user['id'] in stars.get(app, {}) %}
{% else %}
{% set user_starred_this_app = False %}
{% endif %}
<tr class="search-entry"
data-appid="{{ app }}"
data-stars="{{ this_app_stars }}"
data-starred="{{ user_starred_this_app }}"
>
<td class="inline-block sm:table-cell px-4 py-2 font-bold text-gray-900 sm:max-w-[10em]">
{{ infos['name'] }}
</td>
<td class="block sm:table-cell px-4 py-0 sm:py-2 text-gray-700 max-w-md">{{ infos['description'] }}</td>
<td class="float-right sm:float-none sm:table-cell py-2 px-1 sm:px-0">
{% if infos['website'] %}
<a
title="{{ _('Official website') }}"
aria-label="{{ _('Official website') }}"
href="{{ infos['website'] }}"
class="inline-block"
>
<i class="fa fa-globe rounded-md border px-3 py-2 hover:bg-gray-100" aria-hidden="true"></i>
</a>
{% endif %}
</td>
<td class="float-right sm:float-none sm:table-cell py-2 px-1 sm:px-0">
{% if infos['upstream'] %}
<a
title="{{ _('Code repository') }}"
aria-label="{{ _('Code repository') }}"
href="{{ infos['upstream'] }}"
class="inline-block"
>
<i class="fa fa-code rounded-md border px-3 py-2 hover:bg-gray-100" aria-hidden="true"></i>
</a>
{% endif %}
</td>
<td class="float-right sm:float-none sm:table-cell py-2 px-1 sm:px-0 text-center max-w-[5em]">
<a
role="button"
title="{{ _('Star this app') }}"
aria-label="{{ _('Star this app') }}"
href="{{ url_for('star_app', app_id=app, action="unstar" if user_starred_this_app else "star") }}"
class="inline-block group btn-sm border text-violet-600 border-violet-500 hover:bg-violet-500 hover:text-white"
>
{% if not user_starred_this_app %}
<span class="inline-block {% if user %}group-hover:hidden{% endif %}">{{ this_app_stars }}</span>
<span class="hidden {% if user %}group-hover:inline-block{% endif %}">{{ this_app_stars+1 }}</span>
<i class="fa fa-star-o inline-block {% if user %}group-hover:hidden{% endif %}" aria-hidden="true"></i>
<i class="fa fa-star hidden {% if user %}group-hover:inline-block{% endif %}" aria-hidden="true"></i>
{% else %}
<span class="inline-block group-hover:hidden">{{ this_app_stars }}</span>
<span class="hidden group-hover:inline-block">{{ this_app_stars-1 }}</span>
<i class="fa fa-star inline-block group-hover:hidden" aria-hidden="true"></i>
<i class="fa fa-star-o hidden group-hover:inline-block" aria-hidden="true"></i>
{% endif %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="noResultFound" class="text-center pt-5 hidden">
<p class="text-lg font-bold text-gray-900 mb-5">
{{ _("No results found.") }}
</p>
</div>
</div>
<script type="text/javascript">
// A little delay
let typingTimer;
let typeInterval = 500; // Half a second
let searchInput = document.getElementById('search');
let selectSort = document.getElementById('selectsort');
let toggleStarsonly = document.getElementById('starsonly');
function liveSearch() {
// Locate the card elements
let entries = document.querySelectorAll('.search-entry')
// Locate the search input
let search_query = searchInput.value.trim().toLowerCase();
let selectedCategory = false;
let starsOnly = toggleStarsonly.checked;
let at_least_one_match = false;
// Loop through the entries
for (var i = 0; i < entries.length; i++) {
// If the text is within the card and the text matches the search query
if ((entries[i].textContent.toLowerCase().includes(search_query))
&& (! selectedCategory || (entries[i].dataset.category == selectedCategory))
&& (! starsOnly || (entries[i].dataset.starred == "True")))
{
// ...remove the `.is-hidden` class.
entries[i].classList.remove("hidden");
at_least_one_match = true;
}
else
{
// Otherwise, add the class.
entries[i].classList.add("hidden");
}
}
if (at_least_one_match === false)
{
document.getElementById('noResultFound').classList.remove("hidden");
}
else
{
document.getElementById('noResultFound').classList.add("hidden");
}
updateQueryArgsInURL()
}
function liveSort(container_to_sort) {
let sortBy = selectSort.value.trim();
var toSort = document.getElementById(container_to_sort).children;
toSort = Array.prototype.slice.call(toSort, 0);
if (sortBy === "newest") {
toSort.sort(function(a, b) {
return a.dataset.addedincatalog - b.dataset.addedincatalog ? 1 : -1;
});
}
else if (sortBy === "popularity") {
toSort.sort(function(a, b) {
return a.dataset.stars < b.dataset.stars ? 1 : -1;
});
}
else if (sortBy === "") {
toSort.sort(function(a, b) {
return a.dataset.appid > b.dataset.appid ? 1 : -1;
});
}
var parent = document.getElementById(container_to_sort);
parent.innerHTML = "";
for(var i = 0, l = toSort.length; i < l; i++) {
parent.appendChild(toSort[i]);
}
updateQueryArgsInURL()
}
function updateQueryArgsInURL() {
let search_query = searchInput.value.trim();
let category = false;
let sortBy = selectSort.value.trim();
let starsOnly = toggleStarsonly.checked;
if ('URLSearchParams' in window) {
var queryArgs = new URLSearchParams(window.location.search)
if (search_query) { queryArgs.set("search", search_query) } else { queryArgs.delete("search"); };
if (category) { queryArgs.set("category", category) } else { queryArgs.delete("category"); };
if (sortBy) { queryArgs.set("sort", sortBy) } else { queryArgs.delete("sort"); };
if (starsOnly) { queryArgs.set("starsonly", true) } else { queryArgs.delete("starsonly"); };
let newUrl;
if (queryArgs.toString())
{
newUrl = window.location.pathname + '?' + queryArgs.toString();
}
else
{
newUrl = window.location.pathname;
}
if (newUrl != window.location.pathname + window.location.search)
{
history.pushState(null, '', newUrl);
}
}
}
searchInput.addEventListener('keyup', () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(liveSearch, typeInterval);
});
selectSort.addEventListener('change', () => {
liveSort("wishlist");
});
toggleStarsonly.addEventListener('change', () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(liveSearch, typeInterval);
});
liveSearch();
liveSort("wishlist");
</script>
{% endblock %}

View file

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}
{{ _("Suggest an app") }}
{% endblock %}
{% block main %}
<div class="mt-5 text-center px-3 sm:px-0">
<h1 class="text-2xl font-bold text-gray-900">
{{ _("Suggest an application to be added to YunoHost's catalog") }}
</h1>
</div>
<div class="overflow-x-auto max-w-md mx-auto pt-5 px-3 sm:px-0">
{% if successmsg %}
<div role="alert" class="rounded-md border-s-4 border-green-500 bg-green-50 p-4 my-5">
<p class="mt-2 text-sm text-green-700 font-bold">
<i class="fa fa-thumbs-up fa-fw" aria-hidden="true"></i>
{{ successmsg }}
</p>
</div>
{% else %}
{% if not user %}
<div role="alert" class="rounded-md border-s-4 border-orange-500 bg-orange-50 p-4 mb-5">
<p class="mt-2 text-sm text-orange-700 font-bold">
<i class="fa fa-exclamation-triangle fa-fw" aria-hidden="true"></i>
{{ _("You must first login to be allowed to submit an app to the wishlist") }}
</p>
</div>
{% endif %}
<div role="alert" class="rounded-md border-s-4 border-sky-500 bg-sky-50 p-4">
<p class="mt-2 text-sm text-sky-700 font-bold">
<i class="fa fa-info-circle fa-fw" aria-hidden="true"></i>
{{ _("Please check the license of the app your are proposing") }}
</p>
<p class="mt-2 text-sm text-sky-700">
{{ _("The YunoHost project will only package free/open-source software (with possible case-by-case exceptions for apps which are not-totally-free)") }}
</p>
</div>
{% if errormsg %}
<div role="alert" class="rounded-md border-s-4 border-red-500 bg-red-50 p-4 my-5">
<p class="mt-2 text-sm text-red-700 font-bold">
<i class="fa fa-exclamation-triangle fa-fw" aria-hidden="true"></i>
{{ errormsg }}
</p>
</div>
{% endif %}
<form method="POST" action="{{ url_for('add_to_wishlist') }}" class="my-8" >
<input name="csrf_token" type="text" class="hidden" value="{{ csrf_token }}" >
<label for="name" class="mt-5 block font-bold text-gray-700">{{ _("Name") }}</label>
<input name="name" type="text" class="w-full mt-1 rounded-md border-gray-200 text-gray-700 shadow-sm" maxlength="30" required onkeyup="this.value = this.value.replace(/[^a-zA-Z0-9.-\\(\\)\\ ]/, '')" >
<label for="description" class="mt-5 block font-bold text-gray-700">{{ _("App's description") }}</label>
<textarea name="description" type="text" class="w-full mt-1 rounded-md border-gray-200 text-gray-700 shadow-sm" required rows='3' maxlength='100'></textarea>
<span class="text-xs text-gray-600"><span class="font-bold">{{ _("Please be concise and focus on what the app does.") }}</span> {{ _("No need to repeat '[App] is ...'. No need to state that it is free/open-source or self-hosted (otherwise it wouldn't be packaged for YunoHost). Avoid marketing stuff like 'the most', or vague properties like 'easy', 'simple', 'lightweight'.") }}</span>
<label for="upstream" class="mt-5 block font-bold text-gray-700">{{ _("Project code repository") }}</label>
<input name="upstream" type="url" class="w-full mt-1 rounded-md border-gray-200 text-gray-700 shadow-sm" maxlength="150" required >
<label for="website" class="mt-5 block font-bold text-gray-700">{{ _("Project website") }}</label>
<input name="website" type="url" class="w-full mt-1 rounded-md border-gray-200 text-gray-700 shadow-sm" maxlength="150" >
<span class="text-xs text-gray-600">{{ _("Please *do not* just copy-paste the code repository URL. If the project has no proper website, then leave the field empty.") }}</span>
<button
type="submit"
class="block mx-auto btn btn-primary mt-5 {% if user %}hover:bg-blue-700{% endif %}"
{% if not user %}disabled{% endif %}
>
{{ _("Submit") }}
</button>
</form>
{% endif %}
</div>
{% endblock %}

Binary file not shown.

View file

@ -0,0 +1,409 @@
# French translations for PROJECT.
# Copyright (C) 2023 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2023.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-09-19 17:04+0200\n"
"PO-Revision-Date: 2023-09-05 19:50+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
"Language-Team: fr <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
#: app.py:140
msgid "App %(app_id) not found"
msgstr "L'app %(app_id) n'a pas été trouvée"
#: app.py:142
msgid "You must be logged in to be able to star an app"
msgstr "Vous devez être connecté·e pour mettre une app en favoris"
#: app.py:185
msgid "You must be logged in to submit an app to the wishlist"
msgstr "Vous devez être connecté·e pour proposer une app pour la liste de souhaits"
#: app.py:200
msgid "Invalid CSRF token, please refresh the form and try again"
msgstr "Jeton CSRF invalide, prière de rafraîchir la page et retenter"
#: app.py:216
msgid "App name should be at least 3 characters"
msgstr "Le nom d'app devrait contenir au moins 3 caractères"
#: app.py:217
msgid "App name should be less than 30 characters"
msgstr "Le nom d'app devrait contenir moins de 30 caractères"
#: app.py:220
msgid "App description should be at least 5 characters"
msgstr "La description de l'app devrait contenir au moins 5 caractères"
#: app.py:224
msgid "App description should be less than 100 characters"
msgstr "La description de l'app devrait contenir moins de 100 caractères"
#: app.py:228
msgid "Upstream code repo URL should be at least 10 characters"
msgstr "L'URL du dépôt de code devrait contenir au moins 10 caractères"
#: app.py:232
msgid "Upstream code repo URL should be less than 150 characters"
msgstr "L'URL du dépôt de code devrait contenir moins de 150 caractères"
#: app.py:234
msgid "Website URL should be less than 150 characters"
msgstr "L'URL du site web devrait contenir moins de 150 caractères"
#: app.py:237
msgid "App name contains special characters"
msgstr "Le nom de l'app contiens des caractères spéciaux"
#: app.py:270
msgid "An entry with the name %(slug) already exists in the wishlist"
msgstr "Une entrée nommée $(slug) existe déjà dans la liste de souhaits"
#: app.py:295
msgid ""
"Failed to create the pull request to add the app to the wishlist ... "
"please report the issue to the yunohost team"
msgstr ""
"Échec de la création de la demande d'intégration de l'app dans la liste "
"de souhaits ... merci de rapport le problème à l'équipe YunoHost"
#: app.py:340
#, python-format
msgid ""
"Your proposed app has succesfully been submitted. It must now be "
"validated by the YunoHost team. You can track progress here: %(url)s"
msgstr ""
"Un demande d'intégration à la liste de souhaits a bien été créée pour "
"cette app. Elle doit maintenant être validée par l'équipe YunoHost. Vous "
"pouvez suivre cette demande ici: %(url)s"
#: templates/app.html:10 templates/catalog.html:23
#, python-format
msgid "Logo for %(app)s"
msgstr "Logo pour %(app)s"
#: templates/app.html:30 templates/app.html:31 templates/catalog.html:41
#: templates/catalog.html:42
msgid ""
"This app is currently flagged as broken because it failed our automatic "
"tests."
msgstr ""
"Cette app est actuellement marquée comme cassée ou de mauvaise qualité "
"car elle ne passe pas nos tests automatisés."
#: templates/app.html:30 templates/app.html:31 templates/catalog.html:41
#: templates/catalog.html:42 templates/catalog.html:169
msgid ""
"This is usually a temporary situation which requires packagers to fix "
"something in the app."
msgstr ""
"Il s'agit généralement d'une situation temporaire qui requiert que des "
"packageur·euse·s corrigent un problème dans l'app."
#: templates/app.html:37 templates/app.html:38 templates/catalog.html:46
#: templates/catalog.html:47
msgid ""
"This app has been good quality according to our automatic tests over at "
"least one year."
msgstr "Cette app est de bonne qualité d'après nos tests automatisés depuis au moins un an."
#: templates/app.html:81
msgid "Try the demo"
msgstr "Essayer la démo"
#: templates/app.html:82
msgid "Demo"
msgstr "Démo"
#: templates/app.html:85
msgid "Install with YunoHost"
msgstr "Installer avec YunoHost"
#: templates/app.html:93
#, python-format
msgid "Current version: %(version)s"
msgstr "Version actuelle: %(version)s"
#: templates/app.html:95
#, python-format
msgid "Potential alternative to: %(alternatives)s"
msgstr "Alternative potentielle à : %(alternatives)s"
#: templates/app.html:101
#, python-format
msgid "Screenshot for %(app)s"
msgstr "Capture d'écran pour %(app)s"
#: templates/app.html:106
#, python-format
msgid "This app is only compatible with these specific architectures: %(archs)s"
msgstr ""
"Cette app est uniquement compatible avec les architectures suivantes : "
"%(archs)s"
#: templates/app.html:112
#, python-format
msgid "This app requires an unusual amount of RAM to install: %(ram)s"
msgstr ""
"Cette app requiert une quantité inhabituelle de RAM pour être installée :"
" %(ram)s"
#: templates/app.html:118
msgid "Important infos before installing"
msgstr "Informations importantes avant l'installation"
#: templates/app.html:124
msgid "Anti-features"
msgstr "Anti-fonctionnalités"
#: templates/app.html:125
msgid "(This app has features you may not like)"
msgstr "(Cette app a des spécificités que vous pourriez ne pas aimer)"
#: templates/app.html:136
msgid "Useful links"
msgstr "Liens utiles"
#: templates/app.html:139
#, python-format
msgid "License: %(license)s"
msgstr "Licence: %(license)s"
#: templates/app.html:140
msgid " Official website"
msgstr "Site officiel"
#: templates/app.html:141
msgid "Official admin documentation"
msgstr "Documentation officielle pour les admins"
#: templates/app.html:142
msgid "Official user documentation"
msgstr "Documentation officielle pour les utilisateur·ice·s"
#: templates/app.html:143
msgid "Official code repository"
msgstr "Dépôt de code officiel"
#: templates/app.html:144
msgid "YunoHost package repository"
msgstr "Dépôt de code du paquet YunoHost"
#: templates/base.html:5
msgid "YunoHost app store"
msgstr "Store d'apps de YunoHost"
#: templates/base.html:56 templates/base.html:149 templates/index.html:3
msgid "Home"
msgstr "Accueil"
#: templates/base.html:65 templates/base.html:158
msgid "Catalog"
msgstr "Catalogue"
#: templates/base.html:71 templates/base.html:167
msgid "Wishlist"
msgstr "Liste de souhaits"
#: templates/base.html:84 templates/base.html:177
msgid "YunoHost documentation"
msgstr "Documentation YunoHost"
#: templates/base.html:92 templates/base.html:187
msgid "Login using YunoHost's forum"
msgstr "Se connecter via le forum YunoHost"
#: templates/base.html:122 templates/base.html:213
msgid "Logout"
msgstr "Se déconnecter"
#: templates/base.html:135
msgid "Toggle menu"
msgstr "Activer le menu"
#: templates/catalog.html:75 templates/catalog.html:80
msgid "Application Catalog"
msgstr "Catalogue d'applications"
#: templates/catalog.html:86 templates/wishlist.html:16
msgid "Search"
msgstr "Recherche"
#: templates/catalog.html:91 templates/wishlist.html:21
msgid "Search for..."
msgstr "Rechercher..."
#: templates/catalog.html:107
msgid "All apps"
msgstr "Toutes les apps"
#: templates/catalog.html:117 templates/wishlist.html:39
msgid "Sort by"
msgstr "Trier par"
#: templates/catalog.html:123 templates/wishlist.html:45
msgid "Alphabetical"
msgstr "Alphabétique"
#: templates/catalog.html:124
msgid "Newest"
msgstr "Nouveauté"
#: templates/catalog.html:125 templates/wishlist.html:46
#: templates/wishlist.html:78
msgid "Popularity"
msgstr "Popularité"
#: templates/catalog.html:128 templates/wishlist.html:49
msgid "Requires to be logged-in"
msgstr "Nécessite d'être connecté·e"
#: templates/catalog.html:130 templates/catalog.html:139
#: templates/wishlist.html:51 templates/wishlist.html:60
msgid "Show only apps you starred"
msgstr "Montrer uniquement mes favoris"
#: templates/catalog.html:155 templates/wishlist.html:152
msgid "No results found."
msgstr "Aucun résultat trouvé."
#: templates/catalog.html:158
msgid "Not finding what you are looking for?"
msgstr "Vous ne trouvez pas ce que vous cherchez ?"
#: templates/catalog.html:159
msgid "Checkout the wishlist!"
msgstr "Jetez un oeil à la liste de souhaits !"
#: templates/catalog.html:165
msgid "Applications currently flagged as broken"
msgstr "Applications actuellement marquées comme cassées"
#: templates/catalog.html:168
msgid "These are apps which failed our automatic tests."
msgstr "Il s'agit d'apps qui n'ont pas validé nos tests automatisés."
#: templates/index.html:10
msgid "Application Store"
msgstr "Store d'application"
#: templates/index.html:21
msgid "Browse all applications"
msgstr "Toutes les applications"
#: templates/wishlist.html:3 templates/wishlist.html:8
msgid "Application Wishlist"
msgstr "Liste de souhaits d'applications"
#: templates/wishlist.html:10
msgid ""
"The wishlist is the place where people can collectively suggest and vote "
"for apps that they would like to see packaged and made available in "
"YunoHost's official apps catalog. Nevertheless, the fact that apps are "
"listed here should by no mean be interpreted as a fact that the YunoHost "
"project plans to integrate it, and is merely a source of inspiration for "
"packaging volunteers."
msgstr ""
"La liste de souhaits est l'endroit où il est possible de collectivement "
"suggérer et voter pour des applications que vous aimeriez voir packagée "
"et intégrée dans le catalogue officiel de YunoHost. Néanmoins, le fait "
"que des apps soient listées ici ne devrait en aucun cas être interprété "
"comme le fait que le projet YunoHost prévoit leur intégration, et est "
"uniquement une source d'inspiration pour les packageur·euse·s bénévoles."
#: templates/wishlist.html:33 templates/wishlist_add.html:3
msgid "Suggest an app"
msgstr "Suggérer une app"
#: templates/wishlist.html:71 templates/wishlist_add.html:57
msgid "Name"
msgstr "Nom"
#: templates/wishlist.html:74
msgid "Description"
msgstr "Description"
#: templates/wishlist.html:102 templates/wishlist.html:103
msgid "Official website"
msgstr "Site officiel"
#: templates/wishlist.html:114 templates/wishlist.html:115
msgid "Code repository"
msgstr "Dépôt de code officiel"
#: templates/wishlist.html:127 templates/wishlist.html:128
msgid "Star this app"
msgstr "Étoiler cette app"
#: templates/wishlist_add.html:8
msgid "Suggest an application to be added to YunoHost's catalog"
msgstr "Suggérer une application à ajouter dans le catalogue de YunoHost"
#: templates/wishlist_add.html:29
msgid "You must first login to be allowed to submit an app to the wishlist"
msgstr "Vous devez être connecté·e pour proposer une app pour la liste de souhaits"
#: templates/wishlist_add.html:37
msgid "Please check the license of the app your are proposing"
msgstr "Merci de vérifier la licence de l'app que vous proposez"
#: templates/wishlist_add.html:40
msgid ""
"The YunoHost project will only package free/open-source software (with "
"possible case-by-case exceptions for apps which are not-totally-free)"
msgstr ""
"Le projet YunoHost intègrera uniquement des logiciels libre/open-source "
"(avec quelques possibles exceptions au cas-par-cas pour des apps qui ne "
"sont pas entièrement libres)"
#: templates/wishlist_add.html:60
msgid "App's description"
msgstr "Description de l'app"
#: templates/wishlist_add.html:62
msgid "Please be concise and focus on what the app does."
msgstr "Prière de rester concis et de se concentrer sur ce que l'app fait."
#: templates/wishlist_add.html:62
msgid ""
"No need to repeat '[App] is ...'. No need to state that it is free/open-"
"source or self-hosted (otherwise it wouldn't be packaged for YunoHost). "
"Avoid marketing stuff like 'the most', or vague properties like 'easy', "
"'simple', 'lightweight'."
msgstr ""
"Il n'est pas nécessaire de répéter '[App] est ...', ni que l'app est "
"libre/open-source (sinon, elle ne serait pas intégrable au catalogue). "
"Évitez les formulations marketing type 'le meilleur', ou les propriétés "
"vagues telles que 'facile', 'simple', 'léger'."
#: templates/wishlist_add.html:64
msgid "Project code repository"
msgstr "Dépôt de code officiel"
#: templates/wishlist_add.html:67
msgid "Project website"
msgstr "Site officiel"
#: templates/wishlist_add.html:69
msgid ""
"Please *do not* just copy-paste the code repository URL. If the project "
"has no proper website, then leave the field empty."
msgstr ""
"Prière de *ne pas* juste copier-coller l'URL du dépôt de code. Si le "
"projet n'a pas de vrai site web, laissez le champ vide."
#: templates/wishlist_add.html:76
msgid "Submit"
msgstr "Envoyer"

168
store/utils.py Normal file
View file

@ -0,0 +1,168 @@
import base64
import os
import json
import toml
import subprocess
import pycmarkgfm
from emoji import emojize
from flask import request
AVAILABLE_LANGUAGES = ["en"] + os.listdir("translations")
def get_locale():
# try to guess the language from the user accept
# The best match wins.
return request.accept_languages.best_match(AVAILABLE_LANGUAGES) or "en"
def get_catalog():
path = "../builds/default/v3/apps.json"
mtime = os.path.getmtime(path)
if get_catalog.mtime_catalog != mtime:
get_catalog.mtime_catalog = mtime
catalog = json.load(open(path))
catalog["categories"] = {c["id"]: c for c in catalog["categories"]}
catalog["antifeatures"] = {c["id"]: c for c in catalog["antifeatures"]}
category_color = {
"synchronization": "sky",
"publishing": "yellow",
"communication": "amber",
"office": "lime",
"productivity_and_management": "purple",
"small_utilities": "black",
"reading": "emerald",
"multimedia": "fuchsia",
"social_media": "rose",
"games": "violet",
"dev": "stone",
"system_tools": "black",
"iot": "orange",
"wat": "teal",
}
for id_, category in catalog["categories"].items():
category["color"] = category_color[id_]
get_catalog.cache_catalog = catalog
return get_catalog.cache_catalog
get_catalog.mtime_catalog = None
get_catalog()
def get_wishlist():
path = "../wishlist.toml"
mtime = os.path.getmtime(path)
if get_wishlist.mtime_wishlist != mtime:
get_wishlist.mtime_wishlist = mtime
get_wishlist.cache_wishlist = toml.load(open(path))
return get_wishlist.cache_wishlist
get_wishlist.mtime_wishlist = None
get_wishlist()
def get_stars():
checksum = (
subprocess.check_output("find . -type f -printf '%T@,' | md5sum", shell=True)
.decode()
.split()[0]
)
if get_stars.cache_checksum != checksum:
stars = {}
for folder, _, files in os.walk(".stars/"):
app_id = folder.split("/")[-1]
if not app_id:
continue
stars[app_id] = set(files)
get_stars.cache_stars = stars
get_stars.cache_checksum = checksum
return get_stars.cache_stars
get_stars.cache_checksum = None
get_stars()
def human_to_binary(size: str) -> int:
symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
factor = {}
for i, s in enumerate(symbols):
factor[s] = 1 << (i + 1) * 10
suffix = size[-1]
size = size[:-1]
if suffix not in symbols:
raise Exception(f"Invalid size suffix '{suffix}', expected one of {symbols}")
try:
size_ = float(size)
except Exception:
raise Exception(f"Failed to convert size {size} to float")
return int(size_ * factor[suffix])
def get_app_md_and_screenshots(app_folder, infos):
locale = get_locale()
if locale != "en" and os.path.exists(
os.path.join(app_folder, "doc", f"DESCRIPTION_{locale}.md")
):
description_path = os.path.join(app_folder, "doc", f"DESCRIPTION_{locale}.md")
elif os.path.exists(os.path.join(app_folder, "doc", "DESCRIPTION.md")):
description_path = os.path.join(app_folder, "doc", "DESCRIPTION.md")
else:
description_path = None
if description_path:
with open(description_path) as f:
infos["full_description_html"] = emojize(
pycmarkgfm.gfm_to_html(f.read()), language="alias"
)
else:
infos["full_description_html"] = infos["manifest"]["description"][locale]
if locale != "en" and os.path.exists(
os.path.join(app_folder, "doc", f"PRE_INSTALL_{locale}.md")
):
pre_install_path = os.path.join(app_folder, "doc", f"PRE_INSTALL_{locale}.md")
elif os.path.exists(os.path.join(app_folder, "doc", "PRE_INSTALL.md")):
pre_install_path = os.path.join(app_folder, "doc", "PRE_INSTALL.md")
else:
pre_install_path = None
if pre_install_path:
with open(pre_install_path) as f:
infos["pre_install_html"] = emojize(
pycmarkgfm.gfm_to_html(f.read()), language="alias"
)
infos["screenshot"] = None
screenshots_folder = os.path.join(app_folder, "doc", "screenshots")
if os.path.exists(screenshots_folder):
with os.scandir(screenshots_folder) as it:
for entry in it:
ext = os.path.splitext(entry.name)[1].replace(".", "").lower()
if entry.is_file() and ext in ("png", "jpg", "jpeg", "webp", "gif"):
with open(entry.path, "rb") as img_file:
data = base64.b64encode(img_file.read()).decode("utf-8")
infos[
"screenshot"
] = f"data:image/{ext};charset=utf-8;base64,{data}"
break
ram_build_requirement = infos["manifest"]["integration"]["ram"]["build"]
infos["manifest"]["integration"]["ram"]["build_binary"] = human_to_binary(
ram_build_requirement
)

View file

@ -5,6 +5,7 @@ import sys
from functools import cache
from pathlib import Path
from typing import Any, Dict, Generator, List, Tuple
from difflib import SequenceMatcher
import jsonschema
import toml
@ -30,6 +31,12 @@ def get_antifeatures() -> Dict[str, Any]:
return toml.load(antifeatures_path)
@cache
def get_wishlist() -> Dict[str, Dict[str, str]]:
wishlist_path = APPS_ROOT / "wishlist.toml"
return toml.load(wishlist_path)
def validate_schema() -> Generator[str, None, None]:
with open(APPS_ROOT / "schemas" / "apps.toml.schema.json", encoding="utf-8") as file:
apps_catalog_schema = json.load(file)
@ -46,6 +53,19 @@ def check_app(app: str, infos: Dict[str, Any]) -> Generator[Tuple[str, bool], No
if infos["state"] != "working":
return
# validate that the app is not (anymore?) in the wishlist
# we use fuzzy matching because the id in catalog may not be the same exact id as in the wishlist
# some entries are ignore-hard-coded, because e.g. radarr an readarr are really different apps...
ignored_wishlist_entries = ["readarr"]
wishlist_matches = [
wish
for wish in get_wishlist()
if wish not in ignored_wishlist_entries
and SequenceMatcher(None, app, wish).ratio() > 0.9
]
if wishlist_matches:
yield f"app seems to be listed in wishlist: {wishlist_matches}", True
repo_name = infos.get("url", "").split("/")[-1]
if repo_name != f"{app}_ynh":
yield f"repo name should be {app}_ynh, not in {repo_name}", True

1367
wishlist.toml Normal file

File diff suppressed because it is too large Load diff