diff --git a/data.py b/data.py index cf77811..1457b80 100644 --- a/data.py +++ b/data.py @@ -70,6 +70,16 @@ def parse_ignore_file() -> list: return ignore_names +def get_parent_record(parent_id: str, link_data: list) -> list: + """given a parent ID, return the ID for the parent record or -1""" + if parent_id == "": + raise ValueError("parent_id cannot be empty") + for record in link_data: + if record[0] == parent_id.partition("+")[2]: + return record + raise KeyError("there's no parent record for the specified parent_id") + + class LinkData: """Class that contains link_data, categories and categories count tables, plus methods to generate and update these items""" @@ -173,3 +183,27 @@ class LinkData: cat_record["count"] += 1 if cat_record["last_updated"] < timestamp: cat_record["last_updated"] = timestamp + + def search(self, keyword: str) -> list: + """returns a unique list of link_data records for posts that contain + the specified keyword""" + if keyword == "": + raise ValueError("a search keyword must be specified") + + query = (record for record in self.link_data if keyword in record) + + if query: + search_results: set = set() + for record in query: + post_id = record[0] + parent_id = record[3] + if post_id: + search_results.add(tuple(record)) + else: + try: + parent_record = get_parent_record(parent_id, self.link_data) + except KeyError: + continue + search_results.add(tuple(parent_record)) + + return sorted(search_results, key=lambda x: x[0], reverse=True) diff --git a/linkulator b/linkulator.py similarity index 82% rename from linkulator rename to linkulator.py index 9ae28d1..5265078 100755 --- a/linkulator +++ b/linkulator.py @@ -120,9 +120,59 @@ def print_thread_details(post_id) -> tuple: return parent_id, post_url +def print_search_results(keyword: str, search_results: list): + """a view for the search results - prints results to screen""" + print( + "\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format( + keyword, "ID#", "DATE", "AUTHOR", "DESC" + ) + ) + + for display_index, record in enumerate(search_results, start=1): + date = datetime.fromtimestamp(float(record[2])).strftime("%Y-%m-%d") + author = record[1] + desc = record[6] + print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc)) + + ## CONTROLS +def search(): + """Control for the search function""" + while True: + keyword = input("\nEnter your search (or leave empty to cancel):\n") + if keyword == "": + print("Search cancelled\n") + return + search_results = LinkData.search(keyword) + if not search_results: + print("No results found\n") + return + while True: + print_search_results(keyword, search_results) + option = input( + "\nEnter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: \n".format( + style_text("s", "underline"), + style_text("m", "underline"), + style_text("q", "underline"), + ) + ).lower() + if option == "q": + graceful_exit() + if option == "m": + return + if option == "s": + break + try: + if 1 <= int(option) <= len(search_results): + menu_view_thread_details(search_results[int(option) - 1][0]) + else: + raise IndexError("Invalid post ID") + except (KeyError, ValueError, IndexError): + # Catch a Post ID that is not in the thread list or is not a number + print("\n{}\n".format(style_text("Invalid entry", "bold"))) + def view_link_in_browser(url): """Attempts to view the specified URL in the configured browser""" if which(config.USER.browser) is None: @@ -241,28 +291,6 @@ def post_link() -> int: return LinkData.add(record) -def search(keyword): - """Search function - not yet complete""" - raise NotImplementedError - - -## PSEUDOCODE: -## results_found = "" -## for line in link_data: -## if keyword in link_data[title] or keyword in link_data[category] or -## keyword in link_data[comment]: ## results_found = "yes" -## if line is parent post: -## print line -## elif line is reply: -## get parentID -## print(line[parentID]) -## if results_found == "": -## print("No results found") -## else: -## next_step = input("Enter ID to view thread, "M" for main menu, or [Enter] to quit: ") -## if next_step... - - def menu_view_categories(): """Displays list of categories, takes keyboard input and executes corresponding functions.""" @@ -270,8 +298,10 @@ def menu_view_categories(): print_categories() option = input( - "\nEnter a category ID, {} to post a link, or {} to quit: ".format( - style_text("p", "underline"), style_text("q", "underline") + "\nEnter a category ID, {} to post a link, {} to search, or {} to quit: ".format( + style_text("p", "underline"), + style_text("s", "underline"), + style_text("q", "underline"), ) ).lower() @@ -282,6 +312,9 @@ def menu_view_categories(): if post_id >= 0: menu_view_thread_details(post_id) continue + if option == "s": + search() + continue try: cat_index = categories[int(option) - 1] menu_view_category_details(cat_index) @@ -298,8 +331,9 @@ def menu_view_category_details(cat_index): option = input( "Enter a post ID to see its thread, {} to go back, {} to " - "post a link, or {} to quit: ".format( + "search, {} to post a link, or {} to quit: ".format( style_text("m", "underline"), + style_text("s", "underline"), style_text("p", "underline"), style_text("q", "underline"), ) @@ -309,6 +343,9 @@ def menu_view_category_details(cat_index): graceful_exit() if option == "m": return + if option == "s": + search() + continue if option == "p": post_id = post_link() if post_id >= 0: @@ -324,10 +361,11 @@ def menu_view_category_details(cat_index): def menu_view_thread_details(post_id): """Displays thread details, handles related navigation menu""" - option_text = "\nType {} to reply, {} to view in {}, {} to post a new link, {} to go back, or {} to quit: ".format( + option_text = "\nType {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format( style_text("r", "underline"), style_text("b", "underline"), config.USER.browser, + style_text("s", "underline"), style_text("p", "underline"), style_text("m", "underline"), style_text("q", "underline"), @@ -344,6 +382,9 @@ def menu_view_thread_details(post_id): if option == "r": reply(parent_id) continue + if option == "s": + search() + continue if option == "p": post_id = post_link() if post_id >= 0: diff --git a/tests/data_test.py b/tests/data_test.py index 9d895b1..febec99 100644 --- a/tests/data_test.py +++ b/tests/data_test.py @@ -1,11 +1,13 @@ """unit tests for the data module""" import unittest +import unittest.mock from time import time import data class TestDataHelperFunctions(unittest.TestCase): """Tests that cover helper functions, mostly handling string validation""" + def test_wash_line(self): """tests the data.wash_line function""" teststrings = [ @@ -104,6 +106,110 @@ class TestDataHelperFunctions(unittest.TestCase): data.process(item, "username{}".format(i)), teststrings_output[i] ) + def test_get_parent_record(self): + test_link_data = [] + + with self.assertRaises(ValueError): + data.get_parent_record("", test_link_data) + + +class TestLinkDataSearch(unittest.TestCase): + @unittest.mock.patch.object(data.LinkData, "get") + def test_search_exceptions(self, mock_get): + """ensures exceptions are raised""" + link_data = data.LinkData() + mock_get.assert_called() + with self.assertRaises(ValueError): + link_data.search("") + + @unittest.mock.patch.object(data.LinkData, "get") + def test_search(self, mock_get): + """tests search function""" + link_data = data.LinkData() + mock_get.assert_called() + + link_data.link_data = [ + [ + "", + "user1", + "1576486443.8539028", + "poster1+1576289662.7914467", + "", + "", + "this comment contains a keyword regarding the keyword website", + ], + [ + "", + "user2", + "1576486440.65404", + "poster1+1576289662.7914467", + "", + "", + "this is a reply to the site but does not contain the word", + ], + [ + 70, + "poster1", + "1576289662.7914467", + "", + "keyword", + "http://keyword.com", + "the keyword website", + ], + [ + 69, + "poster2", + "1576462584.2307518", + "", + "keyword", + "gemini://keyword", + "keyword site with no replies", + ], + [68, "poster3", "1576462007.9509487", "", "a", "a", "key word"], + [67, "poster4", "1576461366.5580268", "", "b", "b", "key.word"], + [ + "", + "user3", + "1576376868.284987", + "poster3+1576376644.2783155", + "", + "", + "this is an orphaned reply but it contains the keyword", + ], + [66, "keyword", "1576461366.5580268", "", "c", "c", "c"], + [65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"], + [64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"], + [63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"], + [63, "poster9", "1576461370.5580268", "", "c", "c", "z8keyworddui3"], + ] + + test_results = [ + ( + 70, + "poster1", + "1576289662.7914467", + "", + "keyword", + "http://keyword.com", + "the keyword website", + ), + ( + 69, + "poster2", + "1576462584.2307518", + "", + "keyword", + "gemini://keyword", + "keyword site with no replies", + ), + (66, "keyword", "1576461366.5580268", "", "c", "c", "c"), + (65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"), + (64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"), + (63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"), + ] + + self.assertEqual(link_data.search("keyword"), test_results) + if __name__ == "__main__": unittest.main() diff --git a/tests/view_tests.py b/tests/view_tests.py new file mode 100644 index 0000000..85799d2 --- /dev/null +++ b/tests/view_tests.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Tests for Linkulator views""" + +import unittest +from unittest.mock import patch, call +import linkulator + + +class TestPrintSearchResults(unittest.TestCase): + """Tests covering the print_search_results function""" + + @patch("builtins.print") + def test_print_search_results(self, mock_print): + test_keyword = "keyword" + test_search_results = [ + (66, "keyword", "1576461366.5580268", "", "c", "c", "c"), + (65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"), + (64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"), + (63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"), + ] + test_print_calls = [ + call( + "\nShowing results for keyword\n\n ID# DATE AUTHOR DESC " + ), + call(" 1 2019-12-16 keyword c \n"), + call(" 2 2019-12-16 poster6 c \n"), + call(" 3 2019-12-16 poster7 c \n"), + call(" 4 2019-12-16 poster8 keyword \n"), + ] + + linkulator.print_search_results(test_keyword, test_search_results) + + self.assertEqual( + mock_print.call_count, 5 + ) # one count for title, 4 for the items) + + self.assertListEqual(test_print_calls, mock_print.call_args_list)