Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
// @Param labels query []string false "label Ids" collectionFormat(multi)
// @Param locations query []string false "location Ids" collectionFormat(multi)
// @Param parentIds query []string false "parent Ids" collectionFormat(multi)
// @Param fuzzySearch query bool false "fuzzy search"
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
// @Router /v1/items [GET]
// @Security Bearer
Expand Down Expand Up @@ -58,6 +59,7 @@ func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
Page: queryIntOrNegativeOne(params.Get("page")),
PageSize: queryIntOrNegativeOne(params.Get("pageSize")),
Search: params.Get("q"),
FuzzySearch: queryBool(params.Get("fuzzySearch")),
LocationIDs: queryUUIDList(params, "locations"),
LabelIDs: queryUUIDList(params, "labels"),
NegateLabels: queryBool(params.Get("negateLabels")),
Expand Down
43 changes: 43 additions & 0 deletions backend/internal/data/repo/fuzzy_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package repo

import (
"strings"

"github.com/agext/levenshtein"
)

// FuzzyMatch checks if a string matches a query with fuzzy matching
// using Levenshtein distance algorithm
func FuzzyMatch(str, query string, threshold float64) bool {
if query == "" {
return true
}

// Convert to lowercase for case-insensitive comparison
str = strings.ToLower(str)
query = strings.ToLower(query)

// Check if the query is a substring of the string
if strings.Contains(str, query) {
return true
}

// Calculate Levenshtein distance
distance := levenshtein.Distance(str, query, nil)

// Normalize the distance based on the length of the longer string
maxLen := len(str)
if len(query) > maxLen {
maxLen = len(query)
}

if maxLen == 0 {
return true
}

// Calculate similarity ratio (0.0 to 1.0)
similarity := 1.0 - float64(distance)/float64(maxLen)

// Return true if similarity is above the threshold
return similarity >= threshold
}
68 changes: 58 additions & 10 deletions backend/internal/data/repo/repo_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ package repo

import (
"context"
"database/sql"
"errors"
"fmt"
"math/big"
"strings"
"time"

"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
Expand Down Expand Up @@ -33,6 +38,7 @@ type (
Page int
PageSize int
Search string `json:"search"`
FuzzySearch bool `json:"fuzzySearch"`
AssetID AssetID `json:"assetId"`
LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"`
Expand Down Expand Up @@ -346,16 +352,58 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
}

if q.Search != "" {
qb.Where(
item.Or(
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
item.SerialNumberContainsFold(q.Search),
item.ModelNumberContainsFold(q.Search),
item.ManufacturerContainsFold(q.Search),
item.NotesContainsFold(q.Search),
),
)
if q.FuzzySearch {
// For fuzzy search, we need to fetch all items and filter them manually
// since Ent doesn't support custom fuzzy matching directly
fuzzyItems, err := e.db.Item.Query().
Where(
item.HasGroupWith(group.ID(gid)),
).
All(ctx)

if err != nil {
return PaginationResult[ItemSummary]{}, err
}

// Filter items using fuzzy matching
var fuzzyPredicates []predicate.Item
const fuzzyThreshold = 0.7 // Adjust threshold as needed (0.0 to 1.0)

for _, i := range fuzzyItems {
if FuzzyMatch(i.Name, q.Search, fuzzyThreshold) ||
FuzzyMatch(i.Description, q.Search, fuzzyThreshold) ||
FuzzyMatch(i.SerialNumber, q.Search, fuzzyThreshold) ||
FuzzyMatch(i.ModelNumber, q.Search, fuzzyThreshold) ||
FuzzyMatch(i.Manufacturer, q.Search, fuzzyThreshold) ||
FuzzyMatch(i.Notes, q.Search, fuzzyThreshold) {
fuzzyPredicates = append(fuzzyPredicates, item.ID(i.ID))
}
}

if len(fuzzyPredicates) > 0 {
qb = qb.Where(item.Or(fuzzyPredicates...))
} else {
// If no fuzzy matches, return empty result
return PaginationResult[ItemSummary]{
Page: q.Page,
PageSize: q.PageSize,
Total: 0,
Items: []ItemSummary{},
}, nil
}
} else {
// Standard search using contains
qb = qb.Where(
item.Or(
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
item.SerialNumberContainsFold(q.Search),
item.ModelNumberContainsFold(q.Search),
item.ManufacturerContainsFold(q.Search),
item.NotesContainsFold(q.Search),
),
)
}
}

if !q.AssetID.Nil() {
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/api/classes/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type ItemsQuery = {
onlyWithPhoto?: boolean;
parentIds?: string[];
q?: string;
fuzzySearch?: boolean;
fields?: string[];
};

Expand Down
1 change: 1 addition & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
"field_selector": "Field Selector",
"field_value": "Field Value",
"first": "First",
"fuzzy_search": "Fuzzy Search (find similar matches)",
"include_archive": "Include Archived Items",
"insured": "Insured",
"last": "Last",
Expand Down
80 changes: 40 additions & 40 deletions frontend/pages/items.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
});
const pageSize = useRouteQuery("pageSize", 30);
const query = useRouteQuery("q", "");
const query = ref("");
const fuzzySearch = ref(false);
const advanced = useRouteQuery("advanced", false);
const includeArchived = useRouteQuery("archived", false);
const fieldSelector = useRouteQuery("fieldSelector", false);
Expand Down Expand Up @@ -71,6 +72,16 @@
}
}
searchLocked.value = true;
// Handle query parameters from URL
if (route.query.q) {
query.value = route.query.q as string;
}
if (route.query.fuzzySearch) {
fuzzySearch.value = (route.query.fuzzySearch === 'true');
}
const qLoc = route.query.loc as string[];
if (qLoc) {
selectedLocations.value = locations.value.filter(l => qLoc.includes(l.id));
Expand Down Expand Up @@ -262,6 +273,7 @@
const { data, error } = await api.items.getAll({
q: query.value || "",
fuzzySearch: fuzzySearch.value,
locations: locIDs.value,
labels: labIDs.value,
negateLabels: negateLabels.value,
Expand Down Expand Up @@ -299,7 +311,7 @@
initialSearch.value = false;
}
watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], search, { debounce: 250, maxWait: 1000 });
watchDebounced([page, pageSize, query, fuzzySearch, selectedLabels, selectedLocations], search, { debounce: 250, maxWait: 1000 });
async function submit() {
// Set URL Params
Expand All @@ -310,59 +322,43 @@
}
}
// Push non-reactive query fields
await router.push({
query: {
// Reactive
advanced: "true",
archived: includeArchived.value ? "true" : "false",
fieldSelector: fieldSelector.value ? "true" : "false",
negateLabels: negateLabels.value ? "true" : "false",
onlyWithoutPhoto: onlyWithoutPhoto.value ? "true" : "false",
onlyWithPhoto: onlyWithPhoto.value ? "true" : "false",
archived: includeArchived.value.toString(),
advanced: advanced.value.toString(),
fieldSelector: fieldSelector.value.toString(),
pageSize: pageSize.value.toString(),
page: page.value.toString(),
orderBy: orderBy.value,
pageSize: pageSize.value,
page: page.value,
q: query.value,
// Non-reactive
fuzzySearch: fuzzySearch.value.toString(),
loc: locIDs.value,
lab: labIDs.value,
fields,
},
});
// Reset Pagination
page.value = 1;
// Perform Search
await search();
}
async function reset() {
// Set URL Params
const fields = [];
for (const t of fieldTuples.value) {
if (t[0] && t[1]) {
fields.push(`${t[0]}=${t[1]}`);
}
}
await router.push({
query: {
archived: "false",
fieldSelector: "false",
pageSize: 10,
page: 1,
orderBy: "name",
q: "",
loc: [],
lab: [],
fields,
},
});
// Reset all the filters
query.value = "";
fuzzySearch.value = false;
selectedLabels.value = [];
selectedLocations.value = [];
negateLabels.value = false;
onlyWithoutPhoto.value = false;
onlyWithPhoto.value = false;
includeArchived.value = false;
advanced.value = false;
fieldSelector.value = false;
orderBy.value = "name";
fieldTuples.value = [["", ""]];
page.value = 1;
await search();
// Trigger a search with the reset values
search();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not awaited any more?

}
</script>

Expand Down Expand Up @@ -425,6 +421,10 @@
<input v-model="onlyWithPhoto" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="label-text ml-4 text-right"> {{ $t("items.only_with_photo") }} </span>
</label>
<label class="label mr-auto cursor-pointer">
<input v-model="fuzzySearch" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="label-text ml-4 text-right"> {{ $t("items.fuzzy_search") }} </span>
</label>
<label class="label mr-auto cursor-pointer">
<select v-model="orderBy" class="select select-bordered select-sm">
<option value="name" selected>{{ $t("global.name") }}</option>
Expand Down
79 changes: 79 additions & 0 deletions scripts/create-feature-test-branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/bin/bash

# create-feature-test-branch.sh
# Script to create a test branch for a specific feature based on docker/custom-image

set -e # Exit on error

# Check if a feature branch was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <feature-branch-name> [test-branch-suffix]"
echo "Example: $0 feature/homepage-accordions test"
echo "This will create docker/testing-homepage-accordions-test branch"
exit 1
fi

FEATURE_BRANCH=$1
TEST_SUFFIX=${2:-test} # Default suffix is "test" if not provided

# Extract the feature name from the branch name
if [[ $FEATURE_BRANCH == feature/* ]]; then
FEATURE_NAME=${FEATURE_BRANCH#feature/}
else
FEATURE_NAME=$FEATURE_BRANCH
fi

# Create a properly named test branch
TEST_BRANCH="docker/testing-${FEATURE_NAME}-${TEST_SUFFIX}"

# Base branch for testing
BASE_BRANCH="docker/custom-image"

echo "Creating test branch $TEST_BRANCH based on $BASE_BRANCH with feature $FEATURE_BRANCH"

# Make sure we're starting from a clean state
git fetch origin
git checkout $BASE_BRANCH
git pull origin $BASE_BRANCH || true # Continue even if there's no remote tracking

# Create the test branch
if git show-ref --verify --quiet refs/heads/$TEST_BRANCH; then
echo "Branch $TEST_BRANCH already exists. Resetting it to match $BASE_BRANCH."
git checkout $TEST_BRANCH
git reset --hard $BASE_BRANCH
else
echo "Creating new branch $TEST_BRANCH from $BASE_BRANCH."
git checkout -b $TEST_BRANCH $BASE_BRANCH
fi

# Check if the feature branch exists
if ! git show-ref --verify --quiet refs/heads/$FEATURE_BRANCH; then
echo "Error: Feature branch $FEATURE_BRANCH does not exist."
exit 1
fi

# Try to cherry-pick or merge the feature branch
echo "Attempting to cherry-pick commits from $FEATURE_BRANCH..."

# Get the commit hash of the latest commit in the feature branch
FEATURE_COMMIT=$(git rev-parse $FEATURE_BRANCH)

# Try cherry-picking
if git cherry-pick $FEATURE_COMMIT; then
echo "✅ Successfully cherry-picked $FEATURE_BRANCH"
else
echo "⚠️ Cherry-pick failed. Trying merge instead..."
git cherry-pick --abort

if git merge --no-ff $FEATURE_BRANCH -m "Merge $FEATURE_BRANCH into $TEST_BRANCH for testing"; then
echo "✅ Successfully merged $FEATURE_BRANCH"
else
echo "⚠️ Merge failed as well. Manual intervention required."
echo "Please resolve conflicts and commit the changes."
exit 1
fi
fi

echo "Test branch $TEST_BRANCH created and updated with $FEATURE_BRANCH!"
echo "You can now build and test your Docker image from this branch:"
echo "docker build -t homebox-testing-${FEATURE_NAME} ."
Loading