Files
vibetree/internal/folders/folders.go
T
Owen 218687a1ee Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
2026-08-17 13:34:40 -05:00

343 lines
9.7 KiB
Go

// Package folders implements the nested folder tree that organizes assets.
package folders
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"inventree/internal/models"
)
var (
ErrNotFound = errors.New("folder not found")
ErrInvalidParent = errors.New("cannot move a folder into itself or one of its own descendants")
ErrNameRequired = errors.New("folder name is required")
ErrNotEmpty = errors.New("folder still contains child folders or assets")
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
const timeLayout = "2006-01-02T15:04:05.000Z"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeLayout, s)
return t
}
func scanFolder(row interface{ Scan(...any) error }) (*models.Folder, error) {
var f models.Folder
var parentID sql.NullInt64
var created, updated string
if err := row.Scan(&f.ID, &f.Name, &parentID, &f.Description, &f.Icon, &f.SortOrder, &created, &updated); err != nil {
return nil, err
}
if parentID.Valid {
f.ParentID = &parentID.Int64
}
f.CreatedAt = parseTime(created)
f.UpdatedAt = parseTime(updated)
return &f, nil
}
func (s *Store) Get(ctx context.Context, id int64) (*models.Folder, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders WHERE id = ?`, id)
f, err := scanFolder(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return f, nil
}
// Children returns the direct child folders of parentID (nil for top level),
// ordered by sort_order then name, annotated with folder/asset counts.
func (s *Store) Children(ctx context.Context, parentID *int64) ([]models.Folder, error) {
query := `
SELECT f.id, f.name, f.parent_id, f.description, f.icon, f.sort_order, f.created_at, f.updated_at,
(SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) AS folder_count,
(SELECT COUNT(*) FROM assets a WHERE a.folder_id = f.id) AS asset_count
FROM folders f
WHERE f.parent_id IS ?
ORDER BY f.sort_order ASC, f.name COLLATE NOCASE ASC`
rows, err := s.db.QueryContext(ctx, query, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Folder
for rows.Next() {
var f models.Folder
var pid sql.NullInt64
var created, updated string
if err := rows.Scan(&f.ID, &f.Name, &pid, &f.Description, &f.Icon, &f.SortOrder, &created, &updated, &f.FolderCount, &f.AssetCount); err != nil {
return nil, err
}
if pid.Valid {
f.ParentID = &pid.Int64
}
f.CreatedAt = parseTime(created)
f.UpdatedAt = parseTime(updated)
out = append(out, f)
}
return out, rows.Err()
}
// All returns every folder in the system (id, name, parent_id only) for
// building the full in-memory tree or search indexes.
func (s *Store) All(ctx context.Context) ([]models.Folder, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders ORDER BY sort_order ASC, name COLLATE NOCASE ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Folder
for rows.Next() {
f, err := scanFolder(rowsScanner{rows})
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, rows.Err()
}
type rowsScanner struct{ r *sql.Rows }
func (rs rowsScanner) Scan(dest ...any) error { return rs.r.Scan(dest...) }
func (s *Store) Create(ctx context.Context, f *models.Folder) error {
if f.Name == "" {
return ErrNameRequired
}
if f.ParentID != nil {
if _, err := s.Get(ctx, *f.ParentID); err != nil {
return fmt.Errorf("parent folder: %w", err)
}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO folders (name, parent_id, description, icon, sort_order) VALUES (?, ?, ?, ?, ?)`,
f.Name, f.ParentID, f.Description, f.Icon, f.SortOrder)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
f.ID = id
return nil
}
func (s *Store) Update(ctx context.Context, f *models.Folder) error {
if f.Name == "" {
return ErrNameRequired
}
_, err := s.db.ExecContext(ctx,
`UPDATE folders SET name = ?, description = ?, icon = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
f.Name, f.Description, f.Icon, f.SortOrder, f.ID)
return err
}
// IsDescendant reports whether candidateID is descendantOf ancestorID (or
// equal to it), by walking parent pointers.
func (s *Store) isSelfOrDescendant(ctx context.Context, folderID int64, targetParentID *int64) (bool, error) {
if targetParentID == nil {
return false, nil
}
cur := *targetParentID
for {
if cur == folderID {
return true, nil
}
var parentID sql.NullInt64
err := s.db.QueryRowContext(ctx, `SELECT parent_id FROM folders WHERE id = ?`, cur).Scan(&parentID)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if !parentID.Valid {
return false, nil
}
cur = parentID.Int64
}
}
// Move relocates a folder to a new parent (nil = top level), rejecting moves
// into itself or one of its own descendants.
func (s *Store) Move(ctx context.Context, id int64, newParentID *int64) error {
if newParentID != nil && *newParentID == id {
return ErrInvalidParent
}
bad, err := s.isSelfOrDescendant(ctx, id, newParentID)
if err != nil {
return err
}
if bad {
return ErrInvalidParent
}
if newParentID != nil {
if _, err := s.Get(ctx, *newParentID); err != nil {
return fmt.Errorf("parent folder: %w", err)
}
}
_, err = s.db.ExecContext(ctx,
`UPDATE folders SET parent_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
newParentID, id)
return err
}
// Counts returns the number of direct child folders and direct assets in a
// folder, used to decide whether deletion needs confirmation.
func (s *Store) Counts(ctx context.Context, id int64) (folderCount, assetCount int, err error) {
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE parent_id = ?`, id).Scan(&folderCount)
if err != nil {
return
}
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE folder_id = ?`, id).Scan(&assetCount)
return
}
// Delete removes an empty folder. It fails if the folder still has children
// or assets (enforced both here and by the FK constraint).
func (s *Store) Delete(ctx context.Context, id int64) error {
fc, ac, err := s.Counts(ctx, id)
if err != nil {
return err
}
if fc > 0 || ac > 0 {
return ErrNotEmpty
}
_, err = s.db.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id)
return err
}
// DeleteAndRelocate moves all direct child folders and assets of id to
// destParentID/destFolderID (nil = top level / unfiled) and then deletes the
// now-empty folder id. Runs in a single transaction.
func (s *Store) DeleteAndRelocate(ctx context.Context, id int64, dest *int64) error {
if dest != nil {
bad, err := s.isSelfOrDescendant(ctx, id, dest)
if err != nil {
return err
}
if bad || *dest == id {
return ErrInvalidParent
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE folders SET parent_id = ? WHERE parent_id = ?`, dest, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE assets SET folder_id = ? WHERE folder_id = ?`, dest, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// CascadeDelete permanently deletes a folder along with every descendant
// folder and asset inside it. Descendant folders are removed deepest-first
// so the ON DELETE RESTRICT foreign key is always satisfied.
func (s *Store) CascadeDelete(ctx context.Context, id int64) error {
all, err := s.All(ctx)
if err != nil {
return err
}
childrenOf := map[int64][]int64{}
for _, f := range all {
if f.ParentID != nil {
childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID)
}
}
// Depth-first order, id last, so children are deleted before parents.
var order []int64
var walk func(int64)
walk = func(fid int64) {
for _, c := range childrenOf[fid] {
walk(c)
}
order = append(order, fid)
}
walk(id)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, fid := range order {
if _, err := tx.ExecContext(ctx, `DELETE FROM assets WHERE folder_id = ?`, fid); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, fid); err != nil {
return err
}
}
return tx.Commit()
}
// Path returns breadcrumb items from the root down to (and including) id.
func (s *Store) Path(ctx context.Context, id int64) ([]models.BreadcrumbItem, error) {
var path []models.BreadcrumbItem
cur := &id
for cur != nil {
var name string
var parentID sql.NullInt64
err := s.db.QueryRowContext(ctx, `SELECT name, parent_id FROM folders WHERE id = ?`, *cur).Scan(&name, &parentID)
if errors.Is(err, sql.ErrNoRows) {
break
}
if err != nil {
return nil, err
}
path = append([]models.BreadcrumbItem{{ID: *cur, Name: name}}, path...)
if parentID.Valid {
cur = &parentID.Int64
} else {
cur = nil
}
}
return path, nil
}
// Search returns folders whose name matches the query (case-insensitive
// substring match).
func (s *Store) Search(ctx context.Context, query string) ([]models.Folder, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at
FROM folders WHERE name LIKE '%' || ? || '%' COLLATE NOCASE ORDER BY name COLLATE NOCASE ASC LIMIT 50`, query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Folder
for rows.Next() {
f, err := scanFolder(rowsScanner{rows})
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, rows.Err()
}