Compare commits

...

8 Commits

14 changed files with 425 additions and 143 deletions

View File

@ -241,7 +241,7 @@ func declareSyncExercicesRoutes(router *gin.RouterGroup) {
exceptions := sync.LoadExerciceException(sync.GlobalImporter, theme, exercice, nil)
c.JSON(http.StatusOK, flatifySyncErrors(sync.SyncExerciceFiles(sync.GlobalImporter, exercice, exceptions)))
c.JSON(http.StatusOK, flatifySyncErrors(sync.ImportExerciceFiles(sync.GlobalImporter, exercice, exceptions)))
})
apiSyncExercicesRoutes.POST("/fixurlid", func(c *gin.Context) {
exercice := c.MustGet("exercice").(*fic.Exercice)

View File

@ -37,7 +37,7 @@
<dt ng-bind="theme.name"></dt>
<dd>
<ul class="list-unstyled">
<li ng-repeat="(eid,exercice) in theme.exercices" ng-if="my.exercices[eid] && my.exercices[eid].solved_rank"><a href="/{{ my.exercices[eid].theme_id }}/{{ eid }}" target="_blank"><abbr title="{{ my.exercices[eid].statement }}">{{ exercice.title }}</abbr></a> (<abbr title="{{ my.exercices[eid].solved_time | date:'mediumDate' }} à {{ my.exercices[eid].solved_time | date:'mediumTime' }}">{{ my.exercices[eid].solved_rank }}<sup>e</sup></abbr>)</li>
<li ng-repeat="exercice in theme.exercices" ng-if="my.exercices[exercice.id] && my.exercices[exercice.id].solved_rank"><a href="/{{ my.exercices[exercice.id].theme_id }}/{{ exercice.id }}" target="_blank"><abbr title="{{ my.exercices[exercice.id].statement }}">{{ exercice.title }}</abbr></a> (<abbr title="{{ my.exercices[exercice.id].solved_time | date:'mediumDate' }} à {{ my.exercices[exercice.id].solved_time | date:'mediumTime' }}">{{ my.exercices[exercice.id].solved_rank }}<sup>e</sup></abbr>)</li>
</ul>
</dd>
</div>

View File

@ -315,9 +315,57 @@ func DownloadExerciceFile(pf ExerciceFile, dest string, exercice *fic.Exercice,
return
}
// SyncExerciceFiles reads the content of files/ directory and import it as EFile for the given challenge.
type importedFile struct {
file interface{}
Name string
}
func SyncExerciceFiles(i Importer, exercice *fic.Exercice, paramsFiles map[string]ExerciceFile, actionAfterImport func(fname string, digests map[string][]byte, filePath, origin string) (interface{}, error)) (ret []*importedFile, errs error) {
files, digests, berrs := BuildFilesListInto(i, exercice, "files")
errs = multierr.Append(errs, berrs)
// Import standard files
for _, fname := range files {
var f interface{}
var err error
if pf, exists := paramsFiles[fname]; exists && pf.URL != "" && !i.Exists(path.Join(exercice.Path, "files", fname)) {
dest := GetDestinationFilePath(pf.URL, &pf.Filename)
if _, err := os.Stat(dest); !os.IsNotExist(err) {
if d, err := actionAfterImport(fname, digests, dest, pf.URL); err == nil {
f = d
}
}
if f == nil {
errs = multierr.Append(errs, DownloadExerciceFile(paramsFiles[fname], dest, exercice, false))
f, err = actionAfterImport(fname, digests, dest, pf.URL)
}
} else {
f, err = i.importFile(path.Join(exercice.Path, "files", fname), func(filePath, origin string) (interface{}, error) {
return actionAfterImport(fname, digests, filePath, origin)
})
}
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
continue
}
ret = append(ret, &importedFile{
f,
fname,
})
}
return
}
// ImportExerciceFiles reads the content of files/ directory and import it as EFile for the given challenge.
// It takes care of DIGESTS.txt and ensure imported files match.
func SyncExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExceptions) (errs error) {
func ImportExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExceptions) (errs error) {
if _, err := exercice.WipeFiles(); err != nil {
errs = multierr.Append(errs, err)
}
@ -328,63 +376,41 @@ func SyncExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExce
return
}
files, digests, berrs := BuildFilesListInto(i, exercice, "files")
actionAfterImport := func(fname string, digests map[string][]byte, filePath, origin string) (interface{}, error) {
var digest_shown []byte
if strings.HasSuffix(fname, ".gz") {
if d, exists := digests[strings.TrimSuffix(fname, ".gz")]; exists {
digest_shown = d
}
}
published := true
disclaimer := ""
if f, exists := paramsFiles[fname]; exists {
published = !f.Hidden
// Call checks hooks
for _, hk := range hooks.mdTextHooks {
for _, err := range multierr.Errors(hk(f.Disclaimer, exercice.Language, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
}
}
if disclaimer, err = ProcessMarkdown(i, fixnbsp(f.Disclaimer), exercice.Path); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("error during markdown formating of disclaimer: %w", err)))
}
}
return exercice.ImportFile(filePath, origin, digests[fname], digest_shown, disclaimer, published)
}
files, berrs := SyncExerciceFiles(i, exercice, paramsFiles, actionAfterImport)
errs = multierr.Append(errs, berrs)
// Import standard files
for _, fname := range files {
actionAfterImport := func(filePath string, origin string) (interface{}, error) {
var digest_shown []byte
if strings.HasSuffix(fname, ".gz") {
if d, exists := digests[strings.TrimSuffix(fname, ".gz")]; exists {
digest_shown = d
}
}
published := true
disclaimer := ""
if f, exists := paramsFiles[fname]; exists {
published = !f.Hidden
// Call checks hooks
for _, hk := range hooks.mdTextHooks {
for _, err := range multierr.Errors(hk(f.Disclaimer, exercice.Language, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
}
}
if disclaimer, err = ProcessMarkdown(i, fixnbsp(f.Disclaimer), exercice.Path); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("error during markdown formating of disclaimer: %w", err)))
}
}
return exercice.ImportFile(filePath, origin, digests[fname], digest_shown, disclaimer, published)
}
var f interface{}
if pf, exists := paramsFiles[fname]; exists && pf.URL != "" && !i.Exists(path.Join(exercice.Path, "files", fname)) {
dest := GetDestinationFilePath(pf.URL, &pf.Filename)
if _, err := os.Stat(dest); !os.IsNotExist(err) {
if d, err := actionAfterImport(dest, pf.URL); err == nil {
f = d
}
}
if f == nil {
errs = multierr.Append(errs, DownloadExerciceFile(paramsFiles[fname], dest, exercice, false))
f, err = actionAfterImport(dest, pf.URL)
}
} else {
f, err = i.importFile(path.Join(exercice.Path, "files", fname), actionAfterImport)
}
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
continue
}
// Import files in db
for _, file := range files {
fname := file.Name
f := file.file
if f.(*fic.EFile).Size == 0 {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("imported file is empty!")))

View File

@ -398,7 +398,7 @@ func SyncExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]*f
if len(e.Image) > 0 {
if _, err := i.importFile(e.Image,
func(filePath string, origin string) (interface{}, error) {
if err := resizePicture(filePath, image.Rect(0, 0, 500, 300)); err != nil {
if err := resizePicture(i, origin, filePath, image.Rect(0, 0, 500, 300)); err != nil {
return nil, err
}

View File

@ -188,26 +188,35 @@ func GetDestinationFilePath(URI string, filename *string) string {
return path.Join(fic.FilesDir, strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:])), *filename)
}
func importFile(i Importer, URI string, dest string) error {
var fileWriter = fileWriterToFS
func SetWriteFileFunc(writerFunc func(dest string) (io.WriteCloser, error)) {
fileWriter = writerFunc
}
func fileWriterToFS(dest string) (io.WriteCloser, error) {
if err := os.MkdirAll(path.Dir(dest), 0751); err != nil {
return err
return nil, err
}
// Write file
if fdto, err := os.Create(dest); err != nil {
return os.Create(dest)
}
func importFile(i Importer, URI string, dest string) error {
if fdfrom, closer, err := GetFile(i, URI); err != nil {
os.Remove(dest)
return err
} else {
defer fdto.Close()
defer closer()
if fdfrom, closer, err := GetFile(i, URI); err != nil {
os.Remove(dest)
return err
} else {
defer closer()
_, err = io.Copy(fdto, fdfrom)
fdto, err := fileWriter(dest)
if err != nil {
return err
}
defer fdto.Close()
_, err = io.Copy(fdto, fdfrom)
return err
}
}

View File

@ -72,13 +72,9 @@ func SpeedySyncDeep(i Importer) (errs SyncReport) {
errs.ThemesSync = append(errs.ThemesSync, sterr.Error())
}
if themes, err := fic.GetThemes(); err == nil {
if themes, err := fic.GetThemesExtended(); err == nil {
DeepSyncProgress = 2
if i.Exists(fic.StandaloneExercicesDirectory) {
themes = append(themes, &fic.StandaloneExercicesTheme)
}
var themeStep uint8 = uint8(250) / uint8(len(themes))
for tid, theme := range themes {
@ -143,14 +139,9 @@ func SyncDeep(i Importer) (errs SyncReport) {
}
// Synchronize themes
if themes, err := fic.GetThemes(); err == nil {
if themes, err := fic.GetThemesExtended(); err == nil {
DeepSyncProgress = 2
// Also synchronize standalone exercices
if i.Exists(fic.StandaloneExercicesDirectory) {
themes = append(themes, &fic.StandaloneExercicesTheme)
}
var themeStep uint8 = uint8(250) / uint8(len(themes))
for tid, theme := range themes {
@ -246,7 +237,7 @@ func SyncThemeDeep(i Importer, theme *fic.Theme, tid int, themeStep uint8, excep
log.Printf("Deep synchronization in progress: %d/255 - doing Theme %q, Exercice %q: %q\n", DeepSyncProgress, theme.Name, exercice.Title, exercice.Path)
DeepSyncProgress = 3 + uint8(tid)*themeStep + uint8(eid)*exerciceStep
errs = multierr.Append(errs, SyncExerciceFiles(i, exercice, ex_exceptions[eid]))
errs = multierr.Append(errs, ImportExerciceFiles(i, exercice, ex_exceptions[eid]))
DeepSyncProgress += exerciceStep / 3
flagsBindings, ferrs := SyncExerciceFlags(i, exercice, ex_exceptions[eid])

View File

@ -3,9 +3,7 @@ package sync
import (
"bytes"
"encoding/base32"
"io"
"net/url"
"os"
"path"
"strings"
@ -89,27 +87,10 @@ func (t *imageImporterTransformer) Transform(doc *ast.Document, reader text.Read
dPath := path.Join(fic.FilesDir, strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(t.hash[:])), iPath)
child.Destination = []byte(path.Join(t.absPath, string(child.Destination)))
if err := os.MkdirAll(path.Dir(dPath), 0755); err != nil {
err := importFile(t.importer, path.Join(t.rootDir, iPath), dPath)
if err != nil {
return ast.WalkStop, err
}
if fdto, err := os.Create(dPath); err != nil {
return ast.WalkStop, err
} else {
defer fdto.Close()
if fd, closer, err := GetFile(t.importer, path.Join(t.rootDir, iPath)); err != nil {
os.Remove(dPath)
return ast.WalkStop, err
} else {
defer closer()
_, err = io.Copy(fdto, fd)
if err != nil {
return ast.WalkStop, err
}
}
}
}
return ast.WalkContinue, nil

View File

@ -5,6 +5,7 @@ import (
"fmt"
"image"
"image/jpeg"
"io"
"math/rand"
"net/http"
"os"
@ -39,16 +40,34 @@ func GetThemes(i Importer) (themes []string, err error) {
return themes, nil
}
// GetThemesExtended returns all theme directories, including standalone exercices.
func GetThemesExtended(i Importer) (themes []string, err error) {
themes, err = GetThemes(i)
if err != nil {
return
}
if i.Exists(fic.StandaloneExercicesDirectory) {
themes = append(themes, fic.StandaloneExercicesDirectory)
}
return
}
// resizePicture makes the given image just fill the given rectangle.
func resizePicture(importedPath string, rect image.Rectangle) error {
if fl, err := os.Open(importedPath); err != nil {
func resizePicture(i Importer, imgPath string, importedPath string, rect image.Rectangle) error {
if fl, err := i.GetFile(imgPath); err != nil {
return err
} else {
if src, _, err := image.Decode(fl); err != nil {
fl.Close()
if flc, ok := fl.(io.ReadCloser); ok {
flc.Close()
}
return err
} else if src.Bounds().Max.X > rect.Max.X && src.Bounds().Max.Y > rect.Max.Y {
fl.Close()
if flc, ok := fl.(io.ReadCloser); ok {
flc.Close()
}
mWidth := rect.Max.Y * src.Bounds().Max.X / src.Bounds().Max.Y
mHeight := rect.Max.X * src.Bounds().Max.Y / src.Bounds().Max.X
@ -61,7 +80,7 @@ func resizePicture(importedPath string, rect image.Rectangle) error {
dst := image.NewRGBA(rect)
draw.CatmullRom.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
dstFile, err := os.Create(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
dstFile, err := fileWriter(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
if err != nil {
return err
}
@ -71,7 +90,7 @@ func resizePicture(importedPath string, rect image.Rectangle) error {
return err
}
} else {
dstFile, err := os.Create(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
dstFile, err := fileWriter(strings.TrimSuffix(importedPath, ".jpg") + ".thumb.jpg")
if err != nil {
return err
}
@ -273,6 +292,36 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, exceptions *CheckExcept
return
}
// SyncThemeFiles import all theme's related files
func SyncThemeFiles(i Importer, btheme *fic.Theme) (errs error) {
if len(btheme.Image) > 0 {
if _, err := i.importFile(btheme.Image,
func(filePath string, origin string) (interface{}, error) {
if err := resizePicture(i, origin, filePath, image.Rect(0, 0, 500, 300)); err != nil {
return nil, err
}
btheme.Image = strings.TrimPrefix(filePath, fic.FilesDir)
btheme.BackgroundColor, _ = getBackgroundColor(filePath)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import heading image: %w", err)))
}
}
if len(btheme.PartnerImage) > 0 {
if _, err := i.importFile(btheme.PartnerImage,
func(filePath string, origin string) (interface{}, error) {
btheme.PartnerImage = strings.TrimPrefix(filePath, fic.FilesDir)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import partner image: %w", err)))
}
}
return
}
// SyncThemes imports new or updates existing themes.
func SyncThemes(i Importer) (exceptions map[string]*CheckExceptions, errs error) {
if themes, err := GetThemes(i); err != nil {
@ -294,29 +343,9 @@ func SyncThemes(i Importer) (exceptions map[string]*CheckExceptions, errs error)
exceptions[tdir] = excepts
if len(btheme.Image) > 0 {
if _, err := i.importFile(btheme.Image,
func(filePath string, origin string) (interface{}, error) {
if err := resizePicture(filePath, image.Rect(0, 0, 500, 300)); err != nil {
return nil, err
}
btheme.Image = strings.TrimPrefix(filePath, fic.FilesDir)
btheme.BackgroundColor, _ = getBackgroundColor(filePath)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import heading image: %w", err)))
}
}
if len(btheme.PartnerImage) > 0 {
if _, err := i.importFile(btheme.PartnerImage,
func(filePath string, origin string) (interface{}, error) {
btheme.PartnerImage = strings.TrimPrefix(filePath, fic.FilesDir)
return nil, nil
}); err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import partner image: %w", err)))
}
err = SyncThemeFiles(i, btheme)
if err != nil {
errs = multierr.Append(errs, NewThemeError(btheme, fmt.Errorf("unable to import heading image: %w", err)))
}
var theme *fic.Theme

1
fileexporter/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
fileexporter

42
fileexporter/archive.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"archive/zip"
"errors"
"io"
"os"
"path"
)
type archiveFileCreator interface {
Create(name string) (io.Writer, error)
}
func init() {
OutputFormats["archive"] = func(args ...string) (func(string) (io.WriteCloser, error), error) {
if len(args) != 1 {
return nil, errors.New("archive has 1 required argument: [destination-file]")
}
fd, err := os.Create(args[0])
if err != nil {
return nil, err
}
var w archiveFileCreator
if path.Ext(args[0]) == ".zip" {
w = zip.NewWriter(fd)
} else {
return nil, errors.New("destination file has to have .zip extension")
}
return func(dest string) (io.WriteCloser, error) {
fw, err := w.Create(dest)
if err != nil {
return nil, err
}
return NopCloser(fw), nil
}, nil
}
}

22
fileexporter/copy.go Normal file
View File

@ -0,0 +1,22 @@
package main
import (
"errors"
"io"
"srs.epita.fr/fic-server/libfic"
)
func init() {
OutputFormats["copy"] = func(args ...string) (func(string) (io.WriteCloser, error), error) {
if len(args) > 1 {
return nil, errors.New("copy can only take 1 argument: [destination-folder]")
}
if len(args) == 1 {
fic.FilesDir = args[0]
}
return nil, nil
}
}

176
fileexporter/main.go Normal file
View File

@ -0,0 +1,176 @@
package main
import (
"bytes"
"errors"
"flag"
"io"
"log"
"os"
"path"
"strings"
"srs.epita.fr/fic-server/admin/sync"
"srs.epita.fr/fic-server/libfic"
)
var OutputFormats = map[string]func(...string) (func(string) (io.WriteCloser, error), error){}
func exportThemeFiles(tdir string) (errs error) {
theme, exceptions, err := sync.BuildTheme(sync.GlobalImporter, tdir)
errs = errors.Join(errs, err)
err = sync.SyncThemeFiles(sync.GlobalImporter, theme)
if err != nil {
errs = errors.Join(errs, err)
}
exercices, err := sync.GetExercices(sync.GlobalImporter, theme)
if err != nil {
log.Fatalf("Unable to list exercices for theme %q: %s", theme.Name, err)
}
dmap := map[int64]*fic.Exercice{}
for i, edir := range exercices {
log.Printf("In theme %s, doing exercice %d/%d: %s", tdir, i+1, len(exercices), edir)
err = exportExerciceFiles(theme, edir, &dmap, exceptions)
errs = errors.Join(errs, err)
}
return
}
func exportExerciceFiles(theme *fic.Theme, edir string, dmap *map[int64]*fic.Exercice, exceptions *sync.CheckExceptions) (errs error) {
exercice, _, eid, exceptions, _, berrs := sync.BuildExercice(sync.GlobalImporter, theme, path.Join(theme.Path, edir), dmap, nil)
errs = errors.Join(errs, berrs)
if exercice != nil {
paramsFiles, err := sync.GetExerciceFilesParams(sync.GlobalImporter, exercice)
if err != nil {
errs = errors.Join(errs, sync.NewChallengeTxtError(exercice, 0, err))
return
}
_, err = sync.SyncExerciceFiles(sync.GlobalImporter, exercice, paramsFiles, func(fname string, digests map[string][]byte, filePath, origin string) (interface{}, error) {
return nil, nil
})
errs = errors.Join(errs, err)
if dmap != nil {
(*dmap)[int64(eid)] = exercice
}
}
return
}
type nopCloser struct {
w io.Writer
}
func (nc *nopCloser) Close() error {
return nil
}
func (nc *nopCloser) Write(p []byte) (int, error) {
return nc.w.Write(p)
}
func NopCloser(w io.Writer) *nopCloser {
return &nopCloser{w}
}
func writeFileToTar(dest string) (io.WriteCloser, error) {
log.Println("import2Tar", dest)
return NopCloser(bytes.NewBuffer([]byte{})), nil
}
func main() {
cloudDAVBase := ""
cloudUsername := "fic"
cloudPassword := ""
localImporterDirectory := ""
// Read paremeters from environment
if v, exists := os.LookupEnv("FICCLOUD_URL"); exists {
cloudDAVBase = v
}
if v, exists := os.LookupEnv("FICCLOUD_USER"); exists {
cloudUsername = v
}
if v, exists := os.LookupEnv("FICCLOUD_PASS"); exists {
cloudPassword = v
}
// Read parameters from command line
flag.StringVar(&localImporterDirectory, "localimport", localImporterDirectory,
"Base directory where to find challenges files to import, local part")
flag.StringVar(&cloudDAVBase, "clouddav", cloudDAVBase,
"Base directory where to find challenges files to import, cloud part")
flag.StringVar(&cloudUsername, "clouduser", cloudUsername, "Username used to sync")
flag.StringVar(&cloudPassword, "cloudpass", cloudPassword, "Password used to sync")
flag.BoolVar(&fic.OptionalDigest, "optionaldigest", fic.OptionalDigest, "Is the digest required when importing files?")
flag.BoolVar(&fic.StrongDigest, "strongdigest", fic.StrongDigest, "Are BLAKE2b digests required or is SHA-1 good enough?")
flag.Parse()
// Do not display timestamp
log.SetFlags(0)
// Instantiate importer
if localImporterDirectory != "" {
sync.GlobalImporter = sync.LocalImporter{Base: localImporterDirectory, Symlink: false}
} else if cloudDAVBase != "" {
sync.GlobalImporter, _ = sync.NewCloudImporter(cloudDAVBase, cloudUsername, cloudPassword)
}
if sync.GlobalImporter == nil {
log.Fatal("No importer configured!")
}
log.Println("Using", sync.GlobalImporter.Kind())
// Configure destination
if flag.NArg() < 1 {
var formats []string
for k := range OutputFormats {
formats = append(formats, k)
}
log.Fatal("Please define wanted output format between [" + strings.Join(formats, " ") + "]")
} else if outputFormat, ok := OutputFormats[flag.Arg(0)]; !ok {
var formats []string
for k := range OutputFormats {
formats = append(formats, k)
}
log.Fatal("Please define wanted output format between [" + strings.Join(formats, " ") + "]")
} else {
fw, err := outputFormat(flag.Args()[1:]...)
if err != nil {
log.Fatal(err)
} else if fw != nil {
sync.SetWriteFileFunc(fw)
}
}
themes, err := sync.GetThemesExtended(sync.GlobalImporter)
if err != nil {
log.Fatal(err)
}
hasError := false
for i, tdir := range themes {
log.Printf("Doing theme %d/%d: %s", i+1, len(themes), tdir)
err = exportThemeFiles(tdir)
if err != nil {
hasError = true
log.Println(err)
}
}
if hasError {
os.Exit(1)
}
}

View File

@ -6,7 +6,7 @@ import (
"errors"
"flag"
"fmt"
"io/ioutil"
"io"
"log"
"os"
"os/exec"
@ -194,14 +194,8 @@ func main() {
regenImporter = true
}
var err error
// Create temporary directory for storing FILES/ content
fic.FilesDir, err = ioutil.TempDir("", "fic-repochecker.")
if err != nil {
}
defer os.RemoveAll(fic.FilesDir)
// Don't write any files
sync.SetWriteFileFunc(func(dest string) (io.WriteCloser, error) { return &nullFileWriter{}, nil })
if sync.GlobalImporter != nil {
log.Println("Using", sync.GlobalImporter.Kind())

11
repochecker/null.go Normal file
View File

@ -0,0 +1,11 @@
package main
type nullFileWriter struct{}
func (fw *nullFileWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (fw *nullFileWriter) Close() error {
return nil
}