Daniel Brötzmann pushed to branch sanitize-filenames at gajim / gajim
Commits: 63c362f5 by wurstsalat at 2022-08-26T23:29:54+02:00 imprv: ChatList: Always show when switching workspace
Fixes #11108
- - - - - 2cf04a23 by Daniel Brötzmann at 2022-08-27T11:09:45+00:00 feat: Add drag and drop support for pinned chats
- - - - - 5125e2c0 by wurstsalat at 2022-08-27T14:06:16+02:00 chore: Control: Remove obsolete code
- - - - - 8431f218 by wurstsalat at 2022-08-27T14:08:08+02:00 fix: Preview: Sanitize filename from disallowed chars
Fixes #11105, #10752
- - - - -
10 changed files:
- gajim/common/helpers.py - gajim/common/preview_helpers.py - gajim/common/setting_values.py - gajim/common/settings.py - gajim/gtk/chat_list.py - gajim/gtk/chat_list_stack.py - gajim/gtk/chat_page.py - gajim/gtk/control.py - gajim/gtk/main.py - + test/no_gui/test_sanitize_filename.py
Changes:
===================================== gajim/common/helpers.py ===================================== @@ -37,7 +37,6 @@ import re import os import subprocess -import base64 import hashlib import shlex import socket @@ -58,10 +57,10 @@ from datetime import timedelta from urllib.parse import unquote from urllib.parse import urlparse -from encodings.punycode import punycode_encode from functools import wraps from pathlib import Path from packaging.version import Version as V +import unicodedata
from nbxmpp.namespaces import Namespace from nbxmpp.const import Role @@ -358,28 +357,40 @@ def get_file_path_from_dnd_dropped_uri(uri: str) -> str:
def sanitize_filename(filename: str) -> str: ''' - Make sure the filename we will write does contain only acceptable and latin - characters, and is not too long (in that case hash it) + Sanitize filename of elements not allowed on Windows + https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file + Limit filename length to 50 chars on all systems ''' - # 48 is the limit - if len(filename) > 48: - hash_ = hashlib.md5(filename.encode('utf-8')) - filename = base64.b64encode(hash_.digest()).decode('utf-8') - - # make it latin chars only - filename = punycode_encode(filename).decode('utf-8') - filename = filename.replace('/', '_') - if os.name == 'nt': - filename = filename.replace('?', '_')\ - .replace(':', '_')\ - .replace('\', '_')\ - .replace('"', "'")\ - .replace('|', '_')\ - .replace('*', '_')\ - .replace('<', '_')\ - .replace('>', '_') - - return filename + if sys.platform == 'win32': + blacklist = ['\', '/', ':', '*', '?', '"', '<', '>', '|', '\0'] + reserved_filenames = [ + 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', + 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', + ] + filename = ''.join(char for char in filename if char not in blacklist) + + filename = ''.join(char for char in filename if 31 < ord(char)) + + filename = unicodedata.normalize('NFKD', filename) + filename = filename.rstrip('. ') + filename = filename.strip() + + if all(char == '.' for char in filename): + filename = f'__{filename}' + if filename.upper() in reserved_filenames: + filename = f'__{filename}' + if len(filename) == 0: + filename = '__' + + extension = Path(filename).suffix[:10] + filename = Path(filename).stem + final_length = 50 - len(extension) + + # Many Filesystems have a limit on filename length: keep it short + filename = filename[:final_length] + + return f'{filename}{extension}'
def get_contact_dict_for_account(account: str) -> dict[str, types.BareContact]:
===================================== gajim/common/preview_helpers.py ===================================== @@ -45,6 +45,7 @@ from cryptography.hazmat.primitives.ciphers import algorithms from cryptography.hazmat.primitives.ciphers.modes import GCM
+from gajim.common.helpers import sanitize_filename from gajim.common.i18n import _
log = logging.getLogger('gajim.c.preview_helpers') @@ -308,12 +309,7 @@ def get_image_paths(uri: str, web_stem = path.stem extension = path.suffix
- if len(web_stem) > 90: - # Many Filesystems have a limit on filename length - # Most have 255, some encrypted ones only 143 - # We add around 50 chars for the hash, - # so the filename should not exceed 90 - web_stem = web_stem[:90] + web_stem = sanitize_filename(web_stem)
name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
===================================== gajim/common/setting_values.py ===================================== @@ -506,24 +506,33 @@ class _ACCOUNT_DEFAULT: 'name', ]
-AllWorkspaceSettings = Literal[StringWorkspaceSettings, 'open_chats'] +AllWorkspaceSettings = Literal[StringWorkspaceSettings, 'chats']
-OpenChatSettingT = list[tuple[str, JID, str, bool]] -AllWorkspaceSettingsT = Union[str, OpenChatSettingT] + +class OpenChatSettingDetails(TypedDict): + account: str + jid: JID + type: str + pinned: bool + position: int + + +OpenChatsSettingT = list[OpenChatSettingDetails] +AllWorkspaceSettingsT = Union[str, OpenChatsSettingT]
class WorkspaceSettings(TypedDict): name: str color: str avatar_sha: str - open_chats: OpenChatSettingT + chats: OpenChatsSettingT
WORKSPACE_SETTINGS: WorkspaceSettings = { 'name': _('My Workspace'), 'color': '', 'avatar_sha': '', - 'open_chats': [], + 'chats': [], }
===================================== gajim/common/settings.py ===================================== @@ -56,7 +56,7 @@ from gajim.common.setting_values import StringGroupChatSettings from gajim.common.setting_values import AllWorkspaceSettings from gajim.common.setting_values import AllWorkspaceSettingsT -from gajim.common.setting_values import OpenChatSettingT +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.setting_values import StringWorkspaceSettings from gajim.common.setting_values import ACCOUNT_SETTINGS from gajim.common.setting_values import PROXY_SETTINGS @@ -82,6 +82,8 @@
log = logging.getLogger('gajim.c.settings')
+CURRENT_USER_VERSION = 2 + CREATE_SQL = ''' CREATE TABLE settings ( name TEXT UNIQUE, @@ -100,10 +102,11 @@ INSERT INTO settings(name, settings) VALUES ('plugins', '{}'); INSERT INTO settings(name, settings) VALUES ('workspaces', '%s');
- PRAGMA user_version=1; + PRAGMA user_version=%s; ''' % (json.dumps(STATUS_PRESET_EXAMPLES), json.dumps(PROXY_EXAMPLES), - json.dumps(INITAL_WORKSPACE)) + json.dumps(INITAL_WORKSPACE), + CURRENT_USER_VERSION)
_SignalCallable = Callable[[Any, str, Optional[str], Optional[JID]], Any] @@ -217,6 +220,7 @@ def init(self) -> None: self._commit() self._migrate_database() self._load_app_overrides() + self._commit()
@staticmethod def _setup_installation_defaults() -> None: @@ -320,6 +324,23 @@ def _migrate(self) -> None: self._settings['workspaces'] = INITAL_WORKSPACE self._set_user_version(1)
+ if version < 2: + # Migrate open chats to new key and format + for workspace in self._settings['workspaces'].values(): + open_chats = [] + for open_chat in workspace.get('open_chats', []): + account, jid, type_, pinned = open_chat + open_chats.append({'account': account, + 'jid': jid, + 'type': type_, + 'pinned': pinned, + 'position': -1}) + + workspace['chats'] = open_chats + workspace.pop('open_chats', None) + + self._set_user_version(2) + def _migrate_old_config(self) -> None: config_file = configpaths.get('CONFIG_FILE') if not config_file.exists(): @@ -1180,8 +1201,8 @@ def set_workspace_setting(self, @overload def set_workspace_setting(self, workspace_id: str, - setting: Literal['open_chats'], - value: list[tuple[str, JID, str, bool]] + setting: Literal['chats'], + value: OpenChatsSettingT ) -> None: ...
@@ -1207,8 +1228,8 @@ def set_workspace_setting(self, @overload def get_workspace_setting(self, workspace_id: str, - setting: Literal['open_chats'] - ) -> OpenChatSettingT: + setting: Literal['chats'] + ) -> OpenChatsSettingT: ...
@overload
===================================== gajim/gtk/chat_list.py ===================================== @@ -26,6 +26,7 @@ from gi.repository import Gio from gi.repository import Gdk from gi.repository import GLib +from gi.repository import GObject from gi.repository import Gtk import cairo
@@ -47,6 +48,7 @@ from gajim.common.helpers import AdditionalDataDict from gajim.common.preview_helpers import filename_from_uri from gajim.common.preview_helpers import guess_simple_file_type +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.types import ChatContactT from gajim.common.types import OneOnOneContactT from gajim.common.modules.contacts import BareContact @@ -65,6 +67,13 @@
class ChatList(Gtk.ListBox, EventHelper): + + __gsignals__ = { + 'chat-order-changed': (GObject.SignalFlags.RUN_LAST, + None, + ()), + } + def __init__(self, workspace_id: str) -> None: Gtk.ListBox.__init__(self) EventHelper.__init__(self) @@ -94,12 +103,16 @@ def __init__(self, workspace_id: str) -> None: entries, Gdk.DragAction.MOVE)
+ self._drag_row: Optional[ChatRow] = None + self._chat_order: list[ChatRow] = [] + self.register_events([ ('account-enabled', ged.GUI2, self._on_account_changed), ('account-disabled', ged.GUI2, self._on_account_changed), ('bookmarks-received', ged.GUI1, self._on_bookmarks_received), ])
+ self.connect('drag-data-received', self._on_drag_data_received) self.connect('destroy', self._on_destroy)
self._timer_id = GLib.timeout_add_seconds(60, self._update_timer) @@ -146,6 +159,94 @@ def emit_unread_changed(self) -> None: self._workspace_id, count)
+ def _get_row_before(self, row: ChatRow) -> Optional[ChatRow]: + row_before = self.get_row_at_index(row.get_index() - 1) + if row_before is None: + return + return cast(ChatRow, row_before) + + def _get_row_after(self, row: ChatRow) -> Optional[ChatRow]: + row_after = self.get_row_at_index(row.get_index() + 1) + if row_after is None: + return + return cast(ChatRow, row_after) + + def _get_last_row(self) -> ChatRow: + index = len(self.get_children()) - 1 + last_row = self.get_row_at_index(index) + assert last_row is not None + return cast(ChatRow, last_row) + + def set_drag_row(self, row: ChatRow) -> None: + self._drag_row = row + + def _on_drag_data_received(self, + _widget: Gtk.Widget, + _drag_context: Gdk.DragContext, + _x_coord: int, + y_coord: int, + selection_data: Gtk.SelectionData, + _info: int, + _time: int + ) -> None: + + item_type = selection_data.get_data_type().name() + if item_type != 'CHAT_LIST_ITEM': + log.debug('Unknown item type dropped') + return + + assert self._drag_row is not None + + if not self._drag_row.is_pinned: + log.debug('Dropped row is not pinned') + return + + row = cast(ChatRow, self.get_row_at_y(y_coord)) + if row is not None: + alloc = row.get_allocation() + hover_row_y = alloc.y + hover_row_height = alloc.height + + if y_coord < hover_row_y + hover_row_height / 2: + row_before = self._get_row_before(row) + row_after = row + else: + row_before = row + row_after = self._get_row_after(row) + else: + row_before = self._get_last_row() + row_after = None + + if self._drag_row in (row_before, row_after): + log.debug('Dropped row on self') + return + + if row_before is not None and not row_before.is_pinned: + log.debug('Dropped under not pinned row') + return + + self._change_pinned_order(row_before) + + def _change_pinned_order(self, row_before: Optional[ChatRow]) -> None: + assert self._drag_row is not None + + self._chat_order.remove(self._drag_row) + + if row_before is None: + self._chat_order.insert(0, self._drag_row) + else: + offset = 0 + if row_before.position < self._drag_row.position: + offset = 1 + self._chat_order.insert( + row_before.position + offset, self._drag_row) + + for row in self._chat_order: + row.position = self._chat_order.index(row) + + self.emit('chat-order-changed') + self.invalidate_sort() + def _on_destroy(self, _widget: Gtk.Widget) -> None: GLib.source_remove(self._timer_id)
@@ -198,9 +299,11 @@ def _sort_func(self, row1: ChatRow, row2: ChatRow) -> int: log.debug('Mouseover active, don’t sort rows') return 0
- # Don’t sort pinned rows themselves + # Sort pinned rows according to stored order if row1.is_pinned and row2.is_pinned: - return 0 + if row1.position > row2.position: + return 1 + return -1
# Sort pinned rows to top if row1.is_pinned > row2.is_pinned: @@ -250,14 +353,30 @@ def get_chat_type(self, account: str, jid: JID) -> Optional[str]: return row.type return None
- def add_chat(self, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: - if self._chats.get((account, jid)) is not None: + def add_chat(self, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + + key = (account, jid) + if self._chats.get(key) is not None: # Chat is already in the List return
- row = ChatRow(self._workspace_id, account, jid, type_, pinned) - self._chats[(account, jid)] = row + row = ChatRow(self._workspace_id, + account, + jid, + type_, + pinned, + position) + + self._chats[key] = row + if pinned: + self._chat_order.insert(position, row) + self.add(row)
def select_chat(self, account: str, jid: JID) -> None: @@ -332,12 +451,26 @@ def select_chat_number(self, number: int) -> None:
def toggle_chat_pinned(self, account: str, jid: JID) -> None: row = self._chats[(account, jid)] + + if row.is_pinned: + self._chat_order.remove(row) + row.position = -1 + else: + self._chat_order.append(row) + row.position = self._chat_order.index(row) + row.toggle_pinned() self.invalidate_sort()
- def remove_chat(self, account: str, jid: JID, - emit_unread: bool = True) -> None: + def remove_chat(self, + account: str, + jid: JID, + emit_unread: bool = True + ) -> None: + row = self._chats.pop((account, jid)) + if row.is_pinned: + self._chat_order.remove(row) self.remove(row) row.destroy() if emit_unread: @@ -359,10 +492,15 @@ def get_selected_chat(self) -> Optional[ChatRow]: def contains_chat(self, account: str, jid: JID) -> bool: return self._chats.get((account, jid)) is not None
- def get_open_chats(self) -> list[tuple[str, JID, str, bool]]: - open_chats: list[tuple[str, JID, str, bool]] = [] - for key, value in self._chats.items(): - open_chats.append(key + (value.type, value.is_pinned)) + def get_open_chats(self) -> OpenChatsSettingT: + open_chats: OpenChatsSettingT = [] + for key, row in self._chats.items(): + account, jid = key + open_chats.append({'account': account, + 'jid': jid, + 'type': row.type, + 'pinned': row.is_pinned, + 'position': row.position}) return open_chats
def update_time(self) -> None: @@ -536,14 +674,22 @@ def _on_bookmarks_received(self, _event: events.BookmarksReceived) -> None:
class ChatRow(Gtk.ListBoxRow): - def __init__(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool) -> None: + def __init__(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + Gtk.ListBoxRow.__init__(self)
self.account = account self.jid = jid self.workspace_id = workspace_id self.type = type_ + self.position = position
self.active_label = ActiveHeader() self.conversations_label = ConversationsHeader() @@ -735,6 +881,10 @@ def _on_drag_begin(self, widget: Gtk.Widget, drag_context: Gdk.DragContext ) -> None: + + chat_list = cast(ChatList, self.get_parent()) + chat_list.set_drag_row(self) + # Use rendered ChatListRow as drag icon alloc = self.get_allocation() surface = cairo.ImageSurface(
===================================== gajim/gtk/chat_list_stack.py ===================================== @@ -14,7 +14,6 @@
from __future__ import annotations
-from typing import Dict from typing import Optional from typing import cast
@@ -66,7 +65,7 @@ def __init__(self, self.set_vexpand(True) self.set_vhomogeneous(False)
- self._chat_lists: Dict[str, ChatList] = {} + self._chat_lists: dict[str, ChatList] = {}
self._last_visible_child_name: str = 'default'
@@ -153,6 +152,7 @@ def _on_search_changed(self, search_entry: Gtk.SearchEntry) -> None: def add_chat_list(self, workspace_id: str) -> ChatList: chat_list = ChatList(workspace_id) chat_list.connect('row-selected', self._on_row_selected) + chat_list.connect('chat-order-changed', self._on_chat_order_changed)
self._chat_lists[workspace_id] = chat_list self.add_named(chat_list, workspace_id) @@ -161,8 +161,10 @@ def add_chat_list(self, workspace_id: str) -> ChatList: def remove_chat_list(self, workspace_id: str) -> None: chat_list = self._chat_lists[workspace_id] self.remove(chat_list) - for account, jid, _type, _pinned in chat_list.get_open_chats(): - self.remove_chat(workspace_id, account, jid) + for open_chat in chat_list.get_open_chats(): + self.remove_chat(workspace_id, + open_chat['account'], + open_chat['jid'])
self._chat_lists.pop(workspace_id) chat_list.destroy() @@ -177,6 +179,12 @@ def _on_row_selected(self,
self.emit('chat-selected', row.workspace_id, row.account, row.jid)
+ def _on_chat_order_changed(self, + chat_list: ChatList + ) -> None: + + self.store_open_chats(chat_list.workspace_id) + def show_chat_list(self, workspace_id: str) -> None: cur_workspace_id = self.get_visible_child_name() if cur_workspace_id == workspace_id: @@ -187,12 +195,19 @@ def show_chat_list(self, workspace_id: str) -> None:
self.set_visible_child_name(workspace_id)
- def add_chat(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: + def add_chat(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + chat_list = self._chat_lists.get(workspace_id) if chat_list is None: chat_list = self.add_chat_list(workspace_id) - chat_list.add_chat(account, jid, type_, pinned) + chat_list.add_chat(account, jid, type_, pinned, position)
def select_chat(self, account: str, jid: JID) -> None: chat_list = self.find_chat(account, jid) @@ -206,7 +221,7 @@ def store_open_chats(self, workspace_id: str) -> None: chat_list = self._chat_lists[workspace_id] open_chats = chat_list.get_open_chats() app.settings.set_workspace_setting( - workspace_id, 'open_chats', open_chats) + workspace_id, 'chats', open_chats)
@structs.actionmethod def _toggle_chat_pinned(self, @@ -235,7 +250,8 @@ def _move_chat_to_workspace(self, current_chatlist.remove_chat(params.account, params.jid)
new_chatlist = self.get_chatlist(workspace_id) - new_chatlist.add_chat(params.account, params.jid, type_) + new_chatlist.add_chat(params.account, params.jid, type_, False, -1) + self.store_open_chats(current_chatlist.workspace_id) self.store_open_chats(workspace_id)
===================================== gajim/gtk/chat_page.py ===================================== @@ -221,6 +221,7 @@ def add_chat_for_workspace(self, jid: JID, type_: str, pinned: bool = False, + position: int = -1, select: bool = False) -> None:
client = app.get_client(account) @@ -240,7 +241,7 @@ def add_chat_for_workspace(self, return
self._chat_list_stack.add_chat(workspace_id, account, jid, type_, - pinned) + pinned, position)
if self._startup_finished: if select: @@ -249,18 +250,20 @@ def add_chat_for_workspace(self,
def load_workspace_chats(self, workspace_id: str) -> None: open_chats = app.settings.get_workspace_setting(workspace_id, - 'open_chats') + 'chats')
active_accounts = app.settings.get_active_accounts() - for account, jid, type_, pinned in open_chats: + for open_chat in open_chats: + account = open_chat['account'] if account not in active_accounts: continue
self.add_chat_for_workspace(workspace_id, account, - jid, - type_, - pinned=pinned) + open_chat['jid'], + open_chat['type'], + pinned=open_chat['pinned'], + position=open_chat['position'])
def is_chat_active(self, account: str, jid: JID) -> bool: return self._chat_list_stack.is_chat_active(account, jid)
===================================== gajim/gtk/control.py ===================================== @@ -779,15 +779,6 @@ def _on_user_status_show_changed(self, status=status) self.add_info_message(message)
- def invite(self, invited_jid: JID) -> None: - # TODO: Remove, used by command system - self.client.get_module('MUC').invite( - self.contact.jid, invited_jid) - invited_contact = self.client.get_module('Contacts').get_contact( - invited_jid) - self.add_info_message( - _('%s has been invited to this group chat') % invited_contact.name) - def _on_room_voice_request(self, _contact: GroupchatContact, _signal_name: str,
===================================== gajim/gtk/main.py ===================================== @@ -653,6 +653,13 @@ def activate_workspace(self, workspace_id: str) -> None: self._main_stack.show_chats(workspace_id) self._workspace_side_bar.activate_workspace(workspace_id)
+ # Show chatlist if it is hidden + chat_list_stack = self._chat_page.get_chat_list_stack() + chat_list = chat_list_stack.get_current_chat_list() + if chat_list is not None: + if not chat_list.is_visible(): + self._toggle_chat_list() + def update_workspace(self, workspace_id: str) -> None: self._chat_page.update_workspace(workspace_id) self._workspace_side_bar.update_avatar(workspace_id)
===================================== test/no_gui/test_sanitize_filename.py ===================================== @@ -0,0 +1,61 @@ + +import sys +import unittest +from unittest.mock import patch +from gajim.common.helpers import sanitize_filename + + +class SanitizeTest(unittest.TestCase): + '''Tests for the sanitize_filename function.''' + + @patch.object(sys, 'platform', 'win32') + def test_invalid_chars(self): + '''Make sure invalid characters are removed in filenames''' + self.assertEqual(sanitize_filename('A/B/C'), 'ABC') + self.assertEqual(sanitize_filename('A*C.d'), 'AC.d') + self.assertEqual(sanitize_filename('A?C.d'), 'AC.d') + + @patch.object(sys, 'platform', 'win32') + def test_invalid_suffix(self): + '''Dots are not allowed at the end''' + self.assertEqual(sanitize_filename('def.'), 'def') + self.assertEqual(sanitize_filename('def.ghi'), 'def.ghi') + self.assertTrue(sanitize_filename('X' * 1000 + '.').endswith('X')) + + @patch.object(sys, 'platform', 'win32') + def test_reserved_words(self): + '''Make sure reserved Windows words are prefixed''' + self.assertEqual(sanitize_filename('NUL'), '__NUL') + self.assertEqual(sanitize_filename('..'), '__') + + @patch.object(sys, 'platform', 'win32') + def test_long_names(self): + '''Make sure long names are truncated''' + self.assertEqual(len(sanitize_filename('X' * 300)), 50) + self.assertEqual(len(sanitize_filename( + '.'.join(['X' * 100, 'X' * 100, 'X' * 100]))), 50) + self.assertEqual(len(sanitize_filename( + '.'.join(['X' * 300, 'X' * 300, 'X' * 300]))), 50) + self.assertEqual(len(sanitize_filename('.' * 300 + '.txt')), 50) + + @patch.object(sys, 'platform', 'win32') + def test_unicode_normalization(self): + '''Names should be NFKD normalized''' + self.assertEqual(sanitize_filename('ў'), chr(1091) + chr(774)) + + @patch.object(sys, 'platform', 'win32') + def test_extensions(self): + '''Filename extensions should be preserved when possible.''' + really_long_name = 'X' * 1000 + '.pdf' + self.assertTrue(sanitize_filename(really_long_name).endswith('.pdf')) + self.assertTrue(sanitize_filename('X' * 1000).endswith('X')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 100 + '.pdf').endswith('.pdf')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 400).endswith('X')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 400 + '.pdf').endswith('.pdf')) + + +if __name__ == '__main__': + unittest.main()
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/2303790445a5d73c0a6867e4af936f54...