From f14c25c52fec061d1d3acfea2871efe9ab698864 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Wed, 18 Jun 2025 08:25:15 -0400 Subject: [PATCH] feat: add ui and handler for export --- client/api/api.ts | 3 ++ client/app/components/modals/ExportModal.tsx | 45 +++++++++++++++++++ .../app/components/modals/SettingsModal.tsx | 14 ++++-- engine/handlers/export.go | 33 ++++++++++++++ engine/routes.go | 1 + internal/export/export.go | 32 +++++++------ 6 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 client/app/components/modals/ExportModal.tsx create mode 100644 engine/handlers/export.go diff --git a/client/api/api.ts b/client/api/api.ts index e0cf821..be2cda0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -196,6 +196,8 @@ function deleteListen(listen: Listen): Promise { method: "DELETE" }) } +function getExport() { +} export { getLastListens, @@ -224,6 +226,7 @@ export { updateApiKeyLabel, deleteListen, getAlbum, + getExport, } type Track = { id: number diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx new file mode 100644 index 0000000..25c8ddf --- /dev/null +++ b/client/app/components/modals/ExportModal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { AsyncButton } from "../AsyncButton"; +import { getExport } from "api/api"; + +export default function ExportModal() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleExport = () => { + setLoading(true) + fetch(`/apis/web/v1/export`, { + method: "GET" + }) + .then(res => { + if (res.ok) { + res.blob() + .then(blob => { + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "koito_export.json" + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) + setLoading(false) + }) + } else { + res.json().then(r => setError(r.error)) + setLoading(false) + } + }).catch(err => { + setError(err) + setLoading(false) + }) + } + + return ( +
+

Export

+ Export Data + {error &&

{error}

} +
+ ) +} \ No newline at end of file diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx index 66ed7f9..31d915b 100644 --- a/client/app/components/modals/SettingsModal.tsx +++ b/client/app/components/modals/SettingsModal.tsx @@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher"; import ThemeHelper from "../../routes/ThemeHelper"; import { useAppContext } from "~/providers/AppProvider"; import ApiKeysModal from "./ApiKeysModal"; +import { AsyncButton } from "../AsyncButton"; +import ExportModal from "./ExportModal"; interface Props { open: boolean @@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) { Appearance Account {user && ( - - API Keys - + <> + + API Keys + + Export + )} @@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) { + + + ) diff --git a/engine/handlers/export.go b/engine/handlers/export.go new file mode 100644 index 0000000..da8b89b --- /dev/null +++ b/engine/handlers/export.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "net/http" + + "github.com/gabehf/koito/engine/middleware" + "github.com/gabehf/koito/internal/db" + "github.com/gabehf/koito/internal/export" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/internal/utils" +) + +func ExportHandler(store db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="koito_export.json"`) + ctx := r.Context() + l := logger.FromContext(ctx) + l.Debug().Msg("ExportHandler: Recieved request for export file") + u := middleware.GetUserFromContext(ctx) + if u == nil { + l.Debug().Msg("ExportHandler: Unauthorized access") + utils.WriteError(w, "unauthorized", http.StatusUnauthorized) + return + } + err := export.ExportData(ctx, u, store, w) + if err != nil { + l.Err(err).Msg("ExportHandler: Failed to create export file") + utils.WriteError(w, "failed to create export file", http.StatusInternalServerError) + return + } + } +} diff --git a/engine/routes.go b/engine/routes.go index 91d0c62..6f43406 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -70,6 +70,7 @@ func bindRoutes( r.Group(func(r chi.Router) { r.Use(middleware.ValidateSession(db)) + r.Get("/export", handlers.ExportHandler(db)) r.Post("/replace-image", handlers.ReplaceImageHandler(db)) r.Patch("/album", handlers.UpdateAlbumHandler(db)) r.Post("/merge/tracks", handlers.MergeTracksHandler(db)) diff --git a/internal/export/export.go b/internal/export/export.go index 26a9f3e..5e7ffb1 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -4,11 +4,9 @@ import ( "context" "encoding/json" "fmt" - "os" - "path" + "io" "time" - "github.com/gabehf/koito/internal/cfg" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/models" @@ -45,7 +43,7 @@ type KoitoArtist struct { Aliases []models.Alias `json:"aliases"` } -func ExportData(ctx context.Context, username string, store db.DB) error { +func ExportData(ctx context.Context, user *models.User, store db.DB, out io.Writer) error { lastTime := time.Unix(0, 0) lastTrackId := int32(0) pageSize := int32(1000) @@ -54,15 +52,15 @@ func ExportData(ctx context.Context, username string, store db.DB) error { l.Info().Msg("ExportData: Generating Koito export file...") exportedAt := time.Now() - exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix())) - f, err := os.Create(exportFile) - if err != nil { - return fmt.Errorf("ExportData: %w", err) - } - defer f.Close() + // exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix())) + // f, err := os.Create(exportFile) + // if err != nil { + // return fmt.Errorf("ExportData: %w", err) + // } + // defer f.Close() // Write the opening of the JSON manually - _, err = fmt.Fprintf(f, "{\n \"version\": \"1\",\n \"exported_at\": \"%s\",\n \"user\": \"%s\",\n \"listens\": [\n", exportedAt.UTC().Format(time.RFC3339), username) + _, err := fmt.Fprintf(out, "{\n \"version\": \"1\",\n \"exported_at\": \"%s\",\n \"user\": \"%s\",\n \"listens\": [\n", exportedAt.UTC().Format(time.RFC3339), user.Username) if err != nil { return fmt.Errorf("ExportData: %w", err) } @@ -70,7 +68,7 @@ func ExportData(ctx context.Context, username string, store db.DB) error { first := true for { rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{ - UserID: 1, + UserID: user.ID, ListenedAt: lastTime, TrackID: lastTrackId, Limit: pageSize, @@ -85,7 +83,7 @@ func ExportData(ctx context.Context, username string, store db.DB) error { for _, r := range rows { // Adds a comma after each listen item if !first { - _, _ = f.Write([]byte(",\n")) + _, _ = out.Write([]byte(",\n")) } first = false @@ -94,12 +92,12 @@ func ExportData(ctx context.Context, username string, store db.DB) error { raw, err := json.MarshalIndent(exported, " ", " ") // needed to make the listen item start at the right indent level - f.Write([]byte(" ")) + out.Write([]byte(" ")) if err != nil { return fmt.Errorf("ExportData: marshal: %w", err) } - _, _ = f.Write(raw) + _, _ = out.Write(raw) if r.TrackID > lastTrackId { lastTrackId = r.TrackID @@ -111,12 +109,12 @@ func ExportData(ctx context.Context, username string, store db.DB) error { } // Write closing of the JSON array and object - _, err = f.Write([]byte("\n ]\n}\n")) + _, err = out.Write([]byte("\n ]\n}\n")) if err != nil { return fmt.Errorf("ExportData: f.Write: %w", err) } - l.Info().Msgf("Export successful! File can be found at %s", exportFile) + l.Info().Msgf("Export successfully created") return nil }