Daniel Brötzmann pushed to branch time-formats at gajim / gajim
Commits: a3d7d6a3 by mjk at 2022-11-24T19:43:00+00:00 imprv: Prettify display of geographical locations
- - - - - 42adccad by wurstsalat at 2022-11-24T20:54:40+01:00 refactor: MoreMenuButton: Use Gio.Menu
Fixes #11262
- - - - - 532a2f56 by wurstsalat at 2022-11-24T20:57:34+01:00 imprv: Settings: Unify timestamp settings
Part of #10948
- - - - -
14 changed files:
- gajim/common/preview.py - gajim/common/preview_helpers.py - gajim/common/setting_values.py - gajim/common/settings.py - gajim/gtk/chat_list_row.py - gajim/gtk/chat_stack.py - gajim/gtk/const.py - gajim/gtk/conversation/rows/message.py - gajim/gtk/conversation/rows/widgets.py - gajim/gtk/conversation/view.py - gajim/gtk/main.py - gajim/gtk/menus.py - gajim/gtk/preview.py - gajim/gtk/structs.py
Changes:
===================================== gajim/common/preview.py ===================================== @@ -282,12 +282,11 @@ def is_previewable(self, if not self._accept_uri(urlparts, uri, additional_data): return False
- if uri.startswith('geo:'): + if urlparts.scheme == 'geo': try: split_geo_uri(uri) except Exception as err: - log.error(uri) - log.error(err) + log.info('Bad geo URI %s: %s', uri, err) return False
return True
===================================== gajim/common/preview_helpers.py ===================================== @@ -47,6 +47,7 @@
from gajim.common.helpers import sanitize_filename from gajim.common.i18n import _ +from gajim.common.i18n import P_
log = logging.getLogger('gajim.c.preview_helpers')
@@ -382,6 +383,26 @@ def split_geo_uri(uri: str) -> Coords: return Coords(location=location, lat=lat, lon=lon)
+def format_geo_coords(lat: float, lon: float) -> str: + def fmt(f: float) -> str: + i = int(round(f * 3600.0)) + seconds = i % 60 + i //= 60 + minutes = i % 60 + i //= 60 + degrees = i + return '%d°%02d′%02d′′' % (degrees, minutes, seconds) + if lat >= 0: + slat = P_('positive latitude', '%sN') % fmt(lat) + else: + slat = P_('negative latitude', '%sS') % fmt(-lat) + if lon >= 0: + slon = P_('positive longitude', '%sE') % fmt(lon) + else: + slon = P_('negative longitude', '%sW') % fmt(-lon) + return '%s %s' % (slat, slon) + + def filename_from_uri(uri: str) -> str: urlparts = urlparse(unquote(uri)) path = Path(urlparts.path) @@ -389,7 +410,7 @@ def filename_from_uri(uri: str) -> str:
def aes_decrypt(key: bytes, iv: bytes, payload: bytes) -> bytes: - # Use AES128 GCM with the given key and iv to decrypt the payload + # Use AES256 GCM with the given key and iv to decrypt the payload data = payload[:-16] tag = payload[-16:] decryptor = Cipher(algorithms.AES(key), @@ -447,8 +468,6 @@ def guess_simple_file_type(file_path: str, if mime_type == 'application/octet-stream': mime_type = '' icon = get_icon_for_mime_type(mime_type) - if file_path.startswith('geo:'): - return icon, _('Location') if mime_type.startswith('audio/'): return icon, _('Audio File') if mime_type.startswith('image/'):
===================================== gajim/common/setting_values.py ===================================== @@ -132,9 +132,9 @@ class _ACCOUNT_DEFAULT: 'audio_output_device', 'autoaway_message', 'autoxa_message', - 'chat_timestamp_format', 'confirm_block', - 'date_timestamp_format', + 'date_format', + 'date_time_format', 'dictionary_url', 'emoticons_theme', 'gc_refer_to_nick_char', @@ -154,6 +154,7 @@ class _ACCOUNT_DEFAULT: 'show_main_window_on_startup', 'speller_language', 'stun_server', + 'time_format', 'video_framerate', 'video_input_device', 'video_size', @@ -180,13 +181,13 @@ class _ACCOUNT_DEFAULT: 'autoxatime': 15, 'chat_handle_position': 350, 'chat_merge_consecutive_nickname': True, - 'chat_timestamp_format': '%H:%M', 'check_for_update': True, 'confirm_block': '', 'confirm_close_muc': True, 'confirm_on_window_delete': True, 'dark_theme': 2, - 'date_timestamp_format': '%x', + 'date_format': '%x', + 'date_time_format': '%c', 'dev_force_bookmark_2': False, 'dev_use_message_label': True, 'developer_modus': False, @@ -268,6 +269,7 @@ class _ACCOUNT_DEFAULT: 'sounds_on': True, 'speller_language': '', 'stun_server': '', + 'time_format': '%H:%M', 'trayicon_notification_on_events': True, 'use_keyring': True, 'use_kib_mib': False, @@ -635,14 +637,14 @@ class WorkspaceSettings(TypedDict): 'Show message meta data (avatar, nickname, timestamp) only once, ' 'if there are multiple messages from the same sender within a ' 'specific timespan.'), - 'chat_timestamp_format': 'https://docs.python.org/3/library/time.html#time.strftime', # noqa: E501 'confirm_block': _( 'Show a confirmation dialog to block a contact? Empty string ' 'means never show the dialog.'), 'confirm_close_muc': _('Ask before closing a group chat tab/window.'), 'confirm_on_window_delete': _( 'Ask before quitting when Gajim’s window is closed'), - 'date_timestamp_format': 'https://docs.python.org/3/library/time.html#time.strftime', # noqa: E501 + 'date_format': 'https://docs.python.org/3/library/time.html#time.strftime', # noqa: E501 + 'date_time_format': 'https://docs.python.org/3/library/time.html#time.strftime', # noqa: E501 'dev_force_bookmark_2': _('Force Bookmark 2 usage'), 'dev_use_message_label': '', 'developer_modus': '', @@ -691,6 +693,7 @@ class WorkspaceSettings(TypedDict): 'show_chatstate_in_banner': _( 'Show chat state (e.g. "is typing…") next to your contact’s name'), 'stun_server': _('STUN server to use when using Jingle'), + 'time_format': 'https://docs.python.org/3/library/time.html#time.strftime', # noqa: E501 'trayicon_notification_on_events': _( 'Notify of events in the notification area.'), 'use_kib_mib': _(
===================================== gajim/common/settings.py ===================================== @@ -348,6 +348,17 @@ def _migrate(self) -> None:
self._set_user_version(3)
+ if version < 4: + value = self._settings['app'].get('chat_timestamp_format') + if value is not None: + self._settings['app']['time_format'] = value + + value = self._settings['app'].get('date_timestamp_format') + if value is not None: + self._settings['app']['date_format'] = value + + self._set_user_version(4) + def _migrate_old_config(self) -> None: config_file = configpaths.get('CONFIG_FILE') if not config_file.exists():
===================================== gajim/gtk/chat_list_row.py ===================================== @@ -18,6 +18,7 @@ from typing import Any
import pickle +from urllib.parse import urlparse
from gi.repository import Gdk from gi.repository import Gio @@ -39,6 +40,8 @@ from gajim.common.helpers import get_uf_relative_time from gajim.common.helpers import message_needs_highlight from gajim.common.helpers import AdditionalDataDict +from gajim.common.preview_helpers import split_geo_uri +from gajim.common.preview_helpers import format_geo_coords from gajim.common.preview_helpers import filename_from_uri from gajim.common.preview_helpers import guess_simple_file_type from gajim.common.types import ChatContactT @@ -252,9 +255,16 @@ def set_message_text(self, icon = Gio.Icon.new_for_string(icon_name) if additional_data is not None: if app.preview_manager.is_previewable(text, additional_data): - file_name = filename_from_uri(text) - icon, file_type = guess_simple_file_type(text) - text = f'{file_type} ({file_name})' + scheme = urlparse(text).scheme + if scheme == 'geo': + location = split_geo_uri(text) + icon = Gio.Icon.new_for_string('mark-location') + text = format_geo_coords( + float(location.lat), float(location.lon)) + else: + file_name = filename_from_uri(text) + icon, file_type = guess_simple_file_type(text) + text = f'{file_type} ({file_name})'
text = GLib.markup_escape_text(text) if text.startswith('/me') and nickname is not None:
===================================== gajim/gtk/chat_stack.py ===================================== @@ -20,6 +20,7 @@ import sys import logging import time +from urllib.parse import urlparse
from gi.repository import Gdk from gi.repository import GLib @@ -40,6 +41,8 @@ from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant +from gajim.common.preview_helpers import split_geo_uri +from gajim.common.preview_helpers import format_geo_coords from gajim.common.structs import OutgoingMessage from gajim.common.types import ChatContactT from gajim.gui.dialogs import ErrorDialog @@ -389,8 +392,11 @@ def _issue_notification(self, event: events.MessageReceived) -> None: is_previewable = app.preview_manager.is_previewable( text, additional_data) if is_previewable: - if text.startswith('geo:'): - text = _('Location') + scheme = urlparse(text).scheme + if scheme == 'geo': + location = split_geo_uri(text) + text = format_geo_coords( + float(location.lat), float(location.lon)) else: file_name = preview_helpers.filename_from_uri(text) _icon, file_type = preview_helpers.guess_simple_file_type(text) @@ -531,6 +537,7 @@ def _update_group_chat_actions(self, contact: GroupchatContact) -> None:
app.window.get_action('quote').set_enabled(joined) app.window.get_action('mention').set_enabled(joined) + app.window.get_action('retract-message').set_enabled(joined)
def _update_participant_actions(self, contact: GroupchatParticipant) -> None:
===================================== gajim/gtk/const.py ===================================== @@ -194,6 +194,8 @@ def __str__(self): ('show-emoji-chooser', None, True), ('activate-message-selection', 'u', True), ('correct-message', None, False), + ('copy-message', 's', True), + ('retract-message', 'a{sv}', False), ('quote', 's', False), ('mention', 's', False), ('send-file-httpupload', None, False),
===================================== gajim/gtk/conversation/rows/message.py ===================================== @@ -21,7 +21,6 @@ from datetime import timedelta import textwrap
-from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import GLib from gi.repository import Gtk @@ -38,7 +37,6 @@ from gajim.common.const import Trust from gajim.common.const import TRUST_SYMBOL_DATA from gajim.common.helpers import AdditionalDataDict -from gajim.common.helpers import from_one_line from gajim.common.helpers import get_group_chat_nick from gajim.common.helpers import message_needs_highlight from gajim.common.helpers import to_user_string @@ -55,8 +53,6 @@ from .widgets import MessageIcons from .widgets import MoreMenuButton from ..message_widget import MessageWidget -from ...dialogs import InputDialog -from ...dialogs import DialogButton from ...preview import PreviewWidget from ...util import format_fingerprint
@@ -314,37 +310,6 @@ def is_mergeable(self, message: MessageRow) -> bool: def get_text(self) -> str: return self._message_widget.get_text()
- def on_copy_message(self, _widget: Gtk.Widget) -> None: - time_format = from_one_line(app.settings.get('chat_timestamp_format')) - timestamp_formatted = self.timestamp.strftime(time_format) - - clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - text = self.get_text() - clip.set_text(f'{timestamp_formatted} - {self.name}: {text}', -1) - - def on_quote_message(self, _widget: Gtk.Widget) -> None: - app.window.activate_action( - 'quote', GLib.Variant('s', self.get_text())) - - def on_correct_message(self, _widget: Gtk.Widget) -> None: - app.window.activate_action('correct-message', None) - - def on_retract_message(self, _widget: Gtk.Widget) -> None: - def _on_retract(reason: str) -> None: - self._client.get_module('MUC').retract_message( - self._contact.jid, self.stanza_id, reason or None) - - InputDialog( - _('Retract Message'), - _('Retract message?'), - _('Why do you want to retract this message?'), - [DialogButton.make('Cancel'), - DialogButton.make('Remove', - text=_('_Retract'), - callback=_on_retract)], - input_str=_('Spam'), - transient_for=app.window).show() - def _get_encryption_image(self, additional_data: AdditionalDataDict, ) -> Optional[Gtk.Image]:
===================================== gajim/gtk/conversation/rows/widgets.py ===================================== @@ -28,13 +28,13 @@ from gajim.common import app from gajim.common.const import AvatarSize from gajim.common.i18n import Q_ -from gajim.common.helpers import is_retraction_allowed from gajim.common.modules.contacts import GroupchatContact from gajim.common.types import ChatContactT
if TYPE_CHECKING: from .message import MessageRow
+from ...menus import get_chat_row_menu from ...menus import get_groupchat_participant_menu from ...util import wrap_with_event_box from ...util import get_cursor @@ -76,120 +76,26 @@ def __init__(self, self.connect('clicked', self._on_click)
def _on_click(self, _button: Gtk.Button) -> None: - show_retract = False - if isinstance(self._contact, GroupchatContact): - if not self._contact.is_joined: - self._create_popover() - return - - contact = self._contact.get_resource(self._name) - self_contact = self._contact.get_self() - assert self_contact is not None - is_allowed = is_retraction_allowed(self_contact, contact) - - disco_info = app.storage.cache.get_last_disco_info( - self._contact.jid) - assert disco_info is not None - - if disco_info.has_message_moderation and is_allowed: - show_retract = True - - show_correction = False - if self._row.message_id is not None: - show_correction = app.window.is_message_correctable( - self._contact, self._row.message_id) - - self._create_popover(show_retract, - show_correction) - - def _create_popover(self, - show_retract: bool = False, - show_correction: bool = False - ) -> None: - - menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - menu_box.get_style_context().add_class('padding-6') - - quote_enabled = True - if isinstance(self._contact, GroupchatContact): - if self._contact.is_joined: - self_contact = self._contact.get_self() - assert self_contact is not None - quote_enabled = not self_contact.role.is_visitor - else: - quote_enabled = False - - quote_button = Gtk.ModelButton() - quote_button.set_halign(Gtk.Align.START) - quote_button.connect('clicked', self._row.on_quote_message) - quote_button.set_label(Q_('?Message row action:Quote…')) - quote_button.set_image(Gtk.Image.new_from_icon_name( - 'mail-reply-sender-symbolic', Gtk.IconSize.MENU)) - quote_button.set_sensitive(quote_enabled) - menu_box.add(quote_button) - - copy_button = Gtk.ModelButton() - copy_button.set_halign(Gtk.Align.START) - copy_button.connect('clicked', self._row.on_copy_message) - copy_button.set_label(Q_('?Message row action:Copy')) - copy_button.set_image(Gtk.Image.new_from_icon_name( - 'edit-copy-symbolic', Gtk.IconSize.MENU)) - menu_box.add(copy_button) - - select_button = Gtk.ModelButton() - select_button.set_halign(Gtk.Align.START) - select_button.connect('clicked', self._on_activate_message_selection) - select_button.set_label(Q_('?Message row action:Select Messages…')) - select_button.set_image(Gtk.Image.new_from_icon_name( - 'edit-select-symbolic', Gtk.IconSize.MENU)) - menu_box.add(select_button) - - if show_correction: - correct_button = Gtk.ModelButton() - correct_button.set_halign(Gtk.Align.START) - correct_button.connect( - 'clicked', self._row.on_correct_message) - correct_button.set_label(Q_('?Message row action:Correct…')) - correct_button.set_image(Gtk.Image.new_from_icon_name( - 'document-edit-symbolic', Gtk.IconSize.MENU)) - menu_box.add(correct_button) - - if show_retract: - retract_button = Gtk.ModelButton() - retract_button.set_halign(Gtk.Align.START) - retract_button.connect( - 'clicked', self._row.on_retract_message) - retract_button.set_label(Q_('?Message row action:Retract…')) - retract_button.set_image(Gtk.Image.new_from_icon_name( - 'edit-undo-symbolic', Gtk.IconSize.MENU)) - menu_box.add(retract_button) - - menu_box.show_all() - - popover = Gtk.PopoverMenu() - popover.add(menu_box) - popover.set_relative_to(self) - popover.set_position(Gtk.PositionType.BOTTOM) - popover.connect('closed', self._on_closed) + menu = get_chat_row_menu( + self._contact, + self._name, + self._row.get_text(), + self._row.timestamp, + self._row.message_id, + self._row.stanza_id, + self._row.log_line_id) + + popover = GajimPopover(menu, relative_to=self) popover.popup()
- def _on_activate_message_selection(self, _button: Gtk.ModelButton) -> None: - app.window.activate_action( - 'activate-message-selection', - GLib.Variant('u', self._row.log_line_id or 0)) - - @staticmethod - def _on_closed(popover: Gtk.Popover) -> None: - GLib.idle_add(popover.destroy) -
class DateTimeLabel(Gtk.Label): def __init__(self, timestamp: datetime) -> None: Gtk.Label.__init__(self)
- time_format = app.settings.get('chat_timestamp_format') + time_format = app.settings.get('time_format') if timestamp.date() < datetime.today().date(): - date_format = app.settings.get('date_timestamp_format') + date_format = app.settings.get('date_format') time_format = f'{time_format} - {date_format}' timestamp_formatted = timestamp.strftime(time_format)
===================================== gajim/gtk/conversation/view.py ===================================== @@ -144,7 +144,7 @@ def __init__(self) -> None: 'activate', self._on_scroll_view)
def copy_selected_messages(self) -> None: - time_format = from_one_line(app.settings.get('chat_timestamp_format')) + time_format = from_one_line(app.settings.get('time_format')) selection_text = '' for row in cast(list[BaseRow], self._list_box.get_selected_rows()): if isinstance(row, MessageRow):
===================================== gajim/gtk/main.py ===================================== @@ -54,6 +54,7 @@ from .dialogs import DialogButton from .dialogs import ConfirmationDialog from .dialogs import ConfirmationCheckDialog +from .dialogs import InputDialog from .builder import get_builder from .util import get_app_window from .util import resize_window @@ -65,6 +66,7 @@ from .structs import AddChatActionParams from .structs import actionmethod from .structs import ChatListEntryParam +from .structs import RetractMessageParam
if TYPE_CHECKING: from .control import ChatControl @@ -401,6 +403,8 @@ def _connect_actions(self) -> None: ('switch-workspace-8', self._on_action), ('switch-workspace-9', self._on_action), ('toggle-chat-list', self._on_action), + ('copy-message', self._on_copy_message), + ('retract-message', self._on_retract_message), ('add-workspace', self._add_workspace), ('edit-workspace', self._edit_workspace), ('remove-workspace', self._remove_workspace), @@ -510,6 +514,36 @@ def _toggle_chat_list(self) -> None: 'go-previous-symbolic', Gtk.IconSize.BUTTON) self._chat_page.toggle_chat_list()
+ def _on_copy_message(self, + _action: Gio.SimpleAction, + param: GLib.Variant + ) -> None: + + clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clip.set_text(param.get_string(), -1) + + @actionmethod + def _on_retract_message(self, + _action: Gio.SimpleAction, + params: RetractMessageParam + ) -> None: + + def _on_retract(reason: str) -> None: + client = app.get_client(params.account) + client.get_module('MUC').retract_message( + params.jid, params.stanza_id, reason or None) + + InputDialog( + _('Retract Message'), + _('Retract message?'), + _('Why do you want to retract this message?'), + [DialogButton.make('Cancel'), + DialogButton.make('Remove', + text=_('_Retract'), + callback=_on_retract)], + input_str=_('Spam'), + transient_for=app.window).show() + def _on_window_motion_notify(self, _widget: Gtk.ApplicationWindow, _event: Gdk.EventMotion
===================================== gajim/gtk/menus.py ===================================== @@ -21,6 +21,7 @@ from typing import Optional from typing import Union
+from datetime import datetime import textwrap from urllib.parse import quote
@@ -32,10 +33,13 @@
from gajim.common import app from gajim.common import types +from gajim.common.helpers import from_one_line from gajim.common.helpers import is_affiliation_change_allowed +from gajim.common.helpers import is_retraction_allowed from gajim.common.helpers import is_role_change_allowed from gajim.common.helpers import jid_is_blocked from gajim.common.i18n import _ +from gajim.common.i18n import Q_ from gajim.common.i18n import get_short_lang_code from gajim.common.const import URIType from gajim.common.const import XmppUriQuery @@ -48,6 +52,7 @@ from gajim.gui.structs import AccountJidParam from gajim.gui.structs import ChatListEntryParam from gajim.gui.structs import RemoveHistoryActionParams +from gajim.gui.structs import RetractMessageParam from gajim.gui.util import GajimMenu
@@ -594,6 +599,77 @@ def get_component_search_menu(jid: Optional[str], copy_text: str) -> Gio.Menu: return menu
+def get_chat_row_menu(contact: types.ChatContactT, + name: str, + text: str, + timestamp: datetime, + message_id: Optional[str], + stanza_id: Optional[str], + log_line_id: Optional[int] + ) -> GajimMenu: + + menu_items: MenuItemListT = [] + + text = text.replace('"', '\"') + + time_format = from_one_line(app.settings.get('time_format')) + timestamp_formatted = timestamp.strftime(time_format) + copy_text = f'{timestamp_formatted} - {name}: {text}' + menu_items.append( + (Q_('?Message row action:Copy'), 'win.copy-message', f'"{copy_text}"')) + + menu_items.append( + (Q_('?Message row action:Select Messages…'), + 'win.activate-message-selection', + GLib.Variant('u', log_line_id or 0))) + + show_quote = True + if isinstance(contact, GroupchatContact): + if contact.is_joined: + self_contact = contact.get_self() + assert self_contact is not None + show_quote = not self_contact.role.is_visitor + else: + show_quote = False + if show_quote: + menu_items.append(( + Q_('?Message row action:Quote…'), + 'win.quote', + f'"{text}"')) + + show_correction = False + if message_id is not None: + show_correction = app.window.is_message_correctable( + contact, message_id) + if show_correction: + menu_items.append(( + Q_('?Message row action:Correct…'), 'win.correct-message', None)) + + show_retract = False + if isinstance(contact, GroupchatContact) and contact.is_joined: + resource_contact = contact.get_resource(name) + self_contact = contact.get_self() + assert self_contact is not None + is_allowed = is_retraction_allowed(self_contact, resource_contact) + + disco_info = app.storage.cache.get_last_disco_info(contact.jid) + assert disco_info is not None + + if disco_info.has_message_moderation and is_allowed: + show_retract = True + if show_retract: + assert stanza_id is not None + param = RetractMessageParam( + account=contact.account, + jid=contact.jid, + stanza_id=stanza_id) + menu_items.append(( + Q_('?Message row action:Retract…'), 'win.retract-message', param)) + + menu = GajimMenu.from_list(menu_items) + return menu + + def get_format_menu() -> GajimMenu:
menuitems: MenuItemListT = [
===================================== gajim/gtk/preview.py ===================================== @@ -33,6 +33,7 @@ from gajim.common.i18n import _ from gajim.common.preview import Preview from gajim.common.preview_helpers import split_geo_uri +from gajim.common.preview_helpers import format_geo_coords from gajim.common.preview_helpers import contains_audio_streams from gajim.common.preview_helpers import get_icon_for_mime_type from gajim.common.types import GdkPixbufType @@ -136,12 +137,10 @@ def update(self, preview: Preview, data: Optional[GdkPixbufType]) -> None: self._ui.download_button.hide()
location = split_geo_uri(preview.uri) - self._ui.file_size.set_text(_('Lat: %s Lon: %s') % ( - round(float(location.lat), 5), - round(float(location.lon), 5))) + text = format_geo_coords(float(location.lat), float(location.lon)) + self._ui.file_size.set_text(text) self._ui.image_button.set_tooltip_text( - _('Location at Lat: %s Lon: %s') % ( - location.lat, location.lon)) + _('Location at %s') % text) self._ui.preview_box.set_size_request(160, -1) return
===================================== gajim/gtk/structs.py ===================================== @@ -21,7 +21,9 @@ import inspect import functools from dataclasses import dataclass -from gi.repository import GLib, Gio + +from gi.repository import Gio +from gi.repository import GLib
from nbxmpp.protocol import JID
@@ -66,6 +68,13 @@ class AccountJidParam(VariantMixin): jid: JID
+@dataclass +class RetractMessageParam(VariantMixin): + account: str + jid: JID + stanza_id: str + + def get_params_class(func: Callable[..., Any]) -> Any: module = sys.modules[__name__] params = inspect.signature(func).parameters
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/6fdd248cbd09f7610ced7f7a335b4d68...