mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-22 20:11:50 -07:00
feat: add ui and handler for export
This commit is contained in:
parent
3157db8b6d
commit
f14c25c52f
6 changed files with 108 additions and 20 deletions
|
|
@ -196,6 +196,8 @@ function deleteListen(listen: Listen): Promise<Response> {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
function getExport() {
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getLastListens,
|
getLastListens,
|
||||||
|
|
@ -224,6 +226,7 @@ export {
|
||||||
updateApiKeyLabel,
|
updateApiKeyLabel,
|
||||||
deleteListen,
|
deleteListen,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
|
getExport,
|
||||||
}
|
}
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
||||||
45
client/app/components/modals/ExportModal.tsx
Normal file
45
client/app/components/modals/ExportModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h2>Export</h2>
|
||||||
|
<AsyncButton loading={loading} onClick={handleExport}>Export Data</AsyncButton>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
||||||
import ThemeHelper from "../../routes/ThemeHelper";
|
import ThemeHelper from "../../routes/ThemeHelper";
|
||||||
import { useAppContext } from "~/providers/AppProvider";
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
import ApiKeysModal from "./ApiKeysModal";
|
import ApiKeysModal from "./ApiKeysModal";
|
||||||
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
import ExportModal from "./ExportModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||||
{user && (
|
{user && (
|
||||||
<TabsTrigger className={triggerClasses} value="API Keys">
|
<>
|
||||||
API Keys
|
<TabsTrigger className={triggerClasses} value="API Keys">
|
||||||
</TabsTrigger>
|
API Keys
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
<TabsContent value="API Keys" className={contentClasses}>
|
<TabsContent value="API Keys" className={contentClasses}>
|
||||||
<ApiKeysModal />
|
<ApiKeysModal />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="Export" className={contentClasses}>
|
||||||
|
<ExportModal />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
33
engine/handlers/export.go
Normal file
33
engine/handlers/export.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,7 @@ func bindRoutes(
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.ValidateSession(db))
|
r.Use(middleware.ValidateSession(db))
|
||||||
|
r.Get("/export", handlers.ExportHandler(db))
|
||||||
r.Post("/replace-image", handlers.ReplaceImageHandler(db))
|
r.Post("/replace-image", handlers.ReplaceImageHandler(db))
|
||||||
r.Patch("/album", handlers.UpdateAlbumHandler(db))
|
r.Patch("/album", handlers.UpdateAlbumHandler(db))
|
||||||
r.Post("/merge/tracks", handlers.MergeTracksHandler(db))
|
r.Post("/merge/tracks", handlers.MergeTracksHandler(db))
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"io"
|
||||||
"path"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/cfg"
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
"github.com/gabehf/koito/internal/models"
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
|
@ -45,7 +43,7 @@ type KoitoArtist struct {
|
||||||
Aliases []models.Alias `json:"aliases"`
|
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)
|
lastTime := time.Unix(0, 0)
|
||||||
lastTrackId := int32(0)
|
lastTrackId := int32(0)
|
||||||
pageSize := int32(1000)
|
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...")
|
l.Info().Msg("ExportData: Generating Koito export file...")
|
||||||
|
|
||||||
exportedAt := time.Now()
|
exportedAt := time.Now()
|
||||||
exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix()))
|
// exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix()))
|
||||||
f, err := os.Create(exportFile)
|
// f, err := os.Create(exportFile)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return fmt.Errorf("ExportData: %w", err)
|
// return fmt.Errorf("ExportData: %w", err)
|
||||||
}
|
// }
|
||||||
defer f.Close()
|
// defer f.Close()
|
||||||
|
|
||||||
// Write the opening of the JSON manually
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("ExportData: %w", err)
|
return fmt.Errorf("ExportData: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +68,7 @@ func ExportData(ctx context.Context, username string, store db.DB) error {
|
||||||
first := true
|
first := true
|
||||||
for {
|
for {
|
||||||
rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{
|
rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{
|
||||||
UserID: 1,
|
UserID: user.ID,
|
||||||
ListenedAt: lastTime,
|
ListenedAt: lastTime,
|
||||||
TrackID: lastTrackId,
|
TrackID: lastTrackId,
|
||||||
Limit: pageSize,
|
Limit: pageSize,
|
||||||
|
|
@ -85,7 +83,7 @@ func ExportData(ctx context.Context, username string, store db.DB) error {
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
// Adds a comma after each listen item
|
// Adds a comma after each listen item
|
||||||
if !first {
|
if !first {
|
||||||
_, _ = f.Write([]byte(",\n"))
|
_, _ = out.Write([]byte(",\n"))
|
||||||
}
|
}
|
||||||
first = false
|
first = false
|
||||||
|
|
||||||
|
|
@ -94,12 +92,12 @@ func ExportData(ctx context.Context, username string, store db.DB) error {
|
||||||
raw, err := json.MarshalIndent(exported, " ", " ")
|
raw, err := json.MarshalIndent(exported, " ", " ")
|
||||||
|
|
||||||
// needed to make the listen item start at the right indent level
|
// needed to make the listen item start at the right indent level
|
||||||
f.Write([]byte(" "))
|
out.Write([]byte(" "))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ExportData: marshal: %w", err)
|
return fmt.Errorf("ExportData: marshal: %w", err)
|
||||||
}
|
}
|
||||||
_, _ = f.Write(raw)
|
_, _ = out.Write(raw)
|
||||||
|
|
||||||
if r.TrackID > lastTrackId {
|
if r.TrackID > lastTrackId {
|
||||||
lastTrackId = r.TrackID
|
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
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("ExportData: f.Write: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue