Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5

This commit is contained in:
Darks 2023-07-18 21:31:46 +02:00
commit 21b863e0e2
Signed by: Darks
GPG Key ID: 7515644268BE1433
64 changed files with 493 additions and 6774 deletions

7
.gitignore vendored
View File

@ -22,6 +22,9 @@ test.*
# Autosaves
*.dia~
## Logging files
*.log
## Deployment files
@ -39,6 +42,10 @@ local_config.py
wiki/
## JavaScript submodules buld files
# Emoji picker
app/static/scripts/emoji-picker-element/
## Personal folder

View File

@ -9,6 +9,7 @@ from config import FlaskApplicationSettings, V5Config
app = Flask(__name__)
app.config.from_object(FlaskApplicationSettings)
app.v5logger = V5Config.v5logger()
# Check security of secret
if FlaskApplicationSettings.SECRET_KEY == "a-random-secret-key":

View File

@ -397,8 +397,7 @@ class Member(User):
Notify a user with a message.
An hyperlink can be added to redirect to the notification source
"""
return
n = Notification(self.id, message, href=href)
n = Notification(self, message, href=href)
db.session.add(n)
db.session.commit()

View File

@ -1,6 +1,6 @@
# Register routes here
from app.routes import index, search, users, tools, development
from app.routes import index, search, users, tools, development, chat
from app.routes.account import login, account, notification, polls
from app.routes.admin import index, groups, account, forums, \
attachments, config, members, polls, login_as

View File

@ -8,6 +8,7 @@ from app.models.trophy import Title
from app.utils.render import render
from app.utils.send_mail import send_validation_mail, send_reset_password_mail
from app.utils.priv_required import guest_only
from app.utils.glados import say, BOLD
import app.utils.ldap as ldap
import app.utils.validators as vd
from itsdangerous import URLSafeTimedSerializer
@ -30,6 +31,7 @@ def edit_account():
if form.submit.data:
if form.is_submitted() and form.validate(extra_validators=extra_vd):
old_username = current_user.norm
current_user.update(
avatar=form.avatar.data or None,
email=form.email.data or None,
@ -41,10 +43,13 @@ def edit_account():
newsletter=form.newsletter.data,
theme=form.theme.data
)
ldap.edit(old_username, current_user)
current_user.update(password=form.password.data or None)
db.session.merge(current_user)
db.session.commit()
current_user.update_trophies("on-profile-update")
flash('Modifications effectuées', 'ok')
app.v5logger.info(f"<{current_user.name}> has edited their account")
return redirect(request.url)
else:
flash('Erreur lors de la modification', 'error')
@ -62,6 +67,7 @@ def ask_reset_password():
m = Member.query.filter_by(email=form.email.data).first()
if m is not None:
send_reset_password_mail(m.name, m.email)
app.v5logger.info(f"<{m.name}> has asked a password reset token")
flash('Un email a été envoyé à l\'adresse renseignée', 'ok')
return redirect(url_for('login'))
elif request.method == "POST":
@ -87,6 +93,7 @@ def reset_password(token):
db.session.merge(m)
db.session.commit()
flash('Modifications effectuées', 'ok')
app.v5logger.info(f"<{m.name}> has reset their password")
return redirect(url_for('login'))
else:
flash('Erreur lors de la modification', 'error')
@ -102,6 +109,7 @@ def delete_account():
if del_form.submit.data:
if del_form.validate_on_submit():
name = current_user.name
if del_form.transfer.data:
guest = Guest(current_user.generate_guest_name())
db.session.add(guest)
@ -112,10 +120,14 @@ def delete_account():
current_user.delete_posts()
db.session.commit()
if (V5Config.USE_LDAP):
ldap.delete_member(current_user)
current_user.delete()
logout_user()
db.session.commit()
flash('Compte supprimé', 'ok')
app.v5logger.info(f"<{name}> has deleted their account ({'with' if del_form.transfer.data else 'without'} guest transfer)")
return redirect(url_for('index'))
else:
flash('Erreur lors de la suppression du compte', 'error')
@ -141,6 +153,7 @@ def register():
# Email validation message
send_validation_mail(member.name, member.email)
app.v5logger.info(f"<{member.name}> registered")
return redirect(url_for('validation') + "?email=" + form.email.data)
return render('account/register.html', title='Register',
@ -178,4 +191,8 @@ def activate_account(token):
db.session.commit()
flash("L'email a bien été confirmé", "ok")
app.v5logger.info(f"<{m.name}> has activated their account")
say(f"Un nouveau membre sest inscrit ! Il sagit de {BOLD}{m.name}{BOLD}.")
say(url_for('user', username=m.name, _external=True))
return redirect(url_for('login'))

View File

@ -49,6 +49,7 @@ def login():
login_user(member, remember=form.remember_me.data,
duration=datetime.timedelta(days=7))
member.update_trophies("on-login")
app.v5logger.info(f"<{member.name}> has logged in")
# Redirect safely (https://huit.re/open-redirect)
def is_safe_url(target):
@ -71,8 +72,10 @@ def login():
@login_required
@check_csrf
def logout():
name = current_user.name
logout_user()
flash('Déconnexion réussie', 'info')
app.v5logger.info(f"<{name}> has logged out")
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))

View File

@ -28,5 +28,6 @@ def account_polls():
db.session.commit()
flash(f"Le sondage {p.id} a été créé", "info")
app.v5logger.info(f"<{current_user.name}> has created the form #{p.id}")
return render("account/polls.html", polls=polls, form=form)

View File

@ -9,6 +9,7 @@ from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \
AdminAccountEditTrophyForm, AdminAccountEditGroupForm
from app.utils.render import render
from app.utils.notify import notify
from app.utils import ldap as ldap
from app import app, db
from config import V5Config
@ -50,12 +51,12 @@ def adm_edit_account(user_id):
# You cannot user vd.name_available because name will always be
# invalid! Maybe you can add another validator with arguments
raise Exception(f'{newname} is not available')
old_username = user.norm
user.update(
avatar=form.avatar.data or None,
name=form.username.data or None,
email=form.email.data or None,
email_confirmed=form.email_confirmed.data,
password=form.password.data or None,
birthday=form.birthday.data,
signature=form.signature.data,
title=form.title.data,
@ -63,11 +64,14 @@ def adm_edit_account(user_id):
newsletter=form.newsletter.data,
xp=form.xp.data or None,
)
ldap.edit(old_username, user)
user.update(password=form.password.data or None)
db.session.merge(user)
db.session.commit()
# TODO: send an email to member saying his account has been modified
user.notify(f"Vos informations personnelles ont été modifiées par {current_user.name}.")
flash('Modifications effectuées', 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s data")
return redirect(request.url)
else:
flash('Erreur lors de la modification', 'error')
@ -85,6 +89,7 @@ def adm_edit_account(user_id):
db.session.merge(user)
db.session.commit()
flash('Modifications effectuées', 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s trophies")
return redirect(request.url)
else:
flash("Erreur lors de la modification des trophées", 'error')
@ -102,6 +107,7 @@ def adm_edit_account(user_id):
db.session.merge(user)
db.session.commit()
flash('Modifications effectuées', 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s groups")
return redirect(request.url)
else:
flash("Erreur lors de la modification des groupes", 'error')
@ -148,9 +154,13 @@ def adm_delete_account(user_id):
user.delete_posts()
db.session.commit()
if (V5Config.USE_LDAP):
ldap.delete_member(user)
user.delete()
db.session.commit()
flash('Compte supprimé', 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has deleted <{user.name}> account")
return redirect(url_for('adm'))
else:
flash('Erreur lors de la suppression du compte', 'error')

View File

@ -42,6 +42,7 @@ def adm_login_as():
# Create a safe token to flee when needed
s = Serializer(app.config["SECRET_KEY"])
vandal_token = s.dumps(current_user.id)
vandal_name = current_user.name
# Login and display some messages
login_user(user)
@ -51,9 +52,11 @@ def adm_login_as():
else:
flash(f"Connecté en tant que {user.name}")
app.v5logger.info(f"[admin] <{vandal_name}> has logged in as <{user.name}>")
# Return the response
resp = make_response(redirect(url_for('index')))
resp.set_cookie('vandale', vandal_token)
resp.set_cookie('vandale', vandal_token, path='/')
return resp
# Else return form
@ -76,13 +79,22 @@ def adm_logout_as():
abort(403)
user = Member.query.get(id)
# Send a notification to vandalized user
current_user.notify(f"{user.name} a accédé à ce compte à des fins de modération",
url_for('user', username=user.name))
# Switch back to admin
victim_name = current_user.name
logout_user()
login_user(user)
app.v5logger.info(f"[admin] <{user.name}> has logged out from <{victim_name}>'s account")
if request.referrer:
resp = make_response(redirect(request.referrer))
else:
resp = make_response(redirect(url_for('index')))
resp.set_cookie('vandale', '', expires=0)
resp.set_cookie('vandale', '', expires=0, path='/')
return resp

View File

@ -4,7 +4,7 @@ from app.utils.render import render
from app.models.poll import Poll
@app.route('/admin/sondages', methods=['GET'])
@priv_required('access-admin-panel')
@priv_required('misc.admin-panel')
def adm_polls():
polls = Poll.query.order_by(Poll.end.desc()).all()

16
app/routes/chat.py Normal file
View File

@ -0,0 +1,16 @@
from app import app
from app.utils.render import render
from flask import send_file, url_for
@app.route('/chat')
def chat():
return render('chat.html',
styles=[
'+css/v5shoutbox.css'],
scripts=[
'-scripts/trigger_menu.js',
'-scripts/editor.js'])
@app.route('/v5shoutbox.js')
def v5shoutbox_js():
return send_file('static/scripts/v5shoutbox.js')

View File

@ -4,6 +4,7 @@ from flask import request, redirect, url_for, abort, flash
from app import app, db
from config import V5Config
from app.utils.render import render
from app.utils.glados import say, BOLD
from app.forms.forum import TopicCreationForm, AnonymousTopicCreationForm
from app.models.forum import Forum
from app.models.topic import Topic
@ -73,6 +74,11 @@ def forum_page(f, page=1):
current_user.update_trophies('new-post')
flash('Le sujet a bien été créé', 'ok')
app.v5logger.info(f"<{t.author.name}> has created the topic #{t.id}")
if f.is_default_accessible():
say(f"Nouveau topic de {author.name} : {BOLD}{t.title}{BOLD}")
say(url_for('forum_topic', f=f, page=(t, 1), _external=True))
return redirect(url_for('forum_topic', f=f, page=(t,1)))
# Paginate topic pages

View File

@ -5,6 +5,7 @@ from sqlalchemy import desc
from app import app, db
from config import V5Config
from app.utils.render import render
from app.utils.glados import say, BOLD
from app.forms.forum import CommentForm, AnonymousCommentForm
from app.models.thread import Thread
from app.models.comment import Comment
@ -54,6 +55,11 @@ def forum_topic(f, page):
current_user.update_trophies('new-post')
flash('Message envoyé', 'ok')
app.v5logger.info(f"<{c.author.name}> has posted a the comment #{c.id}")
if f.is_default_accessible():
say(f"Nouveau commentaire de {author.name} sur le topic : {BOLD}{t.title}{BOLD}")
say(url_for('forum_topic', f=f, page=(t, "fin"), _anchor=str(c.id), _external=True))
# Redirect to empty the form
return redirect(url_for('forum_topic', f=f, page=(t, "fin"),
_anchor=str(c.id)))

View File

@ -5,7 +5,7 @@ from app.utils.render import render
@app.route('/')
def index():
return render('index.html')
return render('index.html', styles=["+css/homepage.css"])
@app.errorhandler(404)

View File

@ -34,7 +34,8 @@ def poll_vote(poll_id):
db.session.add(answer)
db.session.commit()
flash('Le vote a été pris en compte', 'info')
flash('Le vote a été pris en compte', 'ok')
app.v5logger.info(f"<{current_user.name}> has voted on the poll #{poll.id}")
if request.referrer:
return redirect(request.referrer)

View File

@ -84,6 +84,10 @@ def edit_post(postid):
for a, file in attachments:
a.set_file(file)
flash('Modifications enregistrées', 'ok')
admin_msg = "[admin] " if current_user != p.author else ""
app.v5logger.info(f"{admin_msg}<{current_user.name}> has edited the post #{p.id}")
# Determine topic URL now, in case forum was changed
if isinstance(p, Topic):
return redirect(url_for('forum_topic', f=p.forum, page=(p,1)))
@ -111,6 +115,10 @@ def delete_post(postid):
if not current_user.can_delete_post(p):
abort(403)
# Is a penalty deletion
is_penalty = request.args.get('penalty') == 'True' \
and current_user.priv('delete.posts')
# Users who need to have their trophies updated
authors = set()
@ -126,16 +134,21 @@ def delete_post(postid):
authors.add(comment.author)
if isinstance(p.author, Member):
factor = 3 if request.args.get('penalty') == 'True' else 1
factor = 3 if is_penalty else 1
p.author.add_xp(xp * factor)
db.session.merge(p.author)
authors.add(p.author)
admin_msg = "[admin] " if current_user != p.author else ""
p.delete()
db.session.commit()
for author in authors:
author.update_trophies("new-post")
flash("Le contenu a été supprimé", 'ok')
penalty_msg = " (with penalty)" if is_penalty else ""
app.v5logger.info(f"{admin_msg}<{current_user.name}> has deleted the post #{p.id}{penalty_msg}")
return redirect(next_page)
@ -149,6 +162,9 @@ def set_post_topcomment(postid):
comment.thread.top_comment = comment
db.session.add(comment.thread)
db.session.commit()
flash("Le post a été défini comme nouvel en-tête", 'ok')
admin_msg = "[admin] " if current_user != comment.author else ""
app.v5logger.info(f"{admin_msg}<{current_user.name}> has set a new top comment on thread #{comment.thread.id}")
return redirect(request.referrer)
@ -191,8 +207,11 @@ def move_post(postid):
comment.thread = thread
db.session.add(comment)
db.session.commit()
flash("Le topic a été déplacé", 'ok')
admin_msg = "[admin] " if current_user != comment.author else ""
app.v5logger.info(f"{admin_msg}<{current_user.name}> has moved the comment #{comment.id} to thread #{thread.id}")
return redirect(url_for('forum_topic', f=t.forum, page=(t,1)))
return render('post/move_post.html', comment=comment,
search_form=search_form, move_form=move_form)
@ -212,8 +231,10 @@ def lock_thread(postid):
db.session.commit()
if post.thread.locked:
flash(f"Le thread a été verrouillé")
flash(f"Le thread a été verrouillé", 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has locked the thread #{post.thread.id}")
else:
flash(f"Le thread a été déverrouillé")
flash(f"Le thread a été déverrouillé", 'ok')
app.v5logger.info(f"[admin] <{current_user.name}> has unlocked the thread #{post.thread.id}")
return redirect(request.referrer)

View File

@ -4,6 +4,7 @@ from app.models.program import Program
from app.models.comment import Comment
from app.models.thread import Thread
from app.utils.render import render
from app.utils.glados import say, BOLD
from app.forms.forum import CommentForm, AnonymousCommentForm
from config import V5Config
@ -41,6 +42,10 @@ def program_view(page):
current_user.update_trophies('new-post')
flash('Message envoyé', 'ok')
app.v5logger.info(f"<{c.author.name}> has posted a the comment #{c.id}")
say(f"Nouveau commentaire de {author.name} sur le programme : {BOLD}{p.name}{BOLD}")
say(url_for('program_view', page=(p, "fin"), _anchor=str(c.id), _external=True))
# Redirect to empty the form
return redirect(url_for('program_view', page=(p, "fin"), _anchor=str(c.id)))

View File

@ -5,6 +5,7 @@ from app.models.comment import Comment
from app.models.tag import Tag
from app.models.attachment import Attachment
from app.utils.render import render
from app.utils.glados import say, BOLD
from app.forms.programs import ProgramCreationForm
from flask_login import login_required, current_user
@ -54,6 +55,10 @@ def program_submit():
current_user.update_trophies('new-program')
flash('Le programme a bien été soumis', 'ok')
return redirect(url_for('program_index'))
app.v5logger.info(f"<{p.author.name}> has submitted the program #{c.id}")
say(f"Nouveau programme de {current_user.name} : {BOLD}{p.name}{BOLD}")
say(url_for('program_view', page=(p, 1), _external=True))
return redirect(url_for('program_view', page=(p, 1)))
return render('/programs/submit.html', form=form)

View File

@ -163,6 +163,7 @@ input[type="submit"]:focus {
background: var(--links);
color: var(--warn-text);
border-radius: 1px;
overflow: hidden;
}
.skip-to-content-link:focus {
transform: translateY(0%);

View File

@ -1,135 +1,100 @@
.home-title {
margin: 20px 0;
padding: 10px 5%;
background: #bf1c11;
box-shadow: 0 2px 2px rgba(0,0,0,.3);
border-top: 10px solid #ab170c;
}
.home-title h1 {
margin-top: 0;
color: #ffffff;
border-color: #ffffff;
}
.home-title p {
margin-bottom: 0;
text-align: justify;
color: #ffffff;
}
.home-title a {
color: inherit;
text-decoration: underline;
}
.home-pinned-content > div {
display: flex;
justify-content: space-between;
}
.home-pinned-content h2 {
display: block;
margin: 5px 0;
font-size: 18px;
font-family: NotoSans;
font-weight: 200;
line-height: 20px;
}
.home-pinned-content a {
display: block;
}
.home-pinned-content a:hover img,
.home-pinned-content a:focus img {
filter: blur(3px);
}
.home-pinned-content a:hover div,
.home-pinned-content a:focus div {
padding: 200px 5% 10px 5%;
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
}
.home-pinned-content img {
width: 100%;
filter: blur(0px);
}
.home-pinned-content article {
flex-grow: 1;
margin: 0 1px;
padding: 0;
position: relative;
max-width: 250px;
overflow: hidden;
}
.home-pinned-content article div {
position: absolute;
bottom: 0;
z-index: 3;
.home-pinned-content {
width: 90%;
margin: 0;
padding: 30px 5% 10px 5%;
color: #ffffff;
text-shadow: 1px 1px 0 rgba(0,0,0,.6);
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
display: grid;
grid-template-areas: 'banner news''welcome news''shout news''projects projects';
grid-template-rows: auto auto minmax(200px,1fr)auto;
grid-template-columns: 4fr 3fr;
}
.home-articles {
.home-pinned-content > * {
margin: 10px 20px;
}
.home-pinned-content > * h1 {
font-size: 18px;
}
@media screen and (max-width:1449px) {
.home-pinned-content {
width: 97%;
}
}
@media screen and (max-width:1199px) {
.home-pinned-content {
width: 100%;
grid-template-areas: 'welcome''banner''news''shout''projects';
grid-template-rows: auto;
grid-template-columns: 1fr;
}
}
.home-banner {
grid-area: banner;
text-align: center;
}
.home-banner img {
max-width: 100%;
}
.home-welcome {
grid-area: welcome;
display: flex;
justify-content: space-between;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.home-articles > div {
.home-welcome h1 {
width: 100%;
margin-bottom: 0;
}
.home-welcome ul {
padding-left: 20px;
}
.home-welcome div {
flex-grow: 1;
max-width: 48%;
}
.home-articles h1 {
display: flex;
justify-content: space-between;
align-items: center;
}
.home-articles h1 a {
padding: 0;
font-family: NotoSans;
font-size: 16px;
font-weight: 400;
color: #234d5f;
}
.home-articles h1 a:hover,
.home-articles h1 a:focus {
padding-right: 10px;
}
.home-articles p {
.home-welcome h2 {
margin: 5px 0;
text-align: justify;
color: #808080;
}
.home-articles article {
padding: 10px;
margin: 10px 0;
.home-news {
grid-area: news;
}
.home-news ul {
padding: 0;
}
.home-news li {
display: flex;
flex-direction: row;
align-items: center;
background: #ffffff;
border: 1px solid rgba(0,0,0,.2);
flex-wrap: nowrap;
padding: 10px 0;
border-bottom: var(--hr-border);
}
.home-articles article > img {
float: left;
margin-right: 10px;
flex-shrink: 0;
.home-news li > a {
align-self: baseline;
}
.home-articles article > img.screeshot {
width: 128px;
height: 64px;
.home-news li img {
max-width: 100px;
max-height: 100px;
margin-right: 8px;
}
.home-articles article > div {
flex-shrink: 1;
}
.home-articles article h3 {
.home-news li h3 {
margin: 0;
color: #424242;
font-weight: normal;
font-size: 16px;
font-weight: bold;
font-family: Cantarell;
}
.home-articles article a:hover,
.home-articles article a:focus {
text-decoration: underline;
.home-news li .date {
margin: 4px 0 10px 0;
}
.home-articles .metadata {
margin: 0;
color: #22292c;
.home-news li div {
font-size: 13px;
line-height: 150%;
}
.home-articles .metadata a {
color: #22292c;
font-weight: 400;
font-style: italic;
@media screen and (max-width:499px) {
.home-news li {
flex-direction: column;
align-items: start;
}
}
.home-shoutbox {
grid-area: shout;
}
.home-projects {
grid-area: projects;
}

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/style.css

View File

@ -161,6 +161,7 @@ button, .button, input[type="button"], input[type="submit"] {
background: var(--links);
color: var(--warn-text);
border-radius: 1px;
overflow: hidden;
&:focus {
transform: translateY(0%);

View File

@ -1,140 +1,131 @@
/*
home-title
*/
@import "vars";
.home-title {
margin: 20px 0; padding: 10px 5%;
background: #bf1c11; box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
border-top: 10px solid #ab170c;
.home-pinned-content {
width: 90%;
display: grid;
grid-template-areas:
'banner news'
'welcome news'
'shout news'
'projects projects';
grid-template-rows: auto auto minmax(200px, 1fr) auto;
grid-template-columns: 4fr 3fr;
h1 {
margin-top: 0;
color: #ffffff; border-color: #ffffff;
@media screen and (max-width: @normal) {
width: 97%;
}
p {
margin-bottom: 0; text-align: justify;
color: #ffffff;
@media screen and (max-width: @small) {
width: 100%;
grid-template-areas:
'welcome'
'banner'
'news'
'shout'
'projects';
grid-template-rows: auto;
grid-template-columns: 1fr;
}
a {
color: inherit; text-decoration: underline;
& > * {
//border: 1px solid red;
margin: 10px 20px;
h1 {
font-size: 18px;
}
}
}
.home-banner {
grid-area: banner;
text-align: center;
img {
max-width: 100%;
}
}
/*
pinned-content
*/
.home-welcome {
grid-area: welcome;
.home-pinned-content {
& > div {
display: flex; justify-content: space-between;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
h1 {
width: 100%;
margin-bottom: 0;
}
ul {
padding-left: 20px;
}
div {
flex-grow: 1;
}
h2 {
display: block; margin: 5px 0;
font-size: 18px; font-family: NotoSans; font-weight: 200;
line-height: 20px;
}
a {
display: block;
&:hover, &:focus {
img {
filter: blur(3px);
}
div {
padding: 200px 5% 10px 5%;
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8));
}
}
}
img {
width: 100%; filter: blur(0px);
}
article {
flex-grow: 1; margin: 0 1px; padding: 0;
position: relative;
max-width: 250px; overflow: hidden;
div {
position: absolute; bottom: 0; z-index: 3;
width: 90%; margin: 0;
padding: 30px 5% 10px 5%;
color: #ffffff; text-shadow: 1px 1px 0 rgba(0,0,0,.6);
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8));
}
margin: 5px 0;
}
}
.home-news {
grid-area: news;
/*
home-articles
*/
.home-articles {
display: flex; justify-content: space-between;
& > div {
flex-grow: 1; max-width: 48%;
ul {
padding: 0;
}
h1 {
display: flex; justify-content: space-between; align-items: center;
li {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
padding: 10px 0;
a {
padding: 0;
font-family: NotoSans; font-size: 16px;
font-weight: 400; color: /*#015078*/ /*#bf1c11*/ #234d5f;
border-bottom: var(--hr-border);
&:hover, &:focus {
padding-right: 10px;
}
}
}
p {
margin: 5px 0;
text-align: justify;
color: #808080;
}
article {
padding: 10px; margin: 10px 0; display: flex; align-items: center;
background: #ffffff; border: 1px solid rgba(0, 0, 0, .2);
& > img {
float: left; margin-right: 10px; flex-shrink: 0;
&.screeshot {
width: 128px; height: 64px;
}
@media screen and (max-width: @micro) {
flex-direction: column;
align-items: start;
}
& > div {
flex-shrink: 1;
& > a {
align-self: baseline;
}
img {
max-width: 100px;
max-height: 100px;
margin-right: 8px;
}
h3 {
margin: 0;
color: #424242; font-weight: normal;
font-size: 16px;
font-weight: bold;
font-family: Cantarell;
}
a:hover, a:focus {
text-decoration: underline;
.date {
margin: 4px 0 10px 0;
}
}
.metadata {
margin: 0;
color: #22292c;
a {
color: #22292c; font-weight: 400; font-style: italic;
div {
font-size: 13px;
line-height: 150%;
}
}
}
.home-shoutbox {
grid-area: shout;
}
.home-projects {
grid-area: projects;
}

File diff suppressed because one or more lines are too long

View File

@ -1,972 +0,0 @@
function assertNonEmptyString (str) {
if (typeof str !== 'string' || !str) {
throw new Error('expected a non-empty string, got: ' + str)
}
}
function assertNumber (number) {
if (typeof number !== 'number') {
throw new Error('expected a number, got: ' + number)
}
}
const DB_VERSION_CURRENT = 1;
const DB_VERSION_INITIAL = 1;
const STORE_EMOJI = 'emoji';
const STORE_KEYVALUE = 'keyvalue';
const STORE_FAVORITES = 'favorites';
const FIELD_TOKENS = 'tokens';
const INDEX_TOKENS = 'tokens';
const FIELD_UNICODE = 'unicode';
const INDEX_COUNT = 'count';
const FIELD_GROUP = 'group';
const FIELD_ORDER = 'order';
const INDEX_GROUP_AND_ORDER = 'group-order';
const KEY_ETAG = 'eTag';
const KEY_URL = 'url';
const KEY_PREFERRED_SKINTONE = 'skinTone';
const MODE_READONLY = 'readonly';
const MODE_READWRITE = 'readwrite';
const INDEX_SKIN_UNICODE = 'skinUnicodes';
const FIELD_SKIN_UNICODE = 'skinUnicodes';
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
const DEFAULT_LOCALE = 'en';
// like lodash's uniqBy but much smaller
function uniqBy (arr, func) {
const set = new Set();
const res = [];
for (const item of arr) {
const key = func(item);
if (!set.has(key)) {
set.add(key);
res.push(item);
}
}
return res
}
function uniqEmoji (emojis) {
return uniqBy(emojis, _ => _.unicode)
}
function initialMigration (db) {
function createObjectStore (name, keyPath, indexes) {
const store = keyPath
? db.createObjectStore(name, { keyPath })
: db.createObjectStore(name);
if (indexes) {
for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) {
store.createIndex(indexName, keyPath, { multiEntry });
}
}
return store
}
createObjectStore(STORE_KEYVALUE);
createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
[INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
[INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
[INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true]
});
createObjectStore(STORE_FAVORITES, undefined, {
[INDEX_COUNT]: ['']
});
}
const openIndexedDBRequests = {};
const databaseCache = {};
const onCloseListeners = {};
function handleOpenOrDeleteReq (resolve, reject, req) {
// These things are almost impossible to test with fakeIndexedDB sadly
/* istanbul ignore next */
req.onerror = () => reject(req.error);
/* istanbul ignore next */
req.onblocked = () => reject(new Error('IDB blocked'));
req.onsuccess = () => resolve(req.result);
}
async function createDatabase (dbName) {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
openIndexedDBRequests[dbName] = req;
req.onupgradeneeded = e => {
// Technically there is only one version, so we don't need this `if` check
// But if an old version of the JS is in another browser tab
// and it gets upgraded in the future and we have a new DB version, well...
// better safe than sorry.
/* istanbul ignore else */
if (e.oldVersion < DB_VERSION_INITIAL) {
initialMigration(req.result);
}
};
handleOpenOrDeleteReq(resolve, reject, req);
});
// Handle abnormal closes, e.g. "delete database" in chrome dev tools.
// No need for removeEventListener, because once the DB can no longer
// fire "close" events, it will auto-GC.
// Unfortunately cannot test in fakeIndexedDB: https://github.com/dumbmatter/fakeIndexedDB/issues/50
/* istanbul ignore next */
db.onclose = () => closeDatabase(dbName);
return db
}
function openDatabase (dbName) {
if (!databaseCache[dbName]) {
databaseCache[dbName] = createDatabase(dbName);
}
return databaseCache[dbName]
}
function dbPromise (db, storeName, readOnlyOrReadWrite, cb) {
return new Promise((resolve, reject) => {
// Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
// are really irreplaceable data. IndexedDB is just a cache in this case.
const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' });
const store = typeof storeName === 'string'
? txn.objectStore(storeName)
: storeName.map(name => txn.objectStore(name));
let res;
cb(store, txn, (result) => {
res = result;
});
txn.oncomplete = () => resolve(res);
/* istanbul ignore next */
txn.onerror = () => reject(txn.error);
})
}
function closeDatabase (dbName) {
// close any open requests
const req = openIndexedDBRequests[dbName];
const db = req && req.result;
if (db) {
db.close();
const listeners = onCloseListeners[dbName];
/* istanbul ignore else */
if (listeners) {
for (const listener of listeners) {
listener();
}
}
}
delete openIndexedDBRequests[dbName];
delete databaseCache[dbName];
delete onCloseListeners[dbName];
}
function deleteDatabase (dbName) {
return new Promise((resolve, reject) => {
// close any open requests
closeDatabase(dbName);
const req = indexedDB.deleteDatabase(dbName);
handleOpenOrDeleteReq(resolve, reject, req);
})
}
// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
// However, it doesn't occur with the normal "close" event, so we handle that separately.
// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
function addOnCloseListener (dbName, listener) {
let listeners = onCloseListeners[dbName];
if (!listeners) {
listeners = onCloseListeners[dbName] = [];
}
listeners.push(listener);
}
// list of emoticons that don't match a simple \W+ regex
// extracted using:
// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
const irregularEmoticons = new Set([
':D', 'XD', ":'D", 'O:)',
':X', ':P', ';P', 'XP',
':L', ':Z', ':j', '8D',
'XO', '8)', ':B', ':O',
':S', ":'o", 'Dx', 'X(',
'D:', ':C', '>0)', ':3',
'</3', '<3', '\\M/', ':E',
'8#'
]);
function extractTokens (str) {
return str
.split(/[\s_]+/)
.map(word => {
if (!word.match(/\w/) || irregularEmoticons.has(word)) {
// for pure emoticons like :) or :-), just leave them as-is
return word.toLowerCase()
}
return word
.replace(/[)(:,]/g, '')
.replace(//g, "'")
.toLowerCase()
}).filter(Boolean)
}
const MIN_SEARCH_TEXT_LENGTH = 2;
// This is an extra step in addition to extractTokens(). The difference here is that we expect
// the input to have already been run through extractTokens(). This is useful for cases like
// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
// the user can type ':P' or ':p' and still get the same result.
function normalizeTokens (str) {
return str
.filter(Boolean)
.map(_ => _.toLowerCase())
.filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH)
}
// Transform emoji data for storage in IDB
function transformEmojiData (emojiData) {
const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => {
const tokens = [...new Set(
normalizeTokens([
...(shortcodes || []).map(extractTokens).flat(),
...tags.map(extractTokens).flat(),
...extractTokens(annotation),
emoticon
])
)].sort();
const res = {
annotation,
group,
order,
tags,
tokens,
unicode: emoji,
version
};
if (emoticon) {
res.emoticon = emoticon;
}
if (shortcodes) {
res.shortcodes = shortcodes;
}
if (skins) {
res.skinTones = [];
res.skinUnicodes = [];
res.skinVersions = [];
for (const { tone, emoji, version } of skins) {
res.skinTones.push(tone);
res.skinUnicodes.push(emoji);
res.skinVersions.push(version);
}
}
return res
});
return res
}
// helper functions that help compress the code better
function callStore (store, method, key, cb) {
store[method](key).onsuccess = e => (cb && cb(e.target.result));
}
function getIDB (store, key, cb) {
callStore(store, 'get', key, cb);
}
function getAllIDB (store, key, cb) {
callStore(store, 'getAll', key, cb);
}
function commit (txn) {
/* istanbul ignore else */
if (txn.commit) {
txn.commit();
}
}
// like lodash's minBy
function minBy (array, func) {
let minItem = array[0];
for (let i = 1; i < array.length; i++) {
const item = array[i];
if (func(minItem) > func(item)) {
minItem = item;
}
}
return minItem
}
// return an array of results representing all items that are found in each one of the arrays
function findCommonMembers (arrays, uniqByFunc) {
const shortestArray = minBy(arrays, _ => _.length);
const results = [];
for (const item of shortestArray) {
// if this item is included in every array in the intermediate results, add it to the final results
if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) {
results.push(item);
}
}
return results
}
async function isEmpty (db) {
return !(await get(db, STORE_KEYVALUE, KEY_URL))
}
async function hasData (db, url, eTag) {
const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL]
.map(key => get(db, STORE_KEYVALUE, key)));
return (oldETag === eTag && oldUrl === url)
}
async function doFullDatabaseScanForSingleResult (db, predicate) {
// This batching algorithm is just a perf improvement over a basic
// cursor. The BATCH_SIZE is an estimate of what would give the best
// perf for doing a full DB scan (worst case).
//
// Mini-benchmark for determining the best batch size:
//
// PERF=1 yarn build:rollup && yarn test:adhoc
//
// (async () => {
// performance.mark('start')
// await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
// performance.measure('total', 'start')
// console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
// })()
const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
let lastKey;
const processNextBatch = () => {
emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => {
const results = e.target.result;
for (const result of results) {
lastKey = result.unicode;
if (predicate(result)) {
return cb(result)
}
}
if (results.length < BATCH_SIZE) {
return cb()
}
processNextBatch();
};
};
processNextBatch();
})
}
async function loadData (db, emojiData, url, eTag) {
try {
const transformedData = transformEmojiData(emojiData);
await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => {
let oldETag;
let oldUrl;
let todo = 0;
function checkFetched () {
if (++todo === 2) { // 2 requests made
onFetched();
}
}
function onFetched () {
if (oldETag === eTag && oldUrl === url) {
// check again within the transaction to guard against concurrency, e.g. multiple browser tabs
return
}
// delete old data
emojiStore.clear();
// insert new data
for (const data of transformedData) {
emojiStore.put(data);
}
metaStore.put(eTag, KEY_ETAG);
metaStore.put(url, KEY_URL);
commit(txn);
}
getIDB(metaStore, KEY_ETAG, result => {
oldETag = result;
checkFetched();
});
getIDB(metaStore, KEY_URL, result => {
oldUrl = result;
checkFetched();
});
});
} finally {
}
}
async function getEmojiByGroup (db, group) {
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
})
}
async function getEmojiBySearchQuery (db, query) {
const tokens = normalizeTokens(extractTokens(query));
if (!tokens.length) {
return []
}
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
// get all results that contain all tokens (i.e. an AND query)
const intermediateResults = [];
const checkDone = () => {
if (intermediateResults.length === tokens.length) {
onDone();
}
};
const onDone = () => {
const results = findCommonMembers(intermediateResults, _ => _.unicode);
cb(results.sort((a, b) => a.order < b.order ? -1 : 1));
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const range = i === tokens.length - 1
? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search
: IDBKeyRange.only(token); // treat all other tokens as an exact match
getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => {
intermediateResults.push(result);
checkDone();
});
}
})
}
// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
async function getEmojiByShortcode (db, shortcode) {
const emojis = await getEmojiBySearchQuery(db, shortcode);
// In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
// there are no usable tokens (too short in this case). In that case, we have to do an inefficient
// full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
// index on shortcodes.
if (!emojis.length) {
const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase()));
return (await doFullDatabaseScanForSingleResult(db, predicate)) || null
}
return emojis.filter(_ => {
const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase());
return lowerShortcodes.includes(shortcode.toLowerCase())
})[0] || null
}
async function getEmojiByUnicode (db, unicode) {
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => (
getIDB(emojiStore, unicode, result => {
if (result) {
return cb(result)
}
getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null));
})
))
}
function get (db, storeName, key) {
return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => (
getIDB(store, key, cb)
))
}
function set (db, storeName, key, value) {
return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
store.put(value, key);
commit(txn);
})
}
function incrementFavoriteEmojiCount (db, unicode) {
return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => (
getIDB(store, unicode, result => {
store.put((result || 0) + 1, unicode);
commit(txn);
})
))
}
function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
if (limit === 0) {
return []
}
return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => {
const results = [];
favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => {
const cursor = e.target.result;
if (!cursor) { // no more results
return cb(results)
}
function addResult (result) {
results.push(result);
if (results.length === limit) {
return cb(results) // done, reached the limit
}
cursor.continue();
}
const unicodeOrName = cursor.primaryKey;
const custom = customEmojiIndex.byName(unicodeOrName);
if (custom) {
return addResult(custom)
}
// This could be done in parallel (i.e. make the cursor and the get()s parallelized),
// but my testing suggests it's not actually faster.
getIDB(emojiStore, unicodeOrName, emoji => {
if (emoji) {
return addResult(emoji)
}
// emoji not found somehow, ignore (may happen if custom emoji change)
cursor.continue();
});
};
})
}
// trie data structure for prefix searches
// loosely based on https://github.com/nolanlawson/substring-trie
const CODA_MARKER = ''; // marks the end of the string
function trie (arr, itemToTokens) {
const map = new Map();
for (const item of arr) {
const tokens = itemToTokens(item);
for (const token of tokens) {
let currentMap = map;
for (let i = 0; i < token.length; i++) {
const char = token.charAt(i);
let nextMap = currentMap.get(char);
if (!nextMap) {
nextMap = new Map();
currentMap.set(char, nextMap);
}
currentMap = nextMap;
}
let valuesAtCoda = currentMap.get(CODA_MARKER);
if (!valuesAtCoda) {
valuesAtCoda = [];
currentMap.set(CODA_MARKER, valuesAtCoda);
}
valuesAtCoda.push(item);
}
}
const search = (query, exact) => {
let currentMap = map;
for (let i = 0; i < query.length; i++) {
const char = query.charAt(i);
const nextMap = currentMap.get(char);
if (nextMap) {
currentMap = nextMap;
} else {
return []
}
}
if (exact) {
const results = currentMap.get(CODA_MARKER);
return results || []
}
const results = [];
// traverse
const queue = [currentMap];
while (queue.length) {
const currentMap = queue.shift();
const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1);
for (const [key, value] of entriesSortedByKey) {
if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string
results.push(...value);
} else {
queue.push(value);
}
}
}
return results
};
return search
}
const requiredKeys$1 = [
'name',
'url'
];
function assertCustomEmojis (customEmojis) {
const isArray = customEmojis && Array.isArray(customEmojis);
const firstItemIsFaulty = isArray &&
customEmojis.length &&
(!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0])));
if (!isArray || firstItemIsFaulty) {
throw new Error('Custom emojis are in the wrong format')
}
}
function customEmojiIndex (customEmojis) {
assertCustomEmojis(customEmojis);
const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
//
// all()
//
const all = customEmojis.sort(sortByName);
//
// search()
//
const emojiToTokens = emoji => (
[...new Set((emoji.shortcodes || []).map(shortcode => extractTokens(shortcode)).flat())]
);
const searchTrie = trie(customEmojis, emojiToTokens);
const searchByExactMatch = _ => searchTrie(_, true);
const searchByPrefix = _ => searchTrie(_, false);
// Search by query for custom emoji. Similar to how we do this in IDB, the last token
// is treated as a prefix search, but every other one is treated as an exact match.
// Then we AND the results together
const search = query => {
const tokens = extractTokens(query);
const intermediateResults = tokens.map((token, i) => (
(i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
));
return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName)
};
//
// byShortcode, byName
//
const shortcodeToEmoji = new Map();
const nameToEmoji = new Map();
for (const customEmoji of customEmojis) {
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
for (const shortcode of (customEmoji.shortcodes || [])) {
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
}
}
const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase());
const byName = name => nameToEmoji.get(name.toLowerCase());
return {
all,
search,
byShortcode,
byName
}
}
// remove some internal implementation details, i.e. the "tokens" array on the emoji object
// essentially, convert the emoji from the version stored in IDB to the version used in-memory
function cleanEmoji (emoji) {
if (!emoji) {
return emoji
}
delete emoji.tokens;
if (emoji.skinTones) {
const len = emoji.skinTones.length;
emoji.skins = Array(len);
for (let i = 0; i < len; i++) {
emoji.skins[i] = {
tone: emoji.skinTones[i],
unicode: emoji.skinUnicodes[i],
version: emoji.skinVersions[i]
};
}
delete emoji.skinTones;
delete emoji.skinUnicodes;
delete emoji.skinVersions;
}
return emoji
}
function warnETag (eTag) {
if (!eTag) {
console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.');
}
}
const requiredKeys = [
'annotation',
'emoji',
'group',
'order',
'tags',
'version'
];
function assertEmojiData (emojiData) {
if (!emojiData ||
!Array.isArray(emojiData) ||
!emojiData[0] ||
(typeof emojiData[0] !== 'object') ||
requiredKeys.some(key => (!(key in emojiData[0])))) {
throw new Error('Emoji data is in the wrong format')
}
}
function assertStatus (response, dataSource) {
if (Math.floor(response.status / 100) !== 2) {
throw new Error('Failed to fetch: ' + dataSource + ': ' + response.status)
}
}
async function getETag (dataSource) {
const response = await fetch(dataSource, { method: 'HEAD' });
assertStatus(response, dataSource);
const eTag = response.headers.get('etag');
warnETag(eTag);
return eTag
}
async function getETagAndData (dataSource) {
const response = await fetch(dataSource);
assertStatus(response, dataSource);
const eTag = response.headers.get('etag');
warnETag(eTag);
const emojiData = await response.json();
assertEmojiData(emojiData);
return [eTag, emojiData]
}
// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
/**
* Convert an `ArrayBuffer` to a binary string.
*
* Example:
*
* ```js
* var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
* ```
*
* @param buffer - array buffer
* @returns binary string
*/
function arrayBufferToBinaryString(buffer) {
var binary = '';
var bytes = new Uint8Array(buffer);
var length = bytes.byteLength;
var i = -1;
while (++i < length) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
/**
* Convert a binary string to an `ArrayBuffer`.
*
* ```js
* var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
* ```
*
* @param binary - binary string
* @returns array buffer
*/
function binaryStringToArrayBuffer(binary) {
var length = binary.length;
var buf = new ArrayBuffer(length);
var arr = new Uint8Array(buf);
var i = -1;
while (++i < length) {
arr[i] = binary.charCodeAt(i);
}
return buf;
}
// generate a checksum based on the stringified JSON
async function jsonChecksum (object) {
const inString = JSON.stringify(object);
const inBuffer = binaryStringToArrayBuffer(inString);
// this does not need to be cryptographically secure, SHA-1 is fine
const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer);
const outBinString = arrayBufferToBinaryString(outBuffer);
const res = btoa(outBinString);
return res
}
async function checkForUpdates (db, dataSource) {
// just do a simple HEAD request first to see if the eTags match
let emojiData;
let eTag = await getETag(dataSource);
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
const eTagAndData = await getETagAndData(dataSource);
eTag = eTagAndData[0];
emojiData = eTagAndData[1];
if (!eTag) {
eTag = await jsonChecksum(emojiData);
}
}
if (await hasData(db, dataSource, eTag)) ; else {
if (!emojiData) {
const eTagAndData = await getETagAndData(dataSource);
emojiData = eTagAndData[1];
}
await loadData(db, emojiData, dataSource, eTag);
}
}
async function loadDataForFirstTime (db, dataSource) {
let [eTag, emojiData] = await getETagAndData(dataSource);
if (!eTag) {
// Handle lack of support for ETag or Access-Control-Expose-Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
eTag = await jsonChecksum(emojiData);
}
await loadData(db, emojiData, dataSource, eTag);
}
class Database {
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
this.dataSource = dataSource;
this.locale = locale;
this._dbName = `emoji-picker-element-${this.locale}`;
this._db = undefined;
this._lazyUpdate = undefined;
this._custom = customEmojiIndex(customEmoji);
this._clear = this._clear.bind(this);
this._ready = this._init();
}
async _init () {
const db = this._db = await openDatabase(this._dbName);
addOnCloseListener(this._dbName, this._clear);
const dataSource = this.dataSource;
const empty = await isEmpty(db);
if (empty) {
await loadDataForFirstTime(db, dataSource);
} else { // offline-first - do an update asynchronously
this._lazyUpdate = checkForUpdates(db, dataSource);
}
}
async ready () {
const checkReady = async () => {
if (!this._ready) {
this._ready = this._init();
}
return this._ready
};
await checkReady();
// There's a possibility of a race condition where the element gets added, removed, and then added again
// with a particular timing, which would set the _db to undefined.
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
if (!this._db) {
await checkReady();
}
}
async getEmojiByGroup (group) {
assertNumber(group);
await this.ready();
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
}
async getEmojiBySearchQuery (query) {
assertNonEmptyString(query);
await this.ready();
const customs = this._custom.search(query);
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji);
return [
...customs,
...natives
]
}
async getEmojiByShortcode (shortcode) {
assertNonEmptyString(shortcode);
await this.ready();
const custom = this._custom.byShortcode(shortcode);
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
}
async getEmojiByUnicodeOrName (unicodeOrName) {
assertNonEmptyString(unicodeOrName);
await this.ready();
const custom = this._custom.byName(unicodeOrName);
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
}
async getPreferredSkinTone () {
await this.ready();
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
}
async setPreferredSkinTone (skinTone) {
assertNumber(skinTone);
await this.ready();
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
}
async incrementFavoriteEmojiCount (unicodeOrName) {
assertNonEmptyString(unicodeOrName);
await this.ready();
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
}
async getTopFavoriteEmoji (limit) {
assertNumber(limit);
await this.ready();
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
}
set customEmoji (customEmojis) {
this._custom = customEmojiIndex(customEmojis);
}
get customEmoji () {
return this._custom.all
}
async _shutdown () {
await this.ready(); // reopen if we've already been closed/deleted
try {
await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
} catch (err) { /* ignore network errors (offline-first) */ }
}
// clear references to IDB, e.g. during a close event
_clear () {
// We don't need to call removeEventListener or remove the manual "close" listeners.
// The memory leak tests prove this is unnecessary. It's because:
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
// 2) we clear the manual close listeners in databaseLifecycle.js.
this._db = this._ready = this._lazyUpdate = undefined;
}
async close () {
await this._shutdown();
await closeDatabase(this._dbName);
}
async delete () {
await this._shutdown();
await deleteDatabase(this._dbName);
}
}
export { Database as default };

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'الفئات',
emojiUnsupportedMessage: 'متصفحك لا يدعم رموز المشاعر الملونة.',
favoritesLabel: 'المفضلة',
loadingMessage: 'جارٍ التحميل…',
networkErrorMessage: 'تعذر تحميل رمز مشاعر.',
regionLabel: 'منتقي رموز المشاعر',
searchDescription: 'عندما تكون نتائج البحث متاحة، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
searchLabel: 'بحث',
searchResultsLabel: 'نتائج البحث',
skinToneDescription: 'عند توسيع النتائج، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
skinToneLabel: 'اختر درجة لون البشرة (حاليًا {skinTone})',
skinTonesLabel: 'درجات لون البشرة',
skinTones: [
'افتراضي',
'فاتح',
'فاتح متوسط',
'متوسط',
'داكن متوسط',
'داكن'
],
categories: {
custom: 'مخصص',
'smileys-emotion': 'الوجوه الضاحكة ورموز المشاعر',
'people-body': 'الأشخاص والجسد',
'animals-nature': 'الحيوانات والطبيعة',
'food-drink': 'الطعام والشراب',
'travel-places': 'السفر والأماكن',
activities: 'الأنشطة',
objects: 'الأشياء',
symbols: 'الرموز',
flags: 'الأعلام'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategorien',
emojiUnsupportedMessage: 'Dein Browser unterstützt keine farbigen Emojis.',
favoritesLabel: 'Favoriten',
loadingMessage: 'Wird geladen…',
networkErrorMessage: 'Konnte Emoji nicht laden.',
regionLabel: 'Emoji auswählen',
searchDescription: 'Wenn Suchergebnisse verfügbar sind, wähle sie mit Pfeil rauf und runter, dann Eingabetaste, aus.',
searchLabel: 'Suchen',
searchResultsLabel: 'Suchergebnisse',
skinToneDescription: 'Wenn angezeigt, nutze Pfeiltasten rauf und runter zum Auswählen, Eingabe zum Akzeptieren.',
skinToneLabel: 'Wähle einen Hautton (aktuell {skinTone})',
skinTonesLabel: 'Hauttöne',
skinTones: [
'Standard',
'Hell',
'Mittel-hell',
'Mittel',
'Mittel-dunkel',
'Dunkel'
],
categories: {
custom: 'Benutzerdefiniert',
'smileys-emotion': 'Smileys und Emoticons',
'people-body': 'Menschen und Körper',
'animals-nature': 'Tiere und Natur',
'food-drink': 'Essen und Trinken',
'travel-places': 'Reisen und Orte',
activities: 'Aktivitäten',
objects: 'Objekte',
symbols: 'Symbole',
flags: 'Flaggen'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categories',
emojiUnsupportedMessage: 'Your browser does not support color emoji.',
favoritesLabel: 'Favorites',
loadingMessage: 'Loading…',
networkErrorMessage: 'Could not load emoji.',
regionLabel: 'Emoji picker',
searchDescription: 'When search results are available, press up or down to select and enter to choose.',
searchLabel: 'Search',
searchResultsLabel: 'Search results',
skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
skinToneLabel: 'Choose a skin tone (currently {skinTone})',
skinTonesLabel: 'Skin tones',
skinTones: [
'Default',
'Light',
'Medium-Light',
'Medium',
'Medium-Dark',
'Dark'
],
categories: {
custom: 'Custom',
'smileys-emotion': 'Smileys and emoticons',
'people-body': 'People and body',
'animals-nature': 'Animals and nature',
'food-drink': 'Food and drink',
'travel-places': 'Travel and places',
activities: 'Activities',
objects: 'Objects',
symbols: 'Symbols',
flags: 'Flags'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorías',
emojiUnsupportedMessage: 'El navegador no admite emojis de color.',
favoritesLabel: 'Favoritos',
loadingMessage: 'Cargando…',
networkErrorMessage: 'No se pudo cargar el emoji.',
regionLabel: 'Selector de emojis',
searchDescription: 'Cuando estén disponibles los resultados, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
searchLabel: 'Buscar',
searchResultsLabel: 'Resultados de búsqueda',
skinToneDescription: 'Cuando se abran las opciones, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
skinToneLabel: 'Elige un tono de piel ({skinTone} es el actual)',
skinTonesLabel: 'Tonos de piel',
skinTones: [
'Predeterminado',
'Claro',
'Claro medio',
'Medio',
'Oscuro medio',
'Oscuro'
],
categories: {
custom: 'Personalizado',
'smileys-emotion': 'Emojis y emoticones',
'people-body': 'Personas y partes del cuerpo',
'animals-nature': 'Animales y naturaleza',
'food-drink': 'Comida y bebida',
'travel-places': 'Viajes y lugares',
activities: 'Actividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Banderas'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Catégories',
emojiUnsupportedMessage: 'Votre navigateur ne soutient pas les emojis en couleur.',
favoritesLabel: 'Favoris',
loadingMessage: 'Chargement en cours…',
networkErrorMessage: 'Impossible de charger les emojis.',
regionLabel: 'Choisir un emoji',
searchDescription: 'Quand les résultats sont disponisbles, appuyez la fleche vers le haut ou le bas et la touche entrée pour choisir.',
searchLabel: 'Rechercher',
searchResultsLabel: 'Résultats',
skinToneDescription: 'Quand disponible, appuyez la fleche vers le haut ou le bas et la touch entrée pour choisir.',
skinToneLabel: 'Choisir une couleur de peau (actuellement {skinTone})',
skinTonesLabel: 'Couleurs de peau',
skinTones: [
'Défaut',
'Clair',
'Moyennement clair',
'Moyen',
'Moyennement sombre',
'Sombre'
],
categories: {
custom: 'Customisé',
'smileys-emotion': 'Les smileyes et les émoticônes',
'people-body': 'Les gens et le corps',
'animals-nature': 'Les animaux et la nature',
'food-drink': 'La nourriture et les boissons',
'travel-places': 'Les voyages et les endroits',
activities: 'Les activités',
objects: 'Les objets',
symbols: 'Les symbols',
flags: 'Les drapeaux'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'श्रेणियाँ',
emojiUnsupportedMessage: 'आपका ब्राउज़र कलर इमोजी का समर्थन नहीं करता।',
favoritesLabel: 'पसंदीदा',
loadingMessage: 'लोड हो रहा है...',
networkErrorMessage: 'इमोजी लोड नहीं हो सके।',
regionLabel: 'इमोजी चुननेवाला',
searchDescription: 'जब खोज परिणाम उपलब्ध हों तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
searchLabel: 'खोज',
searchResultsLabel: 'खोज के परिणाम',
skinToneDescription: 'जब विस्तृत किया जाता है तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
skinToneLabel: 'त्वचा का रंग चुनें (वर्तमान में {skinTone})',
skinTonesLabel: 'त्वचा के रंग',
skinTones: [
'डिफॉल्ट',
'हल्का',
'मध्यम हल्का',
'मध्यम',
'मध्यम गहरा',
'गहरा'
],
categories: {
custom: 'कस्टम',
'smileys-emotion': 'स्माइली और इमोटिकॉन्स',
'people-body': 'लोग और शरीर',
'animals-nature': 'पशु और प्रकृति',
'food-drink': 'खाद्य और पेय',
'travel-places': 'यात्रा और स्थान',
activities: 'गतिविधियां',
objects: 'वस्तुएं',
symbols: 'प्रतीक',
flags: 'झंडे'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategori',
emojiUnsupportedMessage: 'Browser Anda tidak mendukung emoji warna.',
favoritesLabel: 'Favorit',
loadingMessage: 'Memuat...',
networkErrorMessage: 'Tidak dapat memuat emoji.',
regionLabel: 'Pemilih emoji',
searchDescription: 'Ketika hasil pencarian tersedia, tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
searchLabel: 'Cari',
searchResultsLabel: 'Hasil Pencarian',
skinToneDescription: 'Saat diperluas tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
skinToneLabel: 'Pilih warna skin (saat ini {skinTone})',
skinTonesLabel: 'Warna skin',
skinTones: [
'Default',
'Light',
'Medium light',
'Medium',
'Medium dark',
'Dark'
],
categories: {
custom: 'Kustom',
'smileys-emotion': 'Smiley dan emoticon',
'people-body': 'Orang dan bagian tubuh',
'animals-nature': 'Hewan dan tumbuhan',
'food-drink': 'Makanan dan minuman',
'travel-places': 'Rekreasi dan tempat',
activities: 'Aktivitas',
objects: 'Objek',
symbols: 'Simbol',
flags: 'Bendera'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorie',
emojiUnsupportedMessage: 'Il tuo browser non supporta le emoji colorate.',
favoritesLabel: 'Preferiti',
loadingMessage: 'Caricamento...',
networkErrorMessage: 'Impossibile caricare le emoji.',
regionLabel: 'Selezione emoji',
searchDescription: 'Quando i risultati della ricerca sono disponibili, premi su o giù per selezionare e invio per scegliere.',
searchLabel: 'Cerca',
searchResultsLabel: 'Risultati di ricerca',
skinToneDescription: 'Quando espanso, premi su o giù per selezionare e invio per scegliere.',
skinToneLabel: 'Scegli una tonalità della pelle (corrente {skinTone})',
skinTonesLabel: 'Tonalità della pelle',
skinTones: [
'Predefinita',
'Chiara',
'Medio-Chiara',
'Media',
'Medio-Scura',
'Scura'
],
categories: {
custom: 'Personalizzata',
'smileys-emotion': 'Faccine ed emozioni',
'people-body': 'Persone e corpi',
'animals-nature': 'Animali e natura',
'food-drink': 'Cibi e bevande',
'travel-places': 'Viaggi e luoghi',
activities: 'Attività',
objects: 'Oggetti',
symbols: 'Simboli',
flags: 'Bandiere'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategori',
emojiUnsupportedMessage: 'Penyemak imbas anda tidak menyokong emoji warna.',
favoritesLabel: 'Kegemaran',
loadingMessage: 'Memuat…',
networkErrorMessage: 'Tidak dapat memuatkan emoji.',
regionLabel: 'Pemilih emoji',
searchDescription: 'Apabila hasil carian tersedia, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
searchLabel: 'Cari',
searchResultsLabel: 'Hasil carian',
skinToneDescription: 'Apabila dikembangkan, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
skinToneLabel: 'Pilih warna kulit (pada masa ini {skinTone})',
skinTonesLabel: 'Warna kulit',
skinTones: [
'Lalai',
'Cerah',
'Kuning langsat',
'Sederhana cerah',
'Sawo matang',
'Gelap'
],
categories: {
custom: 'Tersuai',
'smileys-emotion': 'Smiley dan emotikon',
'people-body': 'Orang dan badan',
'animals-nature': 'Haiwan dan alam semula jadi',
'food-drink': 'Makanan dan minuman',
'travel-places': 'Perjalanan dan tempat',
activities: 'Aktiviti',
objects: 'Objek',
symbols: 'Simbol',
flags: 'Bendera'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorieën',
emojiUnsupportedMessage: 'Uw browser ondersteunt geen kleurenemoji.',
favoritesLabel: 'Favorieten',
loadingMessage: 'Bezig met laden…',
networkErrorMessage: 'Kan emoji niet laden.',
regionLabel: 'Emoji-kiezer',
searchDescription: 'Als er zoekresultaten beschikbaar zijn, drukt u op omhoog of omlaag om te selecteren en op enter om te kiezen.',
searchLabel: 'Zoeken',
searchResultsLabel: 'Zoekresultaten',
skinToneDescription: 'Wanneer uitgevouwen, druk omhoog of omlaag om te selecteren en enter om te kiezen.',
skinToneLabel: 'Kies een huidskleur (momenteel {skinTone})',
skinTonesLabel: 'Huidskleuren',
skinTones: [
'Standaard',
'Licht',
'Medium-Licht',
'Medium',
'Middeldonker',
'Donker'
],
categories: {
custom: 'Aangepast',
'smileys-emotion': 'Smileys en emoticons',
'people-body': 'Mensen en lichaam',
'animals-nature': 'Dieren en natuur',
'food-drink': 'Eten en drinken',
'travel-places': 'Reizen en plaatsen',
activities: 'Activiteiten',
objects: 'Voorwerpen',
symbols: 'Symbolen',
flags: 'Vlaggen'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategorie',
emojiUnsupportedMessage: 'Twoja przeglądarka nie wspiera kolorowych emotikon.',
favoritesLabel: 'Ulubione',
loadingMessage: 'Ładuję…',
networkErrorMessage: 'Nie można załadować emoji.',
regionLabel: 'Selektor emoji',
searchDescription: 'Kiedy wyniki wyszukiwania będą dostępne, wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
searchLabel: 'Wyszukaj',
searchResultsLabel: 'Wyniki wyszukiwania',
skinToneDescription: 'Po rozwinięciu wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
skinToneLabel: 'Wybierz odcień skóry (aktualnie {skinTone})',
skinTonesLabel: 'Odcienie skóry',
skinTones: [
'Domyślna',
'Jasna',
'Średnio-jasna',
'Średnia',
'Średnio-ciemna',
'Ciemna'
],
categories: {
custom: 'Własne',
'smileys-emotion': 'Uśmiechy',
'people-body': 'Ludzie',
'animals-nature': 'Zwierzęta i natura',
'food-drink': 'Żywność i napoje',
'travel-places': 'Podróże i miejsca',
activities: 'Aktywności',
objects: 'Obiekty',
symbols: 'Symbole',
flags: 'Flagi'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorias',
emojiUnsupportedMessage: 'Seu navegador não suporta emojis coloridos.',
favoritesLabel: 'Favoritos',
loadingMessage: 'Carregando…',
networkErrorMessage: 'Não foi possível carregar o emoji.',
regionLabel: 'Seletor de emoji',
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
searchLabel: 'Procurar',
searchResultsLabel: 'Resultados da pesquisa',
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
skinToneLabel: 'Escolha um tom de pele (atualmente {skinTone})',
skinTonesLabel: 'Tons de pele',
skinTones: [
'Padrão',
'Claro',
'Claro médio',
'Médio',
'Escuro médio',
'Escuro'
],
categories: {
custom: 'Personalizar',
'smileys-emotion': 'Carinhas e emoticons',
'people-body': 'Pessoas e corpo',
'animals-nature': 'Animais e natureza',
'food-drink': 'Alimentos e bebidas',
'travel-places': 'Viagem e lugares',
activities: 'Atividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Bandeiras'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorias',
emojiUnsupportedMessage: 'O seu browser não suporta emojis.',
favoritesLabel: 'Favoritos',
loadingMessage: 'A Carregar…',
networkErrorMessage: 'Não foi possível carregar o emoji.',
regionLabel: 'Emoji picker',
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e digite para escolher.',
searchLabel: 'Procurar',
searchResultsLabel: 'Resultados da procura',
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e digite para escolher.',
skinToneLabel: 'Escolha um tom de pele (atual {skinTone})',
skinTonesLabel: 'Tons de pele',
skinTones: [
'Pré-definido',
'Claro',
'Médio-Claro',
'Médio',
'Médio-Escuro',
'Escuro'
],
categories: {
custom: 'Personalizados',
'smileys-emotion': 'Smileys e emoticons',
'people-body': 'Pessoas e corpo',
'animals-nature': 'Animais e natureza',
'food-drink': 'Comida e bebida',
'travel-places': 'Viagens e locais',
activities: 'Atividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Bandeiras'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Категории',
emojiUnsupportedMessage: 'Ваш браузер не поддерживает цветные эмодзи.',
favoritesLabel: 'Избранное',
loadingMessage: 'Загрузка…',
networkErrorMessage: 'Не удалось загрузить эмодзи. Попробуйте перезагрузить страницу.',
regionLabel: 'Выберите эмодзи',
searchDescription: 'Когда результаты поиска станут доступны, выберите их с помощью стрелок вверх и вниз, затем нажмите для подтверждения.',
searchLabel: 'Искать',
searchResultsLabel: 'Результаты поиска',
skinToneDescription: 'При отображении используйте клавиши со стрелками вверх и вниз для выбора, нажмите для подтверждения.',
skinToneLabel: 'Выберите оттенок кожи (текущий {skinTone})',
skinTonesLabel: 'Оттенки кожи',
skinTones: [
'Стандартный',
'Светлый',
'Средне-светлый',
'Средний',
'Средне-темный',
'Темный'
],
categories: {
custom: 'Пользовательский',
'smileys-emotion': 'Смайлики и Эмотиконы',
'people-body': 'Люди и Тела',
'animals-nature': 'Животные и Природа',
'food-drink': 'Еда и Напитки',
'travel-places': 'Путешествия и Места',
activities: 'Виды деятельности',
objects: 'Объекты',
symbols: 'Символы',
flags: 'Флаги'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategoriler',
emojiUnsupportedMessage: 'Tarayıcınız renkli emojiyi desteklemiyor.',
favoritesLabel: 'Favoriler',
loadingMessage: 'Yükleniyor…',
networkErrorMessage: 'Emoji yüklenemedi.',
regionLabel: 'Emoji seçici',
searchDescription: 'Arama sonuçları mevcut olduğunda seçmek için yukarı veya aşağı basın ve seçmek için girin.',
searchLabel: 'Arama',
searchResultsLabel: 'Arama sonuçları',
skinToneDescription: 'Genişletildiğinde seçmek için yukarı veya aşağı basın ve seçmek için girin.',
skinToneLabel: 'Cilt tonu seçin (şu anda {skinTone})',
skinTonesLabel: 'Cilt tonları',
skinTones: [
'Varsayılan',
'Işık',
'Orta ışık',
'Orta',
'Orta koyu',
'Karanlık'
],
categories: {
custom: 'Gelenek',
'smileys-emotion': 'Suratlar ve ifadeler',
'people-body': 'İnsanlar ve vücut',
'animals-nature': 'Hayvanlar ve doğa',
'food-drink': 'Yiyecek ve içecek',
'travel-places': 'Seyahat ve yerler',
activities: 'Aktiviteler',
objects: 'Nesneler',
symbols: 'Semboller',
flags: 'Bayraklar'
}
}

View File

@ -1,27 +0,0 @@
export default {
categoriesLabel: '类别',
emojiUnsupportedMessage: '您的浏览器不支持彩色表情符号。',
favoritesLabel: '收藏夹',
loadingMessage: '正在加载…',
networkErrorMessage: '无法加载表情符号。',
regionLabel: '表情符号选择器',
searchDescription: '当搜索结果可用时,按向上或向下选择并输入选择。',
searchLabel: '搜索',
searchResultsLabel: '搜索结果',
skinToneDescription: '展开时,按向上或向下键进行选择,按回车键进行选择。',
skinToneLabel: '选择肤色(当前为 {skinTone}',
skinTonesLabel: '肤色',
skinTones: ['默认', '明亮', '微亮', '中等', '微暗', '暗'],
categories: {
custom: '自定义',
'smileys-emotion': '笑脸和表情',
'people-body': '人物和身体',
'animals-nature': '动物与自然',
'food-drink': '食品饮料',
'travel-places': '旅行和地方',
activities: '活动',
objects: '物体',
symbols: '符号',
flags: '旗帜'
}
}

View File

@ -1,3 +0,0 @@
import Picker from './picker.js'
import Database from './database.js'
export { Picker, Database }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/v5shoutbox.js

View File

@ -39,7 +39,7 @@
{% endfor %}
</div>
<div>
{{ form.guidelines.label }}
<label for="guidelines">J'accepte les <a href="#">CGU</a></label>
{{ form.guidelines() }}
{% for error in form.guidelines.errors %}
<span class="msgerror">{{ error }}</span>

View File

@ -1,6 +1,6 @@
{% extends "base/base.html" %}
{% set tabtitle = f"Administration - Suppression du compte de " + user.name %}
{% set tabtitle = "Administration - Suppression du compte de " + user.name %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Suppression du compte de '{{ user.name }}'</h1>

View File

@ -1,6 +1,6 @@
{% extends "base/base.html" %}
{% set tabtitle = f"Administration - Édition du compte de " + user.name %}
{% set tabtitle = "Administration - Édition du compte de " + user.name %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Édition du compte de {{ user.name }}</h1>

View File

@ -14,8 +14,8 @@
{% include "base/flash.html" %}
<div id="main-content"></div>
{% block content %}
<div id="main-content"></div>
{% endblock %}
{% include "base/footer.html" %}

View File

@ -1,5 +1,5 @@
<head>
<title>{{ tabtitle or "Planète Casio" }}</title>
<title>{{ V5Config.TABTITLE_PREFIX + (tabtitle or "Planète Casio") }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@ -9,7 +9,7 @@
</form>
<div id="spotlight">
<a href="/forum/actus/projets/2/fin/avancees-de-la-v5" class="button bg-error">Infos sur l'avancée de la v5</a>
<a href="/forum/projets/2/fin/avancees-de-la-v5" class="button bg-error">Infos sur l'avancée de la v5</a>
</div>
{% if current_user.is_authenticated and current_user.priv('misc.dev-infos') %}

View File

@ -1,4 +1,6 @@
{% for s in scripts %}
<script type="text/javascript" src={{url_for('static', filename=s)}}></script>
{% endfor %}
<script type="module" src={{url_for('static', filename='scripts/emoji-picker-element/index.js')}}></script>
{% for m in modules %}
<script type="module" src={{url_for('static', filename=m)}}></script>
{% endfor %}

10
app/templates/chat.html Normal file
View File

@ -0,0 +1,10 @@
{% set tabtitle = "Shoutbox" %}
<!DOCTYPE html>
<html lang="fr-FR">
{% include "base/head.html" with context %}
<body>
{% include "widgets/v5shoutbox.html" %}
{% include "base/scripts.html" %}
</body>
</html>

View File

@ -8,11 +8,114 @@
{% block content %}
<section class="home-pinned-content">
<div>
<div class="home-banner">
<img src="https://www.planet-casio.com/storage/staff/CPC30-banner.png" />
</div>
<div class="home-welcome">
<h1>Bienvenue sur Planète Casio !</h1>
<p>Planète Casio est la communauté française de référence pour toutes les calculatrices Casio.
Apprenez à utiliser votre machine, téléchargez et partagez des programmes, ou initiez-vous à l'informatique sur le forum.
Ou bien venez développer des jeux avec nous pour passer le temps !</p>
<div>
<h2>Les calculatrices</h2>
<ul>
<li>Tout sur sa Casio</li>
<li>Graph 25+E II</li>
<li>Graph 35+E II</li>
<li>Graph 90+E</li>
<li>Classpad 400+E</li>
<li>Comparer les calculatrices CASIO</li>
</ul>
</div>
<div>
<h2>La communauté</h2>
<ul>
<li>Sinscrire ou se connecter</li>
<li>Index du forum</li>
<li>Nos jeux outils et cours</li>
</ul>
</div>
<div>
<h2>Programmer</h2>
<ul>
<li>Apprendre à programmer</li>
<li>Articles et astuces</li>
</ul>
</div>
</div>
<div class="home-news">
<h1>Actualitées</h1>
<ul>
<li>Inscription : dans le menu "Compte" à gauche (les comptes seront ultimement reconnectés à la version originale du site)</li>
<li>Le forum est fonctionnel, les programmes arrivent sous peu.</li>
<li>Pour toute demande particulière, vous pouvez envoyer un email à <code>contact (at) planet-casio (dot) com</code>.</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tdm9_collision2.jpg"/></a>
<div>
<h3><a href="#">Les collisions — partie 2</a></h3>
<p class="date"><i>Publié par <a href="#">Shadow15510</a> le <time>09/06/2023 09:10</time></i></p>
La deuxième partie du tutoriel du mercredi sur les collisions est maintenant disponible en vidéo.
</div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-thumb-results.png"/></a>
<div>
<h3><a href="#">Résultats du CPC #30 — Les profondeurs !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>08/06/2023 22:10</time></i></p>
Les programmes sont profonds et avec un peu de chance les tests aussi ! </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tituya-thumb.png"/></a>
<div>
<h3><a href="#">Un second renardministrateur !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>07/06/2023 22:55</time></i></p>
Vive la renardocratie ! </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/massy2.jpg"/></a>
<div>
<h3><a href="#">Visite chez Casio France à Massy, musée inclus</a>
</h3>
<p class="date"><i>Publié par <a href="#">Critor</a> le <time>05/06/2023 11:51</time></i></p>
Compte-rendu de notre visite au sein même des locaux de Casio France à Massy en mai 2023, passage par leur musée privé inclus. </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-title.jpg"/></a>
<div>
<h3><a href="#">Le CPC#30 - Les Profondeurs ... C'est désormais terminé ... Bravo à tous les participants.</a>
</h3>
<p class="date"><i>Publié par <a href="#">Slyvtt</a> le <time>03/06/2023 21:36</time></i></p>
Aujourd'hui sonne la fin du CPC#30 avec à la clef 6 participations pour vous donner du fun. Graphs Monochromes et Couleurs sont à la fête avec des programmes en Basic et des Addins. </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tdm9_miniature.jpg"/></a>
<div>
<h3><a href="#">Les Tutos du Mercredi débarquent sur Youtube !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Shadow15510</a> le <time>29/05/2023 14:15</time></i></p>
</div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-thumb.jpg"/></a>
<div>
<h3><a href="#">Le CPC #30 - Les profondeurs !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>27/05/2023 18:00</time></i></p>
Le concours CPC revient en force et c'est le moment de se plonger (métaphoriquement <i>et</i> littéralement !) dans le game design fin et la programmation sportive. Une semaine pour les programmer tous, et dans les profondeurs les li— oups ! </div>
</li>
<li><p><i><a href="https://www.planet-casio.com/Fr/forums/partie6-.html">Voir toutes les news</a></i></p></li>
</ul>
</div>
<div class="home-shoutbox" style="border: 1px solid #737373; padding: 20px">
<div id="v5shoutbox">
Ici yaura la shoutbox (plus tard)
</div>
</div>
<div class="home-projects">
<h1>Projets du moment</h1>
<ul>
<li>La v5, bien sûr</li>
<li>Chaos drop</li>
<li>Un easter egg</li>
</ul>
</div>
</section>

View File

@ -2,7 +2,7 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/tag_selector.html" as widget_tag_selector with context %}
{% set tabtitle = f"Programmes - Soumettre un programme" %}
{% set tabtitle = "Programmes - Soumettre un programme" %}
{% block title %}
<a href="{{ url_for('program_index') }}">Programmes</a> » <h1>Soumettre un programme</h1>

View File

@ -32,7 +32,7 @@
<a href="{{ url_for('move_post', postid=post.id) }}">Déplacer</a>
{% endif %}
{% if can_punish %}
{% if can_punish and post.author.type == "member"%}
<a href="{{ url_for('delete_post', postid=post.id, penalty=False, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé.')">Supprimer{{ suffix }} (normal)</a>
<a href="{{ url_for('delete_post', postid=post.id, penalty=True, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé avec pénalité d\'XP.')">Supprimer{{ suffix }} (pénalité)</a>
{% elif can_delete %}

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/widget.html

View File

@ -1,7 +1,7 @@
# Tags suitable for rendering markdown
markdown_tags = [
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "i", "strong", "em", "tt",
"b", "i", "strong", "em", "tt", "ins", "del",
"p", "br",
"span", "div", "blockquote", "code", "pre", "hr",
"ul", "ol", "li", "dd", "dt",

View File

@ -7,6 +7,7 @@ from markdown.extensions.toc import TocExtension, slugify_unicode
from bleach import clean
from app.utils.bleach_allowlist import markdown_tags, markdown_attrs
from app.utils.markdown_extensions.del_ins import DelInsExtension
from app.utils.markdown_extensions.pclinks import PCLinkExtension
from app.utils.markdown_extensions.hardbreaks import HardBreakExtension
from app.utils.markdown_extensions.escape_html import EscapeHtmlExtension
@ -34,6 +35,7 @@ def md(text, prefix=None):
# 'nl2br',
'sane_lists',
'tables',
DelInsExtension(),
CodeHiliteExtension(linenums=True, use_pygments=True),
EscapeHtmlExtension(),
FootnoteExtension(UNIQUE_IDS=True),

View File

@ -36,4 +36,4 @@ def say(msg, channels = ["#general"]):
def new_topic(topic):
""" Example wrapper for glados.say """
say(f"Le topic {BOLD}{topic.title}{BOLD} a été créé")
say(f"Nouveau topic de {topic.author.name}: {BOLD}{topic.title}{BOLD}")

View File

@ -16,18 +16,24 @@ def get_member(username):
return None
def edit(user, fields):
def edit(old_username, new_member):
""" Edit a user. Fields is {'name': ['value'], …} """
old_username = normalize(old_username)
conn = ldap.initialize("ldap://localhost")
# TODO: do this
# Connect as root
# conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ENV}',
# V5Config.LDAP_PASSWORD)
# old_value = {"userPassword": ["my_old_password"]}
# new_value = {"userPassword": ["my_new_password"]}
conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ROOT}',
V5Config.LDAP_PASSWORD)
# Create values fields
old_dn = f'cn={old_username},{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}'
new_dn = f'cn={new_member.norm}'
new_values = [
(ldap.MOD_REPLACE, 'sn', [new_member.norm.encode('utf-8')]),
(ldap.MOD_REPLACE, 'displayName', [new_member.name.encode('utf-8')]),
(ldap.MOD_REPLACE, 'mail', [new_member.email.encode('utf-8')]),
]
# modlist = modifyModlist(old_value, new_value)
# conn.modify_s(dn, modlist)
conn.modify_s(old_dn, new_values)
conn.rename_s(old_dn, new_dn)
def set_email(user, email):

View File

@ -0,0 +1,12 @@
from markdown.extensions import Extension
from markdown.inlinepatterns import SimpleTagPattern
class DelInsExtension(Extension):
def extendMarkdown(self, md):
DEL_RE = r'(~~)(.*?)~~'
del_tag = SimpleTagPattern(DEL_RE, 'del')
md.inlinePatterns.register(del_tag, 'del', 55)
INS_RE = r'(__)(.*?)__'
ins_tag = SimpleTagPattern(INS_RE, 'ins')
md.inlinePatterns.register(ins_tag, 'ins', 55)

View File

@ -1,7 +1,7 @@
from flask import render_template
from flask_login import current_user
def render(*args, styles=[], scripts=[], **kwargs):
def render(*args, styles=[], scripts=[], modules=[], **kwargs):
# Pour jouer sur les feuilles de style ou les scripts :
# render('page.html', styles=['-css/form.css', '+css/admin/forms.css'])
@ -29,6 +29,9 @@ def render(*args, styles=[], scripts=[], **kwargs):
'scripts/tag_selector.js',
'scripts/editor.js',
]
modules_ = [
'scripts/emoji-picker-element/index.js',
]
# Apply theme from user settings
theme = current_user.theme if current_user.is_authenticated else ''
@ -56,4 +59,10 @@ def render(*args, styles=[], scripts=[], **kwargs):
if s[0] == '+':
scripts_.append(s[1:])
return render_template(*args, **kwargs, styles=styles_, scripts=scripts_)
for m in modules:
if m[0] == '-':
modules_.remove(m[1:])
if m[0] == '+':
modules_.append(m[1:])
return render_template(*args, **kwargs, styles=styles_, scripts=scripts_, modules=modules_)

View File

@ -1,4 +1,5 @@
import os
import logging
from datetime import timedelta
try:
@ -34,7 +35,7 @@ class FlaskApplicationSettings(object):
+ LocalConfig.DB_NAME
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_DEFAULT_SENDER = "noreply@v5.planet-casio.com"
MAIL_DEFAULT_SENDER = "noreply-v5@planet-casio.com"
MAIL_SUPPRESS_SEND = None
# Only send cookies over HTTPS connections (use only if HTTPS is enabled)
@ -83,6 +84,20 @@ class DefaultConfig(object):
SLOW_REQUEST_THRESHOLD = 0.400 # s
# Whether to enable flask-debug-toolbar
ENABLE_FLASK_DEBUG_TOOLBAR = False
# Tab title prefix. Useful to dissociate local/dev/prod tabs
TABTITLE_PREFIX = ""
@staticmethod
def v5logger():
""" A fully configured logger for v5 activity logs """
logger = logging.getLogger('v5')
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s')
handler = logging.FileHandler('v5_activity.log')
handler.setLevel(logging.DEBUG)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
class V5Config(LocalConfig, DefaultConfig):