mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
+Datenexport
This commit is contained in:
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Musenalm
|
||||||
|
|
||||||
|
Bibliographie deutscher Almanache des 18. und 19. Jahrhunderts. Runs as a PocketBase-based web app with SQLite storage.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Local build (dev assets + sqlite tags used in this repo):
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual build equivalent:
|
||||||
|
```bash
|
||||||
|
go build -tags=dev,fts5,sqlite_icu -o ./tmp/musenalm .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run (as used in the Docker image):
|
||||||
|
```bash
|
||||||
|
./scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker image build:
|
||||||
|
```bash
|
||||||
|
./scripts/docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset local data directory:
|
||||||
|
```bash
|
||||||
|
./scripts/reset.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose (prod/stage):
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
docker compose -f stage.docker-compose.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build prerequisites
|
||||||
|
|
||||||
|
- Go 1.24 (see `go.mod`, also used in `Dockerfile`).
|
||||||
|
- SQLite build tags used in this repo:
|
||||||
|
- Local/dev build: `dev,fts5,sqlite_icu` (see `scripts/build.sh`).
|
||||||
|
- Docker build: `sqlite_fts5,sqlite_json,sqlite_foreign_keys,sqlite_vtable` (see `Dockerfile`).
|
||||||
|
- The `dev` build tag serves templates/assets from disk (no embedding). Without it, assets are embedded into the binary.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config files are loaded in this order, later values override earlier ones:
|
||||||
|
1) `config.dev.json` (dev defaults)
|
||||||
|
2) `config.json` (runtime overrides)
|
||||||
|
3) Environment variables with prefix `MUSENALM_`
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
`debug` (bool)
|
||||||
|
Enables PocketBase dev mode and, unless `disable_watchers` is true, turns on template watchers.
|
||||||
|
|
||||||
|
`allow_test_login` (bool)
|
||||||
|
Creates/keeps a test superuser account (`demo@example.com` / `password`). When false, the test account is removed if present.
|
||||||
|
|
||||||
|
`disable_watchers` (bool)
|
||||||
|
Disables template watchers even if `debug` is true.
|
||||||
|
|
||||||
|
Environment variable equivalents:
|
||||||
|
- `MUSENALM_DEBUG`
|
||||||
|
- `MUSENALM_ALLOW_TEST_LOGIN`
|
||||||
|
- `MUSENALM_DISABLE_WATCHERS`
|
||||||
|
|
||||||
|
## Hosting
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
`docker-compose.yml` runs the app on port 8090 and persists data in `/app/data/pb_data` via an external volume.
|
||||||
|
|
||||||
|
Prereqs:
|
||||||
|
```bash
|
||||||
|
docker volume create musenalm
|
||||||
|
docker network create caddynet
|
||||||
|
```
|
||||||
|
|
||||||
|
Start:
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage uses `stage.docker-compose.yml` and a separate external volume `musenalmstage`.
|
||||||
|
|
||||||
|
### Bare binary
|
||||||
|
|
||||||
|
Build the binary, then run it with a data directory:
|
||||||
|
```bash
|
||||||
|
./tmp/musenalm serve --http=0.0.0.0:8090 --dir=/path/to/pb_data
|
||||||
|
```
|
||||||
|
|
||||||
337
controllers/exports_admin.go
Normal file
337
controllers/exports_admin.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/exports"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
URL_EXPORTS_ADMIN = "/redaktion/exports/"
|
||||||
|
URL_EXPORTS_RUN = "run/"
|
||||||
|
URL_EXPORTS_LIST = "list/"
|
||||||
|
URL_EXPORTS_DOWNLOAD = "download/"
|
||||||
|
URL_EXPORTS_DELETE = "delete/"
|
||||||
|
TEMPLATE_EXPORTS = "/redaktion/exports/"
|
||||||
|
TEMPLATE_EXPORTS_LIST = "/redaktion/exports/list/"
|
||||||
|
LAYOUT_EXPORTS_FRAGMENT = "fragment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
app.Register(&ExportsAdmin{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportsAdmin struct{}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) Up(ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) Down(ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
|
appInstance := ia.Core()
|
||||||
|
exports.StartCleanup(appInstance, 12*time.Hour)
|
||||||
|
|
||||||
|
rg := router.Group(URL_EXPORTS_ADMIN)
|
||||||
|
rg.BindFunc(middleware.Authenticated(appInstance))
|
||||||
|
rg.BindFunc(middleware.IsAdmin())
|
||||||
|
rg.GET("", p.pageHandler(engine, appInstance))
|
||||||
|
rg.GET(URL_EXPORTS_LIST, p.listHandler(engine, appInstance))
|
||||||
|
rg.POST(URL_EXPORTS_RUN, p.runHandler(appInstance))
|
||||||
|
rg.GET(URL_EXPORTS_DOWNLOAD+"{id}", p.downloadHandler(appInstance))
|
||||||
|
rg.POST(URL_EXPORTS_DELETE+"{id}", p.deleteHandler(appInstance))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exportView struct {
|
||||||
|
Id string
|
||||||
|
Name string
|
||||||
|
Filename string
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Progress int
|
||||||
|
TablesTotal int
|
||||||
|
TablesDone int
|
||||||
|
SizeBytes int64
|
||||||
|
SizeLabel string
|
||||||
|
Created types.DateTime
|
||||||
|
Expires types.DateTime
|
||||||
|
CurrentTable string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) pageHandler(engine *templating.Engine, app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
data, err := exportsData(e, app)
|
||||||
|
if err != nil {
|
||||||
|
return engine.Response500(e, err, nil)
|
||||||
|
}
|
||||||
|
return engine.Response200(e, TEMPLATE_EXPORTS, data, pagemodels.LAYOUT_LOGIN_PAGES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) listHandler(engine *templating.Engine, app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
data, err := exportsData(e, app)
|
||||||
|
if err != nil {
|
||||||
|
return engine.Response500(e, err, nil)
|
||||||
|
}
|
||||||
|
return engine.Response200(e, TEMPLATE_EXPORTS_LIST, data, LAYOUT_EXPORTS_FRAGMENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) runHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Formulardaten ungueltig."})
|
||||||
|
}
|
||||||
|
csrfToken := e.Request.FormValue("csrf_token")
|
||||||
|
if err := req.CheckCSRF(csrfToken); err != nil {
|
||||||
|
session := req.Session()
|
||||||
|
if session == nil {
|
||||||
|
app.Logger().Warn("Export CSRF failed: no session", "token_len", len(csrfToken))
|
||||||
|
} else {
|
||||||
|
app.Logger().Warn(
|
||||||
|
"Export CSRF failed",
|
||||||
|
"token_len", len(csrfToken),
|
||||||
|
"session_token_len", len(session.Token),
|
||||||
|
"session_csrf_len", len(session.CSRF),
|
||||||
|
"matches_token", csrfToken == session.Token,
|
||||||
|
"matches_csrf", csrfToken == session.CSRF,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
exportType := strings.TrimSpace(e.Request.FormValue("export_type"))
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = dbmodels.EXPORT_TYPE_DATA
|
||||||
|
}
|
||||||
|
if exportType != dbmodels.EXPORT_TYPE_DATA && exportType != dbmodels.EXPORT_TYPE_FILES {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Unbekannter Export-Typ."})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.EXPORTS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{"error": "Export-Tabelle nicht verfuegbar."})
|
||||||
|
}
|
||||||
|
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
now := time.Now()
|
||||||
|
var name string
|
||||||
|
if exportType == dbmodels.EXPORT_TYPE_FILES {
|
||||||
|
name = fmt.Sprintf("Dateien-Backup %s", now.Format("02.01.2006 15:04"))
|
||||||
|
} else {
|
||||||
|
name = fmt.Sprintf("XML-Export %s", now.Format("02.01.2006 15:04"))
|
||||||
|
}
|
||||||
|
record.Set(dbmodels.EXPORT_NAME_FIELD, name)
|
||||||
|
record.Set(dbmodels.EXPORT_TYPE_FIELD, exportType)
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_QUEUED)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_TOTAL_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_EXPIRES_FIELD, types.NowDateTime().Add(7*24*time.Hour))
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
|
||||||
|
if user := req.User(); user != nil {
|
||||||
|
record.Set(dbmodels.EXPORT_CREATED_BY_FIELD, user.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(exportID string) {
|
||||||
|
var runErr error
|
||||||
|
if exportType == dbmodels.EXPORT_TYPE_FILES {
|
||||||
|
runErr = exports.RunFiles(app, exportID)
|
||||||
|
} else {
|
||||||
|
runErr = exports.Run(app, exportID)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
app.Logger().Error("Export failed", "error", runErr, "export_id", exportID)
|
||||||
|
}
|
||||||
|
}(record.Id)
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "id": record.Id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) downloadHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
id := strings.TrimSpace(e.Request.PathValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Ungueltige Export-ID."})
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := app.FindRecordById(dbmodels.EXPORTS_TABLE, id)
|
||||||
|
if err != nil || record == nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]any{"error": "Export nicht gefunden."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.GetString(dbmodels.EXPORT_STATUS_FIELD) != dbmodels.EXPORT_STATUS_COMPLETE {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Export ist noch nicht fertig."})
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, err := exports.ExportDir(app)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{"error": "Export-Verzeichnis nicht verfuegbar."})
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
||||||
|
if filename == "" {
|
||||||
|
filename = record.Id + ".zip"
|
||||||
|
}
|
||||||
|
filename = filepath.Base(filename)
|
||||||
|
|
||||||
|
path := filepath.Join(exportDir, filename)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]any{"error": "Exportdatei nicht gefunden."})
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
return e.FileFS(os.DirFS(exportDir), filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ExportsAdmin) deleteHandler(app core.App) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if err := e.Request.ParseForm(); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Formulardaten ungueltig."})
|
||||||
|
}
|
||||||
|
if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil {
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(e.Request.PathValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]any{"error": "Ungueltige Export-ID."})
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := app.FindRecordById(dbmodels.EXPORTS_TABLE, id)
|
||||||
|
if err != nil || record == nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]any{"error": "Export nicht gefunden."})
|
||||||
|
}
|
||||||
|
|
||||||
|
status := record.GetString(dbmodels.EXPORT_STATUS_FIELD)
|
||||||
|
if status == dbmodels.EXPORT_STATUS_RUNNING || status == dbmodels.EXPORT_STATUS_QUEUED {
|
||||||
|
return e.JSON(http.StatusConflict, map[string]any{"error": "Export laeuft noch."})
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, err := exports.ExportDir(app)
|
||||||
|
if err == nil {
|
||||||
|
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
||||||
|
if filename == "" {
|
||||||
|
filename = record.Id + ".xml"
|
||||||
|
}
|
||||||
|
filename = filepath.Base(filename)
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename))
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename+".tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Delete(record); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "message": "Export geloescht."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportsData(e *core.RequestEvent, app core.App) (map[string]any, error) {
|
||||||
|
data := map[string]any{}
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
|
||||||
|
exportsList, hasRunning, err := loadExports(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data["exports"] = exportsList
|
||||||
|
data["has_running"] = hasRunning
|
||||||
|
data["csrf_token"] = ""
|
||||||
|
if req.Session() != nil {
|
||||||
|
data["csrf_token"] = req.Session().Token
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExports(app core.App) ([]exportView, bool, error) {
|
||||||
|
records := []*core.Record{}
|
||||||
|
err := app.RecordQuery(dbmodels.EXPORTS_TABLE).
|
||||||
|
OrderBy(dbmodels.CREATED_FIELD + " DESC").
|
||||||
|
All(&records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
exportsList := make([]exportView, 0, len(records))
|
||||||
|
hasRunning := false
|
||||||
|
for _, record := range records {
|
||||||
|
status := record.GetString(dbmodels.EXPORT_STATUS_FIELD)
|
||||||
|
if status == dbmodels.EXPORT_STATUS_RUNNING || status == dbmodels.EXPORT_STATUS_QUEUED {
|
||||||
|
hasRunning = true
|
||||||
|
}
|
||||||
|
exportType := record.GetString(dbmodels.EXPORT_TYPE_FIELD)
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = dbmodels.EXPORT_TYPE_DATA
|
||||||
|
}
|
||||||
|
sizeBytes := int64(record.GetInt(dbmodels.EXPORT_SIZE_FIELD))
|
||||||
|
exportsList = append(exportsList, exportView{
|
||||||
|
Id: record.Id,
|
||||||
|
Name: record.GetString(dbmodels.EXPORT_NAME_FIELD),
|
||||||
|
Filename: record.GetString(dbmodels.EXPORT_FILENAME_FIELD),
|
||||||
|
Type: exportType,
|
||||||
|
Status: status,
|
||||||
|
Progress: record.GetInt(dbmodels.EXPORT_PROGRESS_FIELD),
|
||||||
|
TablesTotal: record.GetInt(dbmodels.EXPORT_TABLES_TOTAL_FIELD),
|
||||||
|
TablesDone: record.GetInt(dbmodels.EXPORT_TABLES_DONE_FIELD),
|
||||||
|
SizeBytes: sizeBytes,
|
||||||
|
SizeLabel: formatBytes(sizeBytes),
|
||||||
|
Created: record.GetDateTime(dbmodels.CREATED_FIELD),
|
||||||
|
Expires: record.GetDateTime(dbmodels.EXPORT_EXPIRES_FIELD),
|
||||||
|
CurrentTable: record.GetString(dbmodels.EXPORT_CURRENT_TABLE_FIELD),
|
||||||
|
Error: record.GetString(dbmodels.EXPORT_ERROR_FIELD),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportsList, hasRunning, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(size int64) string {
|
||||||
|
if size <= 0 {
|
||||||
|
return "0 B"
|
||||||
|
}
|
||||||
|
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||||
|
value := float64(size)
|
||||||
|
unitIdx := 0
|
||||||
|
for value >= 1024 && unitIdx < len(units)-1 {
|
||||||
|
value /= 1024
|
||||||
|
unitIdx++
|
||||||
|
}
|
||||||
|
if value >= 100 {
|
||||||
|
return fmt.Sprintf("%.0f %s", value, units[unitIdx])
|
||||||
|
}
|
||||||
|
if value >= 10 {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, units[unitIdx])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %s", value, units[unitIdx])
|
||||||
|
}
|
||||||
@@ -444,6 +444,7 @@ const (
|
|||||||
FILES_TABLE = "files"
|
FILES_TABLE = "files"
|
||||||
HTML_TABLE = "html"
|
HTML_TABLE = "html"
|
||||||
PAGES_TABLE = "pages"
|
PAGES_TABLE = "pages"
|
||||||
|
EXPORTS_TABLE = "exports"
|
||||||
|
|
||||||
ID_FIELD = "id"
|
ID_FIELD = "id"
|
||||||
CREATED_FIELD = "created"
|
CREATED_FIELD = "created"
|
||||||
@@ -555,5 +556,18 @@ const (
|
|||||||
LAYOUT_FIELD = "layout"
|
LAYOUT_FIELD = "layout"
|
||||||
TYPE_FIELD = "type"
|
TYPE_FIELD = "type"
|
||||||
|
|
||||||
|
EXPORT_NAME_FIELD = "name"
|
||||||
|
EXPORT_FILENAME_FIELD = "filename"
|
||||||
|
EXPORT_TYPE_FIELD = "export_type"
|
||||||
|
EXPORT_STATUS_FIELD = "status"
|
||||||
|
EXPORT_PROGRESS_FIELD = "progress"
|
||||||
|
EXPORT_TABLES_TOTAL_FIELD = "tables_total"
|
||||||
|
EXPORT_TABLES_DONE_FIELD = "tables_done"
|
||||||
|
EXPORT_SIZE_FIELD = "size_bytes"
|
||||||
|
EXPORT_CURRENT_TABLE_FIELD = "current_table"
|
||||||
|
EXPORT_ERROR_FIELD = "error"
|
||||||
|
EXPORT_EXPIRES_FIELD = "expires_at"
|
||||||
|
EXPORT_CREATED_BY_FIELD = "created_by"
|
||||||
|
|
||||||
SESSION_COOKIE_NAME = "sid"
|
SESSION_COOKIE_NAME = "sid"
|
||||||
)
|
)
|
||||||
|
|||||||
23
dbmodels/exports.go
Normal file
23
dbmodels/exports.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package dbmodels
|
||||||
|
|
||||||
|
const (
|
||||||
|
EXPORT_STATUS_QUEUED = "queued"
|
||||||
|
EXPORT_STATUS_RUNNING = "running"
|
||||||
|
EXPORT_STATUS_COMPLETE = "complete"
|
||||||
|
EXPORT_STATUS_FAILED = "failed"
|
||||||
|
|
||||||
|
EXPORT_TYPE_DATA = "data"
|
||||||
|
EXPORT_TYPE_FILES = "files"
|
||||||
|
)
|
||||||
|
|
||||||
|
var EXPORT_STATUS_VALUES = []string{
|
||||||
|
EXPORT_STATUS_QUEUED,
|
||||||
|
EXPORT_STATUS_RUNNING,
|
||||||
|
EXPORT_STATUS_COMPLETE,
|
||||||
|
EXPORT_STATUS_FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
var EXPORT_TYPE_VALUES = []string{
|
||||||
|
EXPORT_TYPE_DATA,
|
||||||
|
EXPORT_TYPE_FILES,
|
||||||
|
}
|
||||||
67
helpers/exports/cleanup.go
Normal file
67
helpers/exports/cleanup.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package exports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cleanupOnce sync.Once
|
||||||
|
|
||||||
|
func StartCleanup(app core.App, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 12 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
CleanupExpired(app)
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
CleanupExpired(app)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupExpired(app core.App) {
|
||||||
|
now := types.NowDateTime()
|
||||||
|
records := []*core.Record{}
|
||||||
|
err := app.RecordQuery(dbmodels.EXPORTS_TABLE).
|
||||||
|
Where(dbx.NewExp(dbmodels.EXPORT_EXPIRES_FIELD+" <= {:now}", dbx.Params{"now": now})).
|
||||||
|
All(&records)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Export cleanup query failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, err := ExportDir(app)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Export cleanup dir failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
||||||
|
if filename == "" {
|
||||||
|
filename = record.Id + ".xml"
|
||||||
|
}
|
||||||
|
filename = filepath.Base(filename)
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename))
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename+".tmp"))
|
||||||
|
if err := app.Delete(record); err != nil {
|
||||||
|
app.Logger().Error("Export cleanup delete failed", "error", err, "export_id", record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
302
helpers/exports/exporter.go
Normal file
302
helpers/exports/exporter.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package exports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/xml"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exportDirName = "exports"
|
||||||
|
|
||||||
|
func ExportDir(app core.App) (string, error) {
|
||||||
|
base := filepath.Join(app.DataDir(), exportDirName)
|
||||||
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListTables(app core.App) ([]string, error) {
|
||||||
|
tables := []string{}
|
||||||
|
err := app.DB().
|
||||||
|
NewQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").
|
||||||
|
Column(&tables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := map[string]struct{}{
|
||||||
|
"_superusers": {},
|
||||||
|
"_mfas": {},
|
||||||
|
"_otps": {},
|
||||||
|
"_externalAuths": {},
|
||||||
|
"_authorigins": {},
|
||||||
|
"_authOrigins": {},
|
||||||
|
"access_tokens": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := tables[:0]
|
||||||
|
for _, table := range tables {
|
||||||
|
if strings.HasPrefix(table, dbmodels.FTS5_PREFIX) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := excluded[table]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, table)
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(app core.App, exportID string) error {
|
||||||
|
record, err := app.FindRecordById(dbmodels.EXPORTS_TABLE, exportID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := ListTables(app)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_RUNNING)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_TOTAL_FIELD, len(tables))
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, err := ExportDir(app)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := exportID + ".zip"
|
||||||
|
tempPath := filepath.Join(exportDir, filename+".tmp")
|
||||||
|
finalPath := filepath.Join(exportDir, filename)
|
||||||
|
|
||||||
|
file, err := os.Create(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if file != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
|
||||||
|
for idx, table := range tables {
|
||||||
|
updateProgress(app, record, table, idx, len(tables))
|
||||||
|
|
||||||
|
if err := exportTableZipEntry(app, zipWriter, table); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(app, record, table, idx+1, len(tables))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
file = nil
|
||||||
|
|
||||||
|
if err := os.Rename(tempPath, finalPath); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_COMPLETE)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 100)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, len(tables))
|
||||||
|
record.Set(dbmodels.EXPORT_FILENAME_FIELD, filename)
|
||||||
|
record.Set(dbmodels.EXPORT_SIZE_FIELD, stat.Size())
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportTableZipEntry(app core.App, zipWriter *zip.Writer, table string) error {
|
||||||
|
entryName := safeFilename(table)
|
||||||
|
if entryName == "" {
|
||||||
|
entryName = "table"
|
||||||
|
}
|
||||||
|
entryName += ".xml"
|
||||||
|
|
||||||
|
entry, err := zipWriter.Create(entryName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := entry.Write([]byte(xml.Header)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := xml.NewEncoder(entry)
|
||||||
|
start := xml.StartElement{
|
||||||
|
Name: xml.Name{Local: "table"},
|
||||||
|
Attr: []xml.Attr{{Name: xml.Name{Local: "name"}, Value: table}},
|
||||||
|
}
|
||||||
|
if err := encoder.EncodeToken(start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT * FROM " + quoteTableName(table)
|
||||||
|
rows, err := app.DB().NewQuery(query).Rows()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
rowData := dbx.NullStringMap{}
|
||||||
|
if err := rows.ScanMap(rowData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: "row"}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sensitiveFields := map[string]struct{}{}
|
||||||
|
if table == "users" {
|
||||||
|
sensitiveFields = map[string]struct{}{
|
||||||
|
"password": {},
|
||||||
|
"password_hash": {},
|
||||||
|
"passwordhash": {},
|
||||||
|
"tokenkey": {},
|
||||||
|
"token_key": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, col := range columns {
|
||||||
|
lowerCol := strings.ToLower(col)
|
||||||
|
if _, ok := sensitiveFields[lowerCol]; ok {
|
||||||
|
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: col}, Attr: []xml.Attr{{Name: xml.Name{Local: "null"}, Value: "true"}}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: col}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value := rowData[col]
|
||||||
|
attrs := []xml.Attr{}
|
||||||
|
if !value.Valid {
|
||||||
|
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "null"}, Value: "true"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: col}, Attr: attrs}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value.Valid {
|
||||||
|
if err := encoder.EncodeToken(xml.CharData([]byte(value.String))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: col}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: "row"}}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(start.End()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoder.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProgress(app core.App, record *core.Record, table string, done, total int) {
|
||||||
|
progress := 0
|
||||||
|
if total > 0 {
|
||||||
|
progress = int(float64(done) / float64(total) * 100)
|
||||||
|
}
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, table)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, done)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, progress)
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
app.Logger().Error("Export progress update failed", "error", err, "export_id", record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFailed(app core.App, record *core.Record, err error) error {
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_FAILED)
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, err.Error())
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
|
||||||
|
if saveErr := app.Save(record); saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, dirErr := ExportDir(app)
|
||||||
|
if dirErr == nil {
|
||||||
|
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
||||||
|
if filename == "" {
|
||||||
|
filename = record.Id + ".zip"
|
||||||
|
}
|
||||||
|
filename = filepath.Base(filename)
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename))
|
||||||
|
_ = os.Remove(filepath.Join(exportDir, filename+".tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteTableName(name string) string {
|
||||||
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFilename(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(name))
|
||||||
|
for _, r := range name {
|
||||||
|
if (r >= 'a' && r <= 'z') ||
|
||||||
|
(r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') ||
|
||||||
|
r == '_' || r == '-' || r == '.' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
254
helpers/exports/file_exporter.go
Normal file
254
helpers/exports/file_exporter.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package exports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileEntry struct {
|
||||||
|
CollectionName string
|
||||||
|
CollectionId string
|
||||||
|
RecordId string
|
||||||
|
FieldName string
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunFiles(app core.App, exportID string) error {
|
||||||
|
record, err := app.FindRecordById(dbmodels.EXPORTS_TABLE, exportID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := collectFileEntries(app)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_RUNNING)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_TOTAL_FIELD, len(files))
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
|
||||||
|
record.Set(dbmodels.EXPORT_FILENAME_FIELD, exportID+"-files.zip")
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDir, err := ExportDir(app)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := exportID + "-files.zip"
|
||||||
|
tempPath := filepath.Join(exportDir, filename+".tmp")
|
||||||
|
finalPath := filepath.Join(exportDir, filename)
|
||||||
|
|
||||||
|
file, err := os.Create(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if file != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
|
||||||
|
missing := 0
|
||||||
|
lastProgressSave := time.Now()
|
||||||
|
for idx, entry := range files {
|
||||||
|
label := entry.CollectionName + "/" + entry.RecordId + "/" + entry.Filename
|
||||||
|
if shouldUpdateProgress(idx, len(files), lastProgressSave) {
|
||||||
|
updateProgress(app, record, label, idx, len(files))
|
||||||
|
lastProgressSave = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addFileToZip(app, zipWriter, entry); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
missing++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldUpdateProgress(idx+1, len(files), lastProgressSave) {
|
||||||
|
updateProgress(app, record, label, idx+1, len(files))
|
||||||
|
lastProgressSave = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
file = nil
|
||||||
|
|
||||||
|
if err := os.Rename(tempPath, finalPath); err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
return markFailed(app, record, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_COMPLETE)
|
||||||
|
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 100)
|
||||||
|
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, len(files))
|
||||||
|
record.Set(dbmodels.EXPORT_FILENAME_FIELD, filename)
|
||||||
|
record.Set(dbmodels.EXPORT_SIZE_FIELD, stat.Size())
|
||||||
|
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
|
||||||
|
if missing > 0 {
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, fmt.Sprintf("%d Datei(en) fehlen im Speicher.", missing))
|
||||||
|
} else {
|
||||||
|
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
|
||||||
|
}
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFileEntries(app core.App) ([]fileEntry, error) {
|
||||||
|
collections, err := app.FindAllCollections()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]fileEntry, 0)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
if collection == nil || collection.IsView() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(collection.Name, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileFields := make([]string, 0)
|
||||||
|
for _, field := range collection.Fields {
|
||||||
|
if field.Type() == core.FieldTypeFile {
|
||||||
|
fileFields = append(fileFields, field.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fileFields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
records := []*core.Record{}
|
||||||
|
if err := app.RecordQuery(collection.Name).All(&records); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fieldName := range fileFields {
|
||||||
|
raw := record.GetRaw(fieldName)
|
||||||
|
for _, filename := range extractFileNames(raw) {
|
||||||
|
if filename == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := collection.Id + "|" + record.Id + "|" + filename
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
entries = append(entries, fileEntry{
|
||||||
|
CollectionName: collection.Name,
|
||||||
|
CollectionId: collection.Id,
|
||||||
|
RecordId: record.Id,
|
||||||
|
FieldName: fieldName,
|
||||||
|
Filename: filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileNames(raw any) []string {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(v, "[") {
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal([]byte(v), &list); err == nil {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{v}
|
||||||
|
case []string:
|
||||||
|
return value
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(value))
|
||||||
|
for _, item := range value {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []byte:
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal(value, &list); err == nil {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFileToZip(app core.App, zipWriter *zip.Writer, entry fileEntry) error {
|
||||||
|
root := filepath.Join(app.DataDir(), "storage")
|
||||||
|
sourcePath := filepath.Join(root, entry.CollectionId, entry.RecordId, entry.Filename)
|
||||||
|
|
||||||
|
reader, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
zipPath := entry.CollectionName + "/" + entry.RecordId + "/" + entry.Filename
|
||||||
|
zipEntry, err := zipWriter.Create(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(zipEntry, reader)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUpdateProgress(done, total int, lastSave time.Time) bool {
|
||||||
|
if total == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if done == 0 || done >= total {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if done%200 == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(lastSave) > 2*time.Second
|
||||||
|
}
|
||||||
99
migrations/1769000000_exports.go
Normal file
99
migrations/1769000000_exports.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
usersCollection, err := app.FindCollectionByNameOrId(dbmodels.USERS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to find users collection for exports migration", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
collection := core.NewBaseCollection(dbmodels.EXPORTS_TABLE)
|
||||||
|
fields := exportsFields(usersCollection.Id)
|
||||||
|
dbmodels.SetCreatedUpdatedFields(&fields)
|
||||||
|
collection.Fields = fields
|
||||||
|
|
||||||
|
dbmodels.AddIndex(collection, dbmodels.EXPORT_STATUS_FIELD, false)
|
||||||
|
dbmodels.AddIndex(collection, dbmodels.EXPORT_EXPIRES_FIELD, false)
|
||||||
|
dbmodels.AddIndex(collection, dbmodels.EXPORT_CREATED_BY_FIELD, false)
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.EXPORTS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no rows in result set") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app.Logger().Error("Failed to find collection for deletion", "collection", dbmodels.EXPORTS_TABLE, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Delete(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportsFields(usersCollectionId string) core.FieldsList {
|
||||||
|
fields := core.NewFieldsList(
|
||||||
|
&core.TextField{
|
||||||
|
Name: dbmodels.EXPORT_NAME_FIELD,
|
||||||
|
Required: true,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: dbmodels.EXPORT_FILENAME_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.SelectField{
|
||||||
|
Name: dbmodels.EXPORT_STATUS_FIELD,
|
||||||
|
Required: true,
|
||||||
|
Presentable: true,
|
||||||
|
MaxSelect: 1,
|
||||||
|
Values: dbmodels.EXPORT_STATUS_VALUES,
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: dbmodels.EXPORT_PROGRESS_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: dbmodels.EXPORT_TABLES_TOTAL_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: dbmodels.EXPORT_TABLES_DONE_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: dbmodels.EXPORT_SIZE_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: dbmodels.EXPORT_CURRENT_TABLE_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: dbmodels.EXPORT_ERROR_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.DateField{
|
||||||
|
Name: dbmodels.EXPORT_EXPIRES_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
&core.RelationField{
|
||||||
|
Name: dbmodels.EXPORT_CREATED_BY_FIELD,
|
||||||
|
CollectionId: usersCollectionId,
|
||||||
|
MaxSelect: 1,
|
||||||
|
CascadeDelete: true,
|
||||||
|
Presentable: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
41
migrations/1769000001_exports_type.go
Normal file
41
migrations/1769000001_exports_type.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.EXPORTS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to find exports collection for type migration", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
field := &core.SelectField{
|
||||||
|
Name: dbmodels.EXPORT_TYPE_FIELD,
|
||||||
|
Presentable: true,
|
||||||
|
MaxSelect: 1,
|
||||||
|
Values: dbmodels.EXPORT_TYPE_VALUES,
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.Fields.Add(field)
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId(dbmodels.EXPORTS_TABLE)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no rows in result set") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app.Logger().Error("Failed to find exports collection for type rollback", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.Fields.RemoveByName(dbmodels.EXPORT_TYPE_FIELD)
|
||||||
|
return app.Save(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package templating
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -87,7 +88,11 @@ func (r *Request) IsEditor() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Request) CheckCSRF(target string) error {
|
func (r *Request) CheckCSRF(target string) error {
|
||||||
if r.Session() == nil || target == "" || r.Session().Token != target {
|
target = strings.TrimSpace(target)
|
||||||
|
if r.Session() == nil || target == "" {
|
||||||
|
return fmt.Errorf("CSRF-Token nicht vorhanden oder ungültig")
|
||||||
|
}
|
||||||
|
if r.Session().Token != target && r.Session().CSRF != target {
|
||||||
return fmt.Errorf("CSRF-Token nicht vorhanden oder ungültig")
|
return fmt.Errorf("CSRF-Token nicht vorhanden oder ungültig")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
59
views/routes/redaktion/exports/body.gohtml
Normal file
59
views/routes/redaktion/exports/body.gohtml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
|
||||||
|
<div class="flex-col w-full">
|
||||||
|
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
|
||||||
|
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Datenexport</h1>
|
||||||
|
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
|
||||||
|
<i class="ri-question-line"></i>
|
||||||
|
Export von Daten u. Dateien als ZIP-Ordner. Die Exporte werden gespeichert und nach dem
|
||||||
|
Ablauf von sieben Tagen automatisch gelöscht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<export-manager
|
||||||
|
class="block container-normal mx-auto px-8 mt-6"
|
||||||
|
data-run-url="/redaktion/exports/run/"
|
||||||
|
data-list-url="/redaktion/exports/list/"
|
||||||
|
data-delete-url="/redaktion/exports/delete/"
|
||||||
|
data-csrf="{{ $model.csrf_token }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Daten-Export erstellen</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">Sichert alle Daten der Tabellen als
|
||||||
|
XML-Dateien. Der Export läuft im Hintergrund.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="data">
|
||||||
|
<i class="ri-download-2-line"></i> Export starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Dateien sichern</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">Exportiert Bilder und Dateien als ZIP. Der
|
||||||
|
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
|
||||||
|
<i class="ri-folder-zip-line"></i> Dateien sichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-600" data-role="status"></div>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<div class="flex items-center justify-between mb-4 border-b border-slate-200 pb-2">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Letzte Exporte</h3>
|
||||||
|
<div class="text-xs text-slate-500">Aktualisiert automatisch</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3" data-role="export-list">
|
||||||
|
{{ template "_export_list" $model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</export-manager>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
{{- if not $model.exports -}}
|
||||||
|
<div class="text-sm text-slate-500">Noch keine Exporte vorhanden.</div>
|
||||||
|
{{- else -}}
|
||||||
|
{{- range $_, $export := $model.exports -}}
|
||||||
|
<div class="rounded-xs border border-slate-200 bg-white p-4 shadow-sm" data-export-id="{{ $export.Id }}" data-export-status="{{ $export.Status }}">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
|
{{- if eq $export.Type "files" -}}
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-xs bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900">
|
||||||
|
<i class="ri-folder-zip-line"></i>
|
||||||
|
Dateien
|
||||||
|
</span>
|
||||||
|
{{- else -}}
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-xs bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">
|
||||||
|
<i class="ri-database-2-line"></i>
|
||||||
|
Daten
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
<span class="text-xs text-slate-600">
|
||||||
|
{{ GermanDate $export.Created }} {{ GermanTime $export.Created }}
|
||||||
|
</span>
|
||||||
|
{{- if $export.Expires.IsZero | not -}}
|
||||||
|
<span class="inline-flex items-center gap-2 text-xs text-slate-600"><span class="inline-block w-[0.9ch] text-center">•</span>Läuft ab {{ GermanDate $export.Expires }} {{ GermanTime $export.Expires }}</span>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{{- if eq $export.Status "complete" -}}
|
||||||
|
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-green-100 text-green-900">Fertig</span>
|
||||||
|
{{- else if eq $export.Status "failed" -}}
|
||||||
|
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-red-100 text-red-900">Fehler</span>
|
||||||
|
{{- else if eq $export.Status "running" -}}
|
||||||
|
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-amber-100 text-amber-900">Läuft</span>
|
||||||
|
{{- else -}}
|
||||||
|
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-slate-100 text-slate-700">Wartend</span>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-700 mb-2">
|
||||||
|
{{- if eq $export.Type "files" -}}
|
||||||
|
<div>{{ $export.TablesDone }} / {{ $export.TablesTotal }} Dateien</div>
|
||||||
|
{{- else -}}
|
||||||
|
<div>{{ $export.TablesDone }} / {{ $export.TablesTotal }} Tabellen</div>
|
||||||
|
{{- end -}}
|
||||||
|
<div>{{ $export.SizeLabel }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-xs bg-slate-100 overflow-hidden">
|
||||||
|
{{- if eq $export.Status "failed" -}}
|
||||||
|
<div class="h-full bg-red-400" style="width: {{ $export.Progress }}%;"></div>
|
||||||
|
{{- else if eq $export.Status "complete" -}}
|
||||||
|
<div class="h-full bg-green-500" style="width: {{ $export.Progress }}%;"></div>
|
||||||
|
{{- else -}}
|
||||||
|
<div class="h-full bg-slate-700" style="width: {{ $export.Progress }}%;"></div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{- if $export.CurrentTable -}}
|
||||||
|
<div class="text-xs text-slate-500">Aktuell: {{ $export.CurrentTable }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $export.Error -}}
|
||||||
|
<div class="text-xs text-red-700 bg-red-50 border border-red-200 rounded-xs px-3 py-2">{{ $export.Error }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-xs text-slate-500">
|
||||||
|
{{- if ne $export.Status "complete" -}}
|
||||||
|
Download verfügbar, sobald der Export abgeschlossen ist.
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{- if or (eq $export.Status "running") (eq $export.Status "queued") -}}
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-400 shadow-sm cursor-not-allowed" disabled title="Export läuft noch.">
|
||||||
|
<i class="ri-delete-bin-line"></i> Löschen
|
||||||
|
</button>
|
||||||
|
{{- else -}}
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-100 px-3 py-2 text-sm font-semibold text-red-700 shadow-sm hover:bg-slate-200 hover:text-red-800 focus:outline-none focus:ring-2 focus:ring-red-200/70" data-action="delete" data-id="{{ $export.Id }}">
|
||||||
|
<i class="ri-delete-bin-line"></i> Löschen
|
||||||
|
</button>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if eq $export.Status "complete" -}}
|
||||||
|
<a class="no-underline inline-flex items-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" href="/redaktion/exports/download/{{ $export.Id }}">
|
||||||
|
<i class="ri-download-line"></i> Download
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
1
views/routes/redaktion/exports/head.gohtml
Normal file
1
views/routes/redaktion/exports/head.gohtml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<title>{{ .site.title }} – Datenexport</title>
|
||||||
1
views/routes/redaktion/exports/list/body.gohtml
Normal file
1
views/routes/redaktion/exports/list/body.gohtml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ template "_export_list" . }}
|
||||||
198
views/transform/export-manager.js
Normal file
198
views/transform/export-manager.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
export class ExportManager extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.listUrl = "";
|
||||||
|
this.runUrl = "";
|
||||||
|
this.deleteUrl = "";
|
||||||
|
this.csrf = "";
|
||||||
|
this.list = null;
|
||||||
|
this.status = null;
|
||||||
|
this.pollTimer = null;
|
||||||
|
this.pollIntervalMs = 2500;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.listUrl = this.dataset.listUrl || "";
|
||||||
|
this.runUrl = this.dataset.runUrl || "";
|
||||||
|
this.deleteUrl = this.dataset.deleteUrl || "";
|
||||||
|
this.csrf = this.dataset.csrf || "";
|
||||||
|
this.list = this.querySelector("[data-role='export-list']");
|
||||||
|
this.status = this.querySelector("[data-role='status']");
|
||||||
|
|
||||||
|
this.addEventListener("click", (event) => this.handleAction(event));
|
||||||
|
|
||||||
|
this.refreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message, isError) {
|
||||||
|
if (!this.status) return;
|
||||||
|
this.status.textContent = message || "";
|
||||||
|
this.status.classList.remove("text-red-600", "text-green-600");
|
||||||
|
if (isError) {
|
||||||
|
this.status.classList.add("text-red-600");
|
||||||
|
} else if (message) {
|
||||||
|
this.status.classList.add("text-green-600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRun(event, exportType) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.runUrl) return;
|
||||||
|
this.setStatus("Export wird gestartet...");
|
||||||
|
const button = event.target.closest("[data-role='run-export']");
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.set("csrf_token", this.getCsrfToken());
|
||||||
|
payload.set("export_type", exportType || "data");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.runUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Export konnte nicht gestartet werden.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus("Export läuft im Hintergrund.");
|
||||||
|
await this.refreshList();
|
||||||
|
this.startPolling();
|
||||||
|
} catch (err) {
|
||||||
|
this.setStatus("Export konnte nicht gestartet werden.", true);
|
||||||
|
} finally {
|
||||||
|
if (button) button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAction(event) {
|
||||||
|
const runTarget = event.target.closest("[data-role='run-export']");
|
||||||
|
if (runTarget) {
|
||||||
|
const exportType = runTarget.getAttribute("data-export-type") || "data";
|
||||||
|
await this.handleRun(event, exportType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target.closest("[data-action]");
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.getAttribute("data-action");
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = target.getAttribute("data-id");
|
||||||
|
if (!id || !this.deleteUrl) return;
|
||||||
|
const confirmed = confirm("Soll der Export wirklich gelöscht werden?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
await this.deleteExport(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExport(id) {
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.set("csrf_token", this.getCsrfToken());
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.deleteUrl}${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Export konnte nicht gelöscht werden.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus("Export gelöscht.");
|
||||||
|
await this.refreshList();
|
||||||
|
} catch {
|
||||||
|
this.setStatus("Export konnte nicht gelöscht werden.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshList() {
|
||||||
|
if (!this.list || !this.listUrl) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.listUrl, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
this.list.innerHTML = html;
|
||||||
|
this.syncPollingState();
|
||||||
|
} catch {
|
||||||
|
// ignore refresh errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPollingState() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const active = this.list.querySelector("[data-export-status='running'], [data-export-status='queued']");
|
||||||
|
if (active) {
|
||||||
|
this.startPolling();
|
||||||
|
} else {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling() {
|
||||||
|
if (this.pollTimer) return;
|
||||||
|
this.pollTimer = window.setInterval(() => {
|
||||||
|
this.refreshList();
|
||||||
|
}, this.pollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (!this.pollTimer) return;
|
||||||
|
window.clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeJson(response) {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractError(response) {
|
||||||
|
try {
|
||||||
|
const json = await response.json();
|
||||||
|
if (json && json.error) {
|
||||||
|
return json.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.text();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCsrfToken() {
|
||||||
|
if (this.csrf) return this.csrf;
|
||||||
|
const fallback = document.querySelector("input[name='csrf_token']");
|
||||||
|
if (fallback && fallback.value) {
|
||||||
|
this.csrf = fallback.value;
|
||||||
|
}
|
||||||
|
return this.csrf;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -337,6 +337,15 @@ export class FabMenu extends HTMLElement {
|
|||||||
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
Administration
|
Administration
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-[1fr_auto] group">
|
||||||
|
<a href="/redaktion/exports/" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
|
||||||
|
<i class="ri-download-2-line text-base text-gray-700 mr-2.5"></i>
|
||||||
|
<span class="text-gray-900">Datenexport</span>
|
||||||
|
</a>
|
||||||
|
<a href="/redaktion/exports/" target="_blank" class="flex items-center justify-center px-3 py-2 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
|
||||||
|
<i class="ri-external-link-line text-base"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-[1fr_auto] group">
|
<div class="grid grid-cols-[1fr_auto] group">
|
||||||
<a href="/user/management/access/User?redirectTo=${redirectPath}" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
|
<a href="/user/management/access/User?redirectTo=${redirectPath}" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
|
||||||
<i class="ri-group-3-line text-base text-gray-700 mr-2.5"></i>
|
<i class="ri-group-3-line text-base text-gray-700 mr-2.5"></i>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { FabMenu } from "./fab-menu.js";
|
|||||||
import { DuplicateWarningChecker } from "./duplicate-warning.js";
|
import { DuplicateWarningChecker } from "./duplicate-warning.js";
|
||||||
import { ContentImages } from "./content-images.js";
|
import { ContentImages } from "./content-images.js";
|
||||||
import { LookupField } from "./lookup-field.js";
|
import { LookupField } from "./lookup-field.js";
|
||||||
|
import { ExportManager } from "./export-manager.js";
|
||||||
|
|
||||||
const FILTER_LIST_ELEMENT = "filter-list";
|
const FILTER_LIST_ELEMENT = "filter-list";
|
||||||
const FAB_MENU_ELEMENT = "fab-menu";
|
const FAB_MENU_ELEMENT = "fab-menu";
|
||||||
@@ -53,6 +54,7 @@ const EDIT_PAGE_ELEMENT = "edit-page";
|
|||||||
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
|
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
|
||||||
const CONTENT_IMAGES_ELEMENT = "content-images";
|
const CONTENT_IMAGES_ELEMENT = "content-images";
|
||||||
const LOOKUP_FIELD_ELEMENT = "lookup-field";
|
const LOOKUP_FIELD_ELEMENT = "lookup-field";
|
||||||
|
const EXPORT_MANAGER_ELEMENT = "export-manager";
|
||||||
|
|
||||||
window.lookupSeriesValue = ({ item }) => item?.id || "";
|
window.lookupSeriesValue = ({ item }) => item?.id || "";
|
||||||
window.lookupSeriesLink = ({ item }) => (item?.musenalm_id ? `/reihe/${item.musenalm_id}` : "");
|
window.lookupSeriesLink = ({ item }) => (item?.musenalm_id ? `/reihe/${item.musenalm_id}` : "");
|
||||||
@@ -81,6 +83,7 @@ customElements.define(FAB_MENU_ELEMENT, FabMenu);
|
|||||||
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
|
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
|
||||||
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
|
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
|
||||||
customElements.define(LOOKUP_FIELD_ELEMENT, LookupField);
|
customElements.define(LOOKUP_FIELD_ELEMENT, LookupField);
|
||||||
|
customElements.define(EXPORT_MANAGER_ELEMENT, ExportManager);
|
||||||
|
|
||||||
function PathPlusQuery() {
|
function PathPlusQuery() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
|
|||||||
Reference in New Issue
Block a user