Compare commits
	
		
			2 Commits
		
	
	
		
			e68dfc445f
			...
			7c6acad689
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7c6acad689 | |||
| 5482899075 | 
| @ -227,6 +227,7 @@ func (api *API) generateTemplates() *multitemplate.Renderer { | |||||||
| 	render := multitemplate.NewRenderer() | 	render := multitemplate.NewRenderer() | ||||||
| 	helperFuncs := template.FuncMap{ | 	helperFuncs := template.FuncMap{ | ||||||
| 		"dict":            dict, | 		"dict":            dict, | ||||||
|  | 		"slice":           slice, | ||||||
| 		"fields":          fields, | 		"fields":          fields, | ||||||
| 		"getSVGGraphData": getSVGGraphData, | 		"getSVGGraphData": getSVGGraphData, | ||||||
| 		"getTimeZones":    getTimeZones, | 		"getTimeZones":    getTimeZones, | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ package api | |||||||
| import ( | import ( | ||||||
| 	"archive/zip" | 	"archive/zip" | ||||||
| 	"bufio" | 	"bufio" | ||||||
|  | 	"crypto/md5" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" | 	"io" | ||||||
| @ -12,14 +13,19 @@ import ( | |||||||
| 	"os" | 	"os" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
| 	"slices" | 	"slices" | ||||||
|  | 	"sort" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	argon2 "github.com/alexedwards/argon2id" | ||||||
| 	"github.com/gabriel-vasile/mimetype" | 	"github.com/gabriel-vasile/mimetype" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/itchyny/gojq" | 	"github.com/itchyny/gojq" | ||||||
|  | 	"github.com/pkg/errors" | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
|  | 	"reichard.io/antholume/database" | ||||||
| 	"reichard.io/antholume/metadata" | 	"reichard.io/antholume/metadata" | ||||||
|  | 	"reichard.io/antholume/utils" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type adminAction string | type adminAction string | ||||||
| @ -59,12 +65,13 @@ type operationType string | |||||||
| const ( | const ( | ||||||
| 	opUpdate operationType = "UPDATE" | 	opUpdate operationType = "UPDATE" | ||||||
| 	opCreate operationType = "CREATE" | 	opCreate operationType = "CREATE" | ||||||
|  | 	opDelete operationType = "DELETE" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type requestAdminUpdateUser struct { | type requestAdminUpdateUser struct { | ||||||
| 	User      string        `form:"user"` | 	User      string        `form:"user"` | ||||||
| 	Password  string        `form:"password"` | 	Password  *string       `form:"password"` | ||||||
| 	isAdmin   bool          `form:"is_admin"` | 	isAdmin   *bool         `form:"is_admin"` | ||||||
| 	Operation operationType `form:"operation"` | 	Operation operationType `form:"operation"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -72,6 +79,22 @@ type requestAdminLogs struct { | |||||||
| 	Filter string `form:"filter"` | 	Filter string `form:"filter"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | type importStatus string | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	importFailed  importStatus = "FAILED" | ||||||
|  | 	importSuccess importStatus = "SUCCESS" | ||||||
|  | 	importExists  importStatus = "EXISTS" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type importResult struct { | ||||||
|  | 	ID     string | ||||||
|  | 	Name   string | ||||||
|  | 	Path   string | ||||||
|  | 	Status importStatus | ||||||
|  | 	Error  error | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (api *API) appPerformAdminAction(c *gin.Context) { | func (api *API) appPerformAdminAction(c *gin.Context) { | ||||||
| 	templateVars, _ := api.getBaseTemplateVars("admin", c) | 	templateVars, _ := api.getBaseTemplateVars("admin", c) | ||||||
| 
 | 
 | ||||||
| @ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// TODO - Messages |  | ||||||
| 	switch rAdminAction.Action { | 	switch rAdminAction.Action { | ||||||
| 	case adminMetadataMatch: | 	case adminMetadataMatch: | ||||||
| 		// TODO | 		// TODO | ||||||
| 		// 1. Documents xref most recent metadata table? | 		// 1. Documents xref most recent metadata table? | ||||||
| 		// 2. Select all / deselect? | 		// 2. Select all / deselect? | ||||||
| 	case adminCacheTables: | 	case adminCacheTables: | ||||||
| 		go api.db.CacheTempTables() | 		go func() { | ||||||
| 		// TODO - Message | 			err := api.db.CacheTempTables() | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Error("Unable to cache temp tables: ", err) | ||||||
|  | 			} | ||||||
|  | 		}() | ||||||
| 	case adminRestore: | 	case adminRestore: | ||||||
| 		api.processRestoreFile(rAdminAction, c) | 		api.processRestoreFile(rAdminAction, c) | ||||||
| 		return | 		return | ||||||
| @ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) { | |||||||
| 	var err error | 	var err error | ||||||
| 	switch rAdminUserUpdate.Operation { | 	switch rAdminUserUpdate.Operation { | ||||||
| 	case opCreate: | 	case opCreate: | ||||||
| 		err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password) | 		err = api.createUser(rAdminUserUpdate) | ||||||
| 	case opUpdate: | 	case opUpdate: | ||||||
| 		err = fmt.Errorf("unimplemented") | 		err = api.updateUser(rAdminUserUpdate) | ||||||
|  | 	case opDelete: | ||||||
|  | 		err = api.deleteUser(rAdminUserUpdate) | ||||||
| 	default: | 	default: | ||||||
| 		appErrorPage(c, http.StatusNotFound, "Unknown user operation") | 		appErrorPage(c, http.StatusNotFound, "Unknown user operation") | ||||||
| 		return | 		return | ||||||
| 
 |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create user: %v", err)) | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| @ -338,46 +365,157 @@ func (api *API) appPerformAdminImport(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// TODO - Store results for approval? | 	// Get import directory | ||||||
| 
 |  | ||||||
| 	// Walk import directory & copy or import files |  | ||||||
| 	importDirectory := filepath.Clean(rAdminImport.Directory) | 	importDirectory := filepath.Clean(rAdminImport.Directory) | ||||||
| 	_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error { | 
 | ||||||
|  | 	// Get data directory | ||||||
|  | 	absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents")) | ||||||
|  | 
 | ||||||
|  | 	// Validate different path | ||||||
|  | 	if absoluteDataPath == importDirectory { | ||||||
|  | 		appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Do Transaction | ||||||
|  | 	tx, err := api.db.DB.Begin() | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Error("Transaction Begin DB Error:", err) | ||||||
|  | 		apiErrorPage(c, http.StatusBadRequest, "Unknown error") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Defer & Start Transaction | ||||||
|  | 	defer func() { | ||||||
|  | 		if err := tx.Rollback(); err != nil { | ||||||
|  | 			log.Error("DB Rollback Error:", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 	qtx := api.db.Queries.WithTx(tx) | ||||||
|  | 
 | ||||||
|  | 	// Track imports | ||||||
|  | 	importResults := make([]importResult, 0) | ||||||
|  | 
 | ||||||
|  | 	// Walk Directory & Import | ||||||
|  | 	err = filepath.WalkDir(importDirectory, func(importPath string, f fs.DirEntry, err error) error { | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
| 		if f.IsDir() { | 		if f.IsDir() { | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Get metadata | 		// Get relative path | ||||||
| 		fileMeta, err := metadata.GetMetadata(currentPath) | 		basePath := importDirectory | ||||||
|  | 		relFilePath, err := filepath.Rel(importDirectory, importPath) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			fmt.Printf("metadata error: %v\n", err) | 			log.Warnf("path error: %v", err) | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Only needed if copying | 		// Track imports | ||||||
| 		newName := deriveBaseFileName(fileMeta) | 		iResult := importResult{ | ||||||
|  | 			Path:   relFilePath, | ||||||
|  | 			Status: importFailed, | ||||||
|  | 		} | ||||||
|  | 		defer func() { | ||||||
|  | 			importResults = append(importResults, iResult) | ||||||
|  | 		}() | ||||||
| 
 | 
 | ||||||
| 		// Open File on Disk | 		// Get metadata | ||||||
| 		// file, err := os.Open(currentPath) | 		fileMeta, err := metadata.GetMetadata(importPath) | ||||||
| 		// if err != nil { | 		if err != nil { | ||||||
| 		// 	return err | 			log.Errorf("metadata error: %v", err) | ||||||
| 		// } | 			iResult.Error = err | ||||||
| 		// defer file.Close() | 			return nil | ||||||
|  | 		} | ||||||
|  | 		iResult.ID = *fileMeta.PartialMD5 | ||||||
|  | 		iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title) | ||||||
| 
 | 
 | ||||||
| 		// TODO - BasePath in DB | 		// Check already exists | ||||||
| 		// TODO - Copy / Import | 		_, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5) | ||||||
|  | 		if err == nil { | ||||||
|  | 			log.Warnf("document already exists: %s", *fileMeta.PartialMD5) | ||||||
|  | 			iResult.Status = importExists | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
| 
 | 
 | ||||||
| 		fmt.Printf("New File Metadata: %s\n", newName) | 		// Import Copy | ||||||
|  | 		if rAdminImport.Type == importCopy { | ||||||
|  | 			// Derive & Sanitize File Name | ||||||
|  | 			relFilePath = deriveBaseFileName(fileMeta) | ||||||
|  | 			safePath := filepath.Join(api.cfg.DataPath, "documents", relFilePath) | ||||||
| 
 | 
 | ||||||
|  | 			// Open Source File | ||||||
|  | 			srcFile, err := os.Open(importPath) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Errorf("unable to open current file: %v", err) | ||||||
|  | 				iResult.Error = err | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 			defer srcFile.Close() | ||||||
|  | 
 | ||||||
|  | 			// Open Destination File | ||||||
|  | 			destFile, err := os.Create(safePath) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.Errorf("unable to open destination file: %v", err) | ||||||
|  | 				iResult.Error = err | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 			defer destFile.Close() | ||||||
|  | 
 | ||||||
|  | 			// Copy File | ||||||
|  | 			if _, err = io.Copy(destFile, srcFile); err != nil { | ||||||
|  | 				log.Errorf("unable to save file: %v", err) | ||||||
|  | 				iResult.Error = err | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Update Base & Path | ||||||
|  | 			basePath = filepath.Join(api.cfg.DataPath, "documents") | ||||||
|  | 			iResult.Path = relFilePath | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Upsert document | ||||||
|  | 		if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ | ||||||
|  | 			ID:          *fileMeta.PartialMD5, | ||||||
|  | 			Title:       fileMeta.Title, | ||||||
|  | 			Author:      fileMeta.Author, | ||||||
|  | 			Description: fileMeta.Description, | ||||||
|  | 			Md5:         fileMeta.MD5, | ||||||
|  | 			Words:       fileMeta.WordCount, | ||||||
|  | 			Filepath:    &relFilePath, | ||||||
|  | 			Basepath:    &basePath, | ||||||
|  | 		}); err != nil { | ||||||
|  | 			log.Errorf("UpsertDocument DB Error: %v", err) | ||||||
|  | 			iResult.Error = err | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		iResult.Status = importSuccess | ||||||
| 		return nil | 		return nil | ||||||
| 	}) | 	}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory) | 	// Commit transaction | ||||||
|  | 	if err := tx.Commit(); err != nil { | ||||||
|  | 		log.Error("Transaction Commit DB Error: ", err) | ||||||
|  | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	c.HTML(http.StatusOK, "page/admin-import", templateVars) | 	// Sort import results | ||||||
|  | 	sort.Slice(importResults, func(i int, j int) bool { | ||||||
|  | 		return importStatusPriority(importResults[i].Status) < | ||||||
|  | 			importStatusPriority(importResults[j].Status) | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	templateVars["Data"] = importResults | ||||||
|  | 	c.HTML(http.StatusOK, "page/admin-import-results", templateVars) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { | func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { | ||||||
| @ -521,7 +659,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte | |||||||
| 	c.Redirect(http.StatusFound, "/login") | 	c.Redirect(http.StatusFound, "/login") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Restore all data |  | ||||||
| func (api *API) restoreData(zipReader *zip.Reader) error { | func (api *API) restoreData(zipReader *zip.Reader) error { | ||||||
| 	// Ensure Directories | 	// Ensure Directories | ||||||
| 	api.cfg.EnsureDirectories() | 	api.cfg.EnsureDirectories() | ||||||
| @ -552,7 +689,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error { | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Remove all data |  | ||||||
| func (api *API) removeData() error { | func (api *API) removeData() error { | ||||||
| 	allPaths := []string{ | 	allPaths := []string{ | ||||||
| 		"covers", | 		"covers", | ||||||
| @ -575,7 +711,6 @@ func (api *API) removeData() error { | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Backup all data |  | ||||||
| func (api *API) createBackup(w io.Writer, directories []string) error { | func (api *API) createBackup(w io.Writer, directories []string) error { | ||||||
| 	ar := zip.NewWriter(w) | 	ar := zip.NewWriter(w) | ||||||
| 
 | 
 | ||||||
| @ -628,7 +763,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error { | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	io.Copy(newDbFile, dbFile) | 
 | ||||||
|  | 	_, err = io.Copy(newDbFile, dbFile) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	// Backup Covers & Documents | 	// Backup Covers & Documents | ||||||
| 	for _, dir := range directories { | 	for _, dir := range directories { | ||||||
| @ -641,3 +780,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error { | |||||||
| 	ar.Close() | 	ar.Close() | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func (api *API) createUser(createRequest requestAdminUpdateUser) error { | ||||||
|  | 	// Validate Necessary Parameters | ||||||
|  | 	if createRequest.User == "" { | ||||||
|  | 		return fmt.Errorf("username can't be empty") | ||||||
|  | 	} | ||||||
|  | 	if createRequest.Password == nil || *createRequest.Password == "" { | ||||||
|  | 		return fmt.Errorf("password can't be empty") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Base Params | ||||||
|  | 	createParams := database.CreateUserParams{ | ||||||
|  | 		ID: createRequest.User, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Handle Admin (Explicit or False) | ||||||
|  | 	if createRequest.isAdmin != nil { | ||||||
|  | 		createParams.Admin = *createRequest.isAdmin | ||||||
|  | 	} else { | ||||||
|  | 		createParams.Admin = false | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Parse Password | ||||||
|  | 	password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password))) | ||||||
|  | 	hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("unable to create hashed password") | ||||||
|  | 	} | ||||||
|  | 	createParams.Pass = &hashedPassword | ||||||
|  | 
 | ||||||
|  | 	// Generate Auth Hash | ||||||
|  | 	rawAuthHash, err := utils.GenerateToken(64) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("unable to create token for user") | ||||||
|  | 	} | ||||||
|  | 	authHash := fmt.Sprintf("%x", rawAuthHash) | ||||||
|  | 	createParams.AuthHash = &authHash | ||||||
|  | 
 | ||||||
|  | 	// Create user in DB | ||||||
|  | 	if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil { | ||||||
|  | 		log.Error("CreateUser DB Error:", err) | ||||||
|  | 		return fmt.Errorf("unable to create user") | ||||||
|  | 	} else if rows == 0 { | ||||||
|  | 		log.Warn("User Already Exists:", createParams.ID) | ||||||
|  | 		return fmt.Errorf("user already exists") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (api *API) updateUser(updateRequest requestAdminUpdateUser) error { | ||||||
|  | 	// Validate Necessary Parameters | ||||||
|  | 	if updateRequest.User == "" { | ||||||
|  | 		return fmt.Errorf("username can't be empty") | ||||||
|  | 	} | ||||||
|  | 	if updateRequest.Password == nil && updateRequest.isAdmin == nil { | ||||||
|  | 		return fmt.Errorf("nothing to update") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Base Params | ||||||
|  | 	updateParams := database.UpdateUserParams{ | ||||||
|  | 		UserID: updateRequest.User, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Handle Admin (Update or Existing) | ||||||
|  | 	if updateRequest.isAdmin != nil { | ||||||
|  | 		updateParams.Admin = *updateRequest.isAdmin | ||||||
|  | 	} else { | ||||||
|  | 		user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) | ||||||
|  | 		} | ||||||
|  | 		updateParams.Admin = user.Admin | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// TODO: | ||||||
|  | 	//   - Validate Not Last Admin | ||||||
|  | 
 | ||||||
|  | 	// Handle Password | ||||||
|  | 	if updateRequest.Password != nil { | ||||||
|  | 		if *updateRequest.Password == "" { | ||||||
|  | 			return fmt.Errorf("password can't be empty") | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Parse Password | ||||||
|  | 		password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password))) | ||||||
|  | 		hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("unable to create hashed password") | ||||||
|  | 		} | ||||||
|  | 		updateParams.Password = &hashedPassword | ||||||
|  | 
 | ||||||
|  | 		// Generate Auth Hash | ||||||
|  | 		rawAuthHash, err := utils.GenerateToken(64) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("unable to create token for user") | ||||||
|  | 		} | ||||||
|  | 		authHash := fmt.Sprintf("%x", rawAuthHash) | ||||||
|  | 		updateParams.AuthHash = &authHash | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Update User | ||||||
|  | 	_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err)) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error { | ||||||
|  | 	// TODO: | ||||||
|  | 	//   - Validate Not Last Admin | ||||||
|  | 	return errors.New("unimplemented") | ||||||
|  | } | ||||||
|  | |||||||
| @ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) { | |||||||
| 	templateVars, _ := api.getBaseTemplateVars("search", c) | 	templateVars, _ := api.getBaseTemplateVars("search", c) | ||||||
| 
 | 
 | ||||||
| 	var sParams searchParams | 	var sParams searchParams | ||||||
| 	c.BindQuery(&sParams) | 	err := c.BindQuery(&sParams) | ||||||
|  | 	if err != nil { | ||||||
|  | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	// Only Handle Query | 	// Only Handle Query | ||||||
| 	if sParams.Query != nil && sParams.Source != nil { | 	if sParams.Query != nil && sParams.Source != nil { | ||||||
| @ -369,10 +373,9 @@ func (api *API) appGetDocumentProgress(c *gin.Context) { | |||||||
| 		DocumentID: rDoc.DocumentID, | 		DocumentID: rDoc.DocumentID, | ||||||
| 		UserID:     auth.UserName, | 		UserID:     auth.UserName, | ||||||
| 	}) | 	}) | ||||||
| 
 |  | ||||||
| 	if err != nil && err != sql.ErrNoRows { | 	if err != nil && err != sql.ErrNoRows { | ||||||
| 		log.Error("UpsertDocument DB Error: ", err) | 		log.Error("GetDocumentProgress DB Error: ", err) | ||||||
| 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err)) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| @ -461,7 +464,8 @@ func (api *API) appUploadNewDocument(c *gin.Context) { | |||||||
| 
 | 
 | ||||||
| 	// Derive & Sanitize File Name | 	// Derive & Sanitize File Name | ||||||
| 	fileName := deriveBaseFileName(metadataInfo) | 	fileName := deriveBaseFileName(metadataInfo) | ||||||
| 	safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) | 	basePath := filepath.Join(api.cfg.DataPath, "documents") | ||||||
|  | 	safePath := filepath.Join(basePath, fileName) | ||||||
| 
 | 
 | ||||||
| 	// Open Destination File | 	// Open Destination File | ||||||
| 	destFile, err := os.Create(safePath) | 	destFile, err := os.Create(safePath) | ||||||
| @ -488,9 +492,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) { | |||||||
| 		Md5:         metadataInfo.MD5, | 		Md5:         metadataInfo.MD5, | ||||||
| 		Words:       metadataInfo.WordCount, | 		Words:       metadataInfo.WordCount, | ||||||
| 		Filepath:    &fileName, | 		Filepath:    &fileName, | ||||||
| 
 | 		Basepath:    &basePath, | ||||||
| 		// TODO (BasePath): |  | ||||||
| 		//   - Should be current config directory |  | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		log.Errorf("UpsertDocument DB Error: %v", err) | 		log.Errorf("UpsertDocument DB Error: %v", err) | ||||||
| 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) | ||||||
| @ -595,7 +597,6 @@ func (api *API) appEditDocument(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	c.Redirect(http.StatusFound, "./") | 	c.Redirect(http.StatusFound, "./") | ||||||
| 	return |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (api *API) appDeleteDocument(c *gin.Context) { | func (api *API) appDeleteDocument(c *gin.Context) { | ||||||
| @ -764,6 +765,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) { | |||||||
| 
 | 
 | ||||||
| 	// Derive Extension on MIME | 	// Derive Extension on MIME | ||||||
| 	fileMime, err := mimetype.DetectFile(tempFilePath) | 	fileMime, err := mimetype.DetectFile(tempFilePath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Warn("MIME Detect Error: ", err) | ||||||
|  | 		sendDownloadMessage("Unable to download file", gin.H{"Error": true}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
| 	fileExtension := fileMime.Extension() | 	fileExtension := fileMime.Extension() | ||||||
| 
 | 
 | ||||||
| 	// Derive Filename | 	// Derive Filename | ||||||
| @ -797,7 +803,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) { | |||||||
| 	defer sourceFile.Close() | 	defer sourceFile.Close() | ||||||
| 
 | 
 | ||||||
| 	// Generate Storage Path & Open File | 	// Generate Storage Path & Open File | ||||||
| 	safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) | 	basePath := filepath.Join(api.cfg.DataPath, "documents") | ||||||
|  | 	safePath := filepath.Join(basePath, fileName) | ||||||
|  | 
 | ||||||
| 	destFile, err := os.Create(safePath) | 	destFile, err := os.Create(safePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("Dest File Error: ", err) | 		log.Error("Dest File Error: ", err) | ||||||
| @ -844,8 +852,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) { | |||||||
| 		Title:    rDocAdd.Title, | 		Title:    rDocAdd.Title, | ||||||
| 		Author:   rDocAdd.Author, | 		Author:   rDocAdd.Author, | ||||||
| 		Md5:      fileHash, | 		Md5:      fileHash, | ||||||
| 		Filepath: &fileName, |  | ||||||
| 		Words:    wordCount, | 		Words:    wordCount, | ||||||
|  | 		Filepath: &fileName, | ||||||
|  | 		Basepath: &basePath, | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		log.Error("UpsertDocument DB Error: ", err) | 		log.Error("UpsertDocument DB Error: ", err) | ||||||
| 		sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) | 		sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) | ||||||
| @ -951,7 +960,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Defer & Start Transaction | 	// Defer & Start Transaction | ||||||
| 	defer tx.Rollback() | 	defer func() { | ||||||
|  | 		if err := tx.Rollback(); err != nil { | ||||||
|  | 			log.Error("DB Rollback Error:", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
| 	qtx := api.db.Queries.WithTx(tx) | 	qtx := api.db.Queries.WithTx(tx) | ||||||
| 
 | 
 | ||||||
| 	for _, item := range documents { | 	for _, item := range documents { | ||||||
| @ -1000,7 +1013,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au | |||||||
| 
 | 
 | ||||||
| func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { | func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { | ||||||
| 	var qParams queryParams | 	var qParams queryParams | ||||||
| 	c.BindQuery(&qParams) | 	err := c.BindQuery(&qParams) | ||||||
|  | 	if err != nil { | ||||||
|  | 		appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) | ||||||
|  | 		return qParams | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	if qParams.Limit == nil { | 	if qParams.Limit == nil { | ||||||
| 		qParams.Limit = &defaultLimit | 		qParams.Limit = &defaultLimit | ||||||
|  | |||||||
							
								
								
									
										93
									
								
								api/auth.go
									
									
									
									
									
								
							
							
						
						
									
										93
									
								
								api/auth.go
									
									
									
									
									
								
							| @ -28,18 +28,13 @@ type authKOHeader struct { | |||||||
| 	AuthKey  string `header:"x-auth-key"` | 	AuthKey  string `header:"x-auth-key"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // OPDS Auth Headers |  | ||||||
| type authOPDSHeader struct { |  | ||||||
| 	Authorization string `header:"authorization"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (api *API) authorizeCredentials(username string, password string) (auth *authData) { | func (api *API) authorizeCredentials(username string, password string) (auth *authData) { | ||||||
| 	user, err := api.db.Queries.GetUser(api.db.Ctx, username) | 	user, err := api.db.Queries.GetUser(api.db.Ctx, username) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { | 	if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| @ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) { | |||||||
| 	session := sessions.Default(c) | 	session := sessions.Default(c) | ||||||
| 
 | 
 | ||||||
| 	// Check Session First | 	// Check Session First | ||||||
| 	if auth, ok := api.getSession(session); ok == true { | 	if auth, ok := api.getSession(session); ok { | ||||||
| 		c.Set("Authorization", auth) | 		c.Set("Authorization", auth) | ||||||
| 		c.Header("Cache-Control", "private") | 		c.Header("Cache-Control", "private") | ||||||
| 		c.Next() | 		c.Next() | ||||||
| @ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) { | |||||||
| 	user, rawPassword, hasAuth := c.Request.BasicAuth() | 	user, rawPassword, hasAuth := c.Request.BasicAuth() | ||||||
| 
 | 
 | ||||||
| 	// Validate Auth Fields | 	// Validate Auth Fields | ||||||
| 	if hasAuth != true || user == "" || rawPassword == "" { | 	if !hasAuth || user == "" || rawPassword == "" { | ||||||
| 		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) | 		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| @ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { | |||||||
| 	session := sessions.Default(c) | 	session := sessions.Default(c) | ||||||
| 
 | 
 | ||||||
| 	// Check Session | 	// Check Session | ||||||
| 	if auth, ok := api.getSession(session); ok == true { | 	if auth, ok := api.getSession(session); ok { | ||||||
| 		c.Set("Authorization", auth) | 		c.Set("Authorization", auth) | ||||||
| 		c.Header("Cache-Control", "private") | 		c.Header("Cache-Control", "private") | ||||||
| 		c.Next() | 		c.Next() | ||||||
| @ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { | |||||||
| 
 | 
 | ||||||
| 	c.Redirect(http.StatusFound, "/login") | 	c.Redirect(http.StatusFound, "/login") | ||||||
| 	c.Abort() | 	c.Abort() | ||||||
| 	return |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (api *API) authAdminWebAppMiddleware(c *gin.Context) { | func (api *API) authAdminWebAppMiddleware(c *gin.Context) { | ||||||
| 	if data, _ := c.Get("Authorization"); data != nil { | 	if data, _ := c.Get("Authorization"); data != nil { | ||||||
| 		auth := data.(authData) | 		auth := data.(authData) | ||||||
| 		if auth.IsAdmin == true { | 		if auth.IsAdmin { | ||||||
| 			c.Next() | 			c.Next() | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
| @ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) { | |||||||
| 
 | 
 | ||||||
| 	appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") | 	appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") | ||||||
| 	c.Abort() | 	c.Abort() | ||||||
| 	return |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (api *API) appAuthLogin(c *gin.Context) { | func (api *API) appAuthLogin(c *gin.Context) { | ||||||
| @ -276,7 +269,10 @@ func (api *API) appAuthRegister(c *gin.Context) { | |||||||
| func (api *API) appAuthLogout(c *gin.Context) { | func (api *API) appAuthLogout(c *gin.Context) { | ||||||
| 	session := sessions.Default(c) | 	session := sessions.Default(c) | ||||||
| 	session.Clear() | 	session.Clear() | ||||||
| 	session.Save() | 	if err := session.Save(); err != nil { | ||||||
|  | 		log.Error("unable to save session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	c.Redirect(http.StatusFound, "/login") | 	c.Redirect(http.StatusFound, "/login") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) { | |||||||
| 	// Refresh | 	// Refresh | ||||||
| 	if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { | 	if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { | ||||||
| 		log.Info("Refreshing Session") | 		log.Info("Refreshing Session") | ||||||
| 		api.setSession(session, auth) | 		if err := api.setSession(session, auth); err != nil { | ||||||
|  | 			log.Error("unable to get session") | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Authorized | 	// Authorized | ||||||
| @ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Defer & Start Transaction | 	// Defer & Start Transaction | ||||||
| 	defer tx.Rollback() | 	defer func() { | ||||||
|  | 		if err := tx.Rollback(); err != nil { | ||||||
|  | 			log.Error("DB Rollback Error:", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
| 	qtx := api.db.Queries.WithTx(tx) | 	qtx := api.db.Queries.WithTx(tx) | ||||||
| 
 | 
 | ||||||
| 	users, err := qtx.GetUsers(api.db.Ctx) | 	users, err := qtx.GetUsers(api.db.Ctx) | ||||||
| @ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Update users | 	// Update Users | ||||||
|  | 	newAuthHashCache := make(map[string]string, 0) | ||||||
| 	for _, user := range users { | 	for _, user := range users { | ||||||
| 		// Generate Auth Hash | 		// Generate Auth Hash | ||||||
| 		rawAuthHash, err := utils.GenerateToken(64) | 		rawAuthHash, err := utils.GenerateToken(64) | ||||||
| @ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error { | |||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Update Cache | 		// Save New Hash Cache | ||||||
| 		api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) | 		newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Commit Transaction | 	// Commit Transaction | ||||||
| @ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error { | |||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	// Transaction Succeeded -> Update Cache | ||||||
| } | 	for user, hash := range newAuthHashCache { | ||||||
| 
 | 		api.userAuthCache[user] = hash | ||||||
| func (api *API) createUser(username string, rawPassword string) error { |  | ||||||
| 	password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) |  | ||||||
| 
 |  | ||||||
| 	if username == "" { |  | ||||||
| 		return fmt.Errorf("username can't be empty") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if rawPassword == "" { |  | ||||||
| 		return fmt.Errorf("password can't be empty") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("unable to create hashed password") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Generate auth hash |  | ||||||
| 	rawAuthHash, err := utils.GenerateToken(64) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("unable to create token for user") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Get current users |  | ||||||
| 	currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("unable to get current users") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Determine if we should be admin |  | ||||||
| 	isAdmin := false |  | ||||||
| 	if len(currentUsers) == 0 { |  | ||||||
| 		isAdmin = true |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Create user in DB |  | ||||||
| 	authHash := fmt.Sprintf("%x", rawAuthHash) |  | ||||||
| 	if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ |  | ||||||
| 		ID:       username, |  | ||||||
| 		Pass:     &hashedPassword, |  | ||||||
| 		AuthHash: &authHash, |  | ||||||
| 		Admin:    isAdmin, |  | ||||||
| 	}); err != nil { |  | ||||||
| 		log.Error("CreateUser DB Error:", err) |  | ||||||
| 		return fmt.Errorf("unable to create user") |  | ||||||
| 	} else if rows == 0 { |  | ||||||
| 		log.Warn("User Already Exists:", username) |  | ||||||
| 		return fmt.Errorf("user already exists") |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
|  | |||||||
| @ -2,11 +2,12 @@ package api | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	log "github.com/sirupsen/logrus" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 
 | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
|  | 	log "github.com/sirupsen/logrus" | ||||||
| 	"reichard.io/antholume/database" | 	"reichard.io/antholume/database" | ||||||
| 	"reichard.io/antholume/metadata" | 	"reichard.io/antholume/metadata" | ||||||
| ) | ) | ||||||
| @ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int, | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		// Derive Basepath | ||||||
|  | 		basepath := filepath.Join(api.cfg.DataPath, "documents") | ||||||
|  | 		if document.Basepath != nil && *document.Basepath != "" { | ||||||
|  | 			basepath = *document.Basepath | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		// Derive Storage Location | 		// Derive Storage Location | ||||||
| 		filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath) | 		filePath := filepath.Join(basepath, *document.Filepath) | ||||||
| 
 | 
 | ||||||
| 		// Validate File Exists | 		// Validate File Exists | ||||||
| 		_, err = os.Stat(filePath) | 		_, err = os.Stat(filePath) | ||||||
|  | |||||||
| @ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) { | |||||||
| 	allDocuments := getKeys(allDocumentsMap) | 	allDocuments := getKeys(allDocumentsMap) | ||||||
| 
 | 
 | ||||||
| 	// Defer & Start Transaction | 	// Defer & Start Transaction | ||||||
| 	defer tx.Rollback() | 	defer func() { | ||||||
|  | 		if err := tx.Rollback(); err != nil { | ||||||
|  | 			log.Error("DB Rollback Error:", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
| 	qtx := api.db.Queries.WithTx(tx) | 	qtx := api.db.Queries.WithTx(tx) | ||||||
| 
 | 
 | ||||||
| 	// Upsert Documents | 	// Upsert Documents | ||||||
| @ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Defer & Start Transaction | 	// Defer & Start Transaction | ||||||
| 	defer tx.Rollback() | 	defer func() { | ||||||
|  | 		if err := tx.Rollback(); err != nil { | ||||||
|  | 			log.Error("DB Rollback Error:", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
| 	qtx := api.db.Queries.WithTx(tx) | 	qtx := api.db.Queries.WithTx(tx) | ||||||
| 
 | 
 | ||||||
| 	// Upsert Documents | 	// Upsert Documents | ||||||
| @ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	missingDocs := []database.Document{} |  | ||||||
| 	deletedDocIDs := []string{} |  | ||||||
| 
 |  | ||||||
| 	// Get Missing Documents | 	// Get Missing Documents | ||||||
| 	missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) | 	missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("GetMissingDocuments DB Error", err) | 		log.Error("GetMissingDocuments DB Error", err) | ||||||
| 		apiErrorPage(c, http.StatusBadRequest, "Invalid Request") | 		apiErrorPage(c, http.StatusBadRequest, "Invalid Request") | ||||||
| @ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Get Deleted Documents | 	// Get Deleted Documents | ||||||
| 	deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) | 	deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("GetDeletedDocuments DB Error", err) | 		log.Error("GetDeletedDocuments DB Error", err) | ||||||
| 		apiErrorPage(c, http.StatusBadRequest, "Invalid Request") | 		apiErrorPage(c, http.StatusBadRequest, "Invalid Request") | ||||||
| @ -494,7 +499,8 @@ func (api *API) koUploadExistingDocument(c *gin.Context) { | |||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	// Generate Storage Path | 	// Generate Storage Path | ||||||
| 	safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) | 	basePath := filepath.Join(api.cfg.DataPath, "documents") | ||||||
|  | 	safePath := filepath.Join(basePath, fileName) | ||||||
| 
 | 
 | ||||||
| 	// Save & Prevent Overwrites | 	// Save & Prevent Overwrites | ||||||
| 	_, err = os.Stat(safePath) | 	_, err = os.Stat(safePath) | ||||||
| @ -521,6 +527,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) { | |||||||
| 		Md5:      metadataInfo.MD5, | 		Md5:      metadataInfo.MD5, | ||||||
| 		Words:    metadataInfo.WordCount, | 		Words:    metadataInfo.WordCount, | ||||||
| 		Filepath: &fileName, | 		Filepath: &fileName, | ||||||
|  | 		Basepath: &basePath, | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		log.Error("UpsertDocument DB Error:", err) | 		log.Error("UpsertDocument DB Error:", err) | ||||||
| 		apiErrorPage(c, http.StatusBadRequest, "Document Error") | 		apiErrorPage(c, http.StatusBadRequest, "Document Error") | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								api/utils.go
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								api/utils.go
									
									
									
									
									
								
							| @ -108,11 +108,11 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv | |||||||
| 	return graph.GetSVGGraphData(intData, svgWidth, svgHeight) | 	return graph.GetSVGGraphData(intData, svgWidth, svgHeight) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func dict(values ...interface{}) (map[string]interface{}, error) { | func dict(values ...any) (map[string]any, error) { | ||||||
| 	if len(values)%2 != 0 { | 	if len(values)%2 != 0 { | ||||||
| 		return nil, errors.New("invalid dict call") | 		return nil, errors.New("invalid dict call") | ||||||
| 	} | 	} | ||||||
| 	dict := make(map[string]interface{}, len(values)/2) | 	dict := make(map[string]any, len(values)/2) | ||||||
| 	for i := 0; i < len(values); i += 2 { | 	for i := 0; i < len(values); i += 2 { | ||||||
| 		key, ok := values[i].(string) | 		key, ok := values[i].(string) | ||||||
| 		if !ok { | 		if !ok { | ||||||
| @ -123,12 +123,12 @@ func dict(values ...interface{}) (map[string]interface{}, error) { | |||||||
| 	return dict, nil | 	return dict, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func fields(value interface{}) (map[string]interface{}, error) { | func fields(value any) (map[string]any, error) { | ||||||
| 	v := reflect.Indirect(reflect.ValueOf(value)) | 	v := reflect.Indirect(reflect.ValueOf(value)) | ||||||
| 	if v.Kind() != reflect.Struct { | 	if v.Kind() != reflect.Struct { | ||||||
| 		return nil, fmt.Errorf("%T is not a struct", value) | 		return nil, fmt.Errorf("%T is not a struct", value) | ||||||
| 	} | 	} | ||||||
| 	m := make(map[string]interface{}) | 	m := make(map[string]any) | ||||||
| 	t := v.Type() | 	t := v.Type() | ||||||
| 	for i := 0; i < t.NumField(); i++ { | 	for i := 0; i < t.NumField(); i++ { | ||||||
| 		sv := t.Field(i) | 		sv := t.Field(i) | ||||||
| @ -137,6 +137,10 @@ func fields(value interface{}) (map[string]interface{}, error) { | |||||||
| 	return m, nil | 	return m, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func slice(elements ...any) []any { | ||||||
|  | 	return elements | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { | func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { | ||||||
| 	// Derive New FileName | 	// Derive New FileName | ||||||
| 	var newFileName string | 	var newFileName string | ||||||
| @ -155,3 +159,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { | |||||||
| 	fileName := strings.ReplaceAll(newFileName, "/", "") | 	fileName := strings.ReplaceAll(newFileName, "/", "") | ||||||
| 	return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) | 	return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func importStatusPriority(status importStatus) int { | ||||||
|  | 	switch status { | ||||||
|  | 	case importFailed: | ||||||
|  | 		return 1 | ||||||
|  | 	case importExists: | ||||||
|  | 		return 2 | ||||||
|  | 	default: | ||||||
|  | 		return 3 | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | |||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										20
									
								
								assets/sw.js
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								assets/sw.js
									
									
									
									
									
								
							| @ -99,7 +99,7 @@ const PRECACHE_ASSETS = [ | |||||||
| // ----------------------- Helpers ----------------------- //
 | // ----------------------- Helpers ----------------------- //
 | ||||||
| // ------------------------------------------------------- //
 | // ------------------------------------------------------- //
 | ||||||
| 
 | 
 | ||||||
| function purgeCache() { | async function purgeCache() { | ||||||
|   console.log("[purgeCache] Purging Cache"); |   console.log("[purgeCache] Purging Cache"); | ||||||
|   return caches.keys().then(function (names) { |   return caches.keys().then(function (names) { | ||||||
|     for (let name of names) caches.delete(name); |     for (let name of names) caches.delete(name); | ||||||
| @ -136,7 +136,7 @@ async function handleFetch(event) { | |||||||
|   const directive = ROUTES.find( |   const directive = ROUTES.find( | ||||||
|     (item) => |     (item) => | ||||||
|       (item.route instanceof RegExp && url.match(item.route)) || |       (item.route instanceof RegExp && url.match(item.route)) || | ||||||
|       url == item.route |       url == item.route, | ||||||
|   ) || { type: CACHE_NEVER }; |   ) || { type: CACHE_NEVER }; | ||||||
| 
 | 
 | ||||||
|   // Get Fallback
 |   // Get Fallback
 | ||||||
| @ -161,11 +161,11 @@ async function handleFetch(event) { | |||||||
|       ); |       ); | ||||||
|     case CACHE_UPDATE_SYNC: |     case CACHE_UPDATE_SYNC: | ||||||
|       return updateCache(event.request).catch( |       return updateCache(event.request).catch( | ||||||
|         (e) => currentCache || fallbackFunc(event) |         (e) => currentCache || fallbackFunc(event), | ||||||
|       ); |       ); | ||||||
|     case CACHE_UPDATE_ASYNC: |     case CACHE_UPDATE_ASYNC: | ||||||
|       let newResponse = updateCache(event.request).catch((e) => |       let newResponse = updateCache(event.request).catch((e) => | ||||||
|         fallbackFunc(event) |         fallbackFunc(event), | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       return currentCache || newResponse; |       return currentCache || newResponse; | ||||||
| @ -192,7 +192,7 @@ function handleMessage(event) { | |||||||
|         .filter( |         .filter( | ||||||
|           (item) => |           (item) => | ||||||
|             item.startsWith("/documents/") || |             item.startsWith("/documents/") || | ||||||
|             item.startsWith("/reader/progress/") |             item.startsWith("/reader/progress/"), | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|       // Derive Unique IDs
 |       // Derive Unique IDs
 | ||||||
| @ -200,8 +200,8 @@ function handleMessage(event) { | |||||||
|         new Set( |         new Set( | ||||||
|           docResources |           docResources | ||||||
|             .filter((item) => item.startsWith("/documents/")) |             .filter((item) => item.startsWith("/documents/")) | ||||||
|             .map((item) => item.split("/")[2]) |             .map((item) => item.split("/")[2]), | ||||||
|         ) |         ), | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       /** |       /** | ||||||
| @ -214,14 +214,14 @@ function handleMessage(event) { | |||||||
|           .filter( |           .filter( | ||||||
|             (id) => |             (id) => | ||||||
|               docResources.includes("/documents/" + id + "/file") && |               docResources.includes("/documents/" + id + "/file") && | ||||||
|               docResources.includes("/reader/progress/" + id) |               docResources.includes("/reader/progress/" + id), | ||||||
|           ) |           ) | ||||||
|           .map(async (id) => { |           .map(async (id) => { | ||||||
|             let url = "/reader/progress/" + id; |             let url = "/reader/progress/" + id; | ||||||
|             let currentCache = await caches.match(url); |             let currentCache = await caches.match(url); | ||||||
|             let resp = await updateCache(url).catch((e) => currentCache); |             let resp = await updateCache(url).catch((e) => currentCache); | ||||||
|             return resp.json(); |             return resp.json(); | ||||||
|           }) |           }), | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       event.source.postMessage({ id, data: cachedDocuments }); |       event.source.postMessage({ id, data: cachedDocuments }); | ||||||
| @ -233,7 +233,7 @@ function handleMessage(event) { | |||||||
|         Promise.all([ |         Promise.all([ | ||||||
|           cache.delete("/documents/" + data.id + "/file"), |           cache.delete("/documents/" + data.id + "/file"), | ||||||
|           cache.delete("/reader/progress/" + data.id), |           cache.delete("/reader/progress/" + data.id), | ||||||
|         ]) |         ]), | ||||||
|       ) |       ) | ||||||
|       .then(() => event.source.postMessage({ id, data: "SUCCESS" })) |       .then(() => event.source.postMessage({ id, data: "SUCCESS" })) | ||||||
|       .catch(() => event.source.postMessage({ id, data: "FAILURE" })); |       .catch(() => event.source.postMessage({ id, data: "FAILURE" })); | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								database/migrations/20240510123707_import_basepath.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								database/migrations/20240510123707_import_basepath.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | package migrations | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"database/sql" | ||||||
|  | 
 | ||||||
|  | 	"github.com/pressly/goose/v3" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	goose.AddMigrationContext(upImportBasepath, downImportBasepath) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func upImportBasepath(ctx context.Context, tx *sql.Tx) error { | ||||||
|  | 	// Determine if we have a new DB or not | ||||||
|  | 	isNew := ctx.Value("isNew").(bool) | ||||||
|  | 	if isNew { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Add basepath column | ||||||
|  | 	_, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// This code is executed when the migration is applied. | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func downImportBasepath(ctx context.Context, tx *sql.Tx) error { | ||||||
|  | 	// Drop basepath column | ||||||
|  | 	_, err := tx.Exec("ALTER documents DROP COLUMN basepath;") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
| @ -30,6 +30,7 @@ type Device struct { | |||||||
| type Document struct { | type Document struct { | ||||||
| 	ID          string  `json:"id"` | 	ID          string  `json:"id"` | ||||||
| 	Md5         *string `json:"md5"` | 	Md5         *string `json:"md5"` | ||||||
|  | 	Basepath    *string `json:"basepath"` | ||||||
| 	Filepath    *string `json:"filepath"` | 	Filepath    *string `json:"filepath"` | ||||||
| 	Coverfile   *string `json:"coverfile"` | 	Coverfile   *string `json:"coverfile"` | ||||||
| 	Title       *string `json:"title"` | 	Title       *string `json:"title"` | ||||||
|  | |||||||
| @ -396,6 +396,7 @@ RETURNING *; | |||||||
| INSERT INTO documents ( | INSERT INTO documents ( | ||||||
|     id, |     id, | ||||||
|     md5, |     md5, | ||||||
|  |     basepath, | ||||||
|     filepath, |     filepath, | ||||||
|     coverfile, |     coverfile, | ||||||
|     title, |     title, | ||||||
| @ -410,10 +411,11 @@ INSERT INTO documents ( | |||||||
|     isbn10, |     isbn10, | ||||||
|     isbn13 |     isbn13 | ||||||
| ) | ) | ||||||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||||||
| ON CONFLICT DO UPDATE | ON CONFLICT DO UPDATE | ||||||
| SET | SET | ||||||
|     md5 =           COALESCE(excluded.md5, md5), |     md5 =           COALESCE(excluded.md5, md5), | ||||||
|  |     basepath =      COALESCE(excluded.basepath, basepath), | ||||||
|     filepath =      COALESCE(excluded.filepath, filepath), |     filepath =      COALESCE(excluded.filepath, filepath), | ||||||
|     coverfile =     COALESCE(excluded.coverfile, coverfile), |     coverfile =     COALESCE(excluded.coverfile, coverfile), | ||||||
|     title =         COALESCE(excluded.title, title), |     title =         COALESCE(excluded.title, title), | ||||||
|  | |||||||
| @ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const getDocument = `-- name: GetDocument :one | const getDocument = `-- name: GetDocument :one | ||||||
| SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents | SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents | ||||||
| WHERE id = ?1 LIMIT 1 | WHERE id = ?1 LIMIT 1 | ||||||
| ` | ` | ||||||
| 
 | 
 | ||||||
| @ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document, | |||||||
| 	err := row.Scan( | 	err := row.Scan( | ||||||
| 		&i.ID, | 		&i.ID, | ||||||
| 		&i.Md5, | 		&i.Md5, | ||||||
|  | 		&i.Basepath, | ||||||
| 		&i.Filepath, | 		&i.Filepath, | ||||||
| 		&i.Coverfile, | 		&i.Coverfile, | ||||||
| 		&i.Title, | 		&i.Title, | ||||||
| @ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const getDocuments = `-- name: GetDocuments :many | const getDocuments = `-- name: GetDocuments :many | ||||||
| SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents | SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents | ||||||
| ORDER BY created_at DESC | ORDER BY created_at DESC | ||||||
| LIMIT ?2 | LIMIT ?2 | ||||||
| OFFSET ?1 | OFFSET ?1 | ||||||
| @ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D | |||||||
| 		if err := rows.Scan( | 		if err := rows.Scan( | ||||||
| 			&i.ID, | 			&i.ID, | ||||||
| 			&i.Md5, | 			&i.Md5, | ||||||
|  | 			&i.Basepath, | ||||||
| 			&i.Filepath, | 			&i.Filepath, | ||||||
| 			&i.Coverfile, | 			&i.Coverfile, | ||||||
| 			&i.Title, | 			&i.Title, | ||||||
| @ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const getMissingDocuments = `-- name: GetMissingDocuments :many | const getMissingDocuments = `-- name: GetMissingDocuments :many | ||||||
| SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents | SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents | ||||||
| WHERE | WHERE | ||||||
|     documents.filepath IS NOT NULL |     documents.filepath IS NOT NULL | ||||||
|     AND documents.deleted = false |     AND documents.deleted = false | ||||||
| @ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string) | |||||||
| 		if err := rows.Scan( | 		if err := rows.Scan( | ||||||
| 			&i.ID, | 			&i.ID, | ||||||
| 			&i.Md5, | 			&i.Md5, | ||||||
|  | 			&i.Basepath, | ||||||
| 			&i.Filepath, | 			&i.Filepath, | ||||||
| 			&i.Coverfile, | 			&i.Coverfile, | ||||||
| 			&i.Title, | 			&i.Title, | ||||||
| @ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one | |||||||
| INSERT INTO documents ( | INSERT INTO documents ( | ||||||
|     id, |     id, | ||||||
|     md5, |     md5, | ||||||
|  |     basepath, | ||||||
|     filepath, |     filepath, | ||||||
|     coverfile, |     coverfile, | ||||||
|     title, |     title, | ||||||
| @ -1339,10 +1343,11 @@ INSERT INTO documents ( | |||||||
|     isbn10, |     isbn10, | ||||||
|     isbn13 |     isbn13 | ||||||
| ) | ) | ||||||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||||||
| ON CONFLICT DO UPDATE | ON CONFLICT DO UPDATE | ||||||
| SET | SET | ||||||
|     md5 =           COALESCE(excluded.md5, md5), |     md5 =           COALESCE(excluded.md5, md5), | ||||||
|  |     basepath =      COALESCE(excluded.basepath, basepath), | ||||||
|     filepath =      COALESCE(excluded.filepath, filepath), |     filepath =      COALESCE(excluded.filepath, filepath), | ||||||
|     coverfile =     COALESCE(excluded.coverfile, coverfile), |     coverfile =     COALESCE(excluded.coverfile, coverfile), | ||||||
|     title =         COALESCE(excluded.title, title), |     title =         COALESCE(excluded.title, title), | ||||||
| @ -1356,12 +1361,13 @@ SET | |||||||
|     gbid =          COALESCE(excluded.gbid, gbid), |     gbid =          COALESCE(excluded.gbid, gbid), | ||||||
|     isbn10 =        COALESCE(excluded.isbn10, isbn10), |     isbn10 =        COALESCE(excluded.isbn10, isbn10), | ||||||
|     isbn13 =        COALESCE(excluded.isbn13, isbn13) |     isbn13 =        COALESCE(excluded.isbn13, isbn13) | ||||||
| RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at | RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at | ||||||
| ` | ` | ||||||
| 
 | 
 | ||||||
| type UpsertDocumentParams struct { | type UpsertDocumentParams struct { | ||||||
| 	ID          string  `json:"id"` | 	ID          string  `json:"id"` | ||||||
| 	Md5         *string `json:"md5"` | 	Md5         *string `json:"md5"` | ||||||
|  | 	Basepath    *string `json:"basepath"` | ||||||
| 	Filepath    *string `json:"filepath"` | 	Filepath    *string `json:"filepath"` | ||||||
| 	Coverfile   *string `json:"coverfile"` | 	Coverfile   *string `json:"coverfile"` | ||||||
| 	Title       *string `json:"title"` | 	Title       *string `json:"title"` | ||||||
| @ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams) | |||||||
| 	row := q.db.QueryRowContext(ctx, upsertDocument, | 	row := q.db.QueryRowContext(ctx, upsertDocument, | ||||||
| 		arg.ID, | 		arg.ID, | ||||||
| 		arg.Md5, | 		arg.Md5, | ||||||
|  | 		arg.Basepath, | ||||||
| 		arg.Filepath, | 		arg.Filepath, | ||||||
| 		arg.Coverfile, | 		arg.Coverfile, | ||||||
| 		arg.Title, | 		arg.Title, | ||||||
| @ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams) | |||||||
| 	err := row.Scan( | 	err := row.Scan( | ||||||
| 		&i.ID, | 		&i.ID, | ||||||
| 		&i.Md5, | 		&i.Md5, | ||||||
|  | 		&i.Basepath, | ||||||
| 		&i.Filepath, | 		&i.Filepath, | ||||||
| 		&i.Coverfile, | 		&i.Coverfile, | ||||||
| 		&i.Title, | 		&i.Title, | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents ( | |||||||
|     id TEXT NOT NULL PRIMARY KEY, |     id TEXT NOT NULL PRIMARY KEY, | ||||||
| 
 | 
 | ||||||
|     md5 TEXT, |     md5 TEXT, | ||||||
|  |     basepath TEXT, | ||||||
|     filepath TEXT, |     filepath TEXT, | ||||||
|     coverfile TEXT, |     coverfile TEXT, | ||||||
|     title TEXT, |     title TEXT, | ||||||
|  | |||||||
| @ -294,5 +294,3 @@ INNER JOIN | |||||||
|     ON ga.document_id = d.id |     ON ga.document_id = d.id | ||||||
| GROUP BY ga.document_id, ga.user_id | GROUP BY ga.document_id, ga.user_id | ||||||
| ORDER BY total_wpm DESC; | ORDER BY total_wpm DESC; | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @ -49,6 +49,7 @@ require ( | |||||||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect | 	github.com/modern-go/reflect2 v1.0.2 // indirect | ||||||
| 	github.com/pelletier/go-toml/v2 v2.1.1 // indirect | 	github.com/pelletier/go-toml/v2 v2.1.1 // indirect | ||||||
|  | 	github.com/pkg/errors v0.9.1 // indirect | ||||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||||
| 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||||||
| 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | 	github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||||||
|  | |||||||
| @ -19,6 +19,10 @@ sql: | |||||||
|             go_type: |             go_type: | ||||||
|               type: "string" |               type: "string" | ||||||
|               pointer: true |               pointer: true | ||||||
|  |           - column: "documents.basepath" | ||||||
|  |             go_type: | ||||||
|  |               type: "string" | ||||||
|  |               pointer: true | ||||||
|           - column: "documents.coverfile" |           - column: "documents.coverfile" | ||||||
|             go_type: |             go_type: | ||||||
|               type: "string" |               type: "string" | ||||||
|  | |||||||
| @ -54,6 +54,19 @@ | |||||||
|         display: none; |         display: none; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       /* ----------------------------- */ | ||||||
|  |       /* -------- CSS Button  -------- */ | ||||||
|  |       /* ----------------------------- */ | ||||||
|  |       .css-button:checked+div { | ||||||
|  |         visibility: visible; | ||||||
|  |         opacity: 1; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       .css-button+div { | ||||||
|  |         visibility: hidden; | ||||||
|  |         opacity: 0; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       /* ----------------------------- */ |       /* ----------------------------- */ | ||||||
|       /* ------- User Dropdown ------- */ |       /* ------- User Dropdown ------- */ | ||||||
|       /* ----------------------------- */ |       /* ----------------------------- */ | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								templates/components/button.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								templates/components/button.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | |||||||
|  | <!-- Variant --> | ||||||
|  | {{ $baseClass := "transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white" }} | ||||||
|  | {{ if eq .Variant "Secondary" }} | ||||||
|  | {{ $baseClass = printf "bg-black shadow-md hover:text-black hover:bg-white %s" $baseClass }} | ||||||
|  | {{ else }} | ||||||
|  | {{ $baseClass = printf "bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 %s" $baseClass }} | ||||||
|  | {{ end }} | ||||||
|  | <!-- Type --> | ||||||
|  | {{ if eq .Type "Link" }} | ||||||
|  | <a href="{{ .URL }}" class="text-center {{ $baseClass }}" type="submit">{{ .Title }}</a> | ||||||
|  | {{ else }} | ||||||
|  | <button class="{{ $baseClass }}" type="submit" {{ if .FormName }} form="{{ .FormName}}" {{ end }}>{{ .Title }} | ||||||
|  | </button> | ||||||
|  | {{ end }} | ||||||
							
								
								
									
										43
									
								
								templates/components/document-card.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								templates/components/document-card.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | |||||||
|  | <div class="w-full relative"> | ||||||
|  |   <div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"> | ||||||
|  |     <div class="min-w-fit my-auto h-48 relative"> | ||||||
|  |       <a href="./documents/{{.ID}}"> | ||||||
|  |         <img class="rounded object-cover h-full" src="./documents/{{.ID}}/cover" /> | ||||||
|  |       </a> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex flex-col justify-around dark:text-white w-full text-sm"> | ||||||
|  |       <div class="inline-flex shrink-0 items-center"> | ||||||
|  |         <div> | ||||||
|  |           <p class="text-gray-400">Title</p> | ||||||
|  |           <p class="font-medium">{{ or .Title "Unknown" }}</p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="inline-flex shrink-0 items-center"> | ||||||
|  |         <div> | ||||||
|  |           <p class="text-gray-400">Author</p> | ||||||
|  |           <p class="font-medium">{{ or .Author "Unknown" }}</p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="inline-flex shrink-0 items-center"> | ||||||
|  |         <div> | ||||||
|  |           <p class="text-gray-400">Progress</p> | ||||||
|  |           <p class="font-medium">{{ .Percentage }}%</p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="inline-flex shrink-0 items-center"> | ||||||
|  |         <div> | ||||||
|  |           <p class="text-gray-400">Time Read</p> | ||||||
|  |           <p class="font-medium">{{ niceSeconds .TotalTimeSeconds }}</p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"> | ||||||
|  |       <a href="./activity?document={{ .ID }}">{{ template "svg/activity" }}</a> | ||||||
|  |       {{ if .Filepath }} | ||||||
|  |       <a href="./documents/{{.ID}}/file">{{ template "svg/download" }}</a> | ||||||
|  |     {{ else }} | ||||||
|  |       {{ template "svg/download" (dict "Disabled" true) }} | ||||||
|  |       {{ end }} | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										12
									
								
								templates/components/info-card.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								templates/components/info-card.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | {{ if .Link }}<a href="{{ .Link }}" {{ else }} <div {{ end }}class="w-full"> | ||||||
|  | <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> | ||||||
|  |   <div class="flex flex-col justify-around dark:text-white w-full text-sm"> | ||||||
|  |     <p class="text-2xl font-bold text-black dark:text-white">{{ .Size }}</p> | ||||||
|  |     <p class="text-sm text-gray-400">{{ .Title }}</p> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {{ if .Link }} | ||||||
|  | </a> | ||||||
|  | {{ else }} | ||||||
|  | </div> | ||||||
|  | {{ end }} | ||||||
							
								
								
									
										20
									
								
								templates/components/key-val-edit.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								templates/components/key-val-edit.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | <div class="relative"> | ||||||
|  |   <div class="text-gray-500 inline-flex gap-2 relative"> | ||||||
|  |     <p>{{ .Title }}</p> | ||||||
|  |     <label class="my-auto cursor-pointer" for="edit-{{ .FormValue }}-button"> | ||||||
|  |       {{ template "svg/edit" (dict "Size" 18) }} | ||||||
|  |     </label> | ||||||
|  |     <input type="checkbox" | ||||||
|  |            id="edit-{{ .FormValue }}-button" | ||||||
|  |            class="hidden css-button" /> | ||||||
|  |     <div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> | ||||||
|  |       <form method="POST" | ||||||
|  |             action="{{ .URL }}" | ||||||
|  |             class="flex flex-col gap-2 text-black dark:text-white text-sm"> | ||||||
|  |         <input type="text" id="{{ .FormValue }}" name="{{ .FormValue }}" value="{{ or .Value "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" /> | ||||||
|  |         {{ template "component/button" (dict "Title" "Save") }} | ||||||
|  |       </form> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <p class="font-medium text-lg">{{ or .Value "N/A" }}</p> | ||||||
|  | </div> | ||||||
							
								
								
									
										102
									
								
								templates/components/metadata.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								templates/components/metadata.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,102 @@ | |||||||
|  | {{ if .Error }} | ||||||
|  | <div class="absolute top-0 left-0 w-full h-full z-50"> | ||||||
|  |   <div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> | ||||||
|  |   <div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"> | ||||||
|  |     <div class="text-center"> | ||||||
|  |       <h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3> | ||||||
|  |     </div> | ||||||
|  |     {{ template "component/button" (dict | ||||||
|  |     "Title" "Back to Document" | ||||||
|  |     "Type" "Link" | ||||||
|  |     "URL" (printf "/documents/%s" .ID) | ||||||
|  |     )}} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {{ end }} | ||||||
|  | {{ if .Metadata }} | ||||||
|  | <div class="absolute top-0 left-0 w-full h-full z-50"> | ||||||
|  |   <div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> | ||||||
|  |   <div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"> | ||||||
|  |     <div class="py-5 text-center"> | ||||||
|  |       <h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3> | ||||||
|  |     </div> | ||||||
|  |     <form id="metadata-save" | ||||||
|  |           method="POST" | ||||||
|  |           action="/documents/{{ .ID }}/edit" | ||||||
|  |           class="text-black dark:text-white border-b dark:border-black"> | ||||||
|  |       <dl> | ||||||
|  |         <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">Cover</dt> | ||||||
|  |           <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> | ||||||
|  |             <img class="rounded object-fill h-32" | ||||||
|  |                  src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690" /> | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |         <div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">Title</dt> | ||||||
|  |           <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> | ||||||
|  |             {{ or .Metadata.Title "N/A" }} | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |         <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">Author</dt> | ||||||
|  |           <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> | ||||||
|  |             {{ or .Metadata.Author "N/A" }} | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |         <div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">ISBN 10</dt> | ||||||
|  |           <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> | ||||||
|  |             {{ or .Metadata.ISBN10 "N/A" }} | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |         <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">ISBN 13</dt> | ||||||
|  |           <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> | ||||||
|  |             {{ or .Metadata.ISBN13 "N/A" }} | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |         <div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"> | ||||||
|  |           <dt class="my-auto font-medium text-gray-500">Description</dt> | ||||||
|  |           <dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2"> | ||||||
|  |             {{ or .Metadata.Description "N/A" }} | ||||||
|  |           </dd> | ||||||
|  |         </div> | ||||||
|  |       </dl> | ||||||
|  |       <div class="hidden"> | ||||||
|  |         <input type="text" id="title" name="title" value="{{ .Metadata.Title }}" /> | ||||||
|  |         <input type="text" id="author" name="author" value="{{ .Metadata.Author }}" /> | ||||||
|  |         <input type="text" | ||||||
|  |                id="description" | ||||||
|  |                name="description" | ||||||
|  |                value="{{ .Metadata.Description }}" /> | ||||||
|  |         <input type="text" | ||||||
|  |                id="isbn_10" | ||||||
|  |                name="isbn_10" | ||||||
|  |                value="{{ .Metadata.ISBN10 }}" /> | ||||||
|  |         <input type="text" | ||||||
|  |                id="isbn_13" | ||||||
|  |                name="isbn_13" | ||||||
|  |                value="{{ .Metadata.ISBN13 }}" /> | ||||||
|  |         <input type="text" | ||||||
|  |                id="cover_gbid" | ||||||
|  |                name="cover_gbid" | ||||||
|  |                value="{{ .Metadata.ID }}" /> | ||||||
|  |       </div> | ||||||
|  |     </form> | ||||||
|  |     <div class="flex justify-end"> | ||||||
|  |       <div class="flex gap-4 m-4 w-48"> | ||||||
|  |         {{ template "component/button" (dict | ||||||
|  |         "Title" "Cancel" | ||||||
|  |         "Type" "Link" | ||||||
|  |         "URL" (printf "/documents/%s" .ID) | ||||||
|  |         )}} | ||||||
|  |         {{ template "component/button" (dict | ||||||
|  |         "Title" "Save" | ||||||
|  |         "FormName" "metadata-save" | ||||||
|  |         )}} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {{ end }} | ||||||
							
								
								
									
										39
									
								
								templates/components/streak-card.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								templates/components/streak-card.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | |||||||
|  | <div class="w-full"> | ||||||
|  |   <div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"> | ||||||
|  |     <p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"> | ||||||
|  |       {{ if eq .Window "WEEK" }} | ||||||
|  |       Weekly Read Streak | ||||||
|  |     {{ else }} | ||||||
|  |       Daily Read Streak | ||||||
|  |       {{ end }} | ||||||
|  |     </p> | ||||||
|  |     <div class="flex items-end my-6 space-x-2"> | ||||||
|  |       <p class="text-5xl font-bold text-black dark:text-white">{{ .CurrentStreak }}</p> | ||||||
|  |     </div> | ||||||
|  |     <div class="dark:text-white"> | ||||||
|  |       <div class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"> | ||||||
|  |         <div> | ||||||
|  |           <p> | ||||||
|  |           {{ if eq .Window "WEEK" }} Current Weekly Streak {{ else }} | ||||||
|  |             Current Daily Streak {{ end }} | ||||||
|  |           </p> | ||||||
|  |           <div class="flex items-end text-sm text-gray-400">{{ .CurrentStreakStartDate }} ➞ {{ .CurrentStreakEndDate }}</div> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex items-end font-bold">{{ .CurrentStreak }}</div> | ||||||
|  |       </div> | ||||||
|  |       <div class="flex items-center justify-between pb-2 mb-2 text-sm"> | ||||||
|  |         <div> | ||||||
|  |           <p> | ||||||
|  |             {{ if eq .Window "WEEK" }} | ||||||
|  |             Best Weekly Streak | ||||||
|  |           {{ else }} | ||||||
|  |             Best Daily Streak | ||||||
|  |             {{ end }} | ||||||
|  |           </p> | ||||||
|  |           <div class="flex items-end text-sm text-gray-400">{{ .MaxStreakStartDate }} ➞ {{ .MaxStreakEndDate }}</div> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex items-end font-bold">{{ .MaxStreak }}</div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										28
									
								
								templates/components/table.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								templates/components/table.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | {{ $rows := .Rows }} | ||||||
|  | {{ $cols := .Columns }} | ||||||
|  | {{ $keys := .Keys }} | ||||||
|  | <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> | ||||||
|  |   <thead class="text-gray-800 dark:text-gray-400"> | ||||||
|  |     <tr> | ||||||
|  |       {{ range $col := $cols }} | ||||||
|  |       <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">{{ $col }}</th> | ||||||
|  |       {{ end }} | ||||||
|  |     </tr> | ||||||
|  |   </thead> | ||||||
|  |   <tbody class="text-black dark:text-white"> | ||||||
|  |     {{ if not $rows }} | ||||||
|  |     <tr> | ||||||
|  |       <td class="text-center p-3" colspan="4">No Results</td> | ||||||
|  |     </tr> | ||||||
|  |     {{ end }} | ||||||
|  |     {{ range $row := $rows }} | ||||||
|  |     <tr> | ||||||
|  |       {{ range $key := $keys }} | ||||||
|  |       <td class="p-3 border-b border-gray-200"> | ||||||
|  |         <p>{{ index (fields $row) $key }}</p> | ||||||
|  |       </td> | ||||||
|  |       {{ end }} | ||||||
|  |     </tr> | ||||||
|  |     {{ end }} | ||||||
|  |   </tbody> | ||||||
|  | </table> | ||||||
							
								
								
									
										46
									
								
								templates/pages/admin-import-results.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								templates/pages/admin-import-results.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | |||||||
|  | {{ template "base" . }} | ||||||
|  | {{ define "title" }}Admin - Import Results{{ end }} | ||||||
|  | {{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import Results</a>{{ end }} | ||||||
|  | {{ define "content" }} | ||||||
|  | <div class="overflow-x-auto"> | ||||||
|  |   <div class="inline-block min-w-full overflow-hidden rounded shadow"> | ||||||
|  |     <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> | ||||||
|  |       <thead class="text-gray-800 dark:text-gray-400"> | ||||||
|  |         <tr> | ||||||
|  |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th> | ||||||
|  |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Status</th> | ||||||
|  |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Error</th> | ||||||
|  |         </tr> | ||||||
|  |       </thead> | ||||||
|  |       <tbody class="text-black dark:text-white"> | ||||||
|  |         {{ if not .Data }} | ||||||
|  |         <tr> | ||||||
|  |           <td class="text-center p-3" colspan="4">No Results</td> | ||||||
|  |         </tr> | ||||||
|  |         {{ end }} | ||||||
|  |         {{ range $result := .Data }} | ||||||
|  |         <tr> | ||||||
|  |           <td class="p-3 border-b border-gray-200 grid" | ||||||
|  |               style="grid-template-columns: 4rem auto"> | ||||||
|  |             <span class="text-gray-800 dark:text-gray-400">Name:</span> | ||||||
|  |             {{ if (eq $result.ID "") }} | ||||||
|  |             <span>N/A</span> | ||||||
|  |           {{ else }} | ||||||
|  |             <a href="../documents/{{ $result.ID }}">{{ $result.Name }}</a> | ||||||
|  |             {{ end }} | ||||||
|  |             <span class="text-gray-800 dark:text-gray-400">File:</span> | ||||||
|  |             <span>{{ $result.Path }}</span> | ||||||
|  |           </td> | ||||||
|  |           <td class="p-3 border-b border-gray-200"> | ||||||
|  |             <p>{{ $result.Status }}</p> | ||||||
|  |           </td> | ||||||
|  |           <td class="p-3 border-b border-gray-200"> | ||||||
|  |             <p>{{ $result.Error }}</p> | ||||||
|  |           </td> | ||||||
|  |         </tr> | ||||||
|  |         {{ end }} | ||||||
|  |       </tbody> | ||||||
|  |     </table> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {{ end }} | ||||||
| @ -19,12 +19,12 @@ | |||||||
|           </div> |           </div> | ||||||
|           <div class="flex flex-col justify-around gap-2 mr-4"> |           <div class="flex flex-col justify-around gap-2 mr-4"> | ||||||
|             <div class="inline-flex gap-2 items-center"> |             <div class="inline-flex gap-2 items-center"> | ||||||
|               <input checked type="radio" id="copy" name="type" value="COPY" /> |               <input checked type="radio" id="direct" name="type" value="DIRECT" /> | ||||||
|               <label for="copy">Copy</label> |               <label for="direct">Direct</label> | ||||||
|             </div> |             </div> | ||||||
|             <div class="inline-flex gap-2 items-center"> |             <div class="inline-flex gap-2 items-center"> | ||||||
|               <input type="radio" id="direct" name="type" value="DIRECT" /> |               <input type="radio" id="copy" name="type" value="COPY" /> | ||||||
|               <label for="direct">Direct</label> |               <label for="copy">Copy</label> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -17,10 +17,12 @@ | |||||||
|                placeholder="JQ Filter" /> |                placeholder="JQ Filter" /> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <button type="submit" |     <div class="lg:w-60"> | ||||||
|             class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |       {{ template "component/button" (dict | ||||||
|       <span class="w-full">Filter</span> |       "Title" "Filter" | ||||||
|     </button> |       "Variant" "Secondary" | ||||||
|  |       ) }} | ||||||
|  |     </div> | ||||||
|   </form> |   </form> | ||||||
| </div> | </div> | ||||||
| <!-- Required for iOS "Hover" Events (onclick) --> | <!-- Required for iOS "Hover" Events (onclick) --> | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ | |||||||
|             <label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label> |             <label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label> | ||||||
|           </th> |           </th> | ||||||
|           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th> |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th> | ||||||
|  |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th> | ||||||
|           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center"> |           <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center"> | ||||||
|             Permissions |             Permissions | ||||||
|           </th> |           </th> | ||||||
| @ -55,6 +56,33 @@ | |||||||
|           <td class="p-3 border-b border-gray-200"> |           <td class="p-3 border-b border-gray-200"> | ||||||
|             <p>{{ $user.ID }}</p> |             <p>{{ $user.ID }}</p> | ||||||
|           </td> |           </td> | ||||||
|  |           <td class="border-b border-gray-200 relative px-3"> | ||||||
|  |             <label for="edit-{{ $user.ID }}-button" class="cursor-pointer"> | ||||||
|  |               <span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | ||||||
|  |                     type="submit">Reset</span> | ||||||
|  |             </label> | ||||||
|  |             <input type="checkbox" | ||||||
|  |                    id="edit-{{ $user.ID }}-button" | ||||||
|  |                    class="hidden css-button" /> | ||||||
|  |             <div class="absolute z-30 -bottom-1.5 left-16 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> | ||||||
|  |               <form method="POST" | ||||||
|  |                     action="./users" | ||||||
|  |                     class="flex flex gap-2 text-black dark:text-white text-sm"> | ||||||
|  |                 <input type="text" | ||||||
|  |                        id="operation" | ||||||
|  |                        name="operation" | ||||||
|  |                        value="UPDATE" | ||||||
|  |                        class="hidden" /> | ||||||
|  |                 <input type="password" | ||||||
|  |                        id="password" | ||||||
|  |                        name="password" | ||||||
|  |                        placeholder="Password" | ||||||
|  |                        class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" /> | ||||||
|  |                 <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | ||||||
|  |                         type="submit">Change</button> | ||||||
|  |               </form> | ||||||
|  |             </div> | ||||||
|  |           </td> | ||||||
|           <td class="p-3 border-b border-gray-200 text-center min-w-40"> |           <td class="p-3 border-b border-gray-200 text-center min-w-40"> | ||||||
|             <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span> |             <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span> | ||||||
|             <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span> |             <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span> | ||||||
|  | |||||||
| @ -21,10 +21,12 @@ | |||||||
|             <label for="backup_documents">Documents</label> |             <label for="backup_documents">Documents</label> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <button type="submit" |         <div class="w-40 h-10"> | ||||||
|                 class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |           {{ template "component/button" (dict | ||||||
|           <span class="w-full">Backup</span> |           "Title" "Backup" | ||||||
|         </button> |           "Variant" "Secondary" | ||||||
|  |           ) }} | ||||||
|  |         </div> | ||||||
|       </form> |       </form> | ||||||
|       <form method="POST" |       <form method="POST" | ||||||
|             enctype="multipart/form-data" |             enctype="multipart/form-data" | ||||||
| @ -34,10 +36,12 @@ | |||||||
|         <div class="flex items-center w-1/2"> |         <div class="flex items-center w-1/2"> | ||||||
|           <input type="file" accept=".zip" name="restore_file" class="w-full" /> |           <input type="file" accept=".zip" name="restore_file" class="w-full" /> | ||||||
|         </div> |         </div> | ||||||
|         <button type="submit" |         <div class="w-40 h-10"> | ||||||
|                 class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |           {{ template "component/button" (dict | ||||||
|           <span class="w-full">Restore</span> |           "Title" "Restore" | ||||||
|         </button> |           "Variant" "Secondary" | ||||||
|  |           ) }} | ||||||
|  |         </div> | ||||||
|       </form> |       </form> | ||||||
|     </div> |     </div> | ||||||
|     {{ if .PasswordErrorMessage }} |     {{ if .PasswordErrorMessage }} | ||||||
| @ -57,10 +61,12 @@ | |||||||
|           <td class="py-2 float-right"> |           <td class="py-2 float-right"> | ||||||
|             <form action="./admin" method="POST"> |             <form action="./admin" method="POST"> | ||||||
|               <input type="text" name="action" value="METADATA_MATCH" class="hidden" /> |               <input type="text" name="action" value="METADATA_MATCH" class="hidden" /> | ||||||
|               <button type="submit" |               <div class="w-40 h-10 text-base"> | ||||||
|                       class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |                 {{ template "component/button" (dict | ||||||
|                 <span class="w-full">Run</span> |                 "Title" "Run" | ||||||
|               </button> |                 "Variant" "Secondary" | ||||||
|  |                 ) }} | ||||||
|  |               </div> | ||||||
|             </form> |             </form> | ||||||
|           </td> |           </td> | ||||||
|         </tr> |         </tr> | ||||||
| @ -71,10 +77,12 @@ | |||||||
|           <td class="py-2 float-right"> |           <td class="py-2 float-right"> | ||||||
|             <form action="./admin" method="POST"> |             <form action="./admin" method="POST"> | ||||||
|               <input type="text" name="action" value="CACHE_TABLES" class="hidden" /> |               <input type="text" name="action" value="CACHE_TABLES" class="hidden" /> | ||||||
|               <button type="submit" |               <div class="w-40 h-10 text-base"> | ||||||
|                       class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |                 {{ template "component/button" (dict | ||||||
|                 <span class="w-full">Run</span> |                 "Title" "Run" | ||||||
|               </button> |                 "Variant" "Secondary" | ||||||
|  |                 ) }} | ||||||
|  |               </div> | ||||||
|             </form> |             </form> | ||||||
|           </td> |           </td> | ||||||
|         </tr> |         </tr> | ||||||
|  | |||||||
| @ -33,8 +33,7 @@ | |||||||
|                   action="./{{ .Data.ID }}/edit" |                   action="./{{ .Data.ID }}/edit" | ||||||
|                   class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> |                   class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> | ||||||
|               <input type="file" id="cover_file" name="cover_file"> |               <input type="file" id="cover_file" name="cover_file"> | ||||||
|               <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | 	      {{ template "component/button" (dict "Title" "Upload Cover") }} | ||||||
|                       type="submit">Upload Cover</button> |  | ||||||
|             </form> |             </form> | ||||||
|             <form method="POST" |             <form method="POST" | ||||||
|                   action="./{{ .Data.ID }}/edit" |                   action="./{{ .Data.ID }}/edit" | ||||||
| @ -44,8 +43,7 @@ | |||||||
|                      id="remove_cover" |                      id="remove_cover" | ||||||
|                      name="remove_cover" |                      name="remove_cover" | ||||||
|                      class="hidden" /> |                      class="hidden" /> | ||||||
|               <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | 	      {{ template "component/button" (dict "Title" "Remove Cover") }} | ||||||
|                       type="submit">Remove Cover</button> |  | ||||||
|             </form> |             </form> | ||||||
|           </div> |           </div> | ||||||
|           <div class="relative"> |           <div class="relative"> | ||||||
| @ -54,9 +52,8 @@ | |||||||
|             <div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> |             <div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> | ||||||
|               <form method="POST" |               <form method="POST" | ||||||
|                     action="./{{ .Data.ID }}/delete" |                     action="./{{ .Data.ID }}/delete" | ||||||
|                     class="text-black dark:text-white text-sm"> |                     class="text-black dark:text-white text-sm w-24"> | ||||||
|                 <button class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | 		{{ template "component/button" (dict "Title" "Delete") }} | ||||||
|                         type="submit">Delete</button> |  | ||||||
|               </form> |               </form> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
| @ -86,8 +83,7 @@ | |||||||
|                        placeholder="ISBN 10 / ISBN 13" |                        placeholder="ISBN 10 / ISBN 13" | ||||||
|                        value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}" |                        value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}" | ||||||
|                        class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> |                        class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> | ||||||
|                 <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | 		{{ template "component/button" (dict "Title" "Identify") }} | ||||||
|                         type="submit">Identify</button> |  | ||||||
|               </form> |               </form> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
| @ -100,40 +96,18 @@ | |||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <div class="grid sm:grid-cols-2 justify-between gap-4 pb-4"> |     <div class="grid sm:grid-cols-2 justify-between gap-4 pb-4"> | ||||||
|       <div class="relative"> |       {{ template "component/key-val-edit" (dict | ||||||
|         <div class="text-gray-500 inline-flex gap-2 relative"> |       "Title" "Title" | ||||||
|           <p>Title</p> |       "Value" .Data.Title | ||||||
|           <label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label> |       "URL" (printf "./%s/edit" .Data.ID) | ||||||
|           <input type="checkbox" id="edit-title-button" class="hidden css-button" /> |       "FormValue" "title" | ||||||
|           <div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> |       )}} | ||||||
|             <form method="POST" |       {{ template "component/key-val-edit" (dict | ||||||
|                   action="./{{ .Data.ID }}/edit" |       "Title" "Author" | ||||||
|                   class="flex flex-col gap-2 text-black dark:text-white text-sm"> |       "Value" .Data.Author | ||||||
|               <input type="text" id="title" name="title" value="{{ or .Data.Title "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> |       "URL" (printf "./%s/edit" .Data.ID) | ||||||
|               <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" |       "FormValue" "author" | ||||||
|                       type="submit">Save</button> |       )}} | ||||||
|             </form> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <p class="font-medium text-lg">{{ or .Data.Title "N/A" }}</p> |  | ||||||
|       </div> |  | ||||||
|       <div class="relative"> |  | ||||||
|         <div class="text-gray-500 inline-flex gap-2 relative"> |  | ||||||
|           <p>Author</p> |  | ||||||
|           <label class="my-auto" for="edit-author-button">{{ template "svg/edit" (dict "Size" 18) }}</label> |  | ||||||
|           <input type="checkbox" id="edit-author-button" class="hidden css-button" /> |  | ||||||
|           <div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> |  | ||||||
|             <form method="POST" |  | ||||||
|                   action="./{{ .Data.ID }}/edit" |  | ||||||
|                   class="flex flex-col gap-2 text-black dark:text-white text-sm"> |  | ||||||
|               <input type="text" id="author" name="author" value="{{ or .Data.Author "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> |  | ||||||
|               <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" |  | ||||||
|                       type="submit">Save</button> |  | ||||||
|             </form> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <p class="font-medium text-lg">{{ or .Data.Author "N/A" }}</p> |  | ||||||
|       </div> |  | ||||||
|       <div class="relative"> |       <div class="relative"> | ||||||
|         <div class="text-gray-500 inline-flex gap-2 relative"> |         <div class="text-gray-500 inline-flex gap-2 relative"> | ||||||
|           <p>Time Read</p> |           <p>Time Read</p> | ||||||
| @ -181,119 +155,16 @@ | |||||||
|                     id="description" |                     id="description" | ||||||
|                     name="description" |                     name="description" | ||||||
|                     class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">{{ or .Data.Description "N/A" }}</textarea> |                     class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">{{ or .Data.Description "N/A" }}</textarea> | ||||||
|           <button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | 	  {{ template "component/button" (dict "Title" "Save") }} | ||||||
|                   type="submit">Save</button> |  | ||||||
|         </form> |         </form> | ||||||
|       </div> |       </div> | ||||||
|       <p>{{ or .Data.Description "N/A" }}</p> |       <p>{{ or .Data.Description "N/A" }}</p> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   {{ if .MetadataError }} |   {{ template "component/metadata" (dict | ||||||
|   <div class="absolute top-0 left-0 w-full h-full z-50"> |   "ID" .Data.ID | ||||||
|     <div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> |   "Metadata" .Metadata | ||||||
|     <div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"> |   "Error" .MetadataError | ||||||
|       <div class="text-center"> |   )}} | ||||||
|         <h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3> |  | ||||||
|       </div> |  | ||||||
|       <a href="/documents/{{ .Data.ID }}" |  | ||||||
|          class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" |  | ||||||
|          type="submit">Back to Document</a> |  | ||||||
|     </div> |  | ||||||
| </div> | </div> | ||||||
| {{ end }} | {{ end }} | ||||||
|   <!-- Metadata Info --> |  | ||||||
|   {{ if .Metadata }} |  | ||||||
|   <div class="absolute top-0 left-0 w-full h-full z-50"> |  | ||||||
|     <div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> |  | ||||||
|     <div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"> |  | ||||||
|       <div class="py-5 text-center"> |  | ||||||
|         <h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3> |  | ||||||
|       </div> |  | ||||||
|       <form id="metadata-save" |  | ||||||
|             method="POST" |  | ||||||
|             action="/documents/{{ .Data.ID }}/edit" |  | ||||||
|             class="text-black dark:text-white border-b dark:border-black"> |  | ||||||
|         <dl> |  | ||||||
|           <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">Cover</dt> |  | ||||||
|             <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> |  | ||||||
|               <img class="rounded object-fill h-32" |  | ||||||
|                    src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690" /> |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|           <div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">Title</dt> |  | ||||||
|             <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> |  | ||||||
|               {{ or .Metadata.Title "N/A" }} |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|           <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">Author</dt> |  | ||||||
|             <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> |  | ||||||
|               {{ or .Metadata.Author "N/A" }} |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|           <div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">ISBN 10</dt> |  | ||||||
|             <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> |  | ||||||
|               {{ or .Metadata.ISBN10 "N/A" }} |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|           <div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">ISBN 13</dt> |  | ||||||
|             <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> |  | ||||||
|               {{ or .Metadata.ISBN13 "N/A" }} |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|           <div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"> |  | ||||||
|             <dt class="my-auto font-medium text-gray-500">Description</dt> |  | ||||||
|             <dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2"> |  | ||||||
|               {{ or .Metadata.Description "N/A" }} |  | ||||||
|             </dd> |  | ||||||
|           </div> |  | ||||||
|         </dl> |  | ||||||
|         <div class="hidden"> |  | ||||||
|           <input type="text" id="title" name="title" value="{{ .Metadata.Title }}"> |  | ||||||
|           <input type="text" id="author" name="author" value="{{ .Metadata.Author }}"> |  | ||||||
|           <input type="text" |  | ||||||
|                  id="description" |  | ||||||
|                  name="description" |  | ||||||
|                  value="{{ .Metadata.Description }}"> |  | ||||||
|           <input type="text" |  | ||||||
|                  id="isbn_10" |  | ||||||
|                  name="isbn_10" |  | ||||||
|                  value="{{ .Metadata.ISBN10 }}"> |  | ||||||
|           <input type="text" |  | ||||||
|                  id="isbn_13" |  | ||||||
|                  name="isbn_13" |  | ||||||
|                  value="{{ .Metadata.ISBN13 }}"> |  | ||||||
|           <input type="text" |  | ||||||
|                  id="cover_gbid" |  | ||||||
|                  name="cover_gbid" |  | ||||||
|                  value="{{ .Metadata.ID }}"> |  | ||||||
|         </div> |  | ||||||
|       </form> |  | ||||||
|       <div class="flex justify-end gap-4 m-4"> |  | ||||||
|         <a href="/documents/{{ .Data.ID }}" |  | ||||||
|            class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" |  | ||||||
|            type="submit">Cancel</a> |  | ||||||
|         <button form="metadata-save" |  | ||||||
|                 class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" |  | ||||||
|                 type="submit">Save</button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
|   {{ end }} |  | ||||||
| </div> |  | ||||||
| <style> |  | ||||||
|   .css-button:checked+div { |  | ||||||
|     visibility: visible; |  | ||||||
|     opacity: 1; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   .css-button+div { |  | ||||||
|     visibility: hidden; |  | ||||||
|     opacity: 0; |  | ||||||
|   } |  | ||||||
| </style> |  | ||||||
| {{ end }} |  | ||||||
|  | |||||||
| @ -18,58 +18,17 @@ | |||||||
|                placeholder="Search Author / Title" /> |                placeholder="Search Author / Title" /> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <button type="submit" |     <div class="lg:w-60"> | ||||||
|             class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |       {{ template "component/button" (dict | ||||||
|       <span class="w-full">Search</span> |       "Title" "Search" | ||||||
|     </button> |       "Variant" "Secondary" | ||||||
|  |       ) }} | ||||||
|  |     </div> | ||||||
|   </form> |   </form> | ||||||
| </div> | </div> | ||||||
| <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | ||||||
|   {{ range $doc := .Data }} |   {{ range $doc := .Data }} | ||||||
|   <div class="w-full relative"> |   {{ template "component/document-card" $doc }} | ||||||
|     <div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"> |  | ||||||
|       <div class="min-w-fit my-auto h-48 relative"> |  | ||||||
|         <a href="./documents/{{$doc.ID}}"> |  | ||||||
|           <img class="rounded object-cover h-full" |  | ||||||
|                src="./documents/{{$doc.ID}}/cover" /> |  | ||||||
|         </a> |  | ||||||
|       </div> |  | ||||||
|       <div class="flex flex-col justify-around dark:text-white w-full text-sm"> |  | ||||||
|         <div class="inline-flex shrink-0 items-center"> |  | ||||||
|           <div> |  | ||||||
|             <p class="text-gray-400">Title</p> |  | ||||||
|             <p class="font-medium">{{ or $doc.Title "Unknown" }}</p> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="inline-flex shrink-0 items-center"> |  | ||||||
|           <div> |  | ||||||
|             <p class="text-gray-400">Author</p> |  | ||||||
|             <p class="font-medium">{{ or $doc.Author "Unknown" }}</p> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="inline-flex shrink-0 items-center"> |  | ||||||
|           <div> |  | ||||||
|             <p class="text-gray-400">Progress</p> |  | ||||||
|             <p class="font-medium">{{ $doc.Percentage }}%</p> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="inline-flex shrink-0 items-center"> |  | ||||||
|           <div> |  | ||||||
|             <p class="text-gray-400">Time Read</p> |  | ||||||
|             <p class="font-medium">{{ niceSeconds $doc.TotalTimeSeconds }}</p> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|       <div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"> |  | ||||||
|         <a href="./activity?document={{ $doc.ID }}">{{ template "svg/activity" }}</a> |  | ||||||
|         {{ if $doc.Filepath }} |  | ||||||
|         <a href="./documents/{{$doc.ID}}/file">{{ template "svg/download" }}</a> |  | ||||||
|       {{ else }} |  | ||||||
|         {{ template "svg/download" (dict "Disabled" true) }} |  | ||||||
|         {{ end }} |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
|   {{ end }} |   {{ end }} | ||||||
| </div> | </div> | ||||||
| <div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white"> | <div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white"> | ||||||
| @ -89,7 +48,7 @@ | |||||||
|           enctype="multipart/form-data" |           enctype="multipart/form-data" | ||||||
|           action="./documents" |           action="./documents" | ||||||
|           class="flex flex-col gap-2"> |           class="flex flex-col gap-2"> | ||||||
|       <input type="file" accept=".epub" id="document_file" name="document_file"> |       <input type="file" accept=".epub" id="document_file" name="document_file" /> | ||||||
|       <button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800" |       <button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800" | ||||||
|               type="submit">Upload File</button> |               type="submit">Upload File</button> | ||||||
|     </form> |     </form> | ||||||
| @ -102,19 +61,4 @@ | |||||||
|   <label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer" |   <label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer" | ||||||
|          for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label> |          for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label> | ||||||
| </div> | </div> | ||||||
| <style> |  | ||||||
|   .css-button:checked+div { |  | ||||||
|     display: block; |  | ||||||
|     opacity: 1; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   .css-button+div { |  | ||||||
|     display: none; |  | ||||||
|     opacity: 0; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   .css-button:checked+div+label { |  | ||||||
|     display: none; |  | ||||||
|   } |  | ||||||
| </style> |  | ||||||
| {{ end }} | {{ end }} | ||||||
|  | |||||||
| @ -41,82 +41,29 @@ | |||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   <div class="grid grid-cols-2 gap-4 md:grid-cols-4"> |   <div class="grid grid-cols-2 gap-4 md:grid-cols-4"> | ||||||
|     <a href="./documents" class="w-full"> |     {{ template "component/info-card" (dict | ||||||
|       <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> |     "Title" "Documents" | ||||||
|         <div class="flex flex-col justify-around dark:text-white w-full text-sm"> |     "Size" .Data.DatabaseInfo.DocumentsSize | ||||||
|           <p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DocumentsSize }}</p> |     "Link" "./documents" | ||||||
|           <p class="text-sm text-gray-400">Documents</p> |     )}} | ||||||
|         </div> |     {{ template "component/info-card" (dict | ||||||
|       </div> |     "Title" "Activity Records" | ||||||
|     </a> |     "Size" .Data.DatabaseInfo.ActivitySize | ||||||
|     <a href="./activity" class="w-full"> |     "Link" "./activity" | ||||||
|       <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> |     )}} | ||||||
|         <div class="flex flex-col justify-around dark:text-white w-full text-sm"> |     {{ template "component/info-card" (dict | ||||||
|           <p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ActivitySize }}</p> |     "Title" "Progress Records" | ||||||
|           <p class="text-sm text-gray-400">Activity Records</p> |     "Size" .Data.DatabaseInfo.ProgressSize | ||||||
|         </div> |     "Link" "./progress" | ||||||
|       </div> |     )}} | ||||||
|     </a> |     {{ template "component/info-card" (dict | ||||||
|     <a href="./progress" class="w-full"> |     "Title" "Devices" | ||||||
|       <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> |     "Size" .Data.DatabaseInfo.DevicesSize | ||||||
|         <div class="flex flex-col justify-around dark:text-white w-full text-sm"> |     )}} | ||||||
|           <p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ProgressSize }}</p> |  | ||||||
|           <p class="text-sm text-gray-400">Progress Records</p> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </a> |  | ||||||
|     <div class="w-full"> |  | ||||||
|       <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> |  | ||||||
|         <div class="flex flex-col justify-around dark:text-white w-full text-sm"> |  | ||||||
|           <p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DevicesSize }}</p> |  | ||||||
|           <p class="text-sm text-gray-400">Devices</p> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |   </div> | ||||||
|   <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> |   <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> | ||||||
|     {{ range $item := .Data.Streaks }} |     {{ range $item := .Data.Streaks }} | ||||||
|     <div class="w-full"> |     {{ template "component/streak-card" $item }} | ||||||
|       <div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"> |  | ||||||
|         <p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"> |  | ||||||
|           {{ if eq $item.Window "WEEK" }} |  | ||||||
|           Weekly Read Streak |  | ||||||
|         {{ else }} |  | ||||||
|           Daily Read Streak |  | ||||||
|           {{ end }} |  | ||||||
|         </p> |  | ||||||
|         <div class="flex items-end my-6 space-x-2"> |  | ||||||
|           <p class="text-5xl font-bold text-black dark:text-white">{{ $item.CurrentStreak }}</p> |  | ||||||
|         </div> |  | ||||||
|         <div class="dark:text-white"> |  | ||||||
|           <div class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"> |  | ||||||
|             <div> |  | ||||||
|               <p> |  | ||||||
|               {{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }} |  | ||||||
|                 Current Daily Streak {{ end }} |  | ||||||
|               </p> |  | ||||||
|               <div class="flex items-end text-sm text-gray-400"> |  | ||||||
|                 {{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate }} |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div> |  | ||||||
|           </div> |  | ||||||
|           <div class="flex items-center justify-between pb-2 mb-2 text-sm"> |  | ||||||
|             <div> |  | ||||||
|               <p> |  | ||||||
|                 {{ if eq $item.Window "WEEK" }} |  | ||||||
|                 Best Weekly Streak |  | ||||||
|               {{ else }} |  | ||||||
|                 Best Daily Streak |  | ||||||
|                 {{ end }} |  | ||||||
|               </p> |  | ||||||
|               <div class="flex items-end text-sm text-gray-400">{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}</div> |  | ||||||
|             </div> |  | ||||||
|             <div class="flex items-end font-bold">{{ $item.MaxStreak }}</div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     {{ end }} |     {{ end }} | ||||||
|   </div> |   </div> | ||||||
|   <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> |   <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | ||||||
|  | |||||||
| @ -39,6 +39,13 @@ | |||||||
|       {{ end }} |       {{ end }} | ||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
|  |   <!-- | ||||||
|  |   {{ template "component/table" (dict | ||||||
|  |   "Columns" (slice "Author" "Title" "Device Name" "Percentage" "Created At") | ||||||
|  |   "Keys" (slice "Author" "Title" "DeviceName" "Percentage" "CreatedAt") | ||||||
|  |   "Rows" .Data | ||||||
|  |   )}} | ||||||
|  |   --> | ||||||
| </div> | </div> | ||||||
| </div> | </div> | ||||||
| {{ end }} | {{ end }} | ||||||
|  | |||||||
| @ -30,10 +30,12 @@ | |||||||
|             <option value="LibGen Non-fiction">LibGen Non-fiction</option> |             <option value="LibGen Non-fiction">LibGen Non-fiction</option> | ||||||
|           </select> |           </select> | ||||||
|         </div> |         </div> | ||||||
|         <button type="submit" |         <div class="lg:w-60"> | ||||||
|                 class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |           {{ template "component/button" (dict | ||||||
|           <span class="w-full">Search</span> |           "Title" "Search" | ||||||
|         </button> |           "Variant" "Secondary" | ||||||
|  |           ) }} | ||||||
|  |         </div> | ||||||
|       </form> |       </form> | ||||||
|       {{ if .SearchErrorMessage }} |       {{ if .SearchErrorMessage }} | ||||||
|       <span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span> |       <span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span> | ||||||
|  | |||||||
| @ -39,10 +39,12 @@ | |||||||
|                    placeholder="New Password" /> |                    placeholder="New Password" /> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <button type="submit" |         <div class="lg:w-60"> | ||||||
|                 class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |           {{ template "component/button" (dict | ||||||
|           <span class="w-full">Submit</span> |           "Title" "Submit" | ||||||
|         </button> |           "Variant" "Secondary" | ||||||
|  |           ) }} | ||||||
|  |         </div> | ||||||
|       </form> |       </form> | ||||||
|       {{ if .PasswordErrorMessage }} |       {{ if .PasswordErrorMessage }} | ||||||
|       <span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span> |       <span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span> | ||||||
| @ -69,10 +71,12 @@ | |||||||
|             {{ end }} |             {{ end }} | ||||||
|           </select> |           </select> | ||||||
|         </div> |         </div> | ||||||
|         <button type="submit" |         <div class="lg:w-60"> | ||||||
|                 class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> |           {{ template "component/button" (dict | ||||||
|           <span class="w-full">Submit</span> |           "Title" "Submit" | ||||||
|         </button> |           "Variant" "Secondary" | ||||||
|  |           ) }} | ||||||
|  |         </div> | ||||||
|       </form> |       </form> | ||||||
|       {{ if .TimeOffsetErrorMessage }} |       {{ if .TimeOffsetErrorMessage }} | ||||||
|       <span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span> |       <span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span> | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user