// Package folders implements the nested folder tree that organizes assets. package folders import ( "context" "database/sql" "errors" "fmt" "time" "vibetree/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() }