/* * Amsterdam Web Communities System * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * SPDX-License-Identifier: MPL-2.0 */ // The database package contains database management and storage logic. package database import ( "context" "database/sql" "errors" "slices" "strings" "sync" "git.erbosoft.com/amy/amsterdam/util" log "github.com/sirupsen/logrus" ) // Category is the structure defining a category. type Category struct { CatId int32 `db:"catid"` Parent int32 `db:"parent"` SymLink int32 `db:"symlink"` HideDirectory bool `db:"hide_dir"` HideSearch bool `db:"hide_search"` Name string `db:"name"` } // Selectors for operator in category search. const ( SearchCatOperPrefix = 0 SearchCatOperSubstring = 1 SearchCatOperRegex = 2 ) // allCategories is the list of all categories loaded from the database. var allCategories []Category // categoryIdMap maps IDs to categories. var categoryIdMap map[int32]*Category = make(map[int32]*Category) // categoryMutex syncs the loading of the categories. var categoryMutex sync.Mutex // isCatEnabled determines if category features are enabled. func isCatEnabled(ctx context.Context) (bool, error) { g, err := AmGlobals(ctx) if err != nil { return false, err } set, err := g.Flags(ctx) if err != nil { return false, err } return !set.Get(GlobalFlagNoCategories), nil } // loadCategories loads the categories list from the database. func loadCategories(ctx context.Context) error { categoryMutex.Lock() defer categoryMutex.Unlock() if allCategories == nil { var ncats int if err := amdb.GetContext(ctx, &ncats, "SELECT COUNT(*) FROM refcategory"); err != nil { return err } allCategories = make([]Category, 0, ncats) if err := amdb.SelectContext(ctx, &allCategories, "SELECT * FROM refcategory ORDER BY parent, name"); err != nil { return err } for i, c := range allCategories { categoryIdMap[c.CatId] = &(allCategories[i]) } } return nil } /* AmGetCategory returns the category for the given name. * Parameters: * ctx - Standard Go context value. * catid - The ID of the category to get. * Returns: * Pointer to the appropriate Category, or nil. * Standard Go error status. */ func AmGetCategory(ctx context.Context, catid int32) (*Category, error) { ok, err := isCatEnabled(ctx) if err != nil { return nil, err } if !ok { return nil, errors.New("category feature not supported") } if err = loadCategories(ctx); err != nil { return nil, err } c := categoryIdMap[catid] d := 5 for c.SymLink != -1 { d-- if d == 0 { return nil, errors.New("symlink resolution error") } c = categoryIdMap[c.SymLink] } return c, nil } /* AmGetCategoryHierarchy returns the category hierarchy for the given ID. * Parameters: * ctx - Standard Go context value. * catid - The ID of the category to get. * Returns: * Array of pointers to the categories in hierarchical order, or nil. * Standard Go error status. */ func AmGetCategoryHierarchy(ctx context.Context, catid int32) ([]*Category, error) { ok, err := isCatEnabled(ctx) if err != nil { return nil, err } if !ok { return nil, errors.New("category feature not supported") } if err = loadCategories(ctx); err != nil { return nil, err } // walk all the way to the "root" (parent = -1) p := catid ia := make([]*Category, 0, 3) for p != -1 { c := categoryIdMap[p] for c.SymLink != -1 { c = categoryIdMap[c.SymLink] } ia = append(ia, c) p = c.Parent } // reverse the array for return slices.Reverse(ia) return ia, nil } /* AmGetSubCategories returns a list of all subcategories of the given category ID. * Parameters: * ctx - Standard Go context value. * catid - The parent category ID to use. May be -1 to return all "top level" categories. * Returns: * List of subcategories of this category. * Standard Go error status. */ func AmGetSubCategories(ctx context.Context, catid int32) ([]*Category, error) { ok, err := isCatEnabled(ctx) if err != nil { return nil, err } if !ok { return nil, errors.New("category feature not supported") } if err = loadCategories(ctx); err != nil { return nil, err } rc := make([]*Category, 0) for i, cat := range allCategories { if catid == cat.Parent { rc = append(rc, &(allCategories[i])) } } slices.SortFunc(rc, func(a, b *Category) int { return strings.Compare(a.Name, b.Name) }) return rc, nil } /* AmSearchCategories searches for categories matching certain criteria. * Parameters: * ctx - Standard Go context value. * oper - The operation to perform on the category name: * SearchCatOperPrefix - The category name has the string "term" as a prefix. * SearchCatOperSubstring - The category name contains the string "term". * SearchCatOperRegex - The category name matches the regular expression in "term". * term - The search term, as specified above. * offset - Number of categories to skip at beginning of list. * max - Maximum number of categories to return. * Returns: * Array of Category pointers representing the return elements. * The total number of categories matching this query (could be greater than max) * Standard Go error status. */ func AmSearchCategories(ctx context.Context, oper int, term string, offset int, max int, showAll bool, searchAll bool) ([]*Category, int, error) { ok, err := isCatEnabled(ctx) if err != nil { return nil, -1, err } if !ok { return nil, -1, errors.New("category feature not supported") } var queryString strings.Builder queryString.WriteString("name ") switch oper { case SearchCatOperPrefix: queryString.WriteString("LIKE '") queryString.WriteString(util.SqlEscape(term, true)) queryString.WriteString("%'") case SearchCatOperSubstring: queryString.WriteString("LIKE '%") queryString.WriteString(util.SqlEscape(term, true)) queryString.WriteString("%'") case SearchCatOperRegex: queryString.WriteString("REGEXP '") queryString.WriteString(util.SqlEscape(term, false)) queryString.WriteString("'") default: return nil, -1, errors.New("invalid operator to search function") } if !showAll { queryString.WriteString(" AND hide_dir = 0") } if !searchAll { queryString.WriteString(" AND hide_search = 0") } q := queryString.String() var total int if err = amdb.GetContext(ctx, &total, "SELECT COUNT(*) FROM refcategory WHERE "+q); err != nil { return nil, -1, err } if total == 0 { return make([]*Category, 0), 0, nil } var rs *sql.Rows if offset > 0 { rs, err = amdb.QueryContext(ctx, "SELECT catid FROM refcategory WHERE "+q+" ORDER BY parent, name LIMIT ? OFFSET ?", max, offset) } else { rs, err = amdb.QueryContext(ctx, "SELECT catid FROM refcategory WHERE "+q+" ORDER BY parent, name LIMIT ?", max) } if err != nil { return nil, total, err } rc := make([]*Category, 0, min(max, 1000)) for rs.Next() { var catid int32 if err = rs.Scan(&catid); err == nil { c, err := AmGetCategory(ctx, catid) if err == nil { rc = append(rc, c) } } if err != nil { log.Errorf("AmSearchCategoris scan error: %v", err) } } return rc, total, nil }