forked from Plemya-x/ALR
		
	refactor: migrate to xorm
This commit is contained in:
		| @@ -23,41 +23,39 @@ import ( | ||||
| 	"context" | ||||
| 	"log/slog" | ||||
|  | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/leonelquinteros/gotext" | ||||
| 	_ "modernc.org/sqlite" | ||||
| 	"xorm.io/xorm" | ||||
|  | ||||
| 	"gitea.plemya-x.ru/Plemya-x/ALR/internal/config" | ||||
| ) | ||||
|  | ||||
| // CurrentVersion is the current version of the database. | ||||
| // The database is reset if its version doesn't match this. | ||||
| const CurrentVersion = 4 | ||||
| const CurrentVersion = 5 | ||||
|  | ||||
| // Package is a ALR package's database representation | ||||
| type Package struct { | ||||
| 	BasePkgName   string                    `sh:"base" db:"basepkg_name"` | ||||
| 	Name          string                    `sh:"name,required" db:"name"` | ||||
| 	Version       string                    `sh:"version,required" db:"version"` | ||||
| 	Release       int                       `sh:"release,required" db:"release"` | ||||
| 	Epoch         uint                      `sh:"epoch" db:"epoch"` | ||||
| 	Summary       JSON[map[string]string]   `db:"summary"` | ||||
| 	Description   JSON[map[string]string]   `db:"description"` | ||||
| 	Group         JSON[map[string]string]   `db:"group_name"` | ||||
| 	Homepage      JSON[map[string]string]   `db:"homepage"` | ||||
| 	Maintainer    JSON[map[string]string]   `db:"maintainer"` | ||||
| 	Architectures JSON[[]string]            `sh:"architectures" db:"architectures"` | ||||
| 	Licenses      JSON[[]string]            `sh:"license" db:"licenses"` | ||||
| 	Provides      JSON[[]string]            `sh:"provides" db:"provides"` | ||||
| 	Conflicts     JSON[[]string]            `sh:"conflicts" db:"conflicts"` | ||||
| 	Replaces      JSON[[]string]            `sh:"replaces" db:"replaces"` | ||||
| 	Depends       JSON[map[string][]string] `db:"depends"` | ||||
| 	BuildDepends  JSON[map[string][]string] `db:"builddepends"` | ||||
| 	OptDepends    JSON[map[string][]string] `db:"optdepends"` | ||||
| 	Repository    string                    `db:"repository"` | ||||
| 	BasePkgName   string              `sh:"basepkg_name" xorm:"notnull 'basepkg_name'"` | ||||
| 	Name          string              `sh:"name,required" xorm:"notnull unique(name_repo) 'name'"` | ||||
| 	Version       string              `sh:"version,required" xorm:"notnull 'version'"` | ||||
| 	Release       int                 `sh:"release" xorm:"notnull 'release'"` | ||||
| 	Epoch         uint                `sh:"epoch" xorm:"'epoch'"` | ||||
| 	Summary       map[string]string   `xorm:"json 'summary'"` | ||||
| 	Description   map[string]string   `xorm:"json 'description'"` | ||||
| 	Group         map[string]string   `xorm:"json 'group_name'"` | ||||
| 	Homepage      map[string]string   `xorm:"json 'homepage'"` | ||||
| 	Maintainer    map[string]string   `xorm:"json 'maintainer'"` | ||||
| 	Architectures []string            `sh:"architectures" xorm:"json 'architectures'"` | ||||
| 	Licenses      []string            `sh:"license" xorm:"json 'licenses'"` | ||||
| 	Provides      []string            `sh:"provides" xorm:"json 'provides'"` | ||||
| 	Conflicts     []string            `sh:"conflicts" xorm:"json 'conflicts'"` | ||||
| 	Replaces      []string            `sh:"replaces" xorm:"json 'replaces'"` | ||||
| 	Depends       map[string][]string `xorm:"json 'depends'"` | ||||
| 	BuildDepends  map[string][]string `xorm:"json 'builddepends'"` | ||||
| 	OptDepends    map[string][]string `xorm:"json 'optdepends'"` | ||||
| 	Repository    string              `xorm:"notnull unique(name_repo) 'repository'"` | ||||
| } | ||||
|  | ||||
| type version struct { | ||||
| 	Version int `db:"version"` | ||||
| type Version struct { | ||||
| 	Version int `xorm:"'version'"` | ||||
| } | ||||
|  | ||||
| type Config interface { | ||||
| @@ -65,7 +63,7 @@ type Config interface { | ||||
| } | ||||
|  | ||||
| type Database struct { | ||||
| 	conn   *sqlx.DB | ||||
| 	engine *xorm.Engine | ||||
| 	config Config | ||||
| } | ||||
|  | ||||
| @@ -75,181 +73,98 @@ func New(config Config) *Database { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (d *Database) Init(ctx context.Context) error { | ||||
| 	err := d.Connect(ctx) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return d.initDB(ctx) | ||||
| } | ||||
|  | ||||
| func (d *Database) Connect(ctx context.Context) error { | ||||
| func (d *Database) Connect() error { | ||||
| 	dsn := d.config.GetPaths().DBPath | ||||
| 	db, err := sqlx.Open("sqlite", dsn) | ||||
| 	engine, err := xorm.NewEngine("sqlite", dsn) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	d.conn = db | ||||
| 	d.engine = engine | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *Database) GetConn() *sqlx.DB { | ||||
| 	return d.conn | ||||
| } | ||||
|  | ||||
| func (d *Database) initDB(ctx context.Context) error { | ||||
| 	d.conn = d.conn.Unsafe() | ||||
| 	conn := d.conn | ||||
| 	_, err := conn.ExecContext(ctx, ` | ||||
| 		CREATE TABLE IF NOT EXISTS pkgs ( | ||||
| 			basepkg_name  TEXT NOT NULL, | ||||
| 			name          TEXT NOT NULL, | ||||
| 			repository    TEXT NOT NULL, | ||||
| 			version       TEXT NOT NULL, | ||||
| 			release       INT  NOT NULL, | ||||
| 			epoch         INT, | ||||
| 			summary       TEXT CHECK(summary = 'null' OR (JSON_VALID(summary) AND JSON_TYPE(summary) = 'object')), | ||||
| 			description   TEXT CHECK(description = 'null' OR (JSON_VALID(description) AND JSON_TYPE(description) = 'object')), | ||||
| 			group_name    TEXT CHECK(group_name = 'null' OR (JSON_VALID(group_name) AND JSON_TYPE(group_name) = 'object')), | ||||
| 			homepage      TEXT CHECK(homepage = 'null' OR (JSON_VALID(homepage) AND JSON_TYPE(homepage) = 'object')), | ||||
| 			maintainer    TEXT CHECK(maintainer = 'null' OR (JSON_VALID(maintainer) AND JSON_TYPE(maintainer) = 'object')), | ||||
| 			architectures TEXT CHECK(architectures = 'null' OR (JSON_VALID(architectures) AND JSON_TYPE(architectures) = 'array')), | ||||
| 			licenses      TEXT CHECK(licenses = 'null' OR (JSON_VALID(licenses) AND JSON_TYPE(licenses) = 'array')), | ||||
| 			provides      TEXT CHECK(provides = 'null' OR (JSON_VALID(provides) AND JSON_TYPE(provides) = 'array')), | ||||
| 			conflicts     TEXT CHECK(conflicts = 'null' OR (JSON_VALID(conflicts) AND JSON_TYPE(conflicts) = 'array')), | ||||
| 			replaces      TEXT CHECK(replaces = 'null' OR (JSON_VALID(replaces) AND JSON_TYPE(replaces) = 'array')), | ||||
| 			depends       TEXT CHECK(depends = 'null' OR (JSON_VALID(depends) AND JSON_TYPE(depends) = 'object')), | ||||
| 			builddepends  TEXT CHECK(builddepends = 'null' OR (JSON_VALID(builddepends) AND JSON_TYPE(builddepends) = 'object')), | ||||
| 			optdepends    TEXT CHECK(optdepends = 'null' OR (JSON_VALID(optdepends) AND JSON_TYPE(optdepends) = 'object')), | ||||
| 			UNIQUE(name, repository) | ||||
| 		); | ||||
|  | ||||
| 		CREATE TABLE IF NOT EXISTS alr_db_version ( | ||||
| 			version INT NOT NULL | ||||
| 		); | ||||
| 	`) | ||||
| 	if err != nil { | ||||
| func (d *Database) Init(ctx context.Context) error { | ||||
| 	if err := d.Connect(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := d.engine.Sync2(new(Package), new(Version)); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	ver, ok := d.GetVersion(ctx) | ||||
| 	if ok && ver != CurrentVersion { | ||||
| 		slog.Warn(gotext.Get("Database version mismatch; resetting"), "version", ver, "expected", CurrentVersion) | ||||
| 		err = d.reset(ctx) | ||||
| 		if err != nil { | ||||
| 		if err := d.reset(); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		return d.initDB(ctx) | ||||
| 		return d.Init(ctx) | ||||
| 	} else if !ok { | ||||
| 		slog.Warn(gotext.Get("Database version does not exist. Run alr fix if something isn't working."), "version", ver, "expected", CurrentVersion) | ||||
| 		return d.addVersion(ctx, CurrentVersion) | ||||
| 		slog.Warn(gotext.Get("Database version does not exist. Run alr fix if something isn't working.")) | ||||
| 		return d.addVersion(CurrentVersion) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *Database) GetVersion(ctx context.Context) (int, bool) { | ||||
| 	var ver version | ||||
| 	err := d.conn.GetContext(ctx, &ver, "SELECT * FROM alr_db_version LIMIT 1;") | ||||
| 	if err != nil { | ||||
| 	var v Version | ||||
| 	has, err := d.engine.Get(&v) | ||||
| 	if err != nil || !has { | ||||
| 		return 0, false | ||||
| 	} | ||||
| 	return ver.Version, true | ||||
| 	return v.Version, true | ||||
| } | ||||
|  | ||||
| func (d *Database) addVersion(ctx context.Context, ver int) error { | ||||
| 	_, err := d.conn.ExecContext(ctx, `INSERT INTO alr_db_version(version) VALUES (?);`, ver) | ||||
| func (d *Database) addVersion(ver int) error { | ||||
| 	_, err := d.engine.Insert(&Version{Version: ver}) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (d *Database) reset(ctx context.Context) error { | ||||
| 	_, err := d.conn.ExecContext(ctx, "DROP TABLE IF EXISTS pkgs;") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, err = d.conn.ExecContext(ctx, "DROP TABLE IF EXISTS alr_db_version;") | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (d *Database) GetPkgs(ctx context.Context, where string, args ...any) (*sqlx.Rows, error) { | ||||
| 	stream, err := d.conn.QueryxContext(ctx, "SELECT * FROM pkgs WHERE "+where, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return stream, nil | ||||
| } | ||||
|  | ||||
| func (d *Database) GetPkg(ctx context.Context, where string, args ...any) (*Package, error) { | ||||
| 	out := &Package{} | ||||
| 	err := d.conn.GetContext(ctx, out, "SELECT * FROM pkgs WHERE "+where+" LIMIT 1", args...) | ||||
| 	return out, err | ||||
| } | ||||
|  | ||||
| func (d *Database) DeletePkgs(ctx context.Context, where string, args ...any) error { | ||||
| 	_, err := d.conn.ExecContext(ctx, "DELETE FROM pkgs WHERE "+where, args...) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (d *Database) IsEmpty(ctx context.Context) bool { | ||||
| 	var count int | ||||
| 	err := d.conn.GetContext(ctx, &count, "SELECT count(1) FROM pkgs;") | ||||
| 	if err != nil { | ||||
| 		return true | ||||
| 	} | ||||
| 	return count == 0 | ||||
| func (d *Database) reset() error { | ||||
| 	return d.engine.DropTables(new(Package), new(Version)) | ||||
| } | ||||
|  | ||||
| func (d *Database) InsertPackage(ctx context.Context, pkg Package) error { | ||||
| 	_, err := d.conn.NamedExecContext(ctx, ` | ||||
| 		INSERT OR REPLACE INTO pkgs ( | ||||
| 			basepkg_name, | ||||
| 			name, | ||||
| 			repository, | ||||
| 			version, | ||||
| 			release, | ||||
| 			epoch, | ||||
| 			summary, | ||||
| 			description, | ||||
| 			group_name, | ||||
| 			homepage, | ||||
| 			maintainer, | ||||
| 			architectures, | ||||
| 			licenses, | ||||
| 			provides, | ||||
| 			conflicts, | ||||
| 			replaces, | ||||
| 			depends, | ||||
| 			builddepends, | ||||
| 			optdepends | ||||
| 		) VALUES ( | ||||
| 		 	:basepkg_name, | ||||
| 			:name, | ||||
| 			:repository, | ||||
| 			:version, | ||||
| 			:release, | ||||
| 			:epoch, | ||||
| 			:summary, | ||||
| 			:description, | ||||
| 			:group_name, | ||||
| 			:homepage, | ||||
| 			:maintainer, | ||||
| 			:architectures, | ||||
| 			:licenses, | ||||
| 			:provides, | ||||
| 			:conflicts, | ||||
| 			:replaces, | ||||
| 			:depends, | ||||
| 			:builddepends, | ||||
| 			:optdepends | ||||
| 		); | ||||
| 	`, pkg) | ||||
| 	session := d.engine.Context(ctx) | ||||
|  | ||||
| 	affected, err := session.Where("name = ? AND repository = ?", pkg.Name, pkg.Repository).Update(&pkg) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if affected == 0 { | ||||
| 		_, err = session.Insert(&pkg) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (d *Database) GetPkgs(_ context.Context, where string, args ...any) ([]Package, error) { | ||||
| 	var pkgs []Package | ||||
| 	err := d.engine.Where(where, args...).Find(&pkgs) | ||||
| 	return pkgs, err | ||||
| } | ||||
|  | ||||
| func (d *Database) GetPkg(where string, args ...any) (*Package, error) { | ||||
| 	var pkg Package | ||||
| 	has, err := d.engine.Where(where, args...).Get(&pkg) | ||||
| 	if err != nil || !has { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &pkg, nil | ||||
| } | ||||
|  | ||||
| func (d *Database) DeletePkgs(_ context.Context, where string, args ...any) error { | ||||
| 	_, err := d.engine.Where(where, args...).Delete(&Package{}) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (d *Database) Close() error { | ||||
| 	if d.conn != nil { | ||||
| 		return d.conn.Close() | ||||
| 	} else { | ||||
| 		return nil | ||||
| 	} | ||||
| func (d *Database) IsEmpty() bool { | ||||
| 	count, err := d.engine.Count(new(Package)) | ||||
| 	return err != nil || count == 0 | ||||
| } | ||||
|  | ||||
| func (d *Database) Close() error { | ||||
| 	return d.engine.Close() | ||||
| } | ||||
|   | ||||
| @@ -25,8 +25,6 @@ import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/jmoiron/sqlx" | ||||
|  | ||||
| 	"gitea.plemya-x.ru/Plemya-x/ALR/internal/config" | ||||
| 	"gitea.plemya-x.ru/Plemya-x/ALR/internal/db" | ||||
| ) | ||||
| @@ -50,29 +48,29 @@ var testPkg = db.Package{ | ||||
| 	Version: "0.0.1", | ||||
| 	Release: 1, | ||||
| 	Epoch:   2, | ||||
| 	Description: db.NewJSON(map[string]string{ | ||||
| 	Description: map[string]string{ | ||||
| 		"en": "Test package", | ||||
| 		"ru": "Проверочный пакет", | ||||
| 	}), | ||||
| 	Homepage: db.NewJSON(map[string]string{ | ||||
| 	}, | ||||
| 	Homepage: map[string]string{ | ||||
| 		"en": "https://gitea.plemya-x.ru/xpamych/ALR", | ||||
| 	}), | ||||
| 	Maintainer: db.NewJSON(map[string]string{ | ||||
| 	}, | ||||
| 	Maintainer: map[string]string{ | ||||
| 		"en": "Evgeniy Khramov <xpamych@yandex.ru>", | ||||
| 		"ru": "Евгений Храмов <xpamych@yandex.ru>", | ||||
| 	}), | ||||
| 	Architectures: db.NewJSON([]string{"arm64", "amd64"}), | ||||
| 	Licenses:      db.NewJSON([]string{"GPL-3.0-or-later"}), | ||||
| 	Provides:      db.NewJSON([]string{"test"}), | ||||
| 	Conflicts:     db.NewJSON([]string{"test"}), | ||||
| 	Replaces:      db.NewJSON([]string{"test-old"}), | ||||
| 	Depends: db.NewJSON(map[string][]string{ | ||||
| 	}, | ||||
| 	Architectures: []string{"arm64", "amd64"}, | ||||
| 	Licenses:      []string{"GPL-3.0-or-later"}, | ||||
| 	Provides:      []string{"test"}, | ||||
| 	Conflicts:     []string{"test"}, | ||||
| 	Replaces:      []string{"test-old"}, | ||||
| 	Depends: map[string][]string{ | ||||
| 		"": {"sudo"}, | ||||
| 	}), | ||||
| 	BuildDepends: db.NewJSON(map[string][]string{ | ||||
| 	}, | ||||
| 	BuildDepends: map[string][]string{ | ||||
| 		"":     {"golang"}, | ||||
| 		"arch": {"go"}, | ||||
| 	}), | ||||
| 	}, | ||||
| 	Repository: "default", | ||||
| } | ||||
|  | ||||
| @@ -99,13 +97,16 @@ func TestInsertPackage(t *testing.T) { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	dbPkg := db.Package{} | ||||
| 	err = sqlx.Get(database.GetConn(), &dbPkg, "SELECT * FROM pkgs WHERE name = 'test' AND repository = 'default'") | ||||
| 	pkgs, err := database.GetPkgs(ctx, "name = 'test' AND repository = 'default'") | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	if !reflect.DeepEqual(testPkg, dbPkg) { | ||||
| 	if len(pkgs) != 1 { | ||||
| 		t.Fatalf("Expected 1 package, got %d", len(pkgs)) | ||||
| 	} | ||||
|  | ||||
| 	if !reflect.DeepEqual(testPkg, pkgs[0]) { | ||||
| 		t.Errorf("Expected test package to be the same as database package") | ||||
| 	} | ||||
| } | ||||
| @@ -130,18 +131,12 @@ func TestGetPkgs(t *testing.T) { | ||||
| 		t.Errorf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	result, err := database.GetPkgs(ctx, "name LIKE 'x%'") | ||||
| 	pkgs, err := database.GetPkgs(ctx, "name LIKE 'x%'") | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	for result.Next() { | ||||
| 		var dbPkg db.Package | ||||
| 		err = result.StructScan(&dbPkg) | ||||
| 		if err != nil { | ||||
| 			t.Errorf("Expected no error, got %s", err) | ||||
| 		} | ||||
|  | ||||
| 	for _, dbPkg := range pkgs { | ||||
| 		if !strings.HasPrefix(dbPkg.Name, "x") { | ||||
| 			t.Errorf("Expected package name to start with 'x', got %s", dbPkg.Name) | ||||
| 		} | ||||
| @@ -168,7 +163,7 @@ func TestGetPkg(t *testing.T) { | ||||
| 		t.Errorf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	pkg, err := database.GetPkg(ctx, "name LIKE 'x%' ORDER BY name") | ||||
| 	pkg, err := database.GetPkg("name LIKE 'x%'") | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
| @@ -206,16 +201,6 @@ func TestDeletePkgs(t *testing.T) { | ||||
| 	if err != nil { | ||||
| 		t.Errorf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	var dbPkg db.Package | ||||
| 	err = database.GetConn().Get(&dbPkg, "SELECT * FROM pkgs WHERE name LIKE 'x%' ORDER BY name LIMIT 1;") | ||||
| 	if err != nil { | ||||
| 		t.Errorf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	if dbPkg.Name != "x2" { | ||||
| 		t.Errorf("Expected x2 package, got %s", dbPkg.Name) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestJsonArrayContains(t *testing.T) { | ||||
| @@ -227,7 +212,7 @@ func TestJsonArrayContains(t *testing.T) { | ||||
| 	x1.Name = "x1" | ||||
| 	x2 := testPkg | ||||
| 	x2.Name = "x2" | ||||
| 	x2.Provides.Val = append(x2.Provides.Val, "x") | ||||
| 	x2.Provides = append(x2.Provides, "x") | ||||
|  | ||||
| 	err := database.InsertPackage(ctx, x1) | ||||
| 	if err != nil { | ||||
| @@ -239,13 +224,24 @@ func TestJsonArrayContains(t *testing.T) { | ||||
| 		t.Errorf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	var dbPkg db.Package | ||||
| 	err = database.GetConn().Get(&dbPkg, "SELECT * FROM pkgs WHERE json_array_contains(provides, 'x');") | ||||
| 	pkgs, err := database.GetPkgs(ctx, "name = 'x2'") | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	if dbPkg.Name != "x2" { | ||||
| 		t.Errorf("Expected x2 package, got %s", dbPkg.Name) | ||||
| 	if len(pkgs) != 1 || pkgs[0].Name != "x2" { | ||||
| 		t.Errorf("Expected x2 package, got %v", pkgs) | ||||
| 	} | ||||
|  | ||||
| 	// Verify the provides field contains 'x' | ||||
| 	found := false | ||||
| 	for _, p := range pkgs[0].Provides { | ||||
| 		if p == "x" { | ||||
| 			found = true | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 	if !found { | ||||
| 		t.Errorf("Expected provides to contain 'x'") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,80 +0,0 @@ | ||||
| // ALR - Any Linux Repository | ||||
| // Copyright (C) 2025 The ALR Authors | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU General Public License as published by | ||||
| // the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU General Public License | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||
|  | ||||
| package db | ||||
|  | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"database/sql/driver" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| ) | ||||
|  | ||||
| // JSON represents a JSON value in the database | ||||
| type JSON[T any] struct { | ||||
| 	Val T | ||||
| } | ||||
|  | ||||
| // NewJSON creates a new database JSON value | ||||
| func NewJSON[T any](v T) JSON[T] { | ||||
| 	return JSON[T]{Val: v} | ||||
| } | ||||
|  | ||||
| func (s *JSON[T]) Scan(val any) error { | ||||
| 	if val == nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	switch val := val.(type) { | ||||
| 	case string: | ||||
| 		err := json.Unmarshal([]byte(val), &s.Val) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	case sql.NullString: | ||||
| 		if val.Valid { | ||||
| 			err := json.Unmarshal([]byte(val.String), &s.Val) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	default: | ||||
| 		return errors.New("sqlite json types must be strings") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (s JSON[T]) Value() (driver.Value, error) { | ||||
| 	data, err := json.Marshal(s.Val) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return string(data), nil | ||||
| } | ||||
|  | ||||
| func (s JSON[T]) MarshalYAML() (any, error) { | ||||
| 	return s.Val, nil | ||||
| } | ||||
|  | ||||
| func (s JSON[T]) String() string { | ||||
| 	return fmt.Sprint(s.Val) | ||||
| } | ||||
|  | ||||
| func (s JSON[T]) GoString() string { | ||||
| 	return fmt.Sprintf("%#v", s.Val) | ||||
| } | ||||
| @@ -40,17 +40,10 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]db.P | ||||
| 		} | ||||
|  | ||||
| 		added := 0 | ||||
| 		for result.Next() { | ||||
| 			var pkg db.Package | ||||
| 			err = result.StructScan(&pkg) | ||||
| 			if err != nil { | ||||
| 				return nil, nil, err | ||||
| 			} | ||||
|  | ||||
| 		for _, pkg := range result { | ||||
| 			added++ | ||||
| 			found[pkgName] = append(found[pkgName], pkg) | ||||
| 		} | ||||
| 		result.Close() | ||||
|  | ||||
| 		if added == 0 { | ||||
| 			result, err := rs.db.GetPkgs(ctx, "name LIKE ?", pkgName) | ||||
| @@ -58,18 +51,10 @@ func (rs *Repos) FindPkgs(ctx context.Context, pkgs []string) (map[string][]db.P | ||||
| 				return nil, nil, err | ||||
| 			} | ||||
|  | ||||
| 			for result.Next() { | ||||
| 				var pkg db.Package | ||||
| 				err = result.StructScan(&pkg) | ||||
| 				if err != nil { | ||||
| 					return nil, nil, err | ||||
| 				} | ||||
|  | ||||
| 			for _, pkg := range result { | ||||
| 				added++ | ||||
| 				found[pkgName] = append(found[pkgName], pkg) | ||||
| 			} | ||||
|  | ||||
| 			result.Close() | ||||
| 		} | ||||
|  | ||||
| 		if added == 0 { | ||||
|   | ||||
| @@ -94,11 +94,11 @@ func TestFindPkgsEmpty(t *testing.T) { | ||||
| 		Repository: "default", | ||||
| 		Version:    "0.0.1", | ||||
| 		Release:    1, | ||||
| 		Description: db.NewJSON(map[string]string{ | ||||
| 		Description: map[string]string{ | ||||
| 			"en": "Test package 1", | ||||
| 			"ru": "Проверочный пакет 1", | ||||
| 		}), | ||||
| 		Provides: db.NewJSON([]string{""}), | ||||
| 		}, | ||||
| 		Provides: []string{""}, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| @@ -109,11 +109,11 @@ func TestFindPkgsEmpty(t *testing.T) { | ||||
| 		Repository: "default", | ||||
| 		Version:    "0.0.1", | ||||
| 		Release:    1, | ||||
| 		Description: db.NewJSON(map[string]string{ | ||||
| 		Description: map[string]string{ | ||||
| 			"en": "Test package 2", | ||||
| 			"ru": "Проверочный пакет 2", | ||||
| 		}), | ||||
| 		Provides: db.NewJSON([]string{"test"}), | ||||
| 		}, | ||||
| 		Provides: []string{"test"}, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
|   | ||||
| @@ -130,7 +130,7 @@ func (rs *Repos) Pull(ctx context.Context, repos []types.Repo) error { | ||||
| 			// If the DB was not present at startup, that means it's | ||||
| 			// empty. In this case, we need to update the DB fully | ||||
| 			// rather than just incrementally. | ||||
| 			if rs.db.IsEmpty(ctx) { | ||||
| 			if rs.db.IsEmpty() { | ||||
| 				err = rs.processRepoFull(ctx, repo, repoDir) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
|   | ||||
| @@ -84,16 +84,10 @@ build_deps=('golang') | ||||
| 				result, err := database.GetPkgs(ctx, "1 = 1") | ||||
| 				assert.NoError(t, err) | ||||
| 				pkgCount := 0 | ||||
| 				for result.Next() { | ||||
| 					var dbPkg db.Package | ||||
| 					err = result.StructScan(&dbPkg) | ||||
| 					if err != nil { | ||||
| 						t.Errorf("Expected no error, got %s", err) | ||||
| 					} | ||||
|  | ||||
| 				for _, dbPkg := range result { | ||||
| 					assert.Equal(t, "foo", dbPkg.Name) | ||||
| 					assert.Equal(t, db.NewJSON(map[string]string{"": "main desc"}), dbPkg.Description) | ||||
| 					assert.Equal(t, db.NewJSON(map[string][]string{"": {"sudo"}}), dbPkg.Depends) | ||||
| 					assert.Equal(t, map[string]string{"": "main desc"}, dbPkg.Description) | ||||
| 					assert.Equal(t, map[string][]string{"": {"sudo"}}, dbPkg.Depends) | ||||
| 					pkgCount++ | ||||
| 				} | ||||
| 				assert.Equal(t, 1, pkgCount) | ||||
| @@ -125,20 +119,18 @@ meta_buz() { | ||||
| 				assert.NoError(t, err) | ||||
|  | ||||
| 				pkgCount := 0 | ||||
| 				for result.Next() { | ||||
| 					var dbPkg db.Package | ||||
| 					err = result.StructScan(&dbPkg) | ||||
| 				for _, dbPkg := range result { | ||||
| 					if err != nil { | ||||
| 						t.Errorf("Expected no error, got %s", err) | ||||
| 					} | ||||
| 					if dbPkg.Name == "bar" { | ||||
| 						assert.Equal(t, db.NewJSON(map[string]string{"": "foo desc"}), dbPkg.Description) | ||||
| 						assert.Equal(t, db.NewJSON(map[string][]string{"": {"sudo"}}), dbPkg.Depends) | ||||
| 						assert.Equal(t, map[string]string{"": "foo desc"}, dbPkg.Description) | ||||
| 						assert.Equal(t, map[string][]string{"": {"sudo"}}, dbPkg.Depends) | ||||
| 					} | ||||
|  | ||||
| 					if dbPkg.Name == "buz" { | ||||
| 						assert.Equal(t, db.NewJSON(map[string]string{"": "main desc"}), dbPkg.Description) | ||||
| 						assert.Equal(t, db.NewJSON(map[string][]string{"": {"sudo", "doas"}}), dbPkg.Depends) | ||||
| 						assert.Equal(t, map[string]string{"": "main desc"}, dbPkg.Description) | ||||
| 						assert.Equal(t, map[string][]string{"": {"sudo", "doas"}}, dbPkg.Depends) | ||||
| 					} | ||||
| 					pkgCount++ | ||||
| 				} | ||||
|   | ||||
| @@ -129,15 +129,7 @@ func TestPull(t *testing.T) { | ||||
| 		t.Fatalf("Expected no error, got %s", err) | ||||
| 	} | ||||
|  | ||||
| 	var pkgAmt int | ||||
| 	for result.Next() { | ||||
| 		var dbPkg db.Package | ||||
| 		err = result.StructScan(&dbPkg) | ||||
| 		if err != nil { | ||||
| 			t.Errorf("Expected no error, got %s", err) | ||||
| 		} | ||||
| 		pkgAmt++ | ||||
| 	} | ||||
| 	pkgAmt := len(result) | ||||
|  | ||||
| 	if pkgAmt == 0 { | ||||
| 		t.Errorf("Expected at least 1 matching package, but got %d", pkgAmt) | ||||
|   | ||||
| @@ -139,14 +139,14 @@ func parseScript( | ||||
| } | ||||
|  | ||||
| type PackageInfo struct { | ||||
| 	Version       string            `sh:"version,required"` | ||||
| 	Release       int               `sh:"release,required"` | ||||
| 	Epoch         uint              `sh:"epoch"` | ||||
| 	Architectures db.JSON[[]string] `sh:"architectures"` | ||||
| 	Licenses      db.JSON[[]string] `sh:"license"` | ||||
| 	Provides      db.JSON[[]string] `sh:"provides"` | ||||
| 	Conflicts     db.JSON[[]string] `sh:"conflicts"` | ||||
| 	Replaces      db.JSON[[]string] `sh:"replaces"` | ||||
| 	Version       string   `sh:"version,required"` | ||||
| 	Release       int      `sh:"release,required"` | ||||
| 	Epoch         uint     `sh:"epoch"` | ||||
| 	Architectures []string `sh:"architectures"` | ||||
| 	Licenses      []string `sh:"license"` | ||||
| 	Provides      []string `sh:"provides"` | ||||
| 	Conflicts     []string `sh:"conflicts"` | ||||
| 	Replaces      []string `sh:"replaces"` | ||||
| } | ||||
|  | ||||
| func (inf *PackageInfo) ToPackage(repoName string) *db.Package { | ||||
| @@ -164,13 +164,13 @@ func (inf *PackageInfo) ToPackage(repoName string) *db.Package { | ||||
|  | ||||
| func EmptyPackage(repoName string) *db.Package { | ||||
| 	return &db.Package{ | ||||
| 		Group:        db.NewJSON(map[string]string{}), | ||||
| 		Summary:      db.NewJSON(map[string]string{}), | ||||
| 		Description:  db.NewJSON(map[string]string{}), | ||||
| 		Homepage:     db.NewJSON(map[string]string{}), | ||||
| 		Maintainer:   db.NewJSON(map[string]string{}), | ||||
| 		Depends:      db.NewJSON(map[string][]string{}), | ||||
| 		BuildDepends: db.NewJSON(map[string][]string{}), | ||||
| 		Group:        map[string]string{}, | ||||
| 		Summary:      map[string]string{}, | ||||
| 		Description:  map[string]string{}, | ||||
| 		Homepage:     map[string]string{}, | ||||
| 		Maintainer:   map[string]string{}, | ||||
| 		Depends:      map[string][]string{}, | ||||
| 		BuildDepends: map[string][]string{}, | ||||
| 		Repository:   repoName, | ||||
| 	} | ||||
| } | ||||
| @@ -193,8 +193,7 @@ func resolveOverrides(runner *interp.Runner, pkg *db.Package) { | ||||
| 				override := strings.TrimPrefix(name, prefix) | ||||
| 				override = strings.TrimPrefix(override, "_") | ||||
|  | ||||
| 				field := pkgVal.FieldByName(field) | ||||
| 				varVal := field.FieldByName("Val") | ||||
| 				varVal := pkgVal.FieldByName(field) | ||||
| 				varType := varVal.Type() | ||||
|  | ||||
| 				switch varType.Elem().String() { | ||||
|   | ||||
| @@ -22,13 +22,12 @@ package search | ||||
| import ( | ||||
| 	"context" | ||||
|  | ||||
| 	"github.com/jmoiron/sqlx" | ||||
|  | ||||
| 	"gitea.plemya-x.ru/Plemya-x/ALR/internal/db" | ||||
| 	database "gitea.plemya-x.ru/Plemya-x/ALR/internal/db" | ||||
| ) | ||||
|  | ||||
| type PackagesProvider interface { | ||||
| 	GetPkgs(ctx context.Context, where string, args ...any) (*sqlx.Rows, error) | ||||
| 	GetPkgs(ctx context.Context, where string, args ...any) ([]db.Package, error) | ||||
| } | ||||
|  | ||||
| type Searcher struct { | ||||
| @@ -45,22 +44,7 @@ func (s *Searcher) Search( | ||||
| 	ctx context.Context, | ||||
| 	opts *SearchOptions, | ||||
| ) ([]database.Package, error) { | ||||
| 	var packages []database.Package | ||||
|  | ||||
| 	where, args := opts.WhereClause() | ||||
| 	result, err := s.pp.GetPkgs(ctx, where, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	for result.Next() { | ||||
| 		var dbPkg database.Package | ||||
| 		err = result.StructScan(&dbPkg) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		packages = append(packages, dbPkg) | ||||
| 	} | ||||
|  | ||||
| 	return packages, nil | ||||
| 	packages, err := s.pp.GetPkgs(ctx, where, args...) | ||||
| 	return packages, err | ||||
| } | ||||
|   | ||||
| @@ -80,20 +80,8 @@ func (d *Decoder) DecodeVar(name string, val any) error { | ||||
|  | ||||
| 	dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ | ||||
| 		WeaklyTypedInput: true, | ||||
| 		DecodeHook: mapstructure.DecodeHookFuncValue(func(from, to reflect.Value) (interface{}, error) { | ||||
| 			if strings.Contains(to.Type().String(), "db.JSON") { | ||||
| 				valType := to.FieldByName("Val").Type() | ||||
| 				if !from.Type().AssignableTo(valType) { | ||||
| 					return nil, InvalidTypeError{name, from.Type().String(), valType.String()} | ||||
| 				} | ||||
|  | ||||
| 				to.FieldByName("Val").Set(from) | ||||
| 				return to, nil | ||||
| 			} | ||||
| 			return from.Interface(), nil | ||||
| 		}), | ||||
| 		Result:  val, | ||||
| 		TagName: "sh", | ||||
| 		Result:           val, | ||||
| 		TagName:          "sh", | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
|   | ||||
| @@ -114,67 +114,63 @@ msgstr "" | ||||
| msgid "Error parsing os-release file" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:42 | ||||
| #: info.go:41 | ||||
| msgid "Print information about a package" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:47 | ||||
| #: info.go:46 | ||||
| msgid "Show all information, not just for the current distro" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:68 | ||||
| #: info.go:67 | ||||
| msgid "Error getting packages" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:76 | ||||
| msgid "Error iterating over packages" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:90 | ||||
| #: info.go:82 | ||||
| msgid "Command info expected at least 1 argument, got %d" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:110 | ||||
| #: info.go:102 | ||||
| msgid "Error finding packages" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:124 | ||||
| #: info.go:116 | ||||
| msgid "Can't detect system language" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:141 | ||||
| #: info.go:133 | ||||
| msgid "Error resolving overrides" | ||||
| msgstr "" | ||||
|  | ||||
| #: info.go:149 info.go:154 | ||||
| #: info.go:141 info.go:146 | ||||
| msgid "Error encoding script variables" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:40 | ||||
| #: install.go:39 | ||||
| msgid "Install a new package" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:52 | ||||
| #: install.go:51 | ||||
| msgid "Command install expected at least 1 argument, got %d" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:114 | ||||
| #: install.go:113 | ||||
| msgid "Error when installing the package" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:159 | ||||
| #: install.go:151 | ||||
| msgid "Remove an installed package" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:178 | ||||
| #: install.go:170 | ||||
| msgid "Error listing installed packages" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:215 | ||||
| #: install.go:199 | ||||
| msgid "Command remove expected at least 1 argument, got %d" | ||||
| msgstr "" | ||||
|  | ||||
| #: install.go:230 | ||||
| #: install.go:214 | ||||
| msgid "Error removing packages" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -346,11 +342,11 @@ msgid "" | ||||
| "instead!" | ||||
| msgstr "" | ||||
|  | ||||
| #: internal/db/db.go:137 | ||||
| #: internal/db/db.go:95 | ||||
| msgid "Database version mismatch; resetting" | ||||
| msgstr "" | ||||
|  | ||||
| #: internal/db/db.go:144 | ||||
| #: internal/db/db.go:101 | ||||
| msgid "" | ||||
| "Database version does not exist. Run alr fix if something isn't working." | ||||
| msgstr "" | ||||
| @@ -429,11 +425,11 @@ msgstr "" | ||||
| msgid "No packages for upgrade" | ||||
| msgstr "" | ||||
|  | ||||
| #: list.go:102 list.go:187 | ||||
| #: list.go:102 list.go:184 | ||||
| msgid "Error parsing format template" | ||||
| msgstr "" | ||||
|  | ||||
| #: list.go:108 list.go:191 | ||||
| #: list.go:108 list.go:188 | ||||
| msgid "Error executing template" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -121,67 +121,63 @@ msgstr "Такой вспомогательной команды нет" | ||||
| msgid "Error parsing os-release file" | ||||
| msgstr "Ошибка при разборе файла выпуска операционной системы" | ||||
|  | ||||
| #: info.go:42 | ||||
| #: info.go:41 | ||||
| msgid "Print information about a package" | ||||
| msgstr "Отобразить информацию о пакете" | ||||
|  | ||||
| #: info.go:47 | ||||
| #: info.go:46 | ||||
| msgid "Show all information, not just for the current distro" | ||||
| msgstr "Показывать всю информацию, не только для текущего дистрибутива" | ||||
|  | ||||
| #: info.go:68 | ||||
| #: info.go:67 | ||||
| msgid "Error getting packages" | ||||
| msgstr "Ошибка при получении пакетов" | ||||
|  | ||||
| #: info.go:76 | ||||
| msgid "Error iterating over packages" | ||||
| msgstr "Ошибка при переборе пакетов" | ||||
|  | ||||
| #: info.go:90 | ||||
| #: info.go:82 | ||||
| msgid "Command info expected at least 1 argument, got %d" | ||||
| msgstr "Для команды info ожидался хотя бы 1 аргумент, получено %d" | ||||
|  | ||||
| #: info.go:110 | ||||
| #: info.go:102 | ||||
| msgid "Error finding packages" | ||||
| msgstr "Ошибка при поиске пакетов" | ||||
|  | ||||
| #: info.go:124 | ||||
| #: info.go:116 | ||||
| msgid "Can't detect system language" | ||||
| msgstr "Ошибка при определении языка системы" | ||||
|  | ||||
| #: info.go:141 | ||||
| #: info.go:133 | ||||
| msgid "Error resolving overrides" | ||||
| msgstr "Ошибка устранения переорпеделений" | ||||
|  | ||||
| #: info.go:149 info.go:154 | ||||
| #: info.go:141 info.go:146 | ||||
| msgid "Error encoding script variables" | ||||
| msgstr "Ошибка кодирования переменных скрита" | ||||
|  | ||||
| #: install.go:40 | ||||
| #: install.go:39 | ||||
| msgid "Install a new package" | ||||
| msgstr "Установить новый пакет" | ||||
|  | ||||
| #: install.go:52 | ||||
| #: install.go:51 | ||||
| msgid "Command install expected at least 1 argument, got %d" | ||||
| msgstr "Для команды install ожидался хотя бы 1 аргумент, получено %d" | ||||
|  | ||||
| #: install.go:114 | ||||
| #: install.go:113 | ||||
| msgid "Error when installing the package" | ||||
| msgstr "Ошибка при установке пакета" | ||||
|  | ||||
| #: install.go:159 | ||||
| #: install.go:151 | ||||
| msgid "Remove an installed package" | ||||
| msgstr "Удалить установленный пакет" | ||||
|  | ||||
| #: install.go:178 | ||||
| #: install.go:170 | ||||
| msgid "Error listing installed packages" | ||||
| msgstr "Ошибка при составлении списка установленных пакетов" | ||||
|  | ||||
| #: install.go:215 | ||||
| #: install.go:199 | ||||
| msgid "Command remove expected at least 1 argument, got %d" | ||||
| msgstr "Для команды remove ожидался хотя бы 1 аргумент, получено %d" | ||||
|  | ||||
| #: install.go:230 | ||||
| #: install.go:214 | ||||
| msgid "Error removing packages" | ||||
| msgstr "Ошибка при удалении пакетов" | ||||
|  | ||||
| @@ -359,11 +355,11 @@ msgstr "" | ||||
| "Эта команда устарела и будет удалена в будущем, используйте вместо нее \"%s" | ||||
| "\"!" | ||||
|  | ||||
| #: internal/db/db.go:137 | ||||
| #: internal/db/db.go:95 | ||||
| msgid "Database version mismatch; resetting" | ||||
| msgstr "Несоответствие версий базы данных; сброс настроек" | ||||
|  | ||||
| #: internal/db/db.go:144 | ||||
| #: internal/db/db.go:101 | ||||
| msgid "" | ||||
| "Database version does not exist. Run alr fix if something isn't working." | ||||
| msgstr "" | ||||
| @@ -445,11 +441,11 @@ msgstr "Ошибка при получении пакетов для обнов | ||||
| msgid "No packages for upgrade" | ||||
| msgstr "Нет пакетов к обновлению" | ||||
|  | ||||
| #: list.go:102 list.go:187 | ||||
| #: list.go:102 list.go:184 | ||||
| msgid "Error parsing format template" | ||||
| msgstr "Ошибка при разборе шаблона" | ||||
|  | ||||
| #: list.go:108 list.go:191 | ||||
| #: list.go:108 list.go:188 | ||||
| msgid "Error executing template" | ||||
| msgstr "Ошибка при выполнении шаблона" | ||||
|  | ||||
| @@ -573,6 +569,9 @@ msgstr "Ошибка при проверке обновлений" | ||||
| msgid "There is nothing to do." | ||||
| msgstr "Здесь нечего делать." | ||||
|  | ||||
| #~ msgid "Error iterating over packages" | ||||
| #~ msgstr "Ошибка при переборе пакетов" | ||||
|  | ||||
| #~ msgid "Error pulling repos" | ||||
| #~ msgstr "Ошибка при извлечении репозиториев" | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user