Compare commits
No commits in common. "f4497aef7e088fa0e21e96a2684ed95046c84e87" and "5fb296c17696a7a4c951726e42352e55f174694d" have entirely different histories.
f4497aef7e
...
5fb296c176
11 changed files with 3 additions and 350 deletions
16
Taskfile.yml
16
Taskfile.yml
|
|
@ -35,7 +35,6 @@ tasks:
|
||||||
internal: true
|
internal: true
|
||||||
cmds:
|
cmds:
|
||||||
- go build -o build/ ./cmd/{{.CMD}}
|
- go build -o build/ ./cmd/{{.CMD}}
|
||||||
|
|
||||||
build-convids:
|
build-convids:
|
||||||
desc: Builds the convids command
|
desc: Builds the convids command
|
||||||
sources:
|
sources:
|
||||||
|
|
@ -46,7 +45,6 @@ tasks:
|
||||||
- task: cmd-build
|
- task: cmd-build
|
||||||
vars:
|
vars:
|
||||||
CMD: convids
|
CMD: convids
|
||||||
|
|
||||||
build-cool-down:
|
build-cool-down:
|
||||||
desc: Builds the cool-down command
|
desc: Builds the cool-down command
|
||||||
source:
|
source:
|
||||||
|
|
@ -60,7 +58,6 @@ tasks:
|
||||||
- task: cmd-build
|
- task: cmd-build
|
||||||
vars:
|
vars:
|
||||||
CMD: cool-down
|
CMD: cool-down
|
||||||
|
|
||||||
build-all:
|
build-all:
|
||||||
desc: Builds all available commands
|
desc: Builds all available commands
|
||||||
sources:
|
sources:
|
||||||
|
|
@ -73,19 +70,6 @@ tasks:
|
||||||
vars:
|
vars:
|
||||||
CMD: "*"
|
CMD: "*"
|
||||||
|
|
||||||
install-mkflex:
|
|
||||||
desc: Installs the mkflex command
|
|
||||||
source:
|
|
||||||
- cmd/mkflex/**/*.go
|
|
||||||
- convids/**/*.go
|
|
||||||
- internal/cli/mkflex/**/*.go
|
|
||||||
- mkflex/**/*.go
|
|
||||||
- types/**/*.go
|
|
||||||
generates:
|
|
||||||
- '{{.BIN}}/mkflex'
|
|
||||||
cmds:
|
|
||||||
- go install ./cmd/mkflex
|
|
||||||
|
|
||||||
install-cool-down:
|
install-cool-down:
|
||||||
desc: Installs the cool-down command
|
desc: Installs the cool-down command
|
||||||
source:
|
source:
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
c "codeberg.org/danjones000/utils/cli/context"
|
|
||||||
e "codeberg.org/danjones000/utils/cli/err"
|
|
||||||
mkcli "codeberg.org/danjones000/utils/internal/cli/mkflex"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dataPath = "/home/drj/WeboNextCloud/Computer/dotfiles/bigbad/shows.yml"
|
|
||||||
const flexPath = "/home/drj/.flexget/config.yml"
|
|
||||||
const flexTemp = "/home/drj/.flexget/config.temp.yml"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx, done := c.SelfCancelingContextFromBackground()
|
|
||||||
defer done()
|
|
||||||
|
|
||||||
app, err := mkcli.NewApp(ctx, os.Args[0], os.Args[1:], dataPath, flexTemp, flexPath)
|
|
||||||
e.HandleErr(err)
|
|
||||||
|
|
||||||
err = app.Run(ctx)
|
|
||||||
e.HandleErr(err)
|
|
||||||
|
|
||||||
fmt.Println("Generated flexget config")
|
|
||||||
}
|
|
||||||
|
|
@ -18,9 +18,6 @@ func (s *Show) Match(path string) (bool, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return false, ErrNilPointer
|
return false, ErrNilPointer
|
||||||
}
|
}
|
||||||
if !s.Anime && s.Name != "" && s.Pattern == "" {
|
|
||||||
s.Pattern = strings.ReplaceAll(s.Name, " ", ".")
|
|
||||||
}
|
|
||||||
if s.Pattern != "" {
|
if s.Pattern != "" {
|
||||||
return s.matchRegexp(path)
|
return s.matchRegexp(path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
package convids
|
package convids
|
||||||
|
|
||||||
import (
|
import "regexp"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"codeberg.org/danjones000/utils/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
Config *Config
|
Config *Config
|
||||||
|
|
@ -32,13 +28,6 @@ type Show struct {
|
||||||
Url bool
|
Url bool
|
||||||
Backup string
|
Backup string
|
||||||
Sources []string
|
Sources []string
|
||||||
Flexget struct {
|
|
||||||
Name string
|
|
||||||
Begin types.IntOrString
|
|
||||||
AlternateName []string `yaml:"alternate_name"`
|
|
||||||
Exact bool
|
|
||||||
Skip bool
|
|
||||||
}
|
|
||||||
|
|
||||||
re *regexp.Regexp
|
re *regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,6 @@ func (m *Merger) getRecipes(ctx context.Context, items []Item) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
rm := make(map[int32]map[int32][]int32)
|
rm := make(map[int32]map[int32][]int32)
|
||||||
var found bool
|
var found bool
|
||||||
|
|
@ -215,11 +214,11 @@ func addRecs(recs *[][]int32, rec []int32) {
|
||||||
func recSorter(rec []int32) func([]int32) bool {
|
func recSorter(rec []int32) func([]int32) bool {
|
||||||
return func(v []int32) bool {
|
return func(v []int32) bool {
|
||||||
slices.Sort(v)
|
slices.Sort(v)
|
||||||
if len(v) != len(rec) {
|
if !(len(v) == len(rec)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for idx, i := range v {
|
for idx, i := range v {
|
||||||
if i != rec[idx] {
|
if !(i == rec[idx]) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +256,6 @@ func (m *Merger) insertGames(ctx context.Context) (mp map[int64]Game, err error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
for _, g := range m.games {
|
for _, g := range m.games {
|
||||||
res, err = stmt.ExecContext(ctx, g)
|
res, err = stmt.ExecContext(ctx, g)
|
||||||
|
|
@ -291,13 +289,11 @@ func (m *Merger) insertItems(ctx context.Context, gameID int64, items []Item) (i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
fetchIDStmt, err = m.db.PreparexContext(ctx, `SELECT id FROM items WHERE name = ?`)
|
fetchIDStmt, err = m.db.PreparexContext(ctx, `SELECT id FROM items WHERE name = ?`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer fetchIDStmt.Close()
|
defer fetchIDStmt.Close()
|
||||||
relStmt, err = m.db.PreparexContext(ctx, `
|
relStmt, err = m.db.PreparexContext(ctx, `
|
||||||
INSERT INTO games_items
|
INSERT INTO games_items
|
||||||
|
|
@ -307,7 +303,6 @@ func (m *Merger) insertItems(ctx context.Context, gameID int64, items []Item) (i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer relStmt.Close()
|
defer relStmt.Close()
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
_, err = stmt.ExecContext(ctx, it)
|
_, err = stmt.ExecContext(ctx, it)
|
||||||
|
|
@ -339,7 +334,6 @@ func (m *Merger) insertRecipes(ctx context.Context, gameID, itemID int64, recipe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer insertStmt.Close()
|
defer insertStmt.Close()
|
||||||
insertRecItemStmt, err = m.db.PreparexContext(ctx, `
|
insertRecItemStmt, err = m.db.PreparexContext(ctx, `
|
||||||
INSERT INTO recipes_items
|
INSERT INTO recipes_items
|
||||||
|
|
@ -349,7 +343,6 @@ func (m *Merger) insertRecipes(ctx context.Context, gameID, itemID int64, recipe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer insertRecItemStmt.Close()
|
defer insertRecItemStmt.Close()
|
||||||
selStmt, err = m.db.PreparexContext(ctx, `
|
selStmt, err = m.db.PreparexContext(ctx, `
|
||||||
SELECT item_id FROM games_items
|
SELECT item_id FROM games_items
|
||||||
|
|
@ -358,7 +351,6 @@ func (m *Merger) insertRecipes(ctx context.Context, gameID, itemID int64, recipe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer selStmt.Close()
|
defer selStmt.Close()
|
||||||
for _, recipe := range recipes {
|
for _, recipe := range recipes {
|
||||||
var recipeID int64
|
var recipeID int64
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ func NewMerger(args []string) (*Merger, error) {
|
||||||
}
|
}
|
||||||
fs := make([]*os.File, len(args))
|
fs := make([]*os.File, len(args))
|
||||||
for idx, pth := range args {
|
for idx, pth := range args {
|
||||||
//nolint:gosec // Yes, of course
|
|
||||||
fs[idx], err = os.Open(pth)
|
fs[idx], err = os.Open(pth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,12 @@ func (m *Merger) Write(ctx context.Context, g Game) (out string, err error) {
|
||||||
out = base + ".ic"
|
out = base + ".ic"
|
||||||
|
|
||||||
var f *os.File
|
var f *os.File
|
||||||
//nolint:gosec // Yes, of course I'm getting the file from user input
|
|
||||||
if f, err = os.Create(out); err != nil {
|
if f, err = os.Create(out); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
gz := gzip.NewWriter(f)
|
gz := gzip.NewWriter(f)
|
||||||
//nolint:errcheck // I don't care
|
|
||||||
defer gz.Close()
|
defer gz.Close()
|
||||||
gz.Name = base
|
gz.Name = base
|
||||||
gz.ModTime = time.Unix(g.Updated/1000, g.Updated%1000)
|
gz.ModTime = time.Unix(g.Updated/1000, g.Updated%1000)
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
package mkflex
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
conutils "codeberg.org/danjones000/utils/convids"
|
|
||||||
mkutils "codeberg.org/danjones000/utils/mkflex"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewApp(ctx context.Context, name string, args []string, dataPath, templatePath, outPath string) (*App, error) {
|
|
||||||
var err error
|
|
||||||
a := App{
|
|
||||||
Name: name,
|
|
||||||
Path: outPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
a.Data, err = conutils.NewData(dataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.Config, err = mkutils.NewConfig(templatePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
Name string
|
|
||||||
Data *conutils.Data
|
|
||||||
Path string
|
|
||||||
Config *mkutils.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Run(ctx context.Context) error {
|
|
||||||
out, err := os.Create(a.Path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
enc := yaml.NewEncoder(out)
|
|
||||||
enc.SetIndent(2)
|
|
||||||
|
|
||||||
if err := mkutils.AddShows(a.Config, a.Data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
delete(a.Config.Templates, "x-aliases")
|
|
||||||
|
|
||||||
return enc.Encode(a.Config)
|
|
||||||
}
|
|
||||||
142
mkflex/logic.go
142
mkflex/logic.go
|
|
@ -1,142 +0,0 @@
|
||||||
package mkflex
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
conutils "codeberg.org/danjones000/utils/convids"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrMissingAliases = errors.New("missing aliases")
|
|
||||||
var ErrMissingShowGrp = errors.New("missing show group")
|
|
||||||
|
|
||||||
func NewConfig(path string) (*Config, error) {
|
|
||||||
//nolint:gosec // I don't care if this is user input
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ydec := yaml.NewDecoder(f)
|
|
||||||
var conf Config
|
|
||||||
err = ydec.Decode(&conf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &conf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddShows(conf *Config, data *conutils.Data) error {
|
|
||||||
aliases, ok := conf.Templates["x-aliases"]
|
|
||||||
reg, anime, err := getAliases(aliases, ok)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
nonEng := (*data.Shows)["non-eng"]
|
|
||||||
aniShow := (*data.Shows)["anime"]
|
|
||||||
|
|
||||||
dor, err := getDorama(nonEng, reg)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
conf.Templates["dorama"] = Template{Series: dor}
|
|
||||||
|
|
||||||
aniSer, err := getAnime(aniShow, anime)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
conf.Templates["anime"] = Template{Series: aniSer}
|
|
||||||
all, err := getAll(data, reg)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
conf.Templates["shows"] = Template{Series: all}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAliases(aliases Template, ok bool) (reg, anime *Series, err error) {
|
|
||||||
if !ok {
|
|
||||||
err = ErrMissingAliases
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, alias := range aliases.Series {
|
|
||||||
if rF, ok := alias["x-regular"]; ok {
|
|
||||||
reg = &rF
|
|
||||||
}
|
|
||||||
if rA, ok := alias["x-anime"]; ok {
|
|
||||||
anime = &rA
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if reg == nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf("%w: %s", ErrMissingAliases, "x-regular"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if anime == nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf("%w: %s", ErrMissingAliases, "x-anime"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func getShow(show conutils.Show, sh Series) SeriesGroups {
|
|
||||||
if !show.Flexget.Begin.IsZero() {
|
|
||||||
sh.Begin = show.Flexget.Begin
|
|
||||||
}
|
|
||||||
sh.AlternameName = show.Flexget.AlternateName
|
|
||||||
sh.Exact = show.Flexget.Exact
|
|
||||||
name := cmp.Or(show.Flexget.Name, show.Name, show.Pattern)
|
|
||||||
return SeriesGroups{name: sh}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDorama(nonEng *conutils.Shows, tmp *Series) ([]SeriesGroups, error) {
|
|
||||||
if len(*nonEng) == 0 {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrMissingShowGrp, "non-eng")
|
|
||||||
}
|
|
||||||
grp := make([]SeriesGroups, 0, len(*nonEng))
|
|
||||||
|
|
||||||
for _, show := range *nonEng {
|
|
||||||
if !show.Flexget.Skip {
|
|
||||||
grp = append(grp, getShow(*show, *tmp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAnime(anime *conutils.Shows, tmp *Series) ([]SeriesGroups, error) {
|
|
||||||
grp := make([]SeriesGroups, 0, len(*anime))
|
|
||||||
|
|
||||||
for _, show := range *anime {
|
|
||||||
if !show.Flexget.Skip {
|
|
||||||
grp = append(grp, getShow(*show, *tmp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAll(data *conutils.Data, tmp *Series) ([]SeriesGroups, error) {
|
|
||||||
grp := make([]SeriesGroups, 0, 100)
|
|
||||||
|
|
||||||
for _, group := range data.Config.Groups {
|
|
||||||
shGrp, ok := (*data.Shows)[group]
|
|
||||||
if group == "old" || shGrp == nil || !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, show := range *shGrp {
|
|
||||||
if !show.Flexget.Skip && !show.Anime {
|
|
||||||
grp = append(grp, getShow(*show, *tmp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grp, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
package mkflex
|
|
||||||
|
|
||||||
import "codeberg.org/danjones000/utils/types"
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Templates map[string]Template `yaml:"templates"`
|
|
||||||
Tasks map[string]any `yaml:"tasks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Template struct {
|
|
||||||
Series []SeriesGroups `yaml:"series,omitempty"`
|
|
||||||
Aria2 map[string]any `yaml:"aria2,omitempty"`
|
|
||||||
ConMag map[string]any `yaml:"convert_magnet,omitempty"`
|
|
||||||
Download string `yaml:"download,omitempty"`
|
|
||||||
Notify map[string]any `yaml:"notify,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SeriesGroups map[string]Series
|
|
||||||
|
|
||||||
type Identifier string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Sequence Identifier = "sequence"
|
|
||||||
Episode Identifier = "ep"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Series struct {
|
|
||||||
Quality string `yaml:"quality,omitempty"`
|
|
||||||
IdentifiedBy Identifier `yaml:"identified_by,omitempty"`
|
|
||||||
Begin types.IntOrString `yaml:"begin,omitempty"`
|
|
||||||
Exact bool `yaml:"exact,omitempty"`
|
|
||||||
AlternameName []string `yaml:"alternate_name,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Aria struct {
|
|
||||||
Path string `yaml:"path"`
|
|
||||||
Server string `yaml:"server"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package types
|
|
||||||
|
|
||||||
import "gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
type YamlTypeError struct {
|
|
||||||
Node *yaml.Node
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ye *YamlTypeError) Error() string {
|
|
||||||
return ye.Node.Tag + " is not a valid type for this field"
|
|
||||||
}
|
|
||||||
|
|
||||||
type IntOrString struct {
|
|
||||||
intVal int
|
|
||||||
strVal string
|
|
||||||
isInt bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (is IntOrString) MarshalYAML() (any, error) {
|
|
||||||
if is.isInt {
|
|
||||||
return is.intVal, nil
|
|
||||||
}
|
|
||||||
return is.strVal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (is *IntOrString) UnmarshalYAML(value *yaml.Node) error {
|
|
||||||
if value.Tag == `!!int` {
|
|
||||||
is.isInt = true
|
|
||||||
return value.Decode(&is.intVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.Tag == `!!str` {
|
|
||||||
is.isInt = false
|
|
||||||
return value.Decode(&is.strVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &YamlTypeError{Node: value}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (is IntOrString) IsZero() bool {
|
|
||||||
if is.isInt {
|
|
||||||
return is.intVal == 0
|
|
||||||
}
|
|
||||||
return is.strVal == ""
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue