Compare commits

Author SHA1 Message Date
abelith 8c683e87ab feat: import command 2026-08-21 15:52:26 +05:00
abelith c41d13bb77 feat: json importer 2026-08-21 15:48:02 +05:00
abelith 69af7eaee3 feat: import interface 2026-08-21 15:27:37 +05:00
abelith f97e9b0aa9 feat: export command 2026-08-16 22:39:51 +05:00
abelith 92cb9267fc feat: exporter api, json exporter 2026-08-16 22:39:42 +05:00
abelith 4de3218c40 feat: added repo export/import streaming methods 2026-08-16 21:54:31 +05:00
10 changed files with 422 additions and 15 deletions

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
package exporter
import (
"context"
"fmt"
"github.com/abelith/spellbook/internal/models"
"io"
"sync"
)
type Exporter interface {
Export(ctx context.Context, ch <-chan models.Spell, w io.Writer) error
}
var (
exporters = make(map[string]Exporter)
mu sync.Mutex
)
func Register(name string, exp Exporter) {
mu.Lock()
defer mu.Unlock()
if exp == nil {
panic("exporter: Exporter cannot be nil")
}
if _, ok := exporters[name]; ok {
panic("exporter: Register called twice for " + name)
}
exporters[name] = exp
}
func Export(ctx context.Context, name string, ch <-chan models.Spell, w io.Writer) error {
mu.Lock()
exp, ok := exporters[name]
mu.Unlock()
if !ok {
return fmt.Errorf("exporter %s not found", name)
}
return exp.Export(ctx, ch, w)
}
+43
View File
@@ -0,0 +1,43 @@
package importer
import (
"context"
"fmt"
"github.com/abelith/spellbook/internal/models"
"io"
"sync"
)
type Importer interface {
Import(ctx context.Context, r io.Reader) (<-chan models.Spell, <-chan error, error)
}
var (
importers = make(map[string]Importer)
mu sync.Mutex
)
func Register(name string, imp Importer) {
mu.Lock()
defer mu.Unlock()
if imp == nil {
panic("importer: Importer cannot be nil")
}
if _, ok := importers[name]; ok {
panic("importer: Register called twice for " + name)
}
importers[name] = imp
}
func Import(ctx context.Context, name string, r io.Reader) (<-chan models.Spell, <-chan error, error) {
mu.Lock()
imp, ok := importers[name]
mu.Unlock()
if !ok {
return nil, nil, fmt.Errorf("importer %s not found", name)
}
return imp.Import(ctx, r)
}
+67
View File
@@ -0,0 +1,67 @@
package json
import (
"context"
"encoding/json"
"github.com/abelith/spellbook/internal/adapters/exporter"
"github.com/abelith/spellbook/internal/adapters/importer"
"github.com/abelith/spellbook/internal/models"
"io"
)
func init() {
exporter.Register("jsonl", &JsonlExporter{})
importer.Register("jsonl", &JsonlImporter{})
}
type JsonlExporter struct{}
type JsonlImporter struct{}
func (je *JsonlExporter) Export(ctx context.Context, ch <-chan models.Spell, w io.Writer) error {
encoder := json.NewEncoder(w)
for {
select {
case <-ctx.Done():
return ctx.Err()
case spell, ok := <-ch:
if !ok {
return nil
}
if err := encoder.Encode(&spell); err != nil {
return err
}
}
}
}
func (ji JsonlImporter) Import(ctx context.Context, r io.Reader) (<-chan models.Spell, <-chan error, error) {
ch := make(chan models.Spell, 1)
errCh := make(chan error, 1)
dec := json.NewDecoder(r)
go func() {
defer close(ch)
defer close(errCh)
for dec.More() {
var spell models.Spell
if err := dec.Decode(&spell); err != nil {
errCh <- err
return
}
select {
case <-ctx.Done():
errCh <- ctx.Err()
return
case ch <- spell:
}
}
}()
return ch, errCh, nil
}
+105 -9
View File
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
"fmt"
sq "github.com/Masterminds/squirrel"
"github.com/abelith/spellbook/internal/errors"
"github.com/abelith/spellbook/internal/models"
@@ -45,17 +46,10 @@ func (r *Repo) Init(ctx context.Context) error {
}
func (r *Repo) Add(ctx context.Context, spell models.Spell) error {
tags := strings.Join(spell.Tags, ",")
_, err := sq.Insert("spells").
Columns("name", "spell", "short_desc", "description", "tags").
Values(spell.Name, spell.Spell, spell.ShortDesc, spell.Description, tags).
PlaceholderFormat(sq.Question).
RunWith(r.db).
ExecContext(ctx)
if err != nil {
if _, err := insertSpell(ctx, r.db, spell); err != nil {
return err
}
return nil
}
@@ -148,6 +142,97 @@ func (r *Repo) GetMany(ctx context.Context, query models.SearchQuery) ([]models.
return spells, nil
}
func (r *Repo) Import(ctx context.Context, ch <-chan models.Spell) error {
return r.runTx(ctx, func(tx *sql.Tx) error {
cache := sq.NewStmtCache(tx)
defer cache.Clear()
for {
select {
case <-ctx.Done():
return ctx.Err()
case spell, ok := <-ch:
if !ok {
return nil
}
_, err := insertSpell(ctx, cache, spell)
if err != nil {
return err
}
}
}
})
}
func (r *Repo) Count(ctx context.Context) (int, error) {
var count int
err := sq.Select("COUNT(*)").
From("spells").
RunWith(r.db).
ScanContext(ctx, &count)
if err != nil {
return 0, err
}
return count, nil
}
func (r *Repo) Export(ctx context.Context) (<-chan models.Spell, <-chan error, error) {
ch := make(chan models.Spell, 1)
errCh := make(chan error, 1)
rows, err := sq.Select("id", "name", "spell", "short_desc", "description", "tags").
From("spells").
RunWith(r.db).
QueryContext(ctx)
if err != nil {
return nil, nil, err
}
go func() {
defer close(errCh)
defer close(ch)
defer rows.Close()
for rows.Next() {
spell, err := scanSpell(rows)
if err != nil {
errCh <- err
return
}
select {
case <-ctx.Done():
errCh <- ctx.Err()
return
case ch <- spell:
}
}
if err := rows.Err(); err != nil {
errCh <- err
}
}()
return ch, errCh, nil
}
func (r *Repo) runTx(ctx context.Context, f func(tx *sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("failed to begin tx: %w", err)
}
if err := f(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("err: %v, rollback err: %w", err, rbErr)
}
return err
}
return tx.Commit()
}
func scanSpell(row scanner) (models.Spell, error) {
spell := models.Spell{}
tagsString := ""
@@ -162,3 +247,14 @@ func scanSpell(row scanner) (models.Spell, error) {
return spell, nil
}
func insertSpell(ctx context.Context, runner sq.BaseRunner, spell models.Spell) (sql.Result, error) {
tags := strings.Join(spell.Tags, ",")
return sq.Insert("spells").
Columns("name", "spell", "short_desc", "description", "tags").
Values(spell.Name, spell.Spell, spell.ShortDesc, spell.Description, tags).
PlaceholderFormat(sq.Question).
RunWith(runner).
ExecContext(ctx)
}
+53
View File
@@ -2,7 +2,10 @@ package application
import (
"context"
"github.com/abelith/spellbook/internal/adapters/exporter"
"github.com/abelith/spellbook/internal/adapters/importer"
"github.com/abelith/spellbook/internal/models"
"io"
)
type SpellCaster interface {
@@ -12,11 +15,14 @@ type SpellCaster interface {
type SpellCommand interface {
Add(ctx context.Context, spell models.Spell) error
Delete(ctx context.Context, spellName string) error
Import(ctx context.Context, ch <-chan models.Spell) error
}
type SpellQuery interface {
Get(ctx context.Context, spellName string) (models.Spell, error)
GetMany(ctx context.Context, query models.SearchQuery) ([]models.Spell, error)
Count(ctx context.Context) (int, error)
Export(ctx context.Context) (<-chan models.Spell, <-chan error, error)
}
type Application struct {
@@ -67,3 +73,50 @@ func (a *Application) CastSpell(
) (models.CastResult, error) {
return a.caster.Cast(ctx, spellCast)
}
func (a *Application) ExportSpells(
ctx context.Context,
name string,
w io.Writer,
) error {
ch, errCh, err := a.spellQuery.Export(ctx)
if err != nil {
return err
}
if err := exporter.Export(ctx, name, ch, w); err != nil {
return err
}
if err := <-errCh; err != nil {
return err
}
return nil
}
func (a *Application) ImportSpells(
ctx context.Context,
name string,
r io.Reader,
) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch, errCh, err := importer.Import(ctx, name, r)
if err != nil {
cancel()
return err
}
if err := a.spellCmd.Import(ctx, ch); err != nil {
cancel()
return err
}
if err := <-errCh; err != nil {
return err
}
return nil
}
+2
View File
@@ -14,6 +14,8 @@ var reservedCommands = map[string]struct{}{
"help": {},
"completion": {},
"search": {},
"export": {},
"import": {},
}
func NewAddCommand(app Application) *cobra.Command {
+5
View File
@@ -4,6 +4,7 @@ import (
"context"
"github.com/abelith/spellbook/internal/models"
"github.com/spf13/cobra"
"io"
)
type Application interface {
@@ -12,6 +13,8 @@ type Application interface {
GetSpells(ctx context.Context, query models.SearchQuery) ([]models.Spell, error)
DeleteSpell(ctx context.Context, spellName string) error
CastSpell(ctx context.Context, spellCast models.SpellCast) (models.CastResult, error)
ExportSpells(ctx context.Context, exp string, w io.Writer) error
ImportSpells(ctx context.Context, exp string, r io.Reader) error
}
type CLI struct {
@@ -24,6 +27,8 @@ func NewCLI(ver string, app Application) *CLI {
NewAddCommand(app),
NewRmCommand(app),
NewSearchCmd(app),
NewExportCmd(app),
NewImportCmd(app),
)
return &CLI{
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"bufio"
"errors"
_ "github.com/abelith/spellbook/internal/adapters/json"
"github.com/spf13/cobra"
"io"
"os"
)
func NewExportCmd(app Application) *cobra.Command {
var format string
var out string
cmd := &cobra.Command{
Use: "export",
Short: "Export spellbook",
Long: `WIP`,
RunE: func(cmd *cobra.Command, args []string) (err error) {
ctx := cmd.Context()
var w io.Writer = os.Stdout
if out != "" {
f, err := os.OpenFile(out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer func() {
if innerErr := f.Close(); innerErr != nil {
err = errors.Join(err, innerErr)
}
}()
w = f
}
buf := bufio.NewWriter(w)
defer func() {
if innerErr := buf.Flush(); innerErr != nil {
err = errors.Join(err, innerErr)
}
}()
return app.ExportSpells(ctx, format, buf)
},
}
cmd.Flags().StringVarP(&format, "format", "f", "jsonl", "Output format")
cmd.Flags().StringVarP(&out, "out", "o", "", "Output")
return cmd
}
+46
View File
@@ -0,0 +1,46 @@
package cli
import (
"bufio"
"errors"
_ "github.com/abelith/spellbook/internal/adapters/json"
"github.com/spf13/cobra"
"io"
"os"
)
func NewImportCmd(app Application) *cobra.Command {
var format string
var in string
cmd := &cobra.Command{
Use: "import",
Short: "Import spellbook",
Long: `WIP`,
RunE: func(cmd *cobra.Command, args []string) (err error) {
ctx := cmd.Context()
var r io.Reader = os.Stdin
if in != "" {
f, err := os.Open(in)
if err != nil {
return err
}
defer func() {
if innerErr := f.Close(); innerErr != nil {
err = errors.Join(err, innerErr)
}
}()
r = f
}
buf := bufio.NewReader(r)
return app.ImportSpells(ctx, format, buf)
},
}
cmd.Flags().StringVarP(&format, "format", "f", "jsonl", "Input format")
cmd.Flags().StringVarP(&in, "input", "i", "", "Input")
return cmd
}
+6 -6
View File
@@ -6,12 +6,12 @@ import (
)
type Spell struct {
Id int
Name string
Spell string
ShortDesc string
Description string
Tags []string
Id int `json:"-"`
Name string `json:"name,omitempty"`
Spell string `json:"spell,omitempty"`
ShortDesc string `json:"short_desc,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type SpellCast struct {