Skip to content

Commit

Permalink
Merge pull request #782 from LeXofLeviafan/i18n
Browse files Browse the repository at this point in the history
implementing support for translations in bukuserver
  • Loading branch information
jarun authored Sep 20, 2024
2 parents 2ea119e + b203802 commit 905af8d
Show file tree
Hide file tree
Showing 32 changed files with 2,200 additions and 291 deletions.
1 change: 1 addition & 0 deletions bukuserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ The following are os env config variables available for bukuserver.
| REVERSE_PROXY_PATH | reverse proxy path | string |
| THEME | [GUI theme](https://bootswatch.com/3) | string [default: `default`] (`slate` is a good pick for dark mode) |
| LOCALE | GUI language (partial support) | string [default: `en`] |
| DEBUG | debug mode (verbose logging etc.) | boolean [default: `false`] |

Note: `BUKUSERVER_` is the common prefix (_every variable starts with it_).

Expand Down
20 changes: 20 additions & 0 deletions bukuserver/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
try: # as per Flask-Admin-1.6.1
try:
from flask_babelex import gettext, ngettext, pgettext, lazy_gettext, lazy_pgettext, LazyString
except ImportError:
from flask_babel import gettext, ngettext, pgettext, lazy_gettext, lazy_pgettext, LazyString
except ImportError:
from flask_admin.babel import gettext, ngettext, lazy_gettext
pgettext = lambda ctx, s, *a, **kw: gettext(s, *a, **kw)
lazy_pgettext = lambda ctx, s, *a, **kw: lazy_gettext(s, *a, **kw)
LazyString = lambda func, *args, **kwargs: func(*args, **kwargs)

_, _p, _l, _lp = gettext, pgettext, lazy_gettext, lazy_pgettext

def _key(s): # replicates ad-hoc implementation of "get key from lazy string" used in flask-admin
try:
return s._args[0] # works with _/_l, but not with _lp due to the extra context argument
except Exception:
return str(s)

__all__ = ['_', '_p', '_l', '_lp', '_key', 'gettext', 'pgettext', 'ngettext', 'lazy_gettext', 'lazy_pgettext', 'LazyString']
74 changes: 35 additions & 39 deletions bukuserver/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from enum import Enum

from flask_admin.model import filters
from bukuserver import _l, _key


class BookmarkField(Enum):
Expand Down Expand Up @@ -57,16 +58,16 @@ def bottom_x_func(query, value, index):

class FilterType(Enum):

EQUAL = {'func': equal_func, 'text':'equals'}
NOT_EQUAL = {'func': not_equal_func, 'text':'not equal'}
CONTAINS = {'func': contains_func, 'text':'contains'}
NOT_CONTAINS = {'func': not_contains_func, 'text':'not contains'}
GREATER = {'func': greater_func, 'text':'greater than'}
SMALLER = {'func': smaller_func, 'text':'smaller than'}
IN_LIST = {'func': in_list_func, 'text':'in list'}
NOT_IN_LIST = {'func': not_in_list_func, 'text':'not in list'}
TOP_X = {'func': top_x_func, 'text': 'top x'}
BOTTOM_X = {'func': bottom_x_func, 'text': 'bottom x'}
EQUAL = {'func': equal_func, 'text': _l('equals')}
NOT_EQUAL = {'func': not_equal_func, 'text': _l('not equals')}
CONTAINS = {'func': contains_func, 'text': _l('contains')}
NOT_CONTAINS = {'func': not_contains_func, 'text': _l('not contains')}
GREATER = {'func': greater_func, 'text': _l('greater than')}
SMALLER = {'func': smaller_func, 'text': _l('smaller than')}
IN_LIST = {'func': in_list_func, 'text': _l('in list')}
NOT_IN_LIST = {'func': not_in_list_func, 'text': _l('not in list')}
TOP_X = {'func': top_x_func, 'text': _l('top X')}
BOTTOM_X = {'func': bottom_x_func, 'text': _l('bottom X')}


class BaseFilter(filters.BaseFilter):
Expand All @@ -88,31 +89,27 @@ def __init__(
filter_type=None,
options=None,
data_type=None):
if operation_text in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)
if name == 'name':
self.index = 0
elif name == 'usage_count':
self.index = 1
else:
raise ValueError('name: {}'.format(name))
self.filter_type = None
try:
self.index = ['name', 'usage_count'].index(name)
except ValueError as e:
raise ValueError(f'name: {name}') from e
self.filter_type = filter_type
if filter_type:
self.apply_func = filter_type.value['func']
self.operation_text = filter_type.value['text']
self.filter_type = filter_type
else:
self.apply_func = apply_func
self.operation_text = operation_text
if _key(self.operation_text) in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)

def clean(self, value):
if (
self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST) and
self.name == 'usage_count'):
on_list = self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST)
if on_list and self.name == 'usage_count':
value = [int(v.strip()) for v in value.split(',') if v.strip()]
elif self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST):
elif on_list:
value = [v.strip() for v in value.split(',') if v.strip()]
elif self.name == 'usage_count':
value = int(value)
Expand All @@ -124,15 +121,15 @@ def clean(self, value):


class BookmarkOrderFilter(BaseFilter):
DIR_LIST = [('asc', 'natural'), ('desc', 'reversed')]
DIR_LIST = [('asc', _l('natural')), ('desc', _l('reversed'))]
FIELDS = ['index', 'url', 'title', 'description', 'tags']

def __init__(self, field, *args, **kwargs):
self.field = field
super().__init__('order', *args, options=self.DIR_LIST, **kwargs)

def operation(self):
return 'by ' + self.field
return _l(f'by {self.field}')

def apply(self, query, value):
return query
Expand All @@ -157,7 +154,7 @@ def __init__(self, *args, **kwargs):

def operation(self):
parts = ', '.join(v for k, v in self.KEYS.items() if self.params[k])
return 'search' + (parts and ' ' + parts)
return _l(f'search{parts and " " + parts}')

def apply(self, query, value):
return query
Expand All @@ -173,29 +170,28 @@ def __init__(
filter_type=None,
options=None,
data_type=None):
if operation_text in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)
bm_fields_dict = {x.name.lower(): x.value for x in BookmarkField}
if name in bm_fields_dict:
self.index = bm_fields_dict[name]
else:
raise ValueError('name: {}'.format(name))
raise ValueError(f'name: {name}')
self.filter_type = None
if filter_type:
self.apply_func = filter_type.value['func']
self.operation_text = filter_type.value['text']
else:
self.apply_func = apply_func
self.operation_text = operation_text
if _key(self.operation_text) in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)

def clean(self, value):
if (
self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST) and
self.name == BookmarkField.ID.name.lower()):
on_list = self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST)
if on_list and self.name == BookmarkField.ID.name.lower():
value = [int(v.strip()) for v in value.split(',') if v.strip()]
elif self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST):
elif on_list:
value = [v.strip() for v in value.split(',') if v.strip()]
elif self.name == BookmarkField.ID.name.lower():
value = int(value)
Expand Down Expand Up @@ -251,7 +247,7 @@ def apply_func(query, value, index):
if len(tags) != value:
yield item

self. apply_func = apply_func
self.apply_func = apply_func


class BookmarkTagNumberSmallerFilter(BookmarkBaseFilter):
Expand Down
44 changes: 22 additions & 22 deletions bukuserver/forms.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,44 @@
"""Forms module."""
# pylint: disable=too-few-public-methods, missing-docstring
from typing import Any, Dict, Tuple
from textwrap import dedent
from flask_wtf import FlaskForm
from wtforms.fields import BooleanField, FieldList, StringField, TextAreaField, HiddenField
from wtforms.validators import DataRequired, InputRequired, ValidationError
from buku import DELIM, parse_tags
from bukuserver import _, _l, LazyString
from bukuserver.response import Response

def validate_tag(form, field):
if not isinstance(field.data, str):
raise ValidationError('Tag must be a string.')
raise ValidationError(_('Tag must be a string.'))
if DELIM in field.data:
raise ValidationError('Tag must not contain delimiter \"{}\".'.format(DELIM))
raise ValidationError(_('Tag must not contain delimiter "%(delim)s".', delim=DELIM))


class SearchBookmarksForm(FlaskForm):
keywords = FieldList(StringField('Keywords'), min_entries=1)
all_keywords = BooleanField('Match all keywords', default=True, description='Exclude partial matches (with multiple keywords)')
markers = BooleanField('With markers', default=True, description=dedent('''\
The search string will be split into multiple keywords, each will be applied to a field based on prefix:
- keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively
- '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)
- '#,' is the same but will match FULL tags only
- '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)
Keywords need to be separated by placing spaces before the prefix.
'''))
deep = BooleanField('Deep search', description='When unset, only FULL words will be matched.')
regex = BooleanField('Regex', description='The keyword(s) are regular expressions (overrides other options).')
keywords = FieldList(StringField(_l('Keywords')), min_entries=1)
all_keywords = BooleanField(_l('Match all keywords'), default=True, description=_l('Exclude partial matches (with multiple keywords)'))
markers = BooleanField(_l('With markers'), default=True, description=LazyString(lambda: '\n'.join([
_('The search string will be split into multiple keywords, each will be applied to a field based on prefix:'),
_(" - keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively"),
_(" - '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)"),
_(" - '#,' is the same but will match FULL tags only"),
_(" - '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)"),
_('Keywords need to be separated by placing spaces before the prefix.'),
])))
deep = BooleanField(_l('Deep search'), description=_l('When unset, only FULL words will be matched.'))
regex = BooleanField(_l('Regex'), description=_l('The keyword(s) are regular expressions (overrides other options).'))


class HomeForm(SearchBookmarksForm):
keyword = StringField('Keyword')
keyword = StringField(_l('Keyword'))


class BookmarkForm(FlaskForm):
url = StringField('Url', name='link', validators=[InputRequired()])
title = StringField()
tags = StringField()
description = TextAreaField()
url = StringField(_l('URL'), name='link', validators=[InputRequired()])
title = StringField(_l('Title'))
tags = StringField(_l('Tags'))
description = TextAreaField(_l('Description'))
fetch = HiddenField(filters=[bool])


Expand All @@ -54,7 +54,7 @@ def process_data(self, data: Dict[str, Any]) -> Tuple[Response, Dict[str, Any]]:
"""Generate comma-separated string tags_str based on list of tags."""
tags = data.get('tags')
if tags and not isinstance(tags, list):
return Response.INPUT_NOT_VALID, {'errors': {'tags': 'List of tags expected.'}}
return Response.INPUT_NOT_VALID, {'errors': {'tags': _('List of tags expected.')}}

super().process(data=data)
if not self.validate():
Expand All @@ -81,7 +81,7 @@ class ApiBookmarkEditForm(ApiBookmarkCreateForm):

class ApiBookmarkRangeEditForm(ApiBookmarkEditForm):

del_tags = BooleanField('Delete tags list from existing tags', default=False)
del_tags = BooleanField(_('Delete tags list from existing tags'), default=False)

tags_in = None

Expand Down
24 changes: 15 additions & 9 deletions bukuserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
from flask import current_app, redirect, request, url_for

try:
from . import api, views, util
from . import api, views, util, _p, _l, gettext, ngettext
from response import Response
except ImportError:
from bukuserver import api, views, util
from bukuserver import api, views, util, _p, _l, gettext, ngettext
from bukuserver.response import Response


Expand Down Expand Up @@ -61,26 +61,31 @@ def get_tiny_url(rec_id):


_BOOL_VALUES = {'true': True, '1': True, 'false': False, '0': False}
def get_bool_from_env_var(key: str, default_value: bool) -> bool:
def get_bool_from_env_var(key: str, default_value: bool = False) -> bool:
"""Get bool value from env var."""
return _BOOL_VALUES.get(os.getenv(key, '').lower(), default_value)


def init_locale(app):
def init_locale(app, context_processor=lambda: {}):
try: # as per Flask-Admin-1.6.1
try:
from flask_babelex import Babel
Babel(app).localeselector(lambda: app.config['BUKUSERVER_LOCALE'])
except ImportError:
from flask_babel import Babel
Babel().init_app(app, locale_selector=lambda: app.config['BUKUSERVER_LOCALE'])
app.context_processor(lambda: {'lang': app.config['BUKUSERVER_LOCALE'] or 'en', **context_processor()})
except Exception as e:
app.jinja_env.add_extension('jinja2.ext.i18n')
app.jinja_env.install_gettext_callables(gettext, ngettext, newstyle=True)
app.logger.warning(f'failed to init locale ({e})')
app.context_processor(lambda: {'lang': '', **context_processor()})


def create_app(db_file=None):
"""create app."""
app = FlaskAPI(__name__)
os.environ.setdefault('FLASK_DEBUG', ('1' if get_bool_from_env_var('BUKUSERVER_DEBUG') else '0'))
per_page = int(os.getenv('BUKUSERVER_PER_PAGE', str(views.DEFAULT_PER_PAGE)))
per_page = per_page if per_page > 0 else views.DEFAULT_PER_PAGE
app.config['BUKUSERVER_PER_PAGE'] = per_page
Expand All @@ -90,11 +95,11 @@ def create_app(db_file=None):
app.config['BUKUSERVER_URL_RENDER_MODE'] = url_render_mode
app.config['SECRET_KEY'] = os.getenv('BUKUSERVER_SECRET_KEY') or os.urandom(24)
app.config['BUKUSERVER_READONLY'] = \
get_bool_from_env_var('BUKUSERVER_READONLY', False)
get_bool_from_env_var('BUKUSERVER_READONLY')
app.config['BUKUSERVER_DISABLE_FAVICON'] = \
get_bool_from_env_var('BUKUSERVER_DISABLE_FAVICON', True)
app.config['BUKUSERVER_OPEN_IN_NEW_TAB'] = \
get_bool_from_env_var('BUKUSERVER_OPEN_IN_NEW_TAB', False)
get_bool_from_env_var('BUKUSERVER_OPEN_IN_NEW_TAB')
app.config['BUKUSERVER_DB_FILE'] = os.getenv('BUKUSERVER_DB_FILE') or db_file
reverse_proxy_path = os.getenv('BUKUSERVER_REVERSE_PROXY_PATH')
if reverse_proxy_path:
Expand All @@ -117,6 +122,7 @@ def shell_context():
return {'app': app, 'bukudb': bukudb}

app.jinja_env.filters.update(util.JINJA_FILTERS)
app.jinja_env.globals.update(_p=_p)

admin = Admin(
app, name='buku server', template_mode='bootstrap3',
Expand Down Expand Up @@ -151,9 +157,9 @@ def shell_context():
def favicon():
return redirect(url_for('static', filename='bukuserver/favicon.svg'), code=301) # permanent redirect

admin.add_view(views.BookmarkModelView(bukudb, 'Bookmarks'))
admin.add_view(views.TagModelView(bukudb, 'Tags'))
admin.add_view(views.StatisticView(bukudb, 'Statistic', endpoint='statistic'))
admin.add_view(views.BookmarkModelView(bukudb, _l('Bookmarks')))
admin.add_view(views.TagModelView(bukudb, _l('Tags')))
admin.add_view(views.StatisticView(bukudb, _l('Statistic'), endpoint='statistic'))
return app


Expand Down
22 changes: 14 additions & 8 deletions bukuserver/static/bukuserver/css/list.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
.filters .filter-op {width: 130px !important}
.filters .filter-val {width: 220px !important}
.filters .filter-op {width: var(--filter-op) !important}
.filters .filter-val {width: calc(var(--filters) - var(--filter-op) - var(--filter-buttons) - var(--filter-type)) !important}
#filter_form[action^='/tag/'] {--filter-type: var(--filter-type-tags)}

:root {--filters: 645px; --filter-op: 9em; --filter-buttons: 12.5em; --filter-type: 5.5em; --filter-type-tags: 9em}
/* due to how flask-admin filters are set up, each language requires manual adjustments for full-width sizes */
html[lang=de] {--filter-buttons: 19em}
html[lang=fr] {--filter-buttons: 17em}
html[lang=ru] {--filter-buttons: 16.5em; --filter-type: 8.5em; --filter-type-tags: 11em}

@media (min-width: 768px) {
.filters .filter-op {width: 160px !important}
.filters .filter-val {width: 295px !important}
:root {--filters: 710px; --filter-op: 11.5em}
}
@media (min-width: 992px) {
.filters .filter-op {width: 280px !important}
.filters .filter-val {width: 395px !important}
:root {--filters: 930px; --filter-op: 20em}
}
@media (min-width: 1200px) {
.filters .filter-op {width: 280px !important}
.filters .filter-val {width: 595px !important}
:root {--filters: 1130px; --filter-op: 20em}
html[lang=ru] #filter_form[action^='/bookmark/'] {--filter-op: 25em} /* the last 'buku' filter has a rather long name */
}
20 changes: 0 additions & 20 deletions bukuserver/static/bukuserver/js/page_size.js

This file was deleted.

Loading

0 comments on commit 905af8d

Please sign in to comment.