From c38a012c741d915bf51283c7945a36f5b0fb122c Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 16 Nov 2025 11:52:22 +0100 Subject: [PATCH] Working version of tabsearch. Added subsequent and fuzzy matching. Need to fix the unit tests --- Makefile | 1 + src/myfasthtml/assets/myfasthtml.css | 10 ++- src/myfasthtml/auth/README.md | 2 +- src/myfasthtml/controls/Search.py | 60 +++++++++++--- src/myfasthtml/controls/TabsManager.py | 37 +++------ src/myfasthtml/core/commands.py | 7 +- src/myfasthtml/core/matching_utils.py | 85 ++++++++++++++++++++ tests/core/test_commands.py | 30 ++++++- tests/core/test_matching_utils.py | 105 +++++++++++++++++++++++++ 9 files changed, 290 insertions(+), 47 deletions(-) create mode 100644 src/myfasthtml/core/matching_utils.py create mode 100644 tests/core/test_matching_utils.py diff --git a/Makefile b/Makefile index 68ae0f7..537c16a 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,4 @@ clean: clean-build clean-tests clean-all : clean rm -rf src/.sesskey rm -rf src/Users.db + rm -rf src/.myFastHtmlDb diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index b7e38d6..6cb475b 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -383,10 +383,6 @@ flex: 1; overflow: auto; height: 100%; - border: 2px solid black; - /*background-color: var(--color-base-100);*/ - /*padding: 1rem;*/ - /*border-top: 1px solid var(--color-border-primary);*/ } .mf-tab-content-wrapper { @@ -410,4 +406,10 @@ .mf-vis { width: 100%; height: 100%; +} + +.mf-search-results { + margin-top: 0.5rem; + max-height: 200px; + overflow: auto; } \ No newline at end of file diff --git a/src/myfasthtml/auth/README.md b/src/myfasthtml/auth/README.md index c275bcc..e1f622a 100644 --- a/src/myfasthtml/auth/README.md +++ b/src/myfasthtml/auth/README.md @@ -41,7 +41,7 @@ Note: `python-jose` may already be installed if you have FastAPI. ### 1. Update API configuration in `auth/utils.py` ```python -API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL +API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret ``` diff --git a/src/myfasthtml/controls/Search.py b/src/myfasthtml/controls/Search.py index 78cf9e2..8fba746 100644 --- a/src/myfasthtml/controls/Search.py +++ b/src/myfasthtml/controls/Search.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Any from fasthtml.components import * @@ -6,43 +7,78 @@ from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.helpers import Ids, mk from myfasthtml.core.commands import Command from myfasthtml.core.instances import MultipleInstance +from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching logger = logging.getLogger("Search") class Commands(BaseCommands): def search(self): - return (Command("Search", f"Search {self._owner.items_names}", self._owner.search). - htmx(trigger="keyup changed delay:300ms")) + return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search). + htmx(target=f"#{self._owner.get_id()}-results", + trigger="keyup changed delay:300ms", + swap="innerHTML")) class Search(MultipleInstance): - def __init__(self, session, items_names=None, template=None, _id=None): + def __init__(self, + session, + _id=None, + items_names=None, # what is the name of the items to filter + items=None, # first set of items to filter + get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter + template: Callable[[Any], Any] = None): # once filtered, what to render ? + """ + Represents a component for managing and filtering a list of items based on specific criteria. + + This class initializes with a session, an optional identifier, a list of item names, + a callable for extracting a string value from items, and a template callable for rendering + the filtered items. It provides functionality to handle and organize item-based operations. + + :param session: The session object to maintain state or context across operations. + :param _id: Optional identifier for the component. + :param items: An optional list of names for the items to be filtered. + :param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a + function that returns the item as is. + :param template: Callable function to render the filtered items. Defaults to a Div rendering function. + """ super().__init__(session, Ids.Search, _id=_id) - self.items_names = items_names - self.items = [] - self.filtered = [] + self.items_names = items_names or '' + self.items = items or [] + self.filtered = self.items.copy() + self.get_attr = get_attr or (lambda x: x) self.template = template or Div self.commands = Commands(self) def set_items(self, items): self.items = items - self.filtered = self.items + self.filtered = self.items.copy() return self + def on_search(self, query): + logger.debug(f"on_search {query=}") + self.search(query) + return tuple(self._mk_search_results()) + def search(self, query): logger.debug(f"search {query=}") - pass + if query is None or query.strip() == "": + self.filtered = self.items.copy() + + else: + res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr) + res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr) + self.filtered = res_seq + res_fuzzy + + return self.filtered def _mk_search_results(self): return [self.template(item) for item in self.filtered] def render(self): return Div( - # mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"), - # command=self.commands.search()), - mk.mk(Input(name="query", id=f"{self._id}-search", type="search", placeholder="Search...", cls="input input-xs"), - ), + mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"), + command=self.commands.search()), Div( *self._mk_search_results(), id=f"{self._id}-results", diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py index 9334dba..6b5ec55 100644 --- a/src/myfasthtml/controls/TabsManager.py +++ b/src/myfasthtml/controls/TabsManager.py @@ -77,11 +77,6 @@ class Commands(BaseCommands): return Command(f"{self._prefix}UpdateBoundaries", "Update component boundaries", self._owner.update_boundaries).htmx(target=None) - - def search_tab(self, query: str): - return Command(f"{self._prefix}SearchTab", - "Search for a tab by name", - self._owner.search_tab, query).htmx(target=f"#{self._id}-dropdown-content") class TabsManager(MultipleInstance): @@ -97,7 +92,10 @@ class TabsManager(MultipleInstance): self._state = TabsManagerState(self) self.commands = Commands(self) self._boundaries = Boundaries() - self._search = Search(self._session).set_items(self._get_tabs_labels()) + self._search = Search(self._session, + items=self._get_tab_list(), + get_attr=lambda x: x["label"], + template=self._mk_tab_button) logger.debug(f"TabsManager created with id: {self._id}") logger.debug(f" tabs : {self._get_ordered_tabs()}") logger.debug(f" active tab : {self._state.active_tab}") @@ -176,6 +174,7 @@ class TabsManager(MultipleInstance): # Add tab metadata to state state.tabs[tab_id] = { + 'id': tab_id, 'label': label, 'component_type': component_type, 'component_id': component_id @@ -193,7 +192,7 @@ class TabsManager(MultipleInstance): # finally, update the state self._state.update(state) - self._search.set_items(self._get_tabs_labels()) + self._search.set_items(self._get_tab_list()) return tab_id @@ -251,23 +250,10 @@ class TabsManager(MultipleInstance): # Update state self._state.update(state) - self._search.set_items(self._get_tabs_labels()) + self._search.set_items(self._get_tab_list()) return self - def search_tab(self, query: str): - """ - Search tabs by name (for dropdown search). - - Args: - query: Search query - - Returns: - Dropdown content with filtered tabs - """ - # This will be implemented later for search functionality - pass - def add_tab_btn(self): return mk.icon(tab_add24_regular, id=f"{self._id}-add-tab-btn", @@ -285,7 +271,7 @@ class TabsManager(MultipleInstance): def _mk_tabs_header(self, oob=False): # Create visible tab buttons visible_tab_buttons = [ - self._mk_tab_button(tab_id, self._state.tabs[tab_id]) + self._mk_tab_button(self._state.tabs[tab_id]) for tab_id in self._state.tabs_order if tab_id in self._state.tabs ] @@ -300,7 +286,7 @@ class TabsManager(MultipleInstance): hx_swap_oob="true" if oob else None ) - def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False): + def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False): """ Create a single tab button with its label and close button. @@ -312,6 +298,7 @@ class TabsManager(MultipleInstance): Returns: Button element representing the tab """ + tab_id = tab_data["id"] is_active = tab_id == self._state.active_tab close_btn = mk.mk( @@ -382,8 +369,8 @@ class TabsManager(MultipleInstance): hx_swap_oob=f"beforeend:#{self._id}-content-wrapper", ) - def _get_tabs_labels(self): - return [tab["label"] for tab in self._state.tabs.values()] + def _get_tab_list(self): + return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs] def update_boundaries(self): return Script(f"updateBoundaries('{self._id}');") diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 5841a6f..ed0e60e 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -44,7 +44,8 @@ class BaseCommand: def execute(self, client_response: dict = None): raise NotImplementedError - def htmx(self, target="this", swap="innerHTML", trigger=None): + def htmx(self, target="this", swap="outerHTML", trigger=None): + # Note that the default value is the same than in get_htmx_params() if target is None: self._htmx_extra["hx-swap"] = "none" elif target != "this": @@ -52,7 +53,7 @@ class BaseCommand: if swap is None: self._htmx_extra["hx-swap"] = "none" - elif swap != "innerHTML": + elif swap != "outerHTML": self._htmx_extra["hx-swap"] = swap if trigger is not None: @@ -162,7 +163,7 @@ class Command(BaseCommand): # Set the hx-swap-oob attribute on all elements returned by the callback if isinstance(ret, (list, tuple)): for r in ret[1:]: - if hasattr(r, 'attrs'): + if hasattr(r, 'attrs') and r.get("id", None) is not None: r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") if not ret_from_bindings: diff --git a/src/myfasthtml/core/matching_utils.py b/src/myfasthtml/core/matching_utils.py new file mode 100644 index 0000000..61ee816 --- /dev/null +++ b/src/myfasthtml/core/matching_utils.py @@ -0,0 +1,85 @@ +from difflib import SequenceMatcher +from typing import Any + + +def _is_subsequence(query: str, target: str) -> tuple[bool, float]: + """ + Determines if a query string is a subsequence of a target string and calculates + a score based on the compactness of the match. The match is case-insensitive. + + The function iterates through each character of the query and checks if it + exists in the target string while maintaining the order. If all characters of + the query are found in order, it calculates a score based on the smallest + window in the target that contains all the matched characters. + + :param query: The query string to check as a subsequence. + :param target: The target string in which to find the subsequence. + :return: A tuple where the first value is a boolean indicating if a valid + subsequence exists, and the second value is a float representing the + compactness score of the match. + :rtype: tuple[bool, float] + """ + query = query.lower() + target = target.lower() + + positions = [] + idx = 0 + + for char in query: + idx = target.find(char, idx) + if idx == -1: + return False, 0.0 + positions.append(idx) + idx += 1 + + # Smallest window containing all matched chars + window_size = positions[-1] - positions[0] + 1 + + # Score: ratio of query length vs window size (compactness) + score = len(query) / window_size + + return True, score + + +def fuzzy_matching(query: str, choices: list[Any], similarity_threshold: float = 0.7, get_attr=None): + """ + Perform fuzzy matching on a list of items to find the items that are similar + to the given query based on a similarity threshold. + + :param query: The search query to be matched, provided as a string. + :param choices: A list of strings representing the items to be compared against the query. + :param similarity_threshold: A float value representing the minimum similarity score + (between 0 and 1) an item needs to achieve to be considered a match. Defaults to 0.7. + :param get_attr: When choice is a object, give the property to use + :return: A list of strings containing the items from the input list that meet or exceed + the similarity threshold, sorted in descending order of similarity. + """ + get_attr = get_attr or (lambda x: x) + matches = [] + for file_doc in choices: + # Calculate similarity between search term and filename + similarity = SequenceMatcher(None, query.lower(), get_attr(file_doc).lower()).ratio() + + if similarity >= similarity_threshold: + matches.append((file_doc, similarity)) + + # Sort by similarity score (highest first) + matches.sort(key=lambda x: x[1], reverse=True) + + # Return only the FileDocument objects + return [match[0] for match in matches] + + +def subsequence_matching(query: str, choices: list[Any], get_attr=None): + get_attr = get_attr or (lambda x: x) + matches = [] + for item in choices: + matched, score = _is_subsequence(query, get_attr(item)) + if matched: + matches.append((item, score)) + + # Sort by score (highest first) + matches.sort(key=lambda x: x[1], reverse=True) + + # Return only the FileDocument objects + return [match[0] for match in matches] diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index b810dd0..34f77f7 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -104,8 +104,8 @@ class TestCommandBind: assert matches(updated, expected) @pytest.mark.parametrize("return_values", [ - [Div(), Div(), "hello", Div()], # list - (Div(), Div(), "hello", Div()) # tuple + [Div(), Div(id="id1"), "hello", Div(id="id2")], # list + (Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple ]) def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values): """Test that hx-swap-oob is automatically set, but not for the first.""" @@ -157,3 +157,29 @@ class TestCommandExecute: command = Command('test', 'Command description', callback_with_param) command.execute(client_response={"number": "10"}) + + def test_swap_oob_is_added_when_multiple_elements_are_returned(self): + """Test that hx-swap-oob is automatically set, but not for the first.""" + + def another_callback(): + return Div(id="first"), Div(id="second"), "hello", Div(id="third") + + command = Command('test', 'Command description', another_callback) + + res = command.execute() + assert "hx-swap-oob" not in res[0].attrs + assert res[1].attrs["hx-swap-oob"] == "true" + assert res[3].attrs["hx-swap-oob"] == "true" + + def test_swap_oob_is_not_added_when_there_no_id(self): + """Test that hx-swap-oob is automatically set, but not for the first.""" + + def another_callback(): + return Div(id="first"), Div(), "hello", Div() + + command = Command('test', 'Command description', another_callback) + + res = command.execute() + assert "hx-swap-oob" not in res[0].attrs + assert "hx-swap-oob" not in res[1].attrs + assert "hx-swap-oob" not in res[3].attrs diff --git a/tests/core/test_matching_utils.py b/tests/core/test_matching_utils.py new file mode 100644 index 0000000..85c1bfb --- /dev/null +++ b/tests/core/test_matching_utils.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass + +from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching + + +class TestFuzzyMatching: + def test_i_can_find_exact_match_with_fuzzy(self): + # Exact match should always pass + choices = ["hello"] + result = fuzzy_matching("hello", choices) + assert len(result) == 1 + assert result[0] == "hello" + + def test_i_can_find_close_match_with_fuzzy(self): + # "helo.txt" should match "hello.txt" with high similarity + choices = ["hello"] + result = fuzzy_matching("helo", choices, similarity_threshold=0.7) + assert len(result) == 1 + assert result[0] == "hello" + + def test_i_cannot_find_dissimilar_match_with_fuzzy(self): + # "world.txt" should not match "hello.txt" + choices = ["hello"] + result = fuzzy_matching("world", choices, similarity_threshold=0.7) + assert len(result) == 0 + + def test_i_can_sort_by_similarity_in_fuzzy(self): + # hello has a higher similarity than helo + choices = [ + "hello", + "helo", + ] + result = fuzzy_matching("hello", choices, similarity_threshold=0.7) + assert result == ["hello", "helo"] + + + def test_i_can_find_on_object(self): + @dataclass + class DummyObject: + value: str + id: str + + choices = [ + DummyObject("helo", "1"), + DummyObject("hello", "2"), + DummyObject("xyz", "3"), + ] + result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value) + assert len(result) == 2 + assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")] + + +class TestSubsequenceMatching: + def test_i_can_match_subsequence_simple(self): + # "abg" should match "AlphaBetaGamma" + choices = ["AlphaBetaGamma"] + result = subsequence_matching("abg", choices) + assert len(result) == 1 + assert result[0] == "AlphaBetaGamma" + + def test_i_can_match_subsequence_simple_case_insensitive(self): + # "abg" should match "alphabetagamma" + choices = ["alphabetagamma"] + result = subsequence_matching("abg", choices) + assert len(result) == 1 + assert result[0] == "alphabetagamma" + + def test_i_cannot_match_wrong_order_subsequence(self): + # the order is wrong + choices = ["AlphaBetaGamma"] + result = subsequence_matching("gba", choices) + assert len(result) == 0 + + def test_i_can_match_multiple_documents_subsequence(self): + # "abg" should match both filenames, but "AlphaBetaGamma" has a higher score + choices = [ + "AlphaBetaGamma", + "HalleBerryIsGone", + ] + result = subsequence_matching("abg", choices) + assert len(result) == 2 + assert result[0] == "AlphaBetaGamma" + assert result[1] == "HalleBerryIsGone" + + def test_i_cannot_match_unrelated_subsequence(self): + # "xyz" should not match any file + choices = ["AlphaBetaGamma"] + result = subsequence_matching("xyz", choices) + assert len(result) == 0 + + def test_i_can_match_on_object(self): + @dataclass + class DummyObject: + value: str + id: str + + choices = [ + DummyObject("HalleBerryIsGone", "1"), + DummyObject("AlphaBetaGamma", "2"), + DummyObject("xyz", "3"), + ] + + result = subsequence_matching("abg", choices, get_attr=lambda x: x.value) + assert len(result) == 2 + assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")] \ No newline at end of file