commit 157d0caf1452807dfa0c0c71aeb4e3846013d391
parent 0c50f105fcf0a895f43115cf024c0094c94ea446
Author: breadcat <breadcat@users.noreply.github.com>
Date: Fri, 1 May 2026 09:23:41 +0100
Allow properties in /and/ filtering
Diffstat:
4 files changed, 135 insertions(+), 43 deletions(-)
diff --git a/include-filters.go b/include-filters.go
@@ -25,7 +25,15 @@ func buildTagFilterWhere(filters []filter) (string, []interface{}) {
var args []interface{}
for _, f := range filters {
- if f.Value == "unassigned" {
+ if f.IsProperty {
+ where += `
+ AND EXISTS (
+ SELECT 1
+ FROM file_properties fp
+ WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ?
+ )`
+ args = append(args, f.Category, f.Value)
+ } else if f.Value == "unassigned" {
where += `
AND NOT EXISTS (
SELECT 1
@@ -61,12 +69,9 @@ func buildTagFilterWhere(filters []filter) (string, []interface{}) {
return where, args
}
-func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
- page := pageFromRequest(r)
- perPage := perPageFromConfig(50)
-
- fullPath := strings.TrimPrefix(r.URL.Path, "/tag/")
- tagPairs := strings.Split(fullPath, "/and/tag/")
+// parseFilterSegments splits filter path into individual filter structs
+func parseFilterSegments(fullPath, firstKind string) ([]filter, []Breadcrumb, error) {
+ rawSegments := strings.Split(fullPath, "/and/")
breadcrumbs := []Breadcrumb{
{Name: "home", URL: "/"},
@@ -74,58 +79,91 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
}
var filters []filter
- currentPath := "/tag"
+ currentPath := "/" + firstKind
- for i, pair := range tagPairs {
- parts := strings.Split(pair, "/")
- if len(parts) != 2 {
- renderError(w, "Invalid tag filter path", http.StatusBadRequest)
- return
+ for i, seg := range rawSegments {
+ var kind, category, value string
+
+ if i == 0 {
+ // First segment has no explicit kind prefix — the caller supplies it.
+ parts := strings.SplitN(seg, "/", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return nil, nil, fmt.Errorf("invalid %s segment: %q", firstKind, seg)
+ }
+ kind, category, value = firstKind, parts[0], parts[1]
+ } else {
+ // Subsequent segments are "kind/category/value".
+ parts := strings.SplitN(seg, "/", 3)
+ if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
+ return nil, nil, fmt.Errorf("invalid filter segment: %q", seg)
+ }
+ kind, category, value = parts[0], parts[1], parts[2]
+ if kind != "tag" && kind != "property" {
+ return nil, nil, fmt.Errorf("unknown filter kind %q in segment: %q", kind, seg)
+ }
}
f := filter{
- Category: parts[0],
- Value: parts[1],
- IsPreviews: parts[1] == "previews",
+ Category: category,
+ Value: value,
+ IsProperty: kind == "property",
+ IsPreviews: kind == "tag" && value == "previews",
}
- // Expand with aliases (unless it's a special tag)
- if parts[1] != "unassigned" && parts[1] != "previews" {
- f.Values = expandTagWithAliases(parts[0], parts[1])
+ if kind == "tag" && value != "unassigned" && value != "previews" {
+ f.Values = expandTagWithAliases(category, value)
}
filters = append(filters, f)
- // Build breadcrumb path incrementally
+ // Build the cumulative breadcrumb URL for this filter step.
if i == 0 {
- currentPath += "/" + parts[0] + "/" + parts[1]
+ currentPath += "/" + category + "/" + value
} else {
- currentPath += "/and/tag/" + parts[0] + "/" + parts[1]
+ currentPath += "/and/" + kind + "/" + category + "/" + value
}
- // Add category breadcrumb (only if it's the first occurrence)
+ // Add a category/key breadcrumb (deduplicated).
categoryExists := false
for _, bc := range breadcrumbs {
- if bc.Name == parts[0] {
+ if bc.Name == category {
categoryExists = true
break
}
}
if !categoryExists {
+ anchorURL := "/tags#tag-" + category
+ if kind == "property" {
+ anchorURL = "/properties#prop-" + category
+ }
breadcrumbs = append(breadcrumbs, Breadcrumb{
- Name: parts[0],
- URL: "/tags#tag-" + parts[0],
+ Name: category,
+ URL: anchorURL,
})
}
- // Add value breadcrumb
breadcrumbs = append(breadcrumbs, Breadcrumb{
- Name: parts[1],
+ Name: value,
URL: currentPath,
})
}
- // Check if we're in preview mode for any filter
+ return filters, breadcrumbs, nil
+}
+
+func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
+ page := pageFromRequest(r)
+ perPage := perPageFromConfig(50)
+
+ fullPath := strings.TrimPrefix(r.URL.Path, "/tag/")
+
+ filters, breadcrumbs, err := parseFilterSegments(fullPath, "tag")
+ if err != nil {
+ renderError(w, "Invalid filter path", http.StatusBadRequest)
+ return
+ }
+
+ // Check if we're in preview mode for any filter.
hasPreviewFilter := false
for _, f := range filters {
if f.IsPreviews {
@@ -135,7 +173,6 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
}
if hasPreviewFilter {
- // Handle preview mode
files, err := getPreviewFiles(filters)
if err != nil {
log.Printf("Error: tagFilterHandler: failed to fetch preview files: %v", err)
@@ -143,12 +180,7 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
return
}
- var titleParts []string
- for _, f := range filters {
- titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
- }
- title := "Tagged: " + strings.Join(titleParts, " + ")
-
+ title := "Tagged: " + buildFilterTitle(filters, " + ")
pageData := buildPageDataWithPagination(title, ListData{
Tagged: files,
Untagged: nil,
@@ -165,7 +197,7 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
var total int
countArgs := append([]interface{}(nil), whereArgs...) // copy; count query does not need pagination args
- err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total)
+ err = db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total)
if err != nil {
log.Printf("Error: tagFilterHandler: failed to count files: %v", err)
renderError(w, "Failed to count files", http.StatusInternalServerError)
@@ -185,12 +217,7 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
return
}
- var titleParts []string
- for _, f := range filters {
- titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
- }
- title := "Tagged: " + strings.Join(titleParts, ", ")
-
+ title := "Tagged: " + buildFilterTitle(filters, ", ")
pageData := buildPageDataWithPagination(title, ListData{
Tagged: files,
Untagged: nil,
@@ -201,6 +228,14 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "list.html", pageData)
}
+func buildFilterTitle(filters []filter, sep string) string {
+ var parts []string
+ for _, f := range filters {
+ parts = append(parts, fmt.Sprintf("%s: %s", f.Category, f.Value))
+ }
+ return strings.Join(parts, sep)
+}
+
func expandTagWithAliases(category, value string) []string {
values := []string{value}
diff --git a/include-previews.go b/include-previews.go
@@ -71,6 +71,14 @@ func getPreviewFiles(filters []filter) ([]File, error) {
WHERE ft.file_id = f.id AND c.name = ? AND t.value = ?
)`
args = append(args, filter.Category, tagValue)
+ } else if filter.IsProperty {
+ query += `
+ AND EXISTS (
+ SELECT 1
+ FROM file_properties fp
+ WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ?
+ )`
+ args = append(args, filter.Category, filter.Value)
} else if filter.Value == "unassigned" {
query += `
AND NOT EXISTS (
diff --git a/include-properties.go b/include-properties.go
@@ -179,6 +179,54 @@ func getPropertyNav() (map[string][]PropertyDisplay, error) {
func propertyFilterHandler(w http.ResponseWriter, r *http.Request) {
trimmed := strings.TrimPrefix(r.URL.Path, "/property/")
+
+ // If path contains /and/ delegate to the shared multi-filter
+ if strings.Contains(trimmed, "/and/") {
+ page := pageFromRequest(r)
+ perPage := perPageFromConfig(50)
+
+ filters, breadcrumbs, err := parseFilterSegments(trimmed, "property")
+ if err != nil {
+ renderError(w, "Invalid filter path", http.StatusBadRequest)
+ return
+ }
+
+ where, whereArgs := buildTagFilterWhere(filters)
+
+ var total int
+ countArgs := append([]interface{}(nil), whereArgs...)
+ err = db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total)
+ if err != nil {
+ log.Printf("Error: propertyFilterHandler: failed to count files: %v", err)
+ renderError(w, "Failed to count files", http.StatusInternalServerError)
+ return
+ }
+
+ offset := (page - 1) * perPage
+ dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset)
+ files, err := queryFilesWithTags(
+ `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+
+ where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`,
+ dataArgs...,
+ )
+ if err != nil {
+ log.Printf("Error: propertyFilterHandler: failed to fetch files: %v", err)
+ renderError(w, "Failed to fetch files", http.StatusInternalServerError)
+ return
+ }
+
+ title := buildFilterTitle(filters, ", ")
+ pageData := buildPageDataWithPagination(title, ListData{
+ Tagged: files,
+ Untagged: nil,
+ Breadcrumbs: []Breadcrumb{},
+ }, page, total, perPage, r)
+ pageData.Breadcrumbs = breadcrumbs
+
+ renderTemplate(w, "list.html", pageData)
+ return
+ }
+
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
renderError(w, "Invalid property filter path", http.StatusBadRequest)
diff --git a/include-types.go b/include-types.go
@@ -86,6 +86,7 @@ type filter struct {
Value string
Values []string // Expanded values including aliases
IsPreviews bool // New field to indicate preview mode
+ IsProperty bool
}
type BulkTagFormData struct {