320 lines
9.0 KiB
Go
320 lines
9.0 KiB
Go
package tree
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
)
|
|
|
|
type NodeType struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description"`
|
|
IconKey *string `json:"iconKey"`
|
|
Color *string `json:"color"`
|
|
}
|
|
|
|
type Tag struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Color *string `json:"color"`
|
|
}
|
|
|
|
func (s *Store) ListNodeTypes(ctx context.Context) ([]NodeType, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types ORDER BY name COLLATE NOCASE`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []NodeType{}
|
|
for rows.Next() {
|
|
value, err := scanNodeType(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, value)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateNodeType(ctx context.Context, name string, description, iconKey, color *string) (NodeType, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return NodeType{}, ErrInvalid
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return NodeType{}, err
|
|
}
|
|
if _, err = s.db.ExecContext(ctx, `INSERT INTO node_types (id, name, description, icon_key, color) VALUES (?, ?, ?, ?, ?)`, id, name, cleanOptionalString(description), cleanOptionalString(iconKey), cleanOptionalString(color)); err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
return NodeType{}, ErrConflict
|
|
}
|
|
return NodeType{}, err
|
|
}
|
|
return s.GetNodeType(ctx, id)
|
|
}
|
|
|
|
func (s *Store) GetNodeType(ctx context.Context, id string) (NodeType, error) {
|
|
return scanNodeType(s.db.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, id))
|
|
}
|
|
|
|
func (s *Store) UpdateNodeType(ctx context.Context, id, name string, description, iconKey, color *string) (NodeType, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return NodeType{}, ErrInvalid
|
|
}
|
|
result, err := s.db.ExecContext(ctx, `UPDATE node_types SET name = ?, description = ?, icon_key = ?, color = ? WHERE id = ?`, name, cleanOptionalString(description), cleanOptionalString(iconKey), cleanOptionalString(color), id)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
return NodeType{}, ErrConflict
|
|
}
|
|
return NodeType{}, err
|
|
}
|
|
if count, _ := result.RowsAffected(); count == 0 {
|
|
return NodeType{}, ErrNotFound
|
|
}
|
|
return s.GetNodeType(ctx, id)
|
|
}
|
|
|
|
func (s *Store) DeleteNodeType(ctx context.Context, id string) error {
|
|
result, err := s.db.ExecContext(ctx, `DELETE FROM node_types WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count, _ := result.RowsAffected(); count == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ListTags(ctx context.Context) ([]Tag, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, color FROM tags ORDER BY name COLLATE NOCASE`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []Tag{}
|
|
for rows.Next() {
|
|
value, err := scanTag(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, value)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CreateTag(ctx context.Context, name string, color *string) (Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return Tag{}, ErrInvalid
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return Tag{}, err
|
|
}
|
|
if _, err = s.db.ExecContext(ctx, `INSERT INTO tags (id, name, color) VALUES (?, ?, ?)`, id, name, cleanOptionalString(color)); err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
return Tag{}, ErrConflict
|
|
}
|
|
return Tag{}, err
|
|
}
|
|
return s.GetTag(ctx, id)
|
|
}
|
|
|
|
func (s *Store) GetTag(ctx context.Context, id string) (Tag, error) {
|
|
return scanTag(s.db.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, id))
|
|
}
|
|
|
|
func (s *Store) UpdateTag(ctx context.Context, id, name string, color *string) (Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return Tag{}, ErrInvalid
|
|
}
|
|
result, err := s.db.ExecContext(ctx, `UPDATE tags SET name = ?, color = ? WHERE id = ?`, name, cleanOptionalString(color), id)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
return Tag{}, ErrConflict
|
|
}
|
|
return Tag{}, err
|
|
}
|
|
if count, _ := result.RowsAffected(); count == 0 {
|
|
return Tag{}, ErrNotFound
|
|
}
|
|
return s.GetTag(ctx, id)
|
|
}
|
|
|
|
func (s *Store) DeleteTag(ctx context.Context, id string) error {
|
|
result, err := s.db.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count, _ := result.RowsAffected(); count == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetClassification(ctx context.Context, nodeID string, typeID *string, tagIDs []string) (Node, error) {
|
|
if hasDuplicateIDsAllowEmpty(tagIDs) {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Node{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := getWith(ctx, tx, nodeID); err != nil {
|
|
return Node{}, err
|
|
}
|
|
if typeID != nil {
|
|
if _, err := scanNodeType(tx.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, *typeID)); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
for _, tagID := range tagIDs {
|
|
if _, err := scanTag(tx.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, tagID)); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET type_id = ? WHERE id = ?`, typeID, nodeID); err != nil {
|
|
return Node{}, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM node_tags WHERE node_id = ?`, nodeID); err != nil {
|
|
return Node{}, err
|
|
}
|
|
for _, tagID := range tagIDs {
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO node_tags (node_id, tag_id) VALUES (?, ?)`, nodeID, tagID); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Node{}, err
|
|
}
|
|
return s.Get(ctx, nodeID)
|
|
}
|
|
|
|
func (s *Store) ClassifyMany(ctx context.Context, nodeIDs []string, setType bool, typeID *string, addTagIDs, removeTagIDs []string) error {
|
|
if len(nodeIDs) == 0 || hasDuplicateIDs(nodeIDs) || hasDuplicateIDsAllowEmpty(addTagIDs) || hasDuplicateIDsAllowEmpty(removeTagIDs) {
|
|
return ErrInvalid
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if typeID != nil {
|
|
if _, err := scanNodeType(tx.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, *typeID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, tagID := range append(append([]string{}, addTagIDs...), removeTagIDs...) {
|
|
if _, err := scanTag(tx.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, tagID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, nodeID := range nodeIDs {
|
|
if nodeID == RootID || nodeID == UnsortedID {
|
|
return ErrConflict
|
|
}
|
|
if _, err := getWith(ctx, tx, nodeID); err != nil {
|
|
return err
|
|
}
|
|
if setType {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET type_id = ? WHERE id = ?`, typeID, nodeID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, tagID := range addTagIDs {
|
|
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO node_tags (node_id, tag_id) VALUES (?, ?)`, nodeID, tagID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, tagID := range removeTagIDs {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM node_tags WHERE node_id = ? AND tag_id = ?`, nodeID, tagID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Store) loadTagIDs(ctx context.Context, node *Node) error {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT tag_id FROM node_tags WHERE node_id = ? ORDER BY tag_id`, node.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return err
|
|
}
|
|
node.TagIDs = append(node.TagIDs, id)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func (s *Store) loadTreeTagIDs(ctx context.Context, byID map[string]*TreeNode) error {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT node_id, tag_id FROM node_tags ORDER BY tag_id`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var nodeID, tagID string
|
|
if err := rows.Scan(&nodeID, &tagID); err != nil {
|
|
return err
|
|
}
|
|
if node := byID[nodeID]; node != nil {
|
|
node.TagIDs = append(node.TagIDs, tagID)
|
|
}
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func scanNodeType(row scanner) (NodeType, error) {
|
|
var value NodeType
|
|
var description, iconKey, color sql.NullString
|
|
if err := row.Scan(&value.ID, &value.Name, &description, &iconKey, &color); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return NodeType{}, ErrNotFound
|
|
}
|
|
return NodeType{}, err
|
|
}
|
|
if description.Valid {
|
|
value.Description = &description.String
|
|
}
|
|
if iconKey.Valid {
|
|
value.IconKey = &iconKey.String
|
|
}
|
|
if color.Valid {
|
|
value.Color = &color.String
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func scanTag(row scanner) (Tag, error) {
|
|
var value Tag
|
|
var color sql.NullString
|
|
if err := row.Scan(&value.ID, &value.Name, &color); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Tag{}, ErrNotFound
|
|
}
|
|
return Tag{}, err
|
|
}
|
|
if color.Valid {
|
|
value.Color = &color.String
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func hasDuplicateIDsAllowEmpty(ids []string) bool {
|
|
if len(ids) == 0 {
|
|
return false
|
|
}
|
|
return hasDuplicateIDs(ids)
|
|
}
|