ui
This commit is contained in:
61
api/internal/cache/cache.go
vendored
Normal file
61
api/internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Cache is a minimal interface for key/value byte storage with TTL.
|
||||
// Both the Valkey implementation and the no-op satisfy it.
|
||||
type Cache interface {
|
||||
Get(ctx context.Context, key string) ([]byte, bool)
|
||||
Set(ctx context.Context, key string, value []byte, ttl time.Duration)
|
||||
}
|
||||
|
||||
// valkeyCache wraps a go-redis client connected to a Valkey (or Redis) server.
|
||||
type valkeyCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewValkeyCache parses url (e.g. "redis://localhost:6379"), pings the server,
|
||||
// and returns a ready Cache. Returns an error if the server is unreachable.
|
||||
func NewValkeyCache(url string) (Cache, error) {
|
||||
opts, err := redis.ParseURL(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := redis.NewClient(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &valkeyCache{client: client}, nil
|
||||
}
|
||||
|
||||
func (c *valkeyCache) Get(ctx context.Context, key string) ([]byte, bool) {
|
||||
val, err := c.client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func (c *valkeyCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) {
|
||||
// Best-effort: cache errors are non-fatal.
|
||||
c.client.Set(ctx, key, value, ttl)
|
||||
}
|
||||
|
||||
// noopCache is used when VALKEY_URL is not configured.
|
||||
// All Gets miss; all Sets are discarded.
|
||||
type noopCache struct{}
|
||||
|
||||
// NewNoopCache returns a Cache that never stores anything.
|
||||
func NewNoopCache() Cache { return &noopCache{} }
|
||||
|
||||
func (c *noopCache) Get(_ context.Context, _ string) ([]byte, bool) { return nil, false }
|
||||
func (c *noopCache) Set(_ context.Context, _ string, _ []byte, _ time.Duration) {
|
||||
}
|
||||
34
api/internal/cache/key.go
vendored
Normal file
34
api/internal/cache/key.go
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// HashFile computes the SHA-256 hex digest of the file at path.
|
||||
func HashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// AnalysisKey builds a cache key for FFF or SLA analysis.
|
||||
// Format: "<endpoint>:<filehash>:scale=<scale>"
|
||||
// scale uses %.6g so "1.50" and "1.5" map to the same key.
|
||||
func AnalysisKey(endpoint, fileHash string, scale float64) string {
|
||||
return fmt.Sprintf("%s:%s:scale=%.6g", endpoint, fileHash, scale)
|
||||
}
|
||||
|
||||
// ThumbnailKey builds a cache key for thumbnail generation.
|
||||
func ThumbnailKey(fileHash string) string {
|
||||
return "thumbnail:" + fileHash
|
||||
}
|
||||
125
api/internal/cli/cli.go
Normal file
125
api/internal/cli/cli.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var re = regexp.MustCompile(`\w|\%|^(\+|,|\.|:|\/|\\|=|_|-|[a-z]|[A-Z]|[0-9]|\s)+$`)
|
||||
|
||||
func validateCliArgument(arg string) bool {
|
||||
return re.Match([]byte(arg))
|
||||
}
|
||||
|
||||
func validateCliArguments(args []string) bool {
|
||||
for _, p := range args {
|
||||
if !validateCliArgument(p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func execNoCheck(ctx context.Context, command string, args []string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
cmdStr := cmd.String()
|
||||
|
||||
pStdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", cmdStr, err
|
||||
}
|
||||
|
||||
pStderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", cmdStr, err
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return "", cmdStr, err
|
||||
}
|
||||
|
||||
stdout, _ := io.ReadAll(pStdout)
|
||||
stderr, _ := io.ReadAll(pStderr)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
return "", cmdStr, errors.New(err.Error() + " | " + string(stderr) + " | " + string(stdout))
|
||||
}
|
||||
return string(stdout), cmdStr, nil
|
||||
}
|
||||
|
||||
type Pipe struct {
|
||||
Command string
|
||||
SuffixSymbol string
|
||||
Args []string
|
||||
}
|
||||
|
||||
func (p *Pipe) IsSanatory() bool {
|
||||
return validateCliArguments(p.Args) && validateCliArgument(p.Command)
|
||||
}
|
||||
|
||||
func (p *Pipe) Execute(ctx context.Context) (string, string, error) {
|
||||
if !p.IsSanatory() {
|
||||
return "", "", errors.New("failed to execute cli command: " + p.Command + " " + strings.Join(p.Args, " ") + " some arguments are not allowed")
|
||||
} else {
|
||||
return execNoCheck(ctx, p.Command, p.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteCombined runs the command and returns combined stdout+stderr output.
|
||||
// Useful when a tool writes useful output to either stream.
|
||||
func (p *Pipe) ExecuteCombined(ctx context.Context) (string, string, error) {
|
||||
if !p.IsSanatory() {
|
||||
return "", "", errors.New("failed to execute cli command: " + p.Command + " " + strings.Join(p.Args, " ") + " some arguments are not allowed")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.Command, p.Args...)
|
||||
|
||||
cmdStr := cmd.String()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", cmdStr, errors.New(err.Error() + " | " + string(out))
|
||||
}
|
||||
return string(out), cmdStr, nil
|
||||
}
|
||||
|
||||
func ExecuteCLIPipeLine(ctx context.Context, pipes []Pipe) (string, string, error) {
|
||||
|
||||
pipeLen := len(pipes)
|
||||
if pipeLen == 0 {
|
||||
return "", "", errors.New("failed to execute cli command: no pipes provided")
|
||||
}
|
||||
argLen := 0
|
||||
for _, p := range pipes {
|
||||
if !p.IsSanatory() {
|
||||
return "", "", errors.New("failed to execute cli command: " + p.Command + " " + strings.Join(p.Args, " ") + " some arguments are not allowed")
|
||||
}
|
||||
argLen += len(p.Args) + 1
|
||||
}
|
||||
|
||||
command := "/bin/sh"
|
||||
var args []string = []string{}
|
||||
args = append(args, "-c")
|
||||
pipeLen -= 1
|
||||
|
||||
cmdArg := ""
|
||||
|
||||
for i, p := range pipes {
|
||||
cmdArg += p.Command + " " + strings.Join(p.Args, " ")
|
||||
if i != pipeLen {
|
||||
if p.SuffixSymbol != "" {
|
||||
cmdArg += " " + p.SuffixSymbol + " "
|
||||
} else {
|
||||
cmdArg += " | "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, cmdArg)
|
||||
|
||||
return execNoCheck(ctx, command, args)
|
||||
}
|
||||
282
api/internal/slicer/slicer.go
Normal file
282
api/internal/slicer/slicer.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package slicer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sotigr/slic3r-api/internal/cli"
|
||||
)
|
||||
|
||||
// ModelSize holds the bounding box dimensions of the model in millimeters.
|
||||
type ModelSize struct {
|
||||
X float64 `json:"x_mm"`
|
||||
Y float64 `json:"y_mm"`
|
||||
Z float64 `json:"z_mm"`
|
||||
}
|
||||
|
||||
// FFFResult holds the analysis results for a Fused Filament Fabrication print.
|
||||
type FFFResult struct {
|
||||
PrintTimeSeconds int64 `json:"print_time_seconds"`
|
||||
FilamentWeightG float64 `json:"filament_weight_g"`
|
||||
}
|
||||
|
||||
// SLAResult holds the analysis results for a Stereolithography (resin) print.
|
||||
type SLAResult struct {
|
||||
PrintTimeSeconds int64 `json:"print_time_seconds"`
|
||||
MaterialVolumeMl float64 `json:"material_volume_ml"`
|
||||
}
|
||||
|
||||
// AnalyzeOptions configures optional parameters for analysis.
|
||||
type AnalyzeOptions struct {
|
||||
// Scale is the target size in millimeters applied before slicing.
|
||||
// The model is scaled uniformly so its longest axis fits within Scale mm.
|
||||
// A value of 0 means no scaling.
|
||||
Scale float64
|
||||
}
|
||||
|
||||
// SlicerService analyzes 3D model files using PrusaSlicer.
|
||||
type SlicerService interface {
|
||||
AnalyzeFFF(modelPath string, opts AnalyzeOptions) (*FFFResult, error)
|
||||
AnalyzeSLA(modelPath string, opts AnalyzeOptions) (*SLAResult, error)
|
||||
}
|
||||
|
||||
type prusaSlicerService struct {
|
||||
fffConfigPath string
|
||||
slaConfigPath string
|
||||
}
|
||||
|
||||
// NewSlicerService creates a SlicerService backed by PrusaSlicer CLI.
|
||||
func NewSlicerService(fffConfigPath, slaConfigPath string) SlicerService {
|
||||
return &prusaSlicerService{
|
||||
fffConfigPath: fffConfigPath,
|
||||
slaConfigPath: slaConfigPath,
|
||||
}
|
||||
}
|
||||
|
||||
// bedDimensions parses bed_shape and max_print_height from a PrusaSlicer INI config.
|
||||
// Returns the usable bed width (xMM), depth (yMM), and max height (zMM).
|
||||
func bedDimensions(configPath string) (xMM, yMM, zMM float64) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bedShapeRe := regexp.MustCompile(`^bed_shape\s*=\s*(.+)$`)
|
||||
maxHeightRe := regexp.MustCompile(`^max_print_height\s*=\s*([\d.]+)`)
|
||||
pointRe := regexp.MustCompile(`([\d.]+)x([\d.]+)`)
|
||||
|
||||
minX := math.MaxFloat64
|
||||
minY := math.MaxFloat64
|
||||
maxX := -math.MaxFloat64
|
||||
maxY := -math.MaxFloat64
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if m := bedShapeRe.FindStringSubmatch(line); m != nil {
|
||||
for _, pt := range pointRe.FindAllStringSubmatch(m[1], -1) {
|
||||
x, _ := strconv.ParseFloat(pt[1], 64)
|
||||
y, _ := strconv.ParseFloat(pt[2], 64)
|
||||
if x < minX {
|
||||
minX = x
|
||||
}
|
||||
if x > maxX {
|
||||
maxX = x
|
||||
}
|
||||
if y < minY {
|
||||
minY = y
|
||||
}
|
||||
if y > maxY {
|
||||
maxY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
if m := maxHeightRe.FindStringSubmatch(line); m != nil {
|
||||
zMM, _ = strconv.ParseFloat(m[1], 64)
|
||||
}
|
||||
}
|
||||
|
||||
if maxX > -math.MaxFloat64 {
|
||||
xMM = maxX - minX
|
||||
yMM = maxY - minY
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// parsePrintTime converts a PrusaSlicer time string like "3h 27m 26s" into seconds.
|
||||
func parsePrintTime(s string) int64 {
|
||||
var h, m, sec int64
|
||||
if match := regexp.MustCompile(`(\d+)h`).FindStringSubmatch(s); match != nil {
|
||||
h, _ = strconv.ParseInt(match[1], 10, 64)
|
||||
}
|
||||
if match := regexp.MustCompile(`(\d+)m`).FindStringSubmatch(s); match != nil {
|
||||
m, _ = strconv.ParseInt(match[1], 10, 64)
|
||||
}
|
||||
if match := regexp.MustCompile(`(\d+)s`).FindStringSubmatch(s); match != nil {
|
||||
sec, _ = strconv.ParseInt(match[1], 10, 64)
|
||||
}
|
||||
return h*3600 + m*60 + sec
|
||||
}
|
||||
|
||||
// scaleArgs returns --scale-to-fit CLI arguments for the given options, or nil if no scaling is needed.
|
||||
// Scale is treated as the target size in mm; the model is scaled uniformly to fit within a cube of that size.
|
||||
func scaleArgs(opts AnalyzeOptions) []string {
|
||||
if opts.Scale > 0 {
|
||||
s := strconv.FormatFloat(opts.Scale, 'f', -1, 64)
|
||||
return []string{"--scale-to-fit", s + "," + s + "," + s}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseModelSize extracts the bounding box from PrusaSlicer --info output.
|
||||
func parseModelSize(output string) ModelSize {
|
||||
re := regexp.MustCompile(`Size:\s*([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)`)
|
||||
if m := re.FindStringSubmatch(output); m != nil {
|
||||
x, _ := strconv.ParseFloat(m[1], 64)
|
||||
y, _ := strconv.ParseFloat(m[2], 64)
|
||||
z, _ := strconv.ParseFloat(m[3], 64)
|
||||
return ModelSize{X: x, Y: y, Z: z}
|
||||
}
|
||||
return ModelSize{}
|
||||
}
|
||||
func parseDurationToSeconds(input string) (int, error) {
|
||||
// 1. Remove spaces to format the string for time.ParseDuration
|
||||
// Example: "2h 41m 24s" -> "2h41m24s"
|
||||
cleaned := strings.ReplaceAll(input, " ", "")
|
||||
|
||||
// 2. Parse the string into a time.Duration object
|
||||
d, err := time.ParseDuration(cleaned)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 3. Convert duration to seconds (as an integer)
|
||||
return int(d.Seconds()), nil
|
||||
}
|
||||
func (s *prusaSlicerService) AnalyzeFFF(modelPath string, opts AnalyzeOptions) (*FFFResult, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "slicer-fff-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
outputPath := filepath.Join("./", "output.gcode")
|
||||
|
||||
args := []string{"--load", s.fffConfigPath, "--export-gcode", "--info", "--output", outputPath}
|
||||
args = append(args, scaleArgs(opts)...)
|
||||
args = append(args, modelPath)
|
||||
|
||||
pipe := cli.Pipe{
|
||||
Command: "prusa-slicer",
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, _, err = pipe.ExecuteCombined(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prusa-slicer: %w", err)
|
||||
}
|
||||
|
||||
gcodeRe := regexp.MustCompile(`\W?\;\W?(.*)=(.*)`)
|
||||
|
||||
b, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcodeLines := strings.Split(string(b), "\n")
|
||||
|
||||
filamentGrams := 0.0
|
||||
time := 0
|
||||
for _, line := range gcodeLines {
|
||||
l := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(l, ";") {
|
||||
matches := gcodeRe.FindStringSubmatch(l)
|
||||
|
||||
if len(matches) > 0 {
|
||||
key := strings.TrimSpace(matches[1])
|
||||
value := strings.TrimSpace(matches[2])
|
||||
switch key {
|
||||
case "filament used [g]":
|
||||
filamentGrams, _ = strconv.ParseFloat(value, 32)
|
||||
}
|
||||
if strings.HasPrefix(key, "estimated printing time") {
|
||||
time, _ = parseDurationToSeconds(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &FFFResult{
|
||||
PrintTimeSeconds: int64(time),
|
||||
FilamentWeightG: filamentGrams,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type slaPwmxOutputConfig struct {
|
||||
UsedMaterial float32 `json:"usedMaterial"`
|
||||
PrintTimeSeconds float32 `json:"printTime"`
|
||||
}
|
||||
|
||||
func (s *prusaSlicerService) AnalyzeSLA(modelPath string, opts AnalyzeOptions) (*SLAResult, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "slicer-sla-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
outputPath := filepath.Join("./", "output.pwmx")
|
||||
|
||||
args := []string{"--load", s.slaConfigPath, "--export-sla", "--info", "--output", outputPath}
|
||||
args = append(args, scaleArgs(opts)...)
|
||||
args = append(args, modelPath)
|
||||
|
||||
pipe := cli.Pipe{
|
||||
Command: "prusa-slicer",
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, _, err = pipe.Execute(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prusa-slicer: %w", err)
|
||||
}
|
||||
|
||||
pipe = cli.Pipe{
|
||||
SuffixSymbol: ">",
|
||||
Command: "unzip",
|
||||
Args: []string{"-p", outputPath, "config.json"},
|
||||
}
|
||||
outputPipe := cli.Pipe{
|
||||
Command: "config.json",
|
||||
}
|
||||
_, _, err = cli.ExecuteCLIPipeLine(context.Background(), []cli.Pipe{
|
||||
pipe, outputPipe,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sla-analysi: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.ReadFile("config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var outputCfg slaPwmxOutputConfig
|
||||
err = json.Unmarshal(file, &outputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SLAResult{
|
||||
PrintTimeSeconds: int64(outputCfg.PrintTimeSeconds),
|
||||
MaterialVolumeMl: float64(outputCfg.UsedMaterial),
|
||||
}, nil
|
||||
}
|
||||
46
api/internal/thumbnail/thumbnail.go
Normal file
46
api/internal/thumbnail/thumbnail.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package thumbnail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sotigr/slic3r-api/internal/cli"
|
||||
)
|
||||
|
||||
// ThumbnailService generates preview images of 3D model files.
|
||||
type ThumbnailService interface {
|
||||
Generate(ctx context.Context, modelPath, outputPath string) error
|
||||
}
|
||||
|
||||
type f3dService struct{}
|
||||
|
||||
// NewThumbnailService creates a ThumbnailService backed by the f3d renderer.
|
||||
func NewThumbnailService() ThumbnailService {
|
||||
return &f3dService{}
|
||||
}
|
||||
|
||||
func (s *f3dService) Generate(ctx context.Context, modelPath, outputPath string) error {
|
||||
pipe := cli.Pipe{
|
||||
Command: "f3d",
|
||||
Args: []string{
|
||||
modelPath,
|
||||
"--resolution=128,128",
|
||||
"-a",
|
||||
"--anti-aliasing-mode=taa",
|
||||
"--filename=false",
|
||||
"--metadata=false",
|
||||
"--color=#999",
|
||||
"-t",
|
||||
"--grid=false",
|
||||
"--axis=false",
|
||||
"--no-background",
|
||||
"--output=" + outputPath,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := pipe.Execute(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("f3d: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
89
api/internal/valkeydb/db.go
Normal file
89
api/internal/valkeydb/db.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package valkeydb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type DbItem map[string]any
|
||||
|
||||
type ValkeyDB struct {
|
||||
client *redis.Client
|
||||
name string
|
||||
}
|
||||
|
||||
func NewValkeyDB(client *redis.Client, dbName string) *ValkeyDB {
|
||||
return &ValkeyDB{
|
||||
client: client,
|
||||
name: dbName,
|
||||
}
|
||||
}
|
||||
|
||||
func (db *ValkeyDB) GetName() string {
|
||||
return db.name
|
||||
}
|
||||
|
||||
type Table[T any] struct {
|
||||
Name string
|
||||
db *ValkeyDB
|
||||
}
|
||||
|
||||
func NewTable[T any](db *ValkeyDB, tableName string) *Table[T] {
|
||||
return &Table[T]{
|
||||
Name: fmt.Sprintf("%s:%s", db.GetName(), tableName),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table[T]) createItemHash(id string) string {
|
||||
return fmt.Sprintf("%s:%s", t.Name, id)
|
||||
}
|
||||
|
||||
func (t *Table[T]) createNewId() (string, string) {
|
||||
newId := uuid.New().String()
|
||||
hash := t.createItemHash(newId)
|
||||
return hash, newId
|
||||
}
|
||||
|
||||
func (t *Table[T]) Insert(ctx context.Context, item *T) (string, error) {
|
||||
hash, newId := t.createNewId()
|
||||
jItem, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := t.db.client.HSet(ctx, hash, "$", string(jItem))
|
||||
if cmd.Err() != nil {
|
||||
return "", err
|
||||
}
|
||||
return newId, nil
|
||||
}
|
||||
|
||||
func (t *Table[T]) Get(ctx context.Context, id string) (*T, error) {
|
||||
hash := t.createItemHash(id)
|
||||
cmd := t.db.client.HGet(ctx, hash, "$")
|
||||
err := cmd.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res T
|
||||
err = json.Unmarshal([]byte(cmd.Val()), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (t *Table[T]) Remove(ctx context.Context, id string) error {
|
||||
hash := t.createItemHash(id)
|
||||
cmd := t.db.client.HDel(ctx, hash)
|
||||
err := cmd.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user