slidge 0.1.3__py3-none-any.whl → 0.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. slidge/__init__.py +3 -5
  2. slidge/__main__.py +2 -196
  3. slidge/__version__.py +5 -0
  4. slidge/command/adhoc.py +8 -1
  5. slidge/command/admin.py +6 -7
  6. slidge/command/base.py +1 -2
  7. slidge/command/register.py +32 -16
  8. slidge/command/user.py +85 -6
  9. slidge/contact/contact.py +165 -49
  10. slidge/contact/roster.py +122 -47
  11. slidge/core/config.py +14 -11
  12. slidge/core/gateway/base.py +148 -36
  13. slidge/core/gateway/caps.py +7 -5
  14. slidge/core/gateway/disco.py +2 -4
  15. slidge/core/gateway/mam.py +1 -4
  16. slidge/core/gateway/muc_admin.py +1 -1
  17. slidge/core/gateway/ping.py +2 -3
  18. slidge/core/gateway/presence.py +1 -1
  19. slidge/core/gateway/registration.py +32 -21
  20. slidge/core/gateway/search.py +3 -5
  21. slidge/core/gateway/session_dispatcher.py +120 -57
  22. slidge/core/gateway/vcard_temp.py +7 -5
  23. slidge/core/mixins/__init__.py +11 -1
  24. slidge/core/mixins/attachment.py +32 -14
  25. slidge/core/mixins/avatar.py +90 -25
  26. slidge/core/mixins/base.py +8 -2
  27. slidge/core/mixins/db.py +18 -0
  28. slidge/core/mixins/disco.py +0 -10
  29. slidge/core/mixins/message.py +18 -8
  30. slidge/core/mixins/message_maker.py +17 -9
  31. slidge/core/mixins/presence.py +17 -4
  32. slidge/core/pubsub.py +54 -220
  33. slidge/core/session.py +69 -34
  34. slidge/db/__init__.py +4 -0
  35. slidge/db/alembic/env.py +64 -0
  36. slidge/db/alembic/script.py.mako +26 -0
  37. slidge/db/alembic/versions/09f27f098baa_add_missing_attributes_in_room.py +36 -0
  38. slidge/db/alembic/versions/2461390c0af2_store_contacts_caps_verstring_in_db.py +36 -0
  39. slidge/db/alembic/versions/29f5280c61aa_store_subject_setter_in_room.py +37 -0
  40. slidge/db/alembic/versions/2b1f45ab7379_store_room_subject_setter_by_nickname.py +41 -0
  41. slidge/db/alembic/versions/82a4af84b679_add_muc_history_filled.py +48 -0
  42. slidge/db/alembic/versions/8d2ced764698_rely_on_db_to_store_contacts_rooms_and_.py +133 -0
  43. slidge/db/alembic/versions/aa9d82a7f6ef_db_creation.py +85 -0
  44. slidge/db/alembic/versions/b33993e87db3_move_everything_to_persistent_db.py +214 -0
  45. slidge/db/alembic/versions/b64b1a793483_add_source_and_legacy_id_for_archived_.py +48 -0
  46. slidge/db/alembic/versions/c4a8ec35a0e8_per_room_user_nick.py +34 -0
  47. slidge/db/alembic/versions/e91195719c2c_store_users_avatars_persistently.py +26 -0
  48. slidge/db/avatar.py +235 -0
  49. slidge/db/meta.py +65 -0
  50. slidge/db/models.py +375 -0
  51. slidge/db/store.py +1078 -0
  52. slidge/group/archive.py +58 -14
  53. slidge/group/bookmarks.py +72 -57
  54. slidge/group/participant.py +87 -28
  55. slidge/group/room.py +369 -211
  56. slidge/main.py +201 -0
  57. slidge/migration.py +30 -0
  58. slidge/slixfix/__init__.py +35 -2
  59. slidge/slixfix/roster.py +11 -4
  60. slidge/slixfix/xep_0292/vcard4.py +3 -0
  61. slidge/util/archive_msg.py +2 -1
  62. slidge/util/db.py +1 -47
  63. slidge/util/test.py +71 -4
  64. slidge/util/types.py +29 -4
  65. slidge/util/util.py +22 -0
  66. {slidge-0.1.3.dist-info → slidge-0.2.0a1.dist-info}/METADATA +4 -4
  67. slidge-0.2.0a1.dist-info/RECORD +114 -0
  68. slidge/core/cache.py +0 -183
  69. slidge/util/schema.sql +0 -126
  70. slidge/util/sql.py +0 -508
  71. slidge-0.1.3.dist-info/RECORD +0 -96
  72. {slidge-0.1.3.dist-info → slidge-0.2.0a1.dist-info}/LICENSE +0 -0
  73. {slidge-0.1.3.dist-info → slidge-0.2.0a1.dist-info}/WHEEL +0 -0
  74. {slidge-0.1.3.dist-info → slidge-0.2.0a1.dist-info}/entry_points.txt +0 -0
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
 
6
6
  from slixmpp import JID
7
7
 
8
+ from ...db.avatar import CachedAvatar, avatar_cache
8
9
  from ...util.types import (
9
10
  URL,
10
11
  AnyBaseSession,
@@ -12,7 +13,6 @@ from ...util.types import (
12
13
  AvatarType,
13
14
  LegacyFileIdType,
14
15
  )
15
- from ..cache import avatar_cache
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from ..pubsub import PepAvatar
@@ -28,13 +28,14 @@ class AvatarMixin:
28
28
 
29
29
  jid: JID = NotImplemented
30
30
  session: AnyBaseSession = NotImplemented
31
- _avatar_pubsub_broadcast: bool = NotImplemented
32
31
  _avatar_bare_jid: bool = NotImplemented
33
32
 
34
33
  def __init__(self) -> None:
35
34
  super().__init__()
36
35
  self._set_avatar_task: Optional[Task] = None
36
+ self.__broadcast_task: Optional[Task] = None
37
37
  self.__avatar_unique_id: Optional[AvatarIdType] = None
38
+ self._avatar_pk: Optional[int] = None
38
39
 
39
40
  @property
40
41
  def __avatar_jid(self):
@@ -84,17 +85,42 @@ class AvatarMixin:
84
85
  return None
85
86
  raise TypeError("Bad avatar", a)
86
87
 
87
- async def __set_avatar(self, a: Optional[AvatarType], uid: Optional[AvatarIdType]):
88
+ async def __set_avatar(
89
+ self, a: Optional[AvatarType], uid: Optional[AvatarIdType], delete: bool
90
+ ):
88
91
  self.__avatar_unique_id = uid
89
- await self.session.xmpp.pubsub.set_avatar(
90
- jid=self.__avatar_jid,
91
- avatar=a,
92
- unique_id=None if isinstance(uid, URL) else uid,
93
- broadcast_to=self.session.user.jid.bare,
94
- broadcast=self._avatar_pubsub_broadcast,
95
- )
92
+
93
+ if a is None:
94
+ cached_avatar = None
95
+ self._avatar_pk = None
96
+ else:
97
+ try:
98
+ cached_avatar = await avatar_cache.convert_or_get(
99
+ URL(a) if isinstance(a, URL) else a,
100
+ None if isinstance(uid, URL) else uid,
101
+ )
102
+ except Exception as e:
103
+ self.session.log.error("Failed to set avatar %s", a, exc_info=e)
104
+ self._avatar_pk = None
105
+ self.__avatar_unique_id = uid
106
+ return
107
+ self._avatar_pk = cached_avatar.pk
108
+
109
+ if self.__should_pubsub_broadcast():
110
+ await self.session.xmpp.pubsub.broadcast_avatar(
111
+ self.__avatar_jid, self.session.user_jid, cached_avatar
112
+ )
113
+
114
+ if delete and isinstance(a, Path):
115
+ a.unlink()
116
+
96
117
  self._post_avatar_update()
97
118
 
119
+ def __should_pubsub_broadcast(self):
120
+ return getattr(self, "is_friend", False) and getattr(
121
+ self, "added_to_roster", False
122
+ )
123
+
98
124
  async def _no_change(self, a: Optional[AvatarType], uid: Optional[AvatarIdType]):
99
125
  if a is None:
100
126
  return self.__avatar_unique_id is None
@@ -103,23 +129,27 @@ class AvatarMixin:
103
129
  if isinstance(uid, URL):
104
130
  if self.__avatar_unique_id != uid:
105
131
  return False
106
- return not await avatar_cache.url_has_changed(uid)
132
+ return not await avatar_cache.url_modified(uid)
107
133
  return self.__avatar_unique_id == uid
108
134
 
109
135
  async def set_avatar(
110
136
  self,
111
137
  a: Optional[AvatarType],
112
138
  avatar_unique_id: Optional[LegacyFileIdType] = None,
139
+ delete: bool = False,
113
140
  blocking=False,
114
141
  cancel=True,
115
142
  ) -> None:
116
143
  """
117
144
  Set an avatar for this entity
118
145
 
119
- :param a:
120
- :param avatar_unique_id:
121
- :param blocking:
122
- :param cancel:
146
+ :param a: The avatar, in one of the types slidge supports
147
+ :param avatar_unique_id: A globally unique ID for the avatar on the
148
+ legacy network
149
+ :param delete: If the avatar is provided as a Path, whether to delete
150
+ it once used or not.
151
+ :param blocking: Internal use by slidge for tests, do not use!
152
+ :param cancel: Internal use by slidge, do not use!
123
153
  """
124
154
  if avatar_unique_id is None and a is not None:
125
155
  avatar_unique_id = self.__get_uid(a)
@@ -128,7 +158,7 @@ class AvatarMixin:
128
158
  if cancel and self._set_avatar_task:
129
159
  self._set_avatar_task.cancel()
130
160
  awaitable = create_task(
131
- self.__set_avatar(a, avatar_unique_id),
161
+ self.__set_avatar(a, avatar_unique_id, delete),
132
162
  name=f"Set pubsub avatar of {self}",
133
163
  )
134
164
  if not self._set_avatar_task or self._set_avatar_task.done():
@@ -136,32 +166,67 @@ class AvatarMixin:
136
166
  if blocking:
137
167
  await awaitable
138
168
 
139
- def get_avatar(self) -> Optional["PepAvatar"]:
169
+ def get_cached_avatar(self) -> Optional["CachedAvatar"]:
140
170
  if not self.__avatar_unique_id:
141
171
  return None
142
- return self.session.xmpp.pubsub.get_avatar(self.__avatar_jid)
172
+ return avatar_cache.get(self.__avatar_unique_id)
173
+
174
+ def get_avatar(self) -> Optional["PepAvatar"]:
175
+ cached_avatar = self.get_cached_avatar()
176
+ if cached_avatar is None:
177
+ return None
178
+ from ..pubsub import PepAvatar
179
+
180
+ item = PepAvatar()
181
+ item.set_avatar_from_cache(cached_avatar)
182
+ return item
143
183
 
144
184
  def _post_avatar_update(self) -> None:
145
185
  return
146
186
 
187
+ def __get_cached_avatar_id(self):
188
+ i = self._get_cached_avatar_id()
189
+ if i is None:
190
+ return None
191
+ return self.session.xmpp.AVATAR_ID_TYPE(i)
192
+
193
+ def _get_cached_avatar_id(self) -> Optional[str]:
194
+ raise NotImplementedError
195
+
147
196
  async def avatar_wrap_update_info(self):
148
- cached_id = avatar_cache.get_cached_id_for(self.__avatar_jid)
197
+ cached_id = self.__get_cached_avatar_id()
149
198
  self.__avatar_unique_id = cached_id
150
199
  try:
151
200
  await self.update_info() # type:ignore
152
201
  except NotImplementedError:
153
202
  return
154
203
  new_id = self.avatar
155
- if isinstance(new_id, URL) and not await avatar_cache.url_has_changed(new_id):
204
+ if isinstance(new_id, URL) and not await avatar_cache.url_modified(new_id):
156
205
  return
157
206
  elif new_id != cached_id:
158
207
  # at this point it means that update_info set the avatar, and we don't
159
208
  # need to do anything else
160
209
  return
161
210
 
162
- await self.session.xmpp.pubsub.set_avatar_from_cache(
163
- self.__avatar_jid,
164
- new_id is None and cached_id is not None,
165
- self.session.user.jid.bare,
166
- self._avatar_pubsub_broadcast,
211
+ if self.__should_pubsub_broadcast():
212
+ if new_id is None and cached_id is None:
213
+ return
214
+ cached_avatar = avatar_cache.get(cached_id)
215
+ self.__broadcast_task = self.session.xmpp.loop.create_task(
216
+ self.session.xmpp.pubsub.broadcast_avatar(
217
+ self.__avatar_jid, self.session.user_jid, cached_avatar
218
+ )
219
+ )
220
+
221
+ def _set_avatar_from_store(self, stored):
222
+ if stored.avatar_id is None:
223
+ return
224
+ if stored.avatar is None:
225
+ # seems to happen after avatar cleanup for some reason?
226
+ self.__avatar_unique_id = None
227
+ return
228
+ self.__avatar_unique_id = (
229
+ stored.avatar.legacy_id
230
+ if stored.avatar.legacy_id is not None
231
+ else URL(stored.avatar.url)
167
232
  )
@@ -8,7 +8,6 @@ from ...util.types import MessageOrPresenceTypeVar
8
8
  if TYPE_CHECKING:
9
9
  from slidge.core.gateway import BaseGateway
10
10
  from slidge.core.session import BaseSession
11
- from slidge.util.db import GatewayUser
12
11
 
13
12
 
14
13
  class MetaBase(ABCMeta):
@@ -18,11 +17,18 @@ class MetaBase(ABCMeta):
18
17
  class Base:
19
18
  session: "BaseSession" = NotImplemented
20
19
  xmpp: "BaseGateway" = NotImplemented
21
- user: "GatewayUser" = NotImplemented
22
20
 
23
21
  jid: JID = NotImplemented
24
22
  name: str = NotImplemented
25
23
 
24
+ @property
25
+ def user_jid(self):
26
+ return self.session.user_jid
27
+
28
+ @property
29
+ def user_pk(self):
30
+ return self.session.user_pk
31
+
26
32
 
27
33
  class BaseSender(Base):
28
34
  def _send(
@@ -0,0 +1,18 @@
1
+ from contextlib import contextmanager
2
+
3
+
4
+ class UpdateInfoMixin:
5
+ """
6
+ This mixin just adds a context manager that prevents commiting to the DB
7
+ on every attribute change.
8
+ """
9
+
10
+ def __init__(self, *args, **kwargs):
11
+ super().__init__(*args, **kwargs)
12
+ self._updating_info = False
13
+
14
+ @contextmanager
15
+ def updating_info(self):
16
+ self._updating_info = True
17
+ yield
18
+ self._updating_info = False
@@ -13,10 +13,6 @@ class BaseDiscoMixin(Base):
13
13
  DISCO_NAME: str = NotImplemented
14
14
  DISCO_LANG = None
15
15
 
16
- def __init__(self):
17
- super().__init__()
18
- self.__caps_cache: Optional[str] = None
19
-
20
16
  def _get_disco_name(self):
21
17
  if self.DISCO_NAME is NotImplemented:
22
18
  return self.xmpp.COMPONENT_NAME
@@ -44,17 +40,11 @@ class BaseDiscoMixin(Base):
44
40
  return info
45
41
 
46
42
  async def get_caps_ver(self, jid: OptJid = None, node: Optional[str] = None):
47
- if self.__caps_cache:
48
- return self.__caps_cache
49
43
  info = await self.get_disco_info(jid, node)
50
44
  caps = self.xmpp.plugin["xep_0115"]
51
45
  ver = caps.generate_verstring(info, caps.hash)
52
- self.__caps_cache = ver
53
46
  return ver
54
47
 
55
- def reset_caps_cache(self):
56
- self.__caps_cache = None
57
-
58
48
 
59
49
  class ChatterDiscoMixin(BaseDiscoMixin):
60
50
  AVATAR = True
@@ -111,7 +111,7 @@ class MarkerMixin(MessageMaker):
111
111
  self.xmpp.delivery_receipt.make_ack(
112
112
  self._legacy_to_xmpp(legacy_msg_id),
113
113
  mfrom=self.jid,
114
- mto=self.user.jid,
114
+ mto=self.user_jid,
115
115
  )
116
116
  )
117
117
  self._send(
@@ -144,7 +144,7 @@ class MarkerMixin(MessageMaker):
144
144
  # We'll see if we need to implement that later
145
145
  return
146
146
  xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)
147
- iq = Iq(sto=self.user.bare_jid, sfrom=self.user.bare_jid, stype="set")
147
+ iq = Iq(sto=self.user_jid.bare, sfrom=self.user_jid.bare, stype="set")
148
148
  iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0490"].stanza.NS
149
149
  iq["pubsub"]["publish"]["item"]["id"] = muc_jid
150
150
  displayed = self.xmpp["xep_0490"].stanza.Displayed()
@@ -169,8 +169,8 @@ class ContentMessageMixin(AttachmentMixin):
169
169
 
170
170
  def __replace_id(self, legacy_msg_id: LegacyMessageType):
171
171
  if self.mtype == "groupchat":
172
- return self.session.muc_sent_msg_ids.get(
173
- legacy_msg_id
172
+ return self.xmpp.store.sent.get_group_xmpp_id(
173
+ self.session.user_pk, str(legacy_msg_id)
174
174
  ) or self._legacy_to_xmpp(legacy_msg_id)
175
175
  else:
176
176
  return self._legacy_to_xmpp(legacy_msg_id)
@@ -215,14 +215,18 @@ class ContentMessageMixin(AttachmentMixin):
215
215
  but store it in the archive. Meant to be used during ``MUC.backfill()``
216
216
  """
217
217
  if carbon:
218
- if not correction and legacy_msg_id in self.session.sent:
218
+ if not correction and self.xmpp.store.sent.was_sent_by_user(
219
+ self.session.user_pk, str(legacy_msg_id)
220
+ ):
219
221
  log.warning(
220
222
  "Carbon message for a message an XMPP has sent? This is a bug! %s",
221
223
  legacy_msg_id,
222
224
  )
223
225
  return
224
- self.session.sent[legacy_msg_id] = self.session.legacy_to_xmpp_msg_id(
225
- legacy_msg_id
226
+ self.xmpp.store.sent.set_message(
227
+ self.session.user_pk,
228
+ str(legacy_msg_id),
229
+ self.session.legacy_to_xmpp_msg_id(legacy_msg_id),
226
230
  )
227
231
  hints = self.__default_hints(hints)
228
232
  msg = self._make_message(
@@ -237,7 +241,13 @@ class ContentMessageMixin(AttachmentMixin):
237
241
  )
238
242
  if correction:
239
243
  msg["replace"]["id"] = self.__replace_id(legacy_msg_id)
240
- return self._send(msg, archive_only=archive_only, carbon=carbon, **send_kwargs)
244
+ return self._send(
245
+ msg,
246
+ archive_only=archive_only,
247
+ carbon=carbon,
248
+ legacy_msg_id=legacy_msg_id,
249
+ **send_kwargs,
250
+ )
241
251
 
242
252
  def correct(
243
253
  self,
@@ -7,8 +7,8 @@ from uuid import uuid4
7
7
  from slixmpp import Message
8
8
  from slixmpp.types import MessageTypes
9
9
 
10
+ from ...db.models import GatewayUser
10
11
  from ...slixfix.link_preview.stanza import LinkPreview as LinkPreviewStanza
11
- from ...util.db import GatewayUser
12
12
  from ...util.types import (
13
13
  ChatState,
14
14
  LegacyMessageType,
@@ -60,8 +60,9 @@ class MessageMaker(BaseSender):
60
60
  msg["body"] = body
61
61
  state = "active"
62
62
  if thread:
63
- known_threads = self.session.threads.inverse # type:ignore
64
- msg["thread"] = known_threads.get(thread) or str(thread)
63
+ msg["thread"] = self.xmpp.store.sent.get_legacy_thread(
64
+ self.user_pk, str(thread)
65
+ ) or str(thread)
65
66
  if state:
66
67
  msg["chat_state"] = state
67
68
  for hint in hints:
@@ -88,9 +89,9 @@ class MessageMaker(BaseSender):
88
89
  msg["stanza_id"]["by"] = self.muc.jid # type: ignore
89
90
 
90
91
  def _legacy_to_xmpp(self, legacy_id: LegacyMessageType):
91
- return self.session.sent.get(legacy_id) or self.session.legacy_to_xmpp_msg_id(
92
- legacy_id
93
- )
92
+ return self.xmpp.store.sent.get_xmpp_id(
93
+ self.session.user_pk, str(legacy_id)
94
+ ) or self.session.legacy_to_xmpp_msg_id(legacy_id)
94
95
 
95
96
  def _add_delay(self, msg: Message, when: Optional[datetime]):
96
97
  if when:
@@ -110,16 +111,23 @@ class MessageMaker(BaseSender):
110
111
  muc = getattr(self, "muc", None)
111
112
 
112
113
  if entity := reply_to.author:
113
- if isinstance(entity, GatewayUser):
114
+ if entity == "user" or isinstance(entity, GatewayUser):
115
+ if isinstance(entity, GatewayUser):
116
+ warnings.warn(
117
+ "Using a GatewayUser as the author of a "
118
+ "MessageReference is deprecated. Use the string 'user' "
119
+ "instead.",
120
+ DeprecationWarning,
121
+ )
114
122
  if muc:
115
123
  jid = copy(muc.jid)
116
124
  jid.resource = fallback_nick = muc.user_nick
117
125
  msg["reply"]["to"] = jid
118
126
  else:
119
- msg["reply"]["to"] = entity.jid
127
+ msg["reply"]["to"] = self.session.user_jid
120
128
  # TODO: here we should use preferably use the PEP nick of the user
121
129
  # (but it doesn't matter much)
122
- fallback_nick = entity.jid.local
130
+ fallback_nick = self.session.user_jid.local
123
131
  else:
124
132
  if muc:
125
133
  if hasattr(entity, "muc"):
@@ -5,7 +5,7 @@ from typing import Optional
5
5
 
6
6
  from slixmpp.types import PresenceShows, PresenceTypes
7
7
 
8
- from ...util.sql import CachedPresence, db
8
+ from ...util.types import CachedPresence
9
9
  from .. import config
10
10
  from .base import BaseSender
11
11
 
@@ -19,20 +19,32 @@ _FRIEND_REQUEST_PRESENCES = {"subscribe", "unsubscribe", "subscribed", "unsubscr
19
19
 
20
20
  class PresenceMixin(BaseSender):
21
21
  _ONLY_SEND_PRESENCE_CHANGES = False
22
+ contact_pk: Optional[int] = None
22
23
 
23
24
  def __init__(self, *a, **k):
24
25
  super().__init__(*a, **k)
26
+ # FIXME: this should not be an attribute of this mixin to allow garbage
27
+ # collection of instances
25
28
  self.__update_last_seen_fallback_task: Optional[Task] = None
29
+ # this is only used when a presence is set during Contact.update_info(),
30
+ # when the contact does not have a DB primary key yet, and is written
31
+ # to DB at the end of update_info()
32
+ self.cached_presence: Optional[CachedPresence] = None
26
33
 
27
34
  async def __update_last_seen_fallback(self):
28
35
  await sleep(3600 * 7)
29
36
  self.send_last_presence(force=True, no_cache_online=False)
30
37
 
31
38
  def _get_last_presence(self) -> Optional[CachedPresence]:
32
- return db.presence_get(self.jid, self.user)
39
+ if self.contact_pk is None:
40
+ return None
41
+ return self.xmpp.store.contacts.get_presence(self.contact_pk)
33
42
 
34
43
  def _store_last_presence(self, new: CachedPresence):
35
- return db.presence_store(self.jid, new, self.user)
44
+ if self.contact_pk is None:
45
+ self.cached_presence = new
46
+ return
47
+ self.xmpp.store.contacts.set_presence(self.contact_pk, new)
36
48
 
37
49
  def _make_presence(
38
50
  self,
@@ -55,7 +67,8 @@ class PresenceMixin(BaseSender):
55
67
  )
56
68
  if old != new:
57
69
  if hasattr(self, "muc") and ptype == "unavailable":
58
- db.presence_delete(self.jid, self.user)
70
+ if self.contact_pk is not None:
71
+ self.xmpp.store.contacts.reset_presence(self.contact_pk)
59
72
  else:
60
73
  self._store_last_presence(new)
61
74
  if old and not force and self._ONLY_SEND_PRESENCE_CHANGES: