Browse Source

Record installed packages and dependancies in sqlite3 db

feature/create-install-pkgs
Tovi Jaeschke-Rogers 3 years ago
parent
commit
714eadc3ec
15 changed files with 413 additions and 170 deletions
  1. +49
    -74
      Archive/Archive.go
  2. +83
    -48
      Archive/Unarchive.go
  3. +51
    -1
      Client/Database/Init.go
  4. +61
    -0
      Client/Database/InstalledPkgs.go
  5. +0
    -1
      Client/Filesystem/CommitFiles.go
  6. +0
    -2
      Client/Filesystem/FilesystemDiff.go
  7. +0
    -1
      Client/Filesystem/PickFiles.go
  8. +13
    -18
      Client/Package/CreatePackage.go
  9. +54
    -3
      Client/Package/InstallPackage.go
  10. +33
    -0
      Client/Package/Manifest.go
  11. +0
    -4
      Client/ProgressBar/Bar.go
  12. +28
    -14
      Client/main.go
  13. +3
    -1
      Variables/Variables.go
  14. +10
    -1
      go.mod
  15. +28
    -2
      go.sum

+ 49
- 74
Archive/Archive.go View File

@ -5,111 +5,86 @@ import (
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
)
func Gzip(source, target string) error {
func CreateArchive(files []string, target string) error {
var (
reader, writer *os.File
archiver *gzip.Writer
filename string
e error
outFile *os.File
gzipWriter *gzip.Writer
tarWriter *tar.Writer
file string
e error
)
reader, e = os.Open(source)
// Create output file
outFile, e = os.Create(target)
if e != nil {
return e
}
defer outFile.Close()
//filename = filepath.Base(source)
//target = filepath.Join(target, fmt.Sprintf("%s.gz", filename))
gzipWriter = gzip.NewWriter(outFile)
defer gzipWriter.Close()
tarWriter = tar.NewWriter(gzipWriter)
defer tarWriter.Close()
writer, e = os.Create(target)
if e != nil {
return e
}
// Iterate over files and add them to the tar archive
defer writer.Close()
archiver = gzip.NewWriter(writer)
archiver.Name = filename
defer archiver.Close()
for _, file = range files {
e = addToArchive(tarWriter, file)
if e != nil {
return e
}
}
_, e = io.Copy(archiver, reader)
return e
return nil
}
func Tar(source, target string) error {
func addToArchive(tarWriter *tar.Writer, filename string) error {
var (
tarfile, file *os.File
tarball *tar.Writer
header *tar.Header
e error
file *os.File
info os.FileInfo
header *tar.Header
e error
)
tarfile, e = os.Create(target)
// Open the file which will be written into the archive
file, e = os.Open(filename)
if e != nil {
return e
}
defer tarfile.Close()
tarball = tar.NewWriter(tarfile)
defer tarball.Close()
return filepath.Walk(source,
func(path string, info os.FileInfo, e error) error {
if e != nil {
return e
}
header, e = tar.FileInfoHeader(info, info.Name())
if e != nil {
return e
}
// TODO change "/" to work cross platform
header.Name = strings.TrimPrefix(strings.TrimPrefix(path, source), "/")
if header.Name == "" {
return nil
}
e = tarball.WriteHeader(header)
if e != nil {
return e
}
defer file.Close()
if info.IsDir() {
return nil
}
file, e = os.Open(path)
if e != nil {
return e
}
defer file.Close()
// Get FileInfo about our file providing file size, mode, etc.
info, e = file.Stat()
if e != nil {
return e
}
_, e = io.Copy(tarball, file)
return e
})
}
// Create a tar Header from the FileInfo data
header, e = tar.FileInfoHeader(info, info.Name())
if e != nil {
return e
}
func TarGzip(source, target string) error {
var (
tarPath string = strings.ReplaceAll(target, ".gz", "")
e error
)
// Use full path as name (FileInfoHeader only takes the basename)
// If we don't do this the directory strucuture would
// not be preserved
// https://golang.org/src/archive/tar/common.go?#L626
header.Name = strings.TrimPrefix(filename, "/")
e = Tar(source, tarPath)
// Write file header to the tar archive
e = tarWriter.WriteHeader(header)
if e != nil {
return e
}
e = Gzip(tarPath, target)
// Copy file content to tar archive
_, e = io.Copy(tarWriter, file)
if e != nil {
return e
}
return os.Remove(tarPath)
return nil
}

+ 83
- 48
Archive/Unarchive.go View File

@ -2,64 +2,45 @@ package Archive
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
func UnGzip(source, target string) error {
func ExtractArchive(source, target string) error {
var (
reader, writer *os.File
archive *gzip.Reader
e error
inFile *os.File
gzipReader *gzip.Reader
tarReader *tar.Reader
e error
)
reader, e = os.Open(source)
inFile, e = os.Open(source)
if e != nil {
return e
}
defer reader.Close()
defer inFile.Close()
archive, e = gzip.NewReader(reader)
if e != nil {
return e
}
defer archive.Close()
target = filepath.Join(target, archive.Name)
writer, e = os.Create(target)
if e != nil {
return e
}
defer writer.Close()
gzipReader, e = gzip.NewReader(inFile)
defer gzipReader.Close()
tarReader = tar.NewReader(gzipReader)
_, e = io.Copy(writer, archive)
return e
return extractFromArchive(tarReader, target)
}
func Untar(tarball, target string) error {
func extractFromArchive(tarReader *tar.Reader, target string) error {
var (
reader *os.File
tarReader *tar.Reader
header *tar.Header
info fs.FileInfo
file *os.File
path string
e error
header *tar.Header
info fs.FileInfo
file *os.File
path, basePath string
e error
)
reader, e = os.Open(tarball)
if e != nil {
return e
}
defer reader.Close()
tarReader = tar.NewReader(reader)
for {
header, e = tarReader.Next()
if e == io.EOF {
@ -72,6 +53,10 @@ func Untar(tarball, target string) error {
path = filepath.Join(target, header.Name)
info = header.FileInfo()
if filepath.Base(info.Name()) == "manifest.yml" {
continue
}
if info.IsDir() {
e = os.MkdirAll(path, info.Mode())
if e != nil {
@ -80,7 +65,20 @@ func Untar(tarball, target string) error {
continue
}
file, e = os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
basePath, e = filepath.Abs(filepath.Dir(path))
if e != nil {
return e
}
_, e = os.Stat(basePath)
if os.IsNotExist(e) {
e = os.MkdirAll(basePath, info.Mode())
if e != nil {
return e
}
}
file, e = os.OpenFile(path, os.O_CREATE|os.O_RDWR, info.Mode())
if e != nil {
return e
}
@ -94,23 +92,60 @@ func Untar(tarball, target string) error {
}
return nil
}
func UntarGzip(source, target string) error {
func ExtractManifestFile(source string) (string, error) {
var (
tarPath string = strings.ReplaceAll(source, ".gz", "")
e error
inFile *os.File
gzipReader *gzip.Reader
tarReader *tar.Reader
e error
)
e = UnGzip(source, tarPath)
inFile, e = os.Open(source)
if e != nil {
return e
return "", e
}
defer inFile.Close()
e = Untar(tarPath, target)
if e != nil {
return e
gzipReader, e = gzip.NewReader(inFile)
defer gzipReader.Close()
tarReader = tar.NewReader(gzipReader)
return extractManifestFromArchive(tarReader)
}
func extractManifestFromArchive(tarReader *tar.Reader) (string, error) {
var (
header *tar.Header
info fs.FileInfo
manifestWriter *bufio.Writer
manifestBytes bytes.Buffer
e error
)
for {
header, e = tarReader.Next()
if e == io.EOF {
break
}
if e != nil {
return manifestBytes.String(), e
}
info = header.FileInfo()
if filepath.Base(info.Name()) != "manifest.yml" {
continue
}
manifestWriter = bufio.NewWriter(&manifestBytes)
_, e = io.Copy(manifestWriter, tarReader)
if e != nil {
return manifestBytes.String(), e
}
}
return os.Remove(tarPath)
return manifestBytes.String(), nil
}

+ 51
- 1
Client/Database/Init.go View File

@ -32,7 +32,7 @@ func init() {
}
}
func InitDB() error {
func InitBoltDB() error {
var (
tx *bolt.Tx
e error
@ -58,3 +58,53 @@ func InitDB() error {
return e
}
func InitSqlite3DB() error {
var (
stmt *sql.Stmt
e error
)
stmt, e = DB.Prepare(`
CREATE TABLE IF NOT EXISTS installed_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL,
version VARCHAR(64) NOT NULL,
installed_at INTEGER NOT NULL
)
`)
if e != nil {
return e
}
_, e = stmt.Exec()
if e != nil {
return e
}
stmt, e = DB.Prepare(`
CREATE TABLE IF NOT EXISTS dependancy_linker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER,
dependancy_id INTEGER,
FOREIGN KEY(package_id) REFERENCES installed_packages(id),
FOREIGN KEY(dependancy_id) REFERENCES installed_packages(id)
)
`)
if e != nil {
return e
}
_, e = stmt.Exec()
return e
}
func InitDB() error {
var e error
e = InitBoltDB()
if e != nil {
return e
}
return InitSqlite3DB()
}

+ 61
- 0
Client/Database/InstalledPkgs.go View File

@ -0,0 +1,61 @@
package Database
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
func IsPackageInstalled(name, version string) (int64, error) {
var (
row *sql.Row
id int64
e error
)
row = DB.QueryRow(`
SELECT id FROM installed_packages WHERE name = ? AND version = ?;
`, name, version)
e = row.Scan(&id)
return id, e
}
func InsertPackage(name, version string, depIds []int64) error {
var (
stmt *sql.Stmt
result sql.Result
pkgId int64
depId int64
e error
)
stmt, e = DB.Prepare("INSERT INTO installed_packages(name, version, installed_at) VALUES(?,?,?)")
if e != nil {
return e
}
result, e = stmt.Exec(name, version, time.Now().Unix())
if e != nil {
return e
}
pkgId, e = result.LastInsertId()
if e != nil {
return e
}
for _, depId = range depIds {
stmt, e = DB.Prepare("INSERT INTO dependancy_linker(package_id, dependancy_id) VALUES(?,?)")
if e != nil {
return e
}
_, e = stmt.Exec(pkgId, depId)
if e != nil {
return e
}
}
return e
}

+ 0
- 1
Client/Filesystem/CommitFiles.go View File

@ -42,7 +42,6 @@ func CommitFiles() error {
if e != nil {
return e
}
ProgressBar.CloseBar(bar)
}
return nil


+ 0
- 2
Client/Filesystem/FilesystemDiff.go View File

@ -220,8 +220,6 @@ func GetFilesystemDiff(root string) (FilesystemStatus, error) {
return nil
})
ProgressBar.CloseBar(bar)
return nil
})


+ 0
- 1
Client/Filesystem/PickFiles.go View File

@ -92,7 +92,6 @@ func pickFilesRecursive(rootPath string) error {
}
}
ProgressBar.CloseBar(bar)
return nil
})


+ 13
- 18
Client/Package/CreatePackage.go View File

@ -28,12 +28,15 @@ func writeManifestFile(path, name, version string) error {
e error
)
manifest = fmt.Sprintf(`name: "%s"
version: "%s"
dependancies:
- pkg1: v1.0.0
- pkg2: v1.0.0
`, name, version)
manifest, e = Manifest{
Name: name,
Version: version,
Dependancies: make(map[string]string),
}.CreateManifestString()
if e != nil {
return e
}
filePath = filepath.Join(path, "manifest.yml")
@ -58,7 +61,6 @@ func CreatePackage() error {
pkgName string
pkgVersion string
pkgNameVersion string
tmpDir string
index int
e error
)
@ -125,22 +127,15 @@ func CreatePackage() error {
pkgNameVersion = fmt.Sprintf("%s-%s", pkgName, pkgVersion)
tmpDir, e = ioutil.TempDir("/tmp", pkgNameVersion)
e = writeManifestFile("/tmp/", pkgName, pkgVersion)
if e != nil {
return e
}
defer os.RemoveAll(tmpDir)
for _, file := range pkgFiles {
Filesystem.CopyFile(file, filepath.Join(tmpDir, file))
}
e = writeManifestFile(tmpDir, pkgName, pkgVersion)
if e != nil {
return e
}
// TODO: Write this file to a better spot?
pkgFiles = append(pkgFiles, "/tmp/manifest.yml")
e = Archive.TarGzip(tmpDir, pkgNameVersion+".tar.gz")
e = Archive.CreateArchive(pkgFiles, pkgNameVersion+".tar.gz")
if e != nil {
return e
}


+ 54
- 3
Client/Package/InstallPackage.go View File

@ -6,13 +6,36 @@ import (
"os"
"PackageManager/Archive"
"PackageManager/Client/Database"
"PackageManager/Variables"
)
func CheckPackageDependancies(deps map[string]string) ([]int64, error) {
var (
name, version string
depIds []int64
id int64
e error
)
for name, version = range deps {
id, e = Database.IsPackageInstalled(name, version)
if e != nil {
return depIds, e
}
depIds = append(depIds, id)
}
return depIds, e
}
func InstallPackage(pkgs []string) error {
var (
pkg string
e error
manifest Manifest
depIds []int64
pkg string
mStr string
e error
)
for _, pkg = range pkgs {
@ -27,7 +50,35 @@ func InstallPackage(pkgs []string) error {
"Installing %s...\n",
pkg,
)
e = Archive.UntarGzip(pkg, Variables.RootDir)
mStr, e = Archive.ExtractManifestFile(pkg)
if e != nil {
return e
}
manifest, e = ParseManifestFile(mStr)
if e != nil {
return e
}
if !Variables.IgnoreDepsCheck {
depIds, e = CheckPackageDependancies(manifest.Dependancies)
if e != nil {
// TODO: Search for package on error
return e
}
}
e = Archive.ExtractArchive(pkg, Variables.RootDir)
if e != nil {
return e
}
e = Database.InsertPackage(manifest.Name, manifest.Version, depIds)
if e != nil {
return e
}
fmt.Printf(
"%s successfully installed\n",
pkg,


+ 33
- 0
Client/Package/Manifest.go View File

@ -0,0 +1,33 @@
package Package
import (
yaml "gopkg.in/yaml.v2"
)
type Manifest struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Dependancies map[string]string `yaml:"dependancies,flow"`
}
func ParseManifestFile(manifest string) (Manifest, error) {
var (
m Manifest = Manifest{}
e error
)
e = yaml.Unmarshal([]byte(manifest), &m)
return m, e
}
func (m Manifest) CreateManifestString() (string, error) {
var (
mByte []byte
e error
)
mByte, e = yaml.Marshal(&m)
if e != nil {
return "", e
}
return string(mByte), e
}

+ 0
- 4
Client/ProgressBar/Bar.go View File

@ -29,7 +29,3 @@ func InitBar(name string, total int) *mpb.Bar {
return bar
}
func CloseBar(bar *mpb.Bar) {
bar.Abort(false)
}

+ 28
- 14
Client/main.go View File

@ -36,6 +36,12 @@ Filesystem diff:
-Cp | -create-pkg
Create package from fs diff
-Il | -install-local
Install package from local tarball
-ignore-deps-check
Skip dependancies check
`
helpMsg = fmt.Sprintf(helpMsg, os.Args[0])
fmt.Println(helpMsg)
@ -52,8 +58,11 @@ func main() {
resetAddedFilesFlag bool
resetAddedFilesFlagLong bool
createPackageFlag bool
createPackageFlagLong bool
createPackageFlag bool
createPackageFlagLong bool
installLocalPackageFlag bool
installLocalPackageFlagLong bool
ignoreDepsCheckFlag bool
verboseOutputFlag bool
verboseOutputFlagLong bool
@ -74,6 +83,9 @@ func main() {
panic(e)
}
defer Database.DB.Close()
defer Database.FsDB.Close()
// TODO: Rework usage function
// Initialise flags
@ -95,16 +107,21 @@ func main() {
flag.BoolVar(&createPackageFlag, "Cp", false, "Create package from fs diff")
flag.BoolVar(&createPackageFlagLong, "create-pkg", false, "Create package from fs diff")
flag.BoolVar(&installLocalPackageFlag, "Il", false, "Install package from local tarball")
flag.BoolVar(&installLocalPackageFlagLong, "install-local", false, "Install package from local tarball")
flag.BoolVar(&ignoreDepsCheckFlag, "ignore-deps-check", false, "Ignore dependancies check")
flag.Parse()
Variables.VerboseOutput = verboseOutputFlag || verboseOutputFlagLong
Variables.IgnoreDepsCheck = ignoreDepsCheckFlag
if getFilesystemDiffFlag || getFilesystemDiffFlagLong {
var rootPath string = Variables.RootDir
if len(flag.Args()) > 1 {
// TODO: Fix this msg
fmt.Println(Color.Fatal("Option takes one optional argument"))
flag.Usage()
fmt.Println(Color.Fatal("Option takes one optional argument"))
return
}
@ -121,7 +138,7 @@ func main() {
}
if addFileDiffFlag || addFileDiffFlagLong {
if len(flag.Args()) > 1 && len(flag.Args()) < 1 {
if len(flag.Args()) > 1 || len(flag.Args()) < 1 {
fmt.Println(Color.Fatal("Must supply one argument"))
flag.Usage()
return
@ -157,16 +174,13 @@ func main() {
return
}
/*
if installLocalPackageFlag || installLocalPackageFlagLong {
e = Package.InstallPackage(flag.Args())
if e != nil {
panic(e)
}
return
if installLocalPackageFlag || installLocalPackageFlagLong {
e = Package.InstallPackage(flag.Args())
if e != nil {
panic(e)
}
*/
return
}
flag.Usage()
fmt.Println(Color.Fatal("Nothing to do"))


+ 3
- 1
Variables/Variables.go View File

@ -12,7 +12,9 @@ const (
var (
Editor string = "vi"
VerboseOutput bool = false
VerboseOutput bool = false
IgnoreDepsCheck bool = false
RootDir string = "/"
FsHashPicksBucket []byte = []byte("FilesystemPicks")
FsHashIndexBucket []byte = []byte("FilesystemIndex")


+ 10
- 1
go.mod View File

@ -2,4 +2,13 @@ module PackageManager
go 1.16
require github.com/mattn/go-sqlite3 v1.14.7
require (
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/mattn/go-sqlite3 v1.14.8
github.com/vbauerster/mpb v3.4.0+incompatible
go.etcd.io/bbolt v1.3.6
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
gopkg.in/yaml.v2 v2.4.0
)

+ 28
- 2
go.sum View File

@ -1,2 +1,28 @@
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/vbauerster/mpb v3.4.0+incompatible h1:mfiiYw87ARaeRW6x5gWwYRUawxaW1tLAD8IceomUCNw=
github.com/vbauerster/mpb v3.4.0+incompatible/go.mod h1:zAHG26FUhVKETRu+MWqYXcI70POlC6N8up9p1dID7SU=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

Loading…
Cancel
Save