mirror of
https://github.com/YunoHost/apps.git
synced 2024-09-03 20:06:07 +02:00
commit
997e89f44a
29 changed files with 3773 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,3 +15,4 @@ tools/autopatches/token
|
|||
|
||||
__pycache__
|
||||
app_list_auto_update.log
|
||||
venv
|
||||
|
|
3
store/.gitignore
vendored
Normal file
3
store/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
config.toml
|
||||
.stars
|
||||
messages.pot
|
0
store/.stars/.gitkeep
Normal file
0
store/.stars/.gitkeep
Normal file
49
store/README.md
Normal file
49
store/README.md
Normal 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
0
store/__init__.py
Normal file
473
store/app.py
Normal file
473
store/app.py
Normal 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
|
BIN
store/assets/app_logo_placeholder.png
Normal file
BIN
store/assets/app_logo_placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
BIN
store/assets/favicon.png
Normal file
BIN
store/assets/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
13
store/assets/fetch_assets
Normal file
13
store/assets/fetch_assets
Normal 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
|
38
store/assets/horizontal-yunohost.svg
Normal file
38
store/assets/horizontal-yunohost.svg
Normal 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 |
37
store/assets/tailwind-local.css
Normal file
37
store/assets/tailwind-local.css
Normal 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;
|
||||
}
|
||||
}
|
17
store/assets/tailwind.config.js
Normal file
17
store/assets/tailwind.config.js
Normal 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
2
store/babel.cfg
Normal file
|
@ -0,0 +1,2 @@
|
|||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
9
store/config.toml.example
Normal file
9
store/config.toml.example
Normal 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
13
store/gunicorn.py
Normal 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
15
store/nginx.conf.example
Normal 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
9
store/requirements.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
Flask==2.3.2
|
||||
python-slugify
|
||||
PyGithub
|
||||
toml
|
||||
pycmarkgfm
|
||||
gunicorn
|
||||
emoji
|
||||
Babel
|
||||
Flask-Babel
|
53
store/systemd.conf.example
Normal file
53
store/systemd.conf.example
Normal 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
147
store/templates/app.html
Normal 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
209
store/templates/base.html
Normal 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>
|
316
store/templates/catalog.html
Normal file
316
store/templates/catalog.html
Normal 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 %}
|
42
store/templates/index.html
Normal file
42
store/templates/index.html
Normal 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 %}
|
280
store/templates/wishlist.html
Normal file
280
store/templates/wishlist.html
Normal 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 %}
|
83
store/templates/wishlist_add.html
Normal file
83
store/templates/wishlist_add.html
Normal 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 %}
|
BIN
store/translations/fr/LC_MESSAGES/messages.mo
Normal file
BIN
store/translations/fr/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
409
store/translations/fr/LC_MESSAGES/messages.po
Normal file
409
store/translations/fr/LC_MESSAGES/messages.po
Normal 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
168
store/utils.py
Normal 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
|
||||
)
|
|
@ -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
1367
wishlist.toml
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue