From f3c699ab828a3868b32fb0bbd8864b9170ab19ce Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 29 Dec 2023 18:52:39 -0500 Subject: [PATCH] Improve split and truncate overflow methods (#1035) --- apprise/plugins/NotifyBase.py | 41 ++++++++++++++++++++--- test/test_plugin_discord.py | 62 +++++++++++++++++++++++++++++++++++ test/test_rest_plugins.py | 61 ++++++++++++++++++++++++++++++++-- 3 files changed, 157 insertions(+), 7 deletions(-) diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 2ba45a79..5e480c20 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -494,25 +494,58 @@ class NotifyBase(URLBase): # Truncate our Title title = title[:self.title_maxlen] - if self.body_maxlen > 0 and len(body) <= self.body_maxlen: + if self.body_maxlen > self.title_maxlen - 2: + # Combine title length into body if defined (2 for \r\n) + body_maxlen = self.body_maxlen \ + if not title else self.body_maxlen - len(title) - 2 + else: + # status quo + body_maxlen = self.body_maxlen + + if body_maxlen > 0 and len(body) <= body_maxlen: response.append({'body': body, 'title': title}) return response if overflow == OverflowMode.TRUNCATE: # Truncate our body and return response.append({ - 'body': body[:self.body_maxlen], + 'body': body[:body_maxlen], 'title': title, }) # For truncate mode, we're done now return response + # Display Count [XX/XX] + # ^^^^^^^^ + # \\\\\\\\ + # 8 characters (space + count) + display_count_width = 8 + + # the min accepted length of a title to allow for a counter display + display_count_threshold = 130 + + show_counter = title and len(body) > body_maxlen \ + and self.title_maxlen > \ + (display_count_threshold + display_count_width) + + count = 0 + if show_counter: + count = int(len(body) / body_maxlen) \ + + (1 if len(body) % body_maxlen else 0) + + if len(title) > self.title_maxlen - display_count_width: + # Truncate our title further + title = title[:self.title_maxlen - display_count_width] + # If we reach here, then we are in SPLIT mode. # For here, we want to split the message as many times as we have to # in order to fit it within the designated limits. response = [{ - 'body': body[i: i + self.body_maxlen], - 'title': title} for i in range(0, len(body), self.body_maxlen)] + 'body': body[i: i + body_maxlen], + 'title': title + ( + '' if not count else + ' [{:02}/{:02}]'.format(idx, count))} for idx, i in + enumerate(range(0, len(body), body_maxlen), start=1)] return response diff --git a/test/test_plugin_discord.py b/test/test_plugin_discord.py index e0d93627..e68af18f 100644 --- a/test/test_plugin_discord.py +++ b/test/test_plugin_discord.py @@ -40,6 +40,11 @@ from apprise import Apprise from apprise import AppriseAttachment from apprise import NotifyType from apprise import NotifyFormat +from apprise.common import OverflowMode + +from random import choice +from string import ascii_uppercase as str_alpha +from string import digits as str_num # Disable logging for a cleaner testing output import logging @@ -593,6 +598,63 @@ def test_plugin_discord_general(mock_post): assert response['params'].get('thread_id') == '12345' +@mock.patch('requests.post') +def test_plugin_discord_overflow(mock_post): + """ + NotifyDiscord() Overflow Checks + + """ + + # Initialize some generic (but valid) tokens + webhook_id = 'A' * 24 + webhook_token = 'B' * 64 + + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + + # Some variables we use to control the data we work with + body_len = 8000 + title_len = 1024 + + # Number of characters per line + row = 24 + + # Create a large body and title with random data + body = ''.join(choice(str_alpha + str_num + ' ') for _ in range(body_len)) + body = '\r\n'.join([body[i: i + row] for i in range(0, len(body), row)]) + + # Create our title using random data + title = ''.join(choice(str_alpha + str_num) for _ in range(title_len)) + + results = NotifyDiscord.parse_url( + f'discord://{webhook_id}/{webhook_token}/?overflow=split') + + assert isinstance(results, dict) + assert results['user'] is None + assert results['webhook_id'] == webhook_id + assert results['webhook_token'] == webhook_token + assert results['password'] is None + assert results['port'] is None + assert results['host'] == webhook_id + assert results['fullpath'] == f'/{webhook_token}/' + assert results['path'] == f'/{webhook_token}/' + assert results['query'] is None + assert results['schema'] == 'discord' + assert results['url'] == f'discord://{webhook_id}/{webhook_token}/' + + instance = NotifyDiscord(**results) + assert isinstance(instance, NotifyDiscord) + + results = instance._apply_overflow( + body, title=title, overflow=OverflowMode.SPLIT) + + # Ensure we never exceed 2000 characters + for entry in results: + assert len(entry['title']) <= instance.title_maxlen + assert len(entry['title']) + len(entry['body']) < instance.body_maxlen + + @mock.patch('requests.post') def test_plugin_discord_markdown_extra(mock_post): """ diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index b7badc1c..ad9385ff 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -163,6 +163,7 @@ def test_notify_overflow_truncate(): # and that the body remains untouched chunks = obj._apply_overflow(body=body, title=title) assert len(chunks) == 1 + # -2 because \r\n are factored into calculation (safe whitespace) assert body[0:TestNotification.body_maxlen] == chunks[0].get('body') assert title == chunks[0].get('title') @@ -327,10 +328,26 @@ def test_notify_overflow_split(): chunks = obj._apply_overflow(body=body, title=title) offset = 0 assert len(chunks) == 4 - for chunk in chunks: - # Our title never changes - assert title == chunk.get('title') + for idx, chunk in enumerate(chunks, start=1): + # Our title has a counter added to it + assert title[:-8] == chunk.get('title')[:-8] + assert chunk.get('title')[-8:] == \ + ' [{:02}/{:02}]'.format(idx, len(chunks)) + # Our body is only broken up; not lost + _body = chunk.get('body') + assert body[offset: len(_body) + offset].rstrip() == _body + offset += len(_body) + # Another edge case where the title just isn't that long leaving + # a lot of space for the [xx/xx] entries (no truncation needed) + chunks = obj._apply_overflow(body=body, title=title[:20]) + offset = 0 + assert len(chunks) == 4 + for idx, chunk in enumerate(chunks, start=1): + # Our title has a counter added to it + assert title[:20] == chunk.get('title')[:-8] + assert chunk.get('title')[-8:] == \ + ' [{:02}/{:02}]'.format(idx, len(chunks)) # Our body is only broken up; not lost _body = chunk.get('body') assert body[offset: len(_body) + offset].rstrip() == _body @@ -386,6 +403,44 @@ def test_notify_overflow_split(): assert bulk[offset: len(_body) + offset] == _body offset += len(_body) + # + # Test case where our title_len is shorter then the value + # that would otherwise trigger the [XX/XX] elements + # + + class TestNotification(NotifyBase): + + # Set a small title length + title_maxlen = 100 + + # Enforce a body length. Make sure it's an int. + body_maxlen = int(body_len / 4) + + def __init__(self, *args, **kwargs): + super().__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.SPLIT) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + offset = 0 + assert len(chunks) == 7 + for idx, chunk in enumerate(chunks, start=1): + # Our title is truncated and no counter added + assert title[:100] == chunk.get('title') + + # Our body is only broken up; not lost + _body = chunk.get('body') + assert body[offset: len(_body) + offset].rstrip() == _body + offset += len(_body) + def test_notify_markdown_general(): """