mirror of https://github.com/gabehf/Koito.git
feat: v0.0.10 (#23)
* feat: single SOT for themes + basic custom support * fix: adjust colors for yuu theme * feat: Allow loading of environment variables from file (#20) * feat: allow loading of environment variables from file * Panic if a file for an environment variable cannot be read * Use log.Fatalf + os.Exit instead of panic * fix: remove supurfluous call to os.Exit() --------- Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com> Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * chore: add pr test workflow * chore: changelog * feat: make all activity grids configurable * fix: adjust activity grid style * fix: make background gradient consistent size * revert: remove year from activity grid opts * style: adjust top item list min size to 200px * feat: add support for custom themes * fix: stabilized the order of top items * chore: update changelog * feat: native import & export * fix: use correct request body for alias requests * fix: clear input when closing edit modal * chore: changelog * docs: make endpoint clearer for some apps * feat: add ui and handler for export * fix: fix pr test workflow --------- Co-authored-by: adaexec <78047743+adaexec@users.noreply.github.com> Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com>pull/37/head v0.0.10
parent
486f5d0269
commit
c16b557c21
@ -0,0 +1,32 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Go Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Install libvips
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libvips-dev
|
||||||
|
|
||||||
|
- name: Verify libvips install
|
||||||
|
run: vips --version
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -v ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
uses: robherley/go-test-action@v0
|
||||||
@ -1,4 +1,22 @@
|
|||||||
# v0.0.9
|
# v0.0.10
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Support for custom themes added! You can find the custom theme input in the Appearance menu.
|
||||||
|
- Allow loading environment variables from files using the _FILE suffix (#20)
|
||||||
|
- All activity grids (calendar heatmaps) are now configurable
|
||||||
|
- Native import and export
|
||||||
|
|
||||||
|
## Enhancements
|
||||||
|
- The activity grid on the home page is now configurable
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably
|
- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably
|
||||||
|
- Top items are now sorted by id for stability
|
||||||
|
- Clear input when closing edit modal
|
||||||
|
- Use correct request body for create and delete alias requests
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
- Adjusted colors for the "Yuu" theme
|
||||||
|
- Themes now have a single source of truth in themes.css.ts
|
||||||
|
- Configurable activity grids now have a re-styled, collapsible menu
|
||||||
|
- The year option for activity grids has been removed
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,36 +1,69 @@
|
|||||||
// ThemeSwitcher.tsx
|
// ThemeSwitcher.tsx
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { themes } from '~/providers/ThemeProvider';
|
import themes from '~/styles/themes.css';
|
||||||
import ThemeOption from './ThemeOption';
|
import ThemeOption from './ThemeOption';
|
||||||
|
import { AsyncButton } from '../AsyncButton';
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const initialTheme = {
|
||||||
|
bg: "#1e1816",
|
||||||
useEffect(() => {
|
bgSecondary: "#2f2623",
|
||||||
const saved = localStorage.getItem('theme');
|
bgTertiary: "#453733",
|
||||||
if (saved && saved !== theme) {
|
fg: "#f8f3ec",
|
||||||
setTheme(saved);
|
fgSecondary: "#d6ccc2",
|
||||||
} else if (!saved) {
|
fgTertiary: "#b4a89c",
|
||||||
localStorage.setItem('theme', theme)
|
primary: "#f5a97f",
|
||||||
|
primaryDim: "#d88b65",
|
||||||
|
accent: "#f9db6d",
|
||||||
|
accentDim: "#d9bc55",
|
||||||
|
error: "#e26c6a",
|
||||||
|
warning: "#f5b851",
|
||||||
|
success: "#8fc48f",
|
||||||
|
info: "#87b8dd",
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setCustomTheme, getCustomTheme } = useTheme()
|
||||||
|
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
||||||
|
|
||||||
|
const handleCustomTheme = () => {
|
||||||
|
console.log(custom)
|
||||||
|
try {
|
||||||
|
const theme = JSON.parse(custom)
|
||||||
|
theme.name = "custom"
|
||||||
|
setCustomTheme(theme)
|
||||||
|
delete theme.name
|
||||||
|
setCustom(JSON.stringify(theme, null, " "))
|
||||||
|
console.log(theme)
|
||||||
|
} catch(err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
localStorage.setItem('theme', theme)
|
setTheme(theme)
|
||||||
}
|
}
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='flex flex-col gap-10'>
|
||||||
<h2>Select Theme</h2>
|
<div>
|
||||||
<div className="grid grid-cols-2 items-center gap-2">
|
<h2>Select Theme</h2>
|
||||||
{themes.map((t) => (
|
<div className="grid grid-cols-2 items-center gap-2">
|
||||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
{themes.map((t) => (
|
||||||
))}
|
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Use Custom Theme</h2>
|
||||||
|
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||||
|
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} />
|
||||||
|
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,259 +1,95 @@
|
|||||||
import { createContext, useEffect, useState, type ReactNode } from 'react';
|
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import { type Theme } from '~/styles/themes.css';
|
||||||
|
import { themeVars } from '~/styles/vars.css';
|
||||||
|
|
||||||
// a fair number of colors aren't actually used, but i'm keeping
|
interface ThemeContextValue {
|
||||||
// them so that I don't have to worry about colors when adding new ui elements
|
theme: string;
|
||||||
export type Theme = {
|
setTheme: (theme: string) => void;
|
||||||
name: string,
|
setCustomTheme: (theme: Theme) => void;
|
||||||
bg: string
|
getCustomTheme: () => Theme | undefined;
|
||||||
bgSecondary: string
|
|
||||||
bgTertiary: string
|
|
||||||
fg: string
|
|
||||||
fgSecondary: string
|
|
||||||
fgTertiary: string
|
|
||||||
primary: string
|
|
||||||
primaryDim: string
|
|
||||||
accent: string
|
|
||||||
accentDim: string
|
|
||||||
error: string
|
|
||||||
warning: string
|
|
||||||
info: string
|
|
||||||
success: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: Theme[] = [
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
{
|
|
||||||
name: "yuu",
|
|
||||||
bg: "#161312",
|
|
||||||
bgSecondary: "#272120",
|
|
||||||
bgTertiary: "#382F2E",
|
|
||||||
fg: "#faf5f4",
|
|
||||||
fgSecondary: "#CCC7C6",
|
|
||||||
fgTertiary: "#B0A3A1",
|
|
||||||
primary: "#ff826d",
|
|
||||||
primaryDim: "#CE6654",
|
|
||||||
accent: "#464DAE",
|
|
||||||
accentDim: "#393D74",
|
|
||||||
error: "#FF6247",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#3ECE5F",
|
|
||||||
info: "#41C4D8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "varia",
|
|
||||||
bg: "rgb(25, 25, 29)",
|
|
||||||
bgSecondary: "#222222",
|
|
||||||
bgTertiary: "#333333",
|
|
||||||
fg: "#eeeeee",
|
|
||||||
fgSecondary: "#aaaaaa",
|
|
||||||
fgTertiary: "#888888",
|
|
||||||
primary: "rgb(203, 110, 240)",
|
|
||||||
primaryDim: "#c28379",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "midnight",
|
|
||||||
bg: "rgb(8, 15, 24)",
|
|
||||||
bgSecondary: "rgb(15, 27, 46)",
|
|
||||||
bgTertiary: "rgb(15, 41, 70)",
|
|
||||||
fg: "#dbdfe7",
|
|
||||||
fgSecondary: "#9ea3a8",
|
|
||||||
fgTertiary: "#74787c",
|
|
||||||
primary: "#1a97eb",
|
|
||||||
primaryDim: "#2680aa",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "catppuccin",
|
|
||||||
bg: "#1e1e2e",
|
|
||||||
bgSecondary: "#181825",
|
|
||||||
bgTertiary: "#11111b",
|
|
||||||
fg: "#cdd6f4",
|
|
||||||
fgSecondary: "#a6adc8",
|
|
||||||
fgTertiary: "#9399b2",
|
|
||||||
primary: "#89b4fa",
|
|
||||||
primaryDim: "#739df0",
|
|
||||||
accent: "#f38ba8",
|
|
||||||
accentDim: "#d67b94",
|
|
||||||
error: "#f38ba8",
|
|
||||||
warning: "#f9e2af",
|
|
||||||
success: "#a6e3a1",
|
|
||||||
info: "#89dceb",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "autumn",
|
|
||||||
bg: "rgb(44, 25, 18)",
|
|
||||||
bgSecondary: "rgb(70, 40, 18)",
|
|
||||||
bgTertiary: "#4b2f1c",
|
|
||||||
fg: "#fef9f3",
|
|
||||||
fgSecondary: "#dbc6b0",
|
|
||||||
fgTertiary: "#a3917a",
|
|
||||||
primary: "#d97706",
|
|
||||||
primaryDim: "#b45309",
|
|
||||||
accent: "#8c4c28",
|
|
||||||
accentDim: "#6b3b1f",
|
|
||||||
error: "#d1433f",
|
|
||||||
warning: "#e38b29",
|
|
||||||
success: "#6b8e23",
|
|
||||||
info: "#c084fc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "black",
|
|
||||||
bg: "#000000",
|
|
||||||
bgSecondary: "#1a1a1a",
|
|
||||||
bgTertiary: "#2a2a2a",
|
|
||||||
fg: "#dddddd",
|
|
||||||
fgSecondary: "#aaaaaa",
|
|
||||||
fgTertiary: "#888888",
|
|
||||||
primary: "#08c08c",
|
|
||||||
primaryDim: "#08c08c",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wine",
|
|
||||||
bg: "#23181E",
|
|
||||||
bgSecondary: "#2C1C25",
|
|
||||||
bgTertiary: "#422A37",
|
|
||||||
fg: "#FCE0B3",
|
|
||||||
fgSecondary: "#C7AC81",
|
|
||||||
fgTertiary: "#A78E64",
|
|
||||||
primary: "#EA8A64",
|
|
||||||
primaryDim: "#BD7255",
|
|
||||||
accent: "#FAE99B",
|
|
||||||
accentDim: "#C6B464",
|
|
||||||
error: "#fca5a5",
|
|
||||||
warning: "#fde68a",
|
|
||||||
success: "#bbf7d0",
|
|
||||||
info: "#bae6fd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pearl",
|
|
||||||
bg: "#FFFFFF",
|
|
||||||
bgSecondary: "#EEEEEE",
|
|
||||||
bgTertiary: "#E0E0E0",
|
|
||||||
fg: "#333333",
|
|
||||||
fgSecondary: "#555555",
|
|
||||||
fgTertiary: "#777777",
|
|
||||||
primary: "#007BFF",
|
|
||||||
primaryDim: "#0056B3",
|
|
||||||
accent: "#28A745",
|
|
||||||
accentDim: "#1E7E34",
|
|
||||||
error: "#DC3545",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "asuka",
|
|
||||||
bg: "#3B1212",
|
|
||||||
bgSecondary: "#471B1B",
|
|
||||||
bgTertiary: "#020202",
|
|
||||||
fg: "#F1E9E6",
|
|
||||||
fgSecondary: "#CCB6AE",
|
|
||||||
fgTertiary: "#9F8176",
|
|
||||||
primary: "#F1E9E6",
|
|
||||||
primaryDim: "#CCB6AE",
|
|
||||||
accent: "#41CE41",
|
|
||||||
accentDim: "#3BA03B",
|
|
||||||
error: "#DC143C",
|
|
||||||
warning: "#FFD700",
|
|
||||||
success: "#32CD32",
|
|
||||||
info: "#1E90FF",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "urim",
|
|
||||||
bg: "#101713",
|
|
||||||
bgSecondary: "#1B2921",
|
|
||||||
bgTertiary: "#273B30",
|
|
||||||
fg: "#D2E79E",
|
|
||||||
fgSecondary: "#B4DA55",
|
|
||||||
fgTertiary: "#7E9F2A",
|
|
||||||
primary: "#ead500",
|
|
||||||
primaryDim: "#C1B210",
|
|
||||||
accent: "#28A745",
|
|
||||||
accentDim: "#1E7E34",
|
|
||||||
error: "#EE5237",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "match",
|
|
||||||
bg: "#071014",
|
|
||||||
bgSecondary: "#0A181E",
|
|
||||||
bgTertiary: "#112A34",
|
|
||||||
fg: "#ebeaeb",
|
|
||||||
fgSecondary: "#BDBDBD",
|
|
||||||
fgTertiary: "#A2A2A2",
|
|
||||||
primary: "#fda827",
|
|
||||||
primaryDim: "#C78420",
|
|
||||||
accent: "#277CFD",
|
|
||||||
accentDim: "#1F60C1",
|
|
||||||
error: "#F14426",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lemon",
|
|
||||||
bg: "#1a171a",
|
|
||||||
bgSecondary: "#2E272E",
|
|
||||||
bgTertiary: "#443844",
|
|
||||||
fg: "#E6E2DC",
|
|
||||||
fgSecondary: "#B2ACA1",
|
|
||||||
fgTertiary: "#968F82",
|
|
||||||
primary: "#f5c737",
|
|
||||||
primaryDim: "#C29D2F",
|
|
||||||
accent: "#277CFD",
|
|
||||||
accentDim: "#1F60C1",
|
|
||||||
error: "#F14426",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
function toKebabCase(str: string) {
|
||||||
theme: string;
|
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
||||||
setTheme: (theme: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
function applyCustomThemeVars(theme: Theme) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
|
if (key === 'name') continue;
|
||||||
|
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCustomThemeVars() {
|
||||||
|
for (const cssVar of Object.values(themeVars)) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
theme: initialTheme,
|
theme: initialTheme,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
theme: string;
|
theme: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [theme, setTheme] = useState(initialTheme);
|
const [theme, setThemeName] = useState(initialTheme);
|
||||||
|
|
||||||
|
const setTheme = (theme: string) => {
|
||||||
|
setThemeName(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||||
|
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
||||||
|
applyCustomThemeVars(customTheme);
|
||||||
|
setTheme('custom');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const getCustomTheme = (): Theme | undefined => {
|
||||||
if (theme) {
|
const themeStr = localStorage.getItem('custom-theme');
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
if (!themeStr) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let theme = JSON.parse(themeStr) as Theme
|
||||||
|
return theme
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
const root = document.documentElement;
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
root.setAttribute('data-theme', theme);
|
||||||
);
|
localStorage.setItem('theme', theme)
|
||||||
|
console.log(theme)
|
||||||
|
|
||||||
|
if (theme === 'custom') {
|
||||||
|
const saved = localStorage.getItem('custom-theme');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved) as Theme;
|
||||||
|
applyCustomThemeVars(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Invalid custom theme in localStorage', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTheme('yuu')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearCustomThemeVars()
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ThemeContext }
|
export { ThemeContext };
|
||||||
|
|||||||
@ -0,0 +1,256 @@
|
|||||||
|
import { globalStyle } from "@vanilla-extract/css"
|
||||||
|
import { themeVars } from "./vars.css"
|
||||||
|
|
||||||
|
export type Theme = {
|
||||||
|
name: string,
|
||||||
|
bg: string
|
||||||
|
bgSecondary: string
|
||||||
|
bgTertiary: string
|
||||||
|
fg: string
|
||||||
|
fgSecondary: string
|
||||||
|
fgTertiary: string
|
||||||
|
primary: string
|
||||||
|
primaryDim: string
|
||||||
|
accent: string
|
||||||
|
accentDim: string
|
||||||
|
error: string
|
||||||
|
warning: string
|
||||||
|
info: string
|
||||||
|
success: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_KEYS = [
|
||||||
|
'--color'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const themes: Theme[] = [
|
||||||
|
{
|
||||||
|
name: "yuu",
|
||||||
|
bg: "#1e1816",
|
||||||
|
bgSecondary: "#2f2623",
|
||||||
|
bgTertiary: "#453733",
|
||||||
|
fg: "#f8f3ec",
|
||||||
|
fgSecondary: "#d6ccc2",
|
||||||
|
fgTertiary: "#b4a89c",
|
||||||
|
primary: "#fc9174",
|
||||||
|
primaryDim: "#d88b65",
|
||||||
|
accent: "#f9db6d",
|
||||||
|
accentDim: "#d9bc55",
|
||||||
|
error: "#e26c6a",
|
||||||
|
warning: "#f5b851",
|
||||||
|
success: "#8fc48f",
|
||||||
|
info: "#87b8dd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "varia",
|
||||||
|
bg: "rgb(25, 25, 29)",
|
||||||
|
bgSecondary: "#222222",
|
||||||
|
bgTertiary: "#333333",
|
||||||
|
fg: "#eeeeee",
|
||||||
|
fgSecondary: "#aaaaaa",
|
||||||
|
fgTertiary: "#888888",
|
||||||
|
primary: "rgb(203, 110, 240)",
|
||||||
|
primaryDim: "#c28379",
|
||||||
|
accent: "#f0ad0a",
|
||||||
|
accentDim: "#d08d08",
|
||||||
|
error: "#f44336",
|
||||||
|
warning: "#ff9800",
|
||||||
|
success: "#4caf50",
|
||||||
|
info: "#2196f3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "midnight",
|
||||||
|
bg: "rgb(8, 15, 24)",
|
||||||
|
bgSecondary: "rgb(15, 27, 46)",
|
||||||
|
bgTertiary: "rgb(15, 41, 70)",
|
||||||
|
fg: "#dbdfe7",
|
||||||
|
fgSecondary: "#9ea3a8",
|
||||||
|
fgTertiary: "#74787c",
|
||||||
|
primary: "#1a97eb",
|
||||||
|
primaryDim: "#2680aa",
|
||||||
|
accent: "#f0ad0a",
|
||||||
|
accentDim: "#d08d08",
|
||||||
|
error: "#f44336",
|
||||||
|
warning: "#ff9800",
|
||||||
|
success: "#4caf50",
|
||||||
|
info: "#2196f3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catppuccin",
|
||||||
|
bg: "#1e1e2e",
|
||||||
|
bgSecondary: "#181825",
|
||||||
|
bgTertiary: "#11111b",
|
||||||
|
fg: "#cdd6f4",
|
||||||
|
fgSecondary: "#a6adc8",
|
||||||
|
fgTertiary: "#9399b2",
|
||||||
|
primary: "#89b4fa",
|
||||||
|
primaryDim: "#739df0",
|
||||||
|
accent: "#f38ba8",
|
||||||
|
accentDim: "#d67b94",
|
||||||
|
error: "#f38ba8",
|
||||||
|
warning: "#f9e2af",
|
||||||
|
success: "#a6e3a1",
|
||||||
|
info: "#89dceb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "autumn",
|
||||||
|
bg: "rgb(44, 25, 18)",
|
||||||
|
bgSecondary: "rgb(70, 40, 18)",
|
||||||
|
bgTertiary: "#4b2f1c",
|
||||||
|
fg: "#fef9f3",
|
||||||
|
fgSecondary: "#dbc6b0",
|
||||||
|
fgTertiary: "#a3917a",
|
||||||
|
primary: "#d97706",
|
||||||
|
primaryDim: "#b45309",
|
||||||
|
accent: "#8c4c28",
|
||||||
|
accentDim: "#6b3b1f",
|
||||||
|
error: "#d1433f",
|
||||||
|
warning: "#e38b29",
|
||||||
|
success: "#6b8e23",
|
||||||
|
info: "#c084fc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "black",
|
||||||
|
bg: "#000000",
|
||||||
|
bgSecondary: "#1a1a1a",
|
||||||
|
bgTertiary: "#2a2a2a",
|
||||||
|
fg: "#dddddd",
|
||||||
|
fgSecondary: "#aaaaaa",
|
||||||
|
fgTertiary: "#888888",
|
||||||
|
primary: "#08c08c",
|
||||||
|
primaryDim: "#08c08c",
|
||||||
|
accent: "#f0ad0a",
|
||||||
|
accentDim: "#d08d08",
|
||||||
|
error: "#f44336",
|
||||||
|
warning: "#ff9800",
|
||||||
|
success: "#4caf50",
|
||||||
|
info: "#2196f3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wine",
|
||||||
|
bg: "#23181E",
|
||||||
|
bgSecondary: "#2C1C25",
|
||||||
|
bgTertiary: "#422A37",
|
||||||
|
fg: "#FCE0B3",
|
||||||
|
fgSecondary: "#C7AC81",
|
||||||
|
fgTertiary: "#A78E64",
|
||||||
|
primary: "#EA8A64",
|
||||||
|
primaryDim: "#BD7255",
|
||||||
|
accent: "#FAE99B",
|
||||||
|
accentDim: "#C6B464",
|
||||||
|
error: "#fca5a5",
|
||||||
|
warning: "#fde68a",
|
||||||
|
success: "#bbf7d0",
|
||||||
|
info: "#bae6fd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pearl",
|
||||||
|
bg: "#FFFFFF",
|
||||||
|
bgSecondary: "#EEEEEE",
|
||||||
|
bgTertiary: "#E0E0E0",
|
||||||
|
fg: "#333333",
|
||||||
|
fgSecondary: "#555555",
|
||||||
|
fgTertiary: "#777777",
|
||||||
|
primary: "#007BFF",
|
||||||
|
primaryDim: "#0056B3",
|
||||||
|
accent: "#28A745",
|
||||||
|
accentDim: "#1E7E34",
|
||||||
|
error: "#DC3545",
|
||||||
|
warning: "#FFC107",
|
||||||
|
success: "#28A745",
|
||||||
|
info: "#17A2B8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asuka",
|
||||||
|
bg: "#3B1212",
|
||||||
|
bgSecondary: "#471B1B",
|
||||||
|
bgTertiary: "#020202",
|
||||||
|
fg: "#F1E9E6",
|
||||||
|
fgSecondary: "#CCB6AE",
|
||||||
|
fgTertiary: "#9F8176",
|
||||||
|
primary: "#F1E9E6",
|
||||||
|
primaryDim: "#CCB6AE",
|
||||||
|
accent: "#41CE41",
|
||||||
|
accentDim: "#3BA03B",
|
||||||
|
error: "#DC143C",
|
||||||
|
warning: "#FFD700",
|
||||||
|
success: "#32CD32",
|
||||||
|
info: "#1E90FF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "urim",
|
||||||
|
bg: "#101713",
|
||||||
|
bgSecondary: "#1B2921",
|
||||||
|
bgTertiary: "#273B30",
|
||||||
|
fg: "#D2E79E",
|
||||||
|
fgSecondary: "#B4DA55",
|
||||||
|
fgTertiary: "#7E9F2A",
|
||||||
|
primary: "#ead500",
|
||||||
|
primaryDim: "#C1B210",
|
||||||
|
accent: "#28A745",
|
||||||
|
accentDim: "#1E7E34",
|
||||||
|
error: "#EE5237",
|
||||||
|
warning: "#FFC107",
|
||||||
|
success: "#28A745",
|
||||||
|
info: "#17A2B8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match",
|
||||||
|
bg: "#071014",
|
||||||
|
bgSecondary: "#0A181E",
|
||||||
|
bgTertiary: "#112A34",
|
||||||
|
fg: "#ebeaeb",
|
||||||
|
fgSecondary: "#BDBDBD",
|
||||||
|
fgTertiary: "#A2A2A2",
|
||||||
|
primary: "#fda827",
|
||||||
|
primaryDim: "#C78420",
|
||||||
|
accent: "#277CFD",
|
||||||
|
accentDim: "#1F60C1",
|
||||||
|
error: "#F14426",
|
||||||
|
warning: "#FFC107",
|
||||||
|
success: "#28A745",
|
||||||
|
info: "#17A2B8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lemon",
|
||||||
|
bg: "#1a171a",
|
||||||
|
bgSecondary: "#2E272E",
|
||||||
|
bgTertiary: "#443844",
|
||||||
|
fg: "#E6E2DC",
|
||||||
|
fgSecondary: "#B2ACA1",
|
||||||
|
fgTertiary: "#968F82",
|
||||||
|
primary: "#f5c737",
|
||||||
|
primaryDim: "#C29D2F",
|
||||||
|
accent: "#277CFD",
|
||||||
|
accentDim: "#1F60C1",
|
||||||
|
error: "#F14426",
|
||||||
|
warning: "#FFC107",
|
||||||
|
success: "#28A745",
|
||||||
|
info: "#17A2B8",
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default themes
|
||||||
|
|
||||||
|
themes.forEach((theme) => {
|
||||||
|
const selector = `[data-theme="${theme.name}"]`
|
||||||
|
|
||||||
|
globalStyle(selector, {
|
||||||
|
vars: {
|
||||||
|
[themeVars.bg]: theme.bg,
|
||||||
|
[themeVars.bgSecondary]: theme.bgSecondary,
|
||||||
|
[themeVars.bgTertiary]: theme.bgTertiary,
|
||||||
|
[themeVars.fg]: theme.fg,
|
||||||
|
[themeVars.fgSecondary]: theme.fgSecondary,
|
||||||
|
[themeVars.fgTertiary]: theme.fgTertiary,
|
||||||
|
[themeVars.primary]: theme.primary,
|
||||||
|
[themeVars.primaryDim]: theme.primaryDim,
|
||||||
|
[themeVars.accent]: theme.accent,
|
||||||
|
[themeVars.accentDim]: theme.accentDim,
|
||||||
|
[themeVars.error]: theme.error,
|
||||||
|
[themeVars.warning]: theme.warning,
|
||||||
|
[themeVars.success]: theme.success,
|
||||||
|
[themeVars.info]: theme.info,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
export const themeVars = {
|
||||||
|
bg: '--color-bg',
|
||||||
|
bgSecondary: '--color-bg-secondary',
|
||||||
|
bgTertiary: '--color-bg-tertiary',
|
||||||
|
fg: '--color-fg',
|
||||||
|
fgSecondary: '--color-fg-secondary',
|
||||||
|
fgTertiary: '--color-fg-tertiary',
|
||||||
|
primary: '--color-primary',
|
||||||
|
primaryDim: '--color-primary-dim',
|
||||||
|
accent: '--color-accent',
|
||||||
|
accentDim: '--color-accent-dim',
|
||||||
|
error: '--color-error',
|
||||||
|
warning: '--color-warning',
|
||||||
|
info: '--color-info',
|
||||||
|
success: '--color-success',
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package psql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
"github.com/gabehf/koito/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *Psql) GetExportPage(ctx context.Context, opts db.GetExportPageOpts) ([]*db.ExportItem, error) {
|
||||||
|
rows, err := d.q.GetListensExportPage(ctx, repository.GetListensExportPageParams{
|
||||||
|
UserID: opts.UserID,
|
||||||
|
TrackID: opts.TrackID,
|
||||||
|
Limit: opts.Limit,
|
||||||
|
ListenedAt: opts.ListenedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetExportPage: %w", err)
|
||||||
|
}
|
||||||
|
ret := make([]*db.ExportItem, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
|
||||||
|
var trackAliases []models.Alias
|
||||||
|
err = json.Unmarshal(row.TrackAliases, &trackAliases)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetExportPage: json.Unmarshal trackAliases: %w", err)
|
||||||
|
}
|
||||||
|
var albumAliases []models.Alias
|
||||||
|
err = json.Unmarshal(row.ReleaseAliases, &albumAliases)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetExportPage: json.Unmarshal albumAliases: %w", err)
|
||||||
|
}
|
||||||
|
var artists []models.ArtistWithFullAliases
|
||||||
|
err = json.Unmarshal(row.Artists, &artists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetExportPage: json.Unmarshal artists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret[i] = &db.ExportItem{
|
||||||
|
TrackID: row.TrackID,
|
||||||
|
ListenedAt: row.ListenedAt,
|
||||||
|
UserID: row.UserID,
|
||||||
|
Client: row.Client,
|
||||||
|
TrackMbid: row.TrackMbid,
|
||||||
|
TrackDuration: row.TrackDuration,
|
||||||
|
TrackAliases: trackAliases,
|
||||||
|
ReleaseID: row.ReleaseID,
|
||||||
|
ReleaseMbid: row.ReleaseMbid,
|
||||||
|
ReleaseImageSource: row.ReleaseImageSource.String,
|
||||||
|
VariousArtists: row.VariousArtists,
|
||||||
|
ReleaseAliases: albumAliases,
|
||||||
|
Artists: artists,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KoitoExport struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
ExportedAt time.Time `json:"exported_at"` // RFC3339
|
||||||
|
User string `json:"user"` // username
|
||||||
|
Listens []KoitoListen `json:"listens"`
|
||||||
|
}
|
||||||
|
type KoitoListen struct {
|
||||||
|
ListenedAt time.Time `json:"listened_at"`
|
||||||
|
Track KoitoTrack `json:"track"`
|
||||||
|
Album KoitoAlbum `json:"album"`
|
||||||
|
Artists []KoitoArtist `json:"artists"`
|
||||||
|
}
|
||||||
|
type KoitoTrack struct {
|
||||||
|
MBID *uuid.UUID `json:"mbid"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Aliases []models.Alias `json:"aliases"`
|
||||||
|
}
|
||||||
|
type KoitoAlbum struct {
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
MBID *uuid.UUID `json:"mbid"`
|
||||||
|
Aliases []models.Alias `json:"aliases"`
|
||||||
|
VariousArtists bool `json:"various_artists"`
|
||||||
|
}
|
||||||
|
type KoitoArtist struct {
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
MBID *uuid.UUID `json:"mbid"`
|
||||||
|
IsPrimary bool `json:"is_primary"`
|
||||||
|
Aliases []models.Alias `json:"aliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Write the opening of the JSON manually
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{
|
||||||
|
UserID: user.ID,
|
||||||
|
ListenedAt: lastTime,
|
||||||
|
TrackID: lastTrackId,
|
||||||
|
Limit: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ExportData: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
// Adds a comma after each listen item
|
||||||
|
if !first {
|
||||||
|
_, _ = out.Write([]byte(",\n"))
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
exported := convertToExportFormat(r)
|
||||||
|
|
||||||
|
raw, err := json.MarshalIndent(exported, " ", " ")
|
||||||
|
|
||||||
|
// needed to make the listen item start at the right indent level
|
||||||
|
out.Write([]byte(" "))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ExportData: marshal: %w", err)
|
||||||
|
}
|
||||||
|
_, _ = out.Write(raw)
|
||||||
|
|
||||||
|
if r.TrackID > lastTrackId {
|
||||||
|
lastTrackId = r.TrackID
|
||||||
|
}
|
||||||
|
if r.ListenedAt.After(lastTime) {
|
||||||
|
lastTime = r.ListenedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write closing of the JSON array and object
|
||||||
|
_, err = out.Write([]byte("\n ]\n}\n"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ExportData: f.Write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info().Msgf("Export successfully created")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertToExportFormat(item *db.ExportItem) *KoitoListen {
|
||||||
|
ret := &KoitoListen{
|
||||||
|
ListenedAt: item.ListenedAt.UTC(),
|
||||||
|
Track: KoitoTrack{
|
||||||
|
MBID: item.TrackMbid,
|
||||||
|
Duration: int(item.TrackDuration),
|
||||||
|
Aliases: item.TrackAliases,
|
||||||
|
},
|
||||||
|
Album: KoitoAlbum{
|
||||||
|
MBID: item.ReleaseMbid,
|
||||||
|
ImageUrl: item.ReleaseImageSource,
|
||||||
|
VariousArtists: item.VariousArtists,
|
||||||
|
Aliases: item.ReleaseAliases,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range item.Artists {
|
||||||
|
ret.Artists = append(ret.Artists, KoitoArtist{
|
||||||
|
IsPrimary: item.Artists[i].IsPrimary,
|
||||||
|
MBID: item.Artists[i].MbzID,
|
||||||
|
Aliases: item.Artists[i].Aliases,
|
||||||
|
ImageUrl: item.Artists[i].ImageSource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/export"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ImportKoitoFile(ctx context.Context, store db.DB, filename string) error {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
l.Info().Msgf("Beginning Koito import on file: %s", filename)
|
||||||
|
data := new(export.KoitoExport)
|
||||||
|
f, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: os.Open: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
err = json.NewDecoder(f).Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: Decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Version != "1" {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: unupported version: %s", data.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info().Msgf("Beginning data import for user: %s", data.User)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for i := range data.Listens {
|
||||||
|
// use this for save/get mbid for all artist/album/track
|
||||||
|
mbid := uuid.Nil
|
||||||
|
|
||||||
|
artistIds := make([]int32, 0)
|
||||||
|
for _, ia := range data.Listens[i].Artists {
|
||||||
|
if ia.MBID != nil {
|
||||||
|
mbid = *ia.MBID
|
||||||
|
}
|
||||||
|
artist, err := store.GetArtist(ctx, db.GetArtistOpts{
|
||||||
|
MusicBrainzID: mbid,
|
||||||
|
Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
var imgid = uuid.Nil
|
||||||
|
// not a perfect way to check if the image url is an actual source vs manual upload but
|
||||||
|
// im like 99% sure it will work perfectly
|
||||||
|
if strings.HasPrefix(ia.ImageUrl, "http") {
|
||||||
|
imgid = uuid.New()
|
||||||
|
}
|
||||||
|
// save artist
|
||||||
|
artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
|
||||||
|
Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
|
||||||
|
Image: imgid,
|
||||||
|
ImageSrc: ia.ImageUrl,
|
||||||
|
MusicBrainzID: mbid,
|
||||||
|
Aliases: utils.FlattenAliases(ia.Aliases),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
artistIds = append(artistIds, artist.ID)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
} else {
|
||||||
|
artistIds = append(artistIds, artist.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// call associate album
|
||||||
|
albumId := int32(0)
|
||||||
|
if data.Listens[i].Album.MBID != nil {
|
||||||
|
mbid = *data.Listens[i].Album.MBID
|
||||||
|
}
|
||||||
|
album, err := store.GetAlbum(ctx, db.GetAlbumOpts{
|
||||||
|
MusicBrainzID: mbid,
|
||||||
|
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
|
||||||
|
ArtistID: artistIds[0],
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
var imgid = uuid.Nil
|
||||||
|
// not a perfect way to check if the image url is an actual source vs manual upload but
|
||||||
|
// im like 99% sure it will work perfectly
|
||||||
|
if strings.HasPrefix(data.Listens[i].Album.ImageUrl, "http") {
|
||||||
|
imgid = uuid.New()
|
||||||
|
}
|
||||||
|
// save album
|
||||||
|
album, err = store.SaveAlbum(ctx, db.SaveAlbumOpts{
|
||||||
|
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
|
||||||
|
Image: imgid,
|
||||||
|
ImageSrc: data.Listens[i].Album.ImageUrl,
|
||||||
|
MusicBrainzID: mbid,
|
||||||
|
Aliases: utils.FlattenAliases(data.Listens[i].Album.Aliases),
|
||||||
|
ArtistIDs: artistIds,
|
||||||
|
VariousArtists: data.Listens[i].Album.VariousArtists,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
albumId = album.ID
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
} else {
|
||||||
|
albumId = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// call associate track
|
||||||
|
if data.Listens[i].Track.MBID != nil {
|
||||||
|
mbid = *data.Listens[i].Track.MBID
|
||||||
|
}
|
||||||
|
track, err := store.GetTrack(ctx, db.GetTrackOpts{
|
||||||
|
MusicBrainzID: mbid,
|
||||||
|
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
|
||||||
|
ArtistIDs: artistIds,
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// save track
|
||||||
|
track, err = store.SaveTrack(ctx, db.SaveTrackOpts{
|
||||||
|
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
|
||||||
|
RecordingMbzID: mbid,
|
||||||
|
Duration: int32(data.Listens[i].Track.Duration),
|
||||||
|
ArtistIDs: artistIds,
|
||||||
|
AlbumID: albumId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
// save track aliases
|
||||||
|
err = store.SaveTrackAliases(ctx, track.ID, utils.FlattenAliases(data.Listens[i].Track.Aliases), "Import")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// save listen
|
||||||
|
err = store.SaveListen(ctx, db.SaveListenOpts{
|
||||||
|
TrackID: track.ID,
|
||||||
|
Time: data.Listens[i].ListenedAt,
|
||||||
|
UserID: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info().Msgf("ImportKoitoFile: Imported listen for track %s", track.Title)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishImport(ctx, filename, count)
|
||||||
|
}
|
||||||
|
func getPrimaryAliasFromAliasSlice(aliases []models.Alias) string {
|
||||||
|
for _, a := range aliases {
|
||||||
|
if a.Primary {
|
||||||
|
return a.Alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Loading…
Reference in new issue