quick_frontend (constants, quick_app, quick_contact_list): blogging refactoring (not finished):
- adaptation to backend modifications
- moved common blogging parts from Libervia to quick frontend
- QuickApp use a MB_HANDLE class variable to indicated if blogging is managed by the frontend (and avoid waste of resources if not)
- comments are now managed inside parent entry, as a result a tree of comments is now possible
- Entry.level indicate where in the tree we are (-1 mean parent QuickBlog, 0 mean main item, more means comment)
- items + comments are requested in 1 shot, and RTDeferred are used to avoid blocking while waiting for them
- in QuickBlog, id2entries allow to get Entry from it's item id, and node2entries allow to get Entry(ies) hosting a comments node
- QuickBlog.new_message_target tell where a new message will be sent by default
This commit is contained in:
parent
0f10526746
commit
b747c1b733
3 changed files with 494 additions and 7 deletions
|
|
@ -61,6 +61,11 @@ class Const(constants.Const):
|
|||
"paused": u"⦷"
|
||||
}
|
||||
|
||||
# Blogs
|
||||
ENTRY_MODE_TEXT = "text"
|
||||
ENTRY_MODE_RICH = "rich"
|
||||
ENTRY_MODE_XHTML = "xhtml"
|
||||
|
||||
# Widgets management
|
||||
# FIXME: should be in quick_frontend.constant, but Libervia doesn't inherit from it
|
||||
WIDGET_NEW = 'NEW'
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from sat.tools import trigger
|
|||
from sat_frontends.tools import jid
|
||||
from sat_frontends.quick_frontend import quick_widgets
|
||||
from sat_frontends.quick_frontend import quick_menus
|
||||
from sat_frontends.quick_frontend import quick_blog
|
||||
from sat_frontends.quick_frontend import quick_chat, quick_games
|
||||
from sat_frontends.quick_frontend.constants import Const as C
|
||||
|
||||
|
|
@ -196,6 +197,7 @@ class ProfilesManager(object):
|
|||
|
||||
class QuickApp(object):
|
||||
"""This class contain the main methods needed for the frontend"""
|
||||
MB_HANDLE = True # Set to false if the frontend doesn't manage microblog
|
||||
|
||||
def __init__(self, create_bridge, check_options=None):
|
||||
"""Create a frontend application
|
||||
|
|
@ -254,7 +256,7 @@ class QuickApp(object):
|
|||
self.registerSignal("roomUserChangedNick", iface="plugin")
|
||||
self.registerSignal("roomNewSubject", iface="plugin")
|
||||
self.registerSignal("chatStateReceived", iface="plugin")
|
||||
self.registerSignal("personalEvent", iface="plugin")
|
||||
self.registerSignal("psEvent", iface="plugin")
|
||||
|
||||
# FIXME: do it dynamically
|
||||
quick_games.Tarot.registerSignals(self)
|
||||
|
|
@ -607,15 +609,42 @@ class QuickApp(object):
|
|||
contact_list.setCache(from_jid, 'chat_state', to_display)
|
||||
widget.update(from_jid)
|
||||
|
||||
def personalEventHandler(self, sender, event_type, data, profile):
|
||||
"""Called when a PEP event is received.
|
||||
def psEventHandler(self, category, service_s, node, event_type, data, profile):
|
||||
"""Called when a PubSub event is received.
|
||||
|
||||
@param sender (jid.JID): event sender
|
||||
@param event_type (unicode): event type, e.g. 'MICROBLOG' or 'MICROBLOG_DELETE'
|
||||
@param category(unicode): event category (e.g. "PEP", "MICROBLOG")
|
||||
@param service_s (unicode): pubsub service
|
||||
@param node (unicode): pubsub node
|
||||
@param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
|
||||
@param data (dict): event data
|
||||
"""
|
||||
# FIXME move some code from Libervia to here and put the magic strings to constants
|
||||
pass
|
||||
service_s = jid.JID(service_s)
|
||||
|
||||
if category == C.PS_MICROBLOG and self.MB_HANDLE:
|
||||
if event_type == C.PS_PUBLISH:
|
||||
if not 'content' in data:
|
||||
log.warning("No content found in microblog data")
|
||||
return
|
||||
if 'groups' in data:
|
||||
_groups = set(data['groups'].split() if data['groups'] else [])
|
||||
else:
|
||||
_groups = None
|
||||
|
||||
for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
|
||||
wid.addEntryIfAccepted(service_s, node, data, _groups, profile)
|
||||
|
||||
try:
|
||||
comments_node, comments_service = data['comments_node'], data['comments_service']
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
self.bridge.mbGetLast(comments_service, comments_node, C.NO_LIMIT, {"subscribe":C.BOOL_TRUE}, profile=profile)
|
||||
elif event_type == C.PS_RETRACT:
|
||||
for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
|
||||
wid.deleteEntryIfPresent(service_s, node, data['id'], profile)
|
||||
pass
|
||||
else:
|
||||
log.warning("Unmanaged PubSub event type {}".format(event_type))
|
||||
|
||||
def _subscribe_cb(self, answer, data):
|
||||
entity, profile = data
|
||||
|
|
|
|||
453
frontends/src/quick_frontend/quick_blog.py
Normal file
453
frontends/src/quick_frontend/quick_blog.py
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# helper class for making a SAT frontend
|
||||
# Copyright (C) 2011, 2012, 2013, 2014, 2015 Jérôme Poisson <goffi@goffi.org>
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# from sat.core.i18n import _, D_
|
||||
from sat.core.log import getLogger
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
from sat_frontends.quick_frontend.constants import Const as C
|
||||
from sat_frontends.quick_frontend import quick_widgets
|
||||
from sat_frontends.tools import jid
|
||||
|
||||
try:
|
||||
# FIXME: to be removed when an acceptable solution is here
|
||||
unicode('') # XXX: unicode doesn't exist in pyjamas
|
||||
except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
|
||||
unicode = str
|
||||
|
||||
ENTRY_CLS = None
|
||||
COMMENTS_CLS = None
|
||||
|
||||
|
||||
class Item(object):
|
||||
"""Manage all (meta)data of an item"""
|
||||
|
||||
def __init__(self, data):
|
||||
"""
|
||||
@param data(dict, None): microblog data as return by bridge methods
|
||||
if data is None, set a default values
|
||||
"""
|
||||
self.id = data['id']
|
||||
self.title = data.get('title')
|
||||
self.title_rich = None
|
||||
self.title_xhtml = data.get('title_xhtml')
|
||||
self.content = data.get('content')
|
||||
self.content_rich = None
|
||||
self.content_xhtml = data.get('content_xhtml')
|
||||
self.author = data['author']
|
||||
try:
|
||||
author_jid = data['author_jid']
|
||||
self.author_jid = jid.JID(author_jid) if author_jid else None
|
||||
except KeyError:
|
||||
self.author_jid = None
|
||||
|
||||
try:
|
||||
self.author_verified = C.bool(data['author_jid_verified'])
|
||||
except KeyError:
|
||||
self.author_verified = False
|
||||
|
||||
try:
|
||||
self.updated = float(data['updated']) # XXX: int doesn't work here (pyjamas bug)
|
||||
except KeyError:
|
||||
self.updated = None
|
||||
|
||||
try:
|
||||
self.published = float(data['published']) # XXX: int doesn't work here (pyjamas bug)
|
||||
except KeyError:
|
||||
self.published = None
|
||||
|
||||
self.comments = data.get('comments')
|
||||
try:
|
||||
self.comments_service = jid.JID(data['comments_service'])
|
||||
except KeyError:
|
||||
self.comments_service = None
|
||||
self.comments_node = data.get('comments_node')
|
||||
|
||||
# def loadComments(self):
|
||||
# """Load all the comments"""
|
||||
# index = str(main_entry.comments_count - main_entry.hidden_count)
|
||||
# rsm = {'max': str(main_entry.hidden_count), 'index': index}
|
||||
# self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)
|
||||
|
||||
|
||||
class EntriesManager(object):
|
||||
"""Class which manages list of (micro)blog entries"""
|
||||
|
||||
def __init__(self, manager):
|
||||
"""
|
||||
@param manager (EntriesManager, None): parent EntriesManager
|
||||
must be None for QuickBlog (and only for QuickBlog)
|
||||
"""
|
||||
self.manager = manager
|
||||
if manager is None:
|
||||
self.blog = self
|
||||
else:
|
||||
self.blog = manager.blog
|
||||
self.entries = []
|
||||
self._first_entry = None
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
"""indicate how deep is this entry in the tree
|
||||
|
||||
if level == -1, we have a QuickBlog
|
||||
if level == 0, we have a main item
|
||||
else we have a comment
|
||||
"""
|
||||
level = -1
|
||||
manager = self.manager
|
||||
while manager is not None:
|
||||
level += 1
|
||||
manager = manager.manager
|
||||
return level
|
||||
|
||||
def _addMBItems(self, items_tuple, service=None, node=None):
|
||||
"""Add Microblog items to this panel
|
||||
update is NOT called after addition
|
||||
|
||||
@param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGetLast
|
||||
"""
|
||||
items, metadata = items_tuple
|
||||
for item in items:
|
||||
self.addEntry(item, service=service, node=node, with_update=False)
|
||||
|
||||
def _addMBItemsWithComments(self, items_tuple, service=None, node=None):
|
||||
"""Add Microblog items to this panel
|
||||
update is NOT called after addition
|
||||
|
||||
@param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGetLast
|
||||
"""
|
||||
items, metadata = items_tuple
|
||||
for item, comments in items:
|
||||
self.addEntry(item, comments, service=service, node=node, with_update=False)
|
||||
|
||||
def addEntry(self, item=None, comments=None, service=None, node=None, with_update=True, editable=False, first=False):
|
||||
"""Add a microblog entry
|
||||
|
||||
@param editable (bool): True if the entry can be modified
|
||||
@param item (dict, None): blog item data, or None for an empty entry
|
||||
@param comments (list, None): list of comments data if available
|
||||
@param service (jid.JID, None): service where the entry is coming from
|
||||
@param service (unicode, None): node hosting the entry
|
||||
@param with_update (bool): if True, udpate is called with the new entry
|
||||
@param first(bool): if True, will be the first entry regardless of sorting
|
||||
"""
|
||||
new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
|
||||
new_entry.setEditable(editable)
|
||||
if first:
|
||||
self._first_entry = new_entry
|
||||
else:
|
||||
self.entries.append(new_entry)
|
||||
if with_update:
|
||||
self.update()
|
||||
return new_entry
|
||||
|
||||
def update(self, entry=None):
|
||||
"""Update the display with entries
|
||||
|
||||
@param entry (Entry, None): if not None, must be the new entry.
|
||||
If None, all the items will be checked to update the display
|
||||
"""
|
||||
# update is separated from addEntry to allow adding
|
||||
# several entries at once, and updating at the end
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Entry(EntriesManager):
|
||||
"""Graphical representation of an Item
|
||||
This class must be overriden by frontends"""
|
||||
|
||||
def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None):
|
||||
"""
|
||||
@param blog(QuickBlog): the parent QuickBlog
|
||||
@param manager(EntriesManager): the parent EntriesManager
|
||||
@param item_data(dict, None): dict containing the blog item data, or None for an empty entry
|
||||
@param comments_data(list, None): list of comments data
|
||||
"""
|
||||
assert manager is not None
|
||||
EntriesManager.__init__(self, manager)
|
||||
self.service = service
|
||||
self.node = node
|
||||
self.editable = False
|
||||
self.reset(item_data)
|
||||
self.blog.id2entries[self.item.id] = self
|
||||
if self.item.comments:
|
||||
node_tuple = (self.item.comments_service, self.item.comments_node)
|
||||
self.blog.node2entries.setdefault(node_tuple,[]).append(self)
|
||||
|
||||
def reset(self, item_data):
|
||||
"""Reset the entry with given data
|
||||
|
||||
used during init (it's a set and not a reset then)
|
||||
or later (e.g. message sent, or cancellation of an edition
|
||||
@param idem_data(dict): data as in __init__
|
||||
"""
|
||||
if item_data is None:
|
||||
self.new = True
|
||||
item_data = {'id': None,
|
||||
# TODO: find a best author value
|
||||
'author': self.blog.host.whoami.node
|
||||
}
|
||||
else:
|
||||
self.new = False
|
||||
self.item = Item(item_data)
|
||||
self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid
|
||||
if self.author_jid is None and self.service and self.service.node:
|
||||
self.author_jid = self.service
|
||||
self.mode = C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML
|
||||
|
||||
def refresh(semf):
|
||||
"""Refresh the display when data have been modified"""
|
||||
pass
|
||||
|
||||
def setEditable(self, editable=True):
|
||||
"""tell if the entry can be edited or not
|
||||
|
||||
@param editable(bool): True if the entry can be edited
|
||||
"""
|
||||
#XXX: we don't use @property as property setter doesn't play well with pyjamas
|
||||
raise NotImplementedError
|
||||
|
||||
def addComments(self, comments_data):
|
||||
"""Add comments to this entry by calling addEntry repeatidly
|
||||
|
||||
@param comments_data(tuple): data as returned by mbGetFromMany*RTResults
|
||||
"""
|
||||
# TODO: manage seperator between comments of coming from different services/nodes
|
||||
for data in comments_data:
|
||||
service, node, failure, comments, metadata = data
|
||||
for comment in comments:
|
||||
if not failure:
|
||||
self.addEntry(comment, service=jid.JID(service), node=node)
|
||||
else:
|
||||
log.warning("getting comment failed: {}".format(failure))
|
||||
self.update()
|
||||
|
||||
def send(self):
|
||||
"""Send entry according to parent QuickBlog configuration and current level"""
|
||||
|
||||
# keys other to keep other than content* and title*
|
||||
keys_to_keep = ('id', 'comments', 'author', 'author_jid', 'published')
|
||||
|
||||
mb_data = {}
|
||||
for key in keys_to_keep:
|
||||
value = getattr(self.item, key)
|
||||
if value is not None:
|
||||
mb_data[key] = unicode(value)
|
||||
|
||||
for prefix in ('content', 'title'):
|
||||
for suffix in ('', '_rich', '_xhtml'):
|
||||
name = '{}{}'.format(prefix, suffix)
|
||||
value = getattr(self.item, name)
|
||||
if value is not None:
|
||||
mb_data[name] = value
|
||||
|
||||
if self.level == 0:
|
||||
if self.blog.new_message_target == C.PUBLIC:
|
||||
if self.new:
|
||||
mb_data["allow_comments"] = C.BOOL_TRUE
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
self.blog.host.bridge.mbSend(
|
||||
unicode(self.service or ''),
|
||||
self.node or '',
|
||||
mb_data,
|
||||
profile=self.blog.profile)
|
||||
|
||||
def delete(self):
|
||||
"""Remove this Entry from parent manager
|
||||
|
||||
This doesn't delete any entry in PubSub, just locally
|
||||
all children entries will be recursively removed too
|
||||
"""
|
||||
# XXX: named delete and not remove to avoid conflict with pyjamas
|
||||
log.debug(u"deleting entry {}".format('EDIT ENTRY' if self.new else self.item.id))
|
||||
for child in self.entries:
|
||||
child.delete()
|
||||
self.manager.entries.remove(self)
|
||||
if not self.new:
|
||||
# we must remove references to self
|
||||
# in QuickBlog's dictionary
|
||||
del self.blog.id2entries[self.item.id]
|
||||
if self.item.comments:
|
||||
comments_tuple = (self.item.comments_service,
|
||||
self.item.comments_node)
|
||||
other_entries = self.blog.node2entries[comments_tuple].remove(self)
|
||||
if not other_entries:
|
||||
del self.blog.node2entries[comments_tuple]
|
||||
|
||||
def retract(self):
|
||||
"""Retract this item from microblog node
|
||||
|
||||
if there is a comments node, it will be purged too
|
||||
"""
|
||||
# TODO: manage several comments nodes case.
|
||||
if self.item.comments:
|
||||
self.blog.host.bridge.psDeleteNode(unicode(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile)
|
||||
self.blog.host.bridge.mbRetract(unicode(self.service or ""), self.node or "", self.item.id, profile=self.blog.profile)
|
||||
|
||||
|
||||
class QuickBlog(EntriesManager, quick_widgets.QuickWidget):
|
||||
|
||||
def __init__(self, host, targets, profiles=None):
|
||||
"""Panel used to show microblog
|
||||
|
||||
@param targets (tuple(unicode)): contact groups displayed in this panel.
|
||||
If empty, show all microblogs from all contacts. targets is also used
|
||||
to know where to send new messages.
|
||||
"""
|
||||
EntriesManager.__init__(self, None)
|
||||
self.id2entries = {} # used to find an entry with it's item id
|
||||
# must be kept up-to-date by Entry
|
||||
self.node2entries = {} # same as above, values are lists in case of
|
||||
# two entries link to the same comments node
|
||||
if not targets:
|
||||
targets = () # XXX: we use empty tuple instead of None to workaround a pyjamas bug
|
||||
quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
|
||||
self._targets_type = C.ALL
|
||||
else:
|
||||
assert isinstance(targets[0], basestring)
|
||||
quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
|
||||
for target in targets[1:]:
|
||||
assert isinstance(target, basestring)
|
||||
self.addTarget(target)
|
||||
self._targets_type = C.GROUP
|
||||
|
||||
@property
|
||||
def new_message_target(self):
|
||||
if self._targets_type == C.ALL:
|
||||
return C.PUBLIC
|
||||
elif self._targets_type == C.GROUP:
|
||||
return self.targets
|
||||
else:
|
||||
raise ValueError("Unkown targets type")
|
||||
|
||||
def __str__(self):
|
||||
return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.targets), self.profile)
|
||||
|
||||
def _getResultsCb(self, data, rt_session):
|
||||
remaining, results = data
|
||||
log.debug("Got {got_len} results, {rem_len} remaining".format(got_len=len(results), rem_len=remaining))
|
||||
for result in results:
|
||||
service, node, failure, items, metadata = result
|
||||
if not failure:
|
||||
self._addMBItemsWithComments((items, metadata), service=jid.JID(service))
|
||||
|
||||
self.update()
|
||||
if remaining:
|
||||
self._getResults(rt_session)
|
||||
|
||||
def _getResultsEb(self, failure):
|
||||
log.warning("microblog getFromMany error: {}".format(failure))
|
||||
|
||||
def _getResults(self, rt_session):
|
||||
"""Manage results from mbGetFromMany RT Session
|
||||
|
||||
@param rt_session(str): session id as returned by mbGetFromMany
|
||||
"""
|
||||
self.host.bridge.mbGetFromManyWithCommentsRTResult(rt_session, profile=self.profile,
|
||||
callback=lambda data:self._getResultsCb(data, rt_session),
|
||||
errback=self._getResultsEb)
|
||||
|
||||
def getAll(self):
|
||||
"""Get all (micro)blogs from self.targets"""
|
||||
def gotSession(rt_session):
|
||||
self._getResults(rt_session)
|
||||
|
||||
if self._targets_type == C.ALL:
|
||||
self.host.bridge.mbGetFromManyWithComments(C.ALL, (), 10, 10, {}, {"subscribe":C.BOOL_TRUE}, profile=self.profile, callback=gotSession)
|
||||
own_pep = self.host.whoami.bare
|
||||
self.host.bridge.mbGetFromManyWithComments(C.JID, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession)
|
||||
|
||||
def isJidAccepted(self, jid_):
|
||||
"""Tell if a jid is actepted and must be shown in this panel
|
||||
|
||||
@param jid_(jid.JID): jid to check
|
||||
@return: True if the jid is accepted
|
||||
"""
|
||||
if self._targets_type == C.ALL:
|
||||
return True
|
||||
assert self._targets_type is C.GROUP # we don't manage other types for now
|
||||
for group in self.targets:
|
||||
if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group):
|
||||
return True
|
||||
return False
|
||||
|
||||
def addEntryIfAccepted(self, service, node, mb_data, groups, profile):
|
||||
"""add entry to this panel if it's acceptable
|
||||
|
||||
This method check if the entry is new or an update,
|
||||
if it below to a know node, or if it acceptable anyway
|
||||
@param service(jid.JID): jid of the emitting pubsub service
|
||||
@param node(unicode): node identifier
|
||||
@param mb_data: microblog data
|
||||
@param groups(list[unicode], None): groups which can receive this entry
|
||||
None to accept everything
|
||||
@param profile: %(doc_profile)s
|
||||
"""
|
||||
try:
|
||||
entry = self.id2entries[mb_data['id']]
|
||||
except KeyError:
|
||||
# The entry is new
|
||||
try:
|
||||
parent_entries = self.node2entries[(service, node)]
|
||||
except:
|
||||
# The node is unknown,
|
||||
# we need to check that we can accept the entry
|
||||
if (self.isJidAccepted(service)
|
||||
or (groups is None and service == self.host.profiles[self.profile].whoami.bare)
|
||||
or (groups and groups.intersection(self.targets))):
|
||||
self.addEntry(mb_data, service=service, node=node)
|
||||
else:
|
||||
# the entry is a comment in a known node
|
||||
for parent_entry in parent_entries:
|
||||
parent_entry.addEntry(mb_data, service=service, node=node)
|
||||
else:
|
||||
# The entry exist, it's an update
|
||||
entry.reset(mb_data)
|
||||
entry.refresh()
|
||||
|
||||
def deleteEntryIfPresent(self, service, node, item_id, profile):
|
||||
"""Delete and entry if present in this QuickBlog
|
||||
|
||||
@param sender(jid.JID): jid of the entry sender
|
||||
@param mb_data: microblog data
|
||||
@param service(jid.JID): sending service
|
||||
@param node(unicode): hosting node
|
||||
"""
|
||||
try:
|
||||
entry = self.id2entries[item_id]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
entry.delete()
|
||||
|
||||
|
||||
def registerClass(type_, cls):
|
||||
global ENTRY_CLS, COMMENTS_CLS
|
||||
if type_ == "ENTRY":
|
||||
ENTRY_CLS = cls
|
||||
elif type == "COMMENT":
|
||||
COMMENTS_CLS = cls
|
||||
else:
|
||||
raise ValueError("type_ should be ENTRY or COMMENT")
|
||||
if COMMENTS_CLS is None:
|
||||
COMMENTS_CLS = ENTRY_CLS
|
||||
Loading…
Reference in a new issue