Compare commits
17
Commits
1.6.0
...
v2-final-1.6.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8df8dcce7 | ||
|
|
2bf8d3887e | ||
|
|
efe45f39e7 | ||
|
|
336a1f353f | ||
|
|
a19080ef39 | ||
|
|
2bddc6a224 | ||
|
|
3bf5d8290e | ||
|
|
a5da544dd7 | ||
|
|
270faeb507 | ||
|
|
abba1803fc | ||
|
|
ce35a20eba | ||
|
|
e5ce250392 | ||
|
|
0cca48adb1 | ||
|
|
d2f2f8a618 | ||
|
|
fcbcda7eac | ||
|
|
0a89ec3652 | ||
|
|
5a14863a8f |
@@ -0,0 +1,280 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||||
|
# runs list shows "Release 1.6.7" instead.
|
||||||
|
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||||
|
|
||||||
|
# Builds the Wails Linux binary via `make build`, packages
|
||||||
|
# AniTrack-<version>.tar.gz from build/, and creates/updates a Gitea Release
|
||||||
|
# named AniTrack-<version> with the archive attached. Triggered by a plain
|
||||||
|
# version tag push (1.6.7), or manually via workflow_dispatch with a tag for
|
||||||
|
# re-runs and secrets tests. Pushing to main does nothing, so
|
||||||
|
# work-in-progress commits never ship.
|
||||||
|
#
|
||||||
|
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
|
||||||
|
# wails.json, commits the bump, creates an annotated tag carrying the
|
||||||
|
# git-cliff notes, and pushes commit + tag. This workflow verifies
|
||||||
|
# wails.json matches the tag before building.
|
||||||
|
#
|
||||||
|
# Secrets test (verifies environment.go wiring without shipping): tag the
|
||||||
|
# current bumped commit with a suffix and push, e.g.
|
||||||
|
# git tag 1.6.7-rc1 && git push origin 1.6.7-rc1
|
||||||
|
# Tags containing '-' are published as pre-releases; delete the test
|
||||||
|
# Release/tag afterwards.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '[0-9]*.[0-9]*.[0-9]*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g. 1.6.7)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
# Resolve the target tag for both triggers: explicit input on manual
|
||||||
|
# dispatch, otherwise the pushed tag ref.
|
||||||
|
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history ensures the tag annotation (the release notes) is present.
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||||
|
|
||||||
|
- name: Guard wails.json matches tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
# Normalize "v1.6.7" / "AniTrack-1.6.7" to plain "1.6.7" (make release
|
||||||
|
# only ever creates plain tags; this tolerates manual typos).
|
||||||
|
NORM="${TAG#v}"
|
||||||
|
NORM="${NORM#AniTrack-}"
|
||||||
|
BASE="${NORM%%-*}"
|
||||||
|
WAILS_VER="$(python3 -c "import json; print(json.load(open('wails.json'))['info']['productVersion'])")"
|
||||||
|
if [ "${NORM}" = "${BASE}" ]; then
|
||||||
|
if [ "${WAILS_VER}" != "${NORM}" ]; then
|
||||||
|
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
|
||||||
|
if [ "${WAILS_VER}" != "${BASE}" ]; then
|
||||||
|
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
||||||
|
echo "Version guard passed: wails.json=${WAILS_VER} tag=${NORM}"
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Cache Go build cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
# setup-go only caches modules (GOMODCACHE). This caches compiled
|
||||||
|
# packages (GOCACHE) so the cgo/WebKit compile goes incremental.
|
||||||
|
# restore-keys gives a close cache instead of a cold one when
|
||||||
|
# go.sum changes. First run after adding this still compiles cold
|
||||||
|
# (it saves); the payoff shows from the second run on.
|
||||||
|
path: ~/.cache/go-build
|
||||||
|
key: go-build-1.25-${{ runner.os }}-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
go-build-1.25-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
# Caches npm's download cache keyed on the frontend lockfile, so
|
||||||
|
# the `npm install` inside `wails build` stops fetching cold.
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install Wails Linux build dependencies
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential pkg-config \
|
||||||
|
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||||
|
jq
|
||||||
|
|
||||||
|
- name: Cache Wails CLI
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: wails-cli
|
||||||
|
with:
|
||||||
|
# The compiled CLI binary. Bump the key whenever the @version pin
|
||||||
|
# below changes, or the old CLI will be silently reused.
|
||||||
|
path: ~/go/bin/wails
|
||||||
|
key: wails-v2.15.0-${{ runner.os }}
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
if: steps.wails-cli.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go install github.com/wailsapp/wails/v2/cmd/wails@v2.15.0
|
||||||
|
|
||||||
|
- name: Add Go bin to PATH
|
||||||
|
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
|
- name: Write environment.go from secrets
|
||||||
|
env:
|
||||||
|
ANILIST_SECRET_TOKEN: ${{ secrets.ANILIST_SECRET_TOKEN }}
|
||||||
|
ANILIST_APP_ID: ${{ secrets.ANILIST_APP_ID }}
|
||||||
|
ANILIST_APP_NAME: ${{ secrets.ANILIST_APP_NAME }}
|
||||||
|
ANILIST_CALLBACK_URI: ${{ secrets.ANILIST_CALLBACK_URI }}
|
||||||
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
|
SIMKL_CALLBACK_URI: ${{ secrets.SIMKL_CALLBACK_URI }}
|
||||||
|
MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }}
|
||||||
|
MAL_CLIENT_SECRET: ${{ secrets.MAL_CLIENT_SECRET }}
|
||||||
|
MAL_CALLBACK_URI: ${{ secrets.MAL_CALLBACK_URI }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# All 10 fields come from Gitea Actions secrets (never committed).
|
||||||
|
# Values are masked in logs; do not echo them or run with set -x.
|
||||||
|
for v in ANILIST_SECRET_TOKEN ANILIST_APP_ID ANILIST_APP_NAME ANILIST_CALLBACK_URI SIMKL_CLIENT_ID SIMKL_CLIENT_SECRET SIMKL_CALLBACK_URI MAL_CLIENT_ID MAL_CLIENT_SECRET MAL_CALLBACK_URI; do
|
||||||
|
if [ -z "${!v:-}" ]; then
|
||||||
|
echo "::error::Missing secret ${v}. Add it under Settings → Secrets → Actions." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cat > environment.go <<EOF
|
||||||
|
package main
|
||||||
|
|
||||||
|
var Environment = EnvironmentStruct{
|
||||||
|
ANILIST_SECRET_TOKEN: "${ANILIST_SECRET_TOKEN}",
|
||||||
|
ANILIST_APP_ID: "${ANILIST_APP_ID}",
|
||||||
|
ANILIST_APP_NAME: "${ANILIST_APP_NAME}",
|
||||||
|
ANILIST_CALLBACK_URI: "${ANILIST_CALLBACK_URI}",
|
||||||
|
SIMKL_CLIENT_ID: "${SIMKL_CLIENT_ID}",
|
||||||
|
SIMKL_CLIENT_SECRET: "${SIMKL_CLIENT_SECRET}",
|
||||||
|
SIMKL_CALLBACK_URI: "${SIMKL_CALLBACK_URI}",
|
||||||
|
MAL_CLIENT_ID: "${MAL_CLIENT_ID}",
|
||||||
|
MAL_CLIENT_SECRET: "${MAL_CLIENT_SECRET}",
|
||||||
|
MAL_CALLBACK_URI: "${MAL_CALLBACK_URI}",
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Wrote environment.go from secrets."
|
||||||
|
|
||||||
|
- name: Build Linux binary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="${HOME}/go/bin:${PATH}"
|
||||||
|
make build
|
||||||
|
test -x build/bin/AniTrack || { echo "::error::build/bin/AniTrack missing after make build" >&2; exit 1; }
|
||||||
|
|
||||||
|
- name: Package release archive
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${VERSION:?VERSION missing from version-guard step}"
|
||||||
|
STAGE="dist/AniTrack-${VERSION}"
|
||||||
|
rm -rf dist "AniTrack-${VERSION}.tar.gz"
|
||||||
|
mkdir -p "${STAGE}/bin"
|
||||||
|
cp build/bin/AniTrack "${STAGE}/bin/"
|
||||||
|
cp -r build/icon "${STAGE}/"
|
||||||
|
cp build/AniTrack.desktop build/install_linux.sh build/README.md "${STAGE}/"
|
||||||
|
chmod +x "${STAGE}/bin/AniTrack" "${STAGE}/install_linux.sh"
|
||||||
|
tar -czf "AniTrack-${VERSION}.tar.gz" -C dist "AniTrack-${VERSION}"
|
||||||
|
tar tzf "AniTrack-${VERSION}.tar.gz"
|
||||||
|
echo "ARCHIVE=AniTrack-${VERSION}.tar.gz" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||||
|
# token cannot create releases on this instance. The PAT must carry
|
||||||
|
# write:repository scope. Idempotent: re-runs update an existing
|
||||||
|
# release for this tag instead of failing with 409. On any HTTP error
|
||||||
|
# the API response body is printed so a 403 names the missing scope.
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
: "${VERSION:?VERSION missing from version-guard step}"
|
||||||
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
# Release body = the annotated tag's message (the git-cliff notes).
|
||||||
|
# Manual test tags without an annotation fall back to the tag name.
|
||||||
|
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||||
|
if [ -z "$(printf '%s' "${BODY}" | tr -d '[:space:]')" ]; then
|
||||||
|
BODY="${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tags containing a '-' (e.g. 1.6.7-rc1) are published as pre-releases.
|
||||||
|
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg t "${TAG}" --arg n "AniTrack-${VERSION}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||||
|
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||||
|
|
||||||
|
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||||
|
# (e.g. "token does not have write scope") instead of failing silently.
|
||||||
|
api_call() {
|
||||||
|
local method="$1" url="$2" resp code rbody
|
||||||
|
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||||
|
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" "${url}")"
|
||||||
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [ "${code}" -ge 400 ]; then
|
||||||
|
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
api_call PATCH "${API}/${EXISTING_ID}"
|
||||||
|
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||||
|
else
|
||||||
|
api_call POST "${API}"
|
||||||
|
echo "Created new release for ${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload release archive
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||||
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||||
|
if [ -z "${RID}" ]; then
|
||||||
|
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Replace a same-named asset so re-runs stay idempotent.
|
||||||
|
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | select(.name==$n) | .id // empty')"
|
||||||
|
if [ -n "${AID}" ]; then
|
||||||
|
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||||
|
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||||
|
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
||||||
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [ "${code}" -ge 400 ]; then
|
||||||
|
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
||||||
+4
-1
@@ -23,10 +23,13 @@ go.work
|
|||||||
|
|
||||||
# ---> Wails
|
# ---> Wails
|
||||||
build/bin
|
build/bin
|
||||||
|
.task/
|
||||||
node_modules
|
node_modules
|
||||||
frontend/dist
|
frontend/dist
|
||||||
package.json.md5
|
package.json.md5
|
||||||
package-lock.json
|
# Root-level lockfile only: the frontend lockfile IS tracked so CI installs
|
||||||
|
# are reproducible and setup-node's npm cache has something to hash.
|
||||||
|
/package-lock.json
|
||||||
.idea
|
.idea
|
||||||
.env
|
.env
|
||||||
environment.go
|
environment.go
|
||||||
|
|||||||
+133
-20
@@ -7,26 +7,42 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
||||||
reader, _ := json.Marshal(body)
|
reader, err := json.Marshal(body)
|
||||||
response, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed at response, %s\n", err)
|
log.Printf("AniList request failed: could not encode request body\n")
|
||||||
|
return nil, "Could not prepare the AniList request."
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AniList request failed: could not create request\n")
|
||||||
|
return nil, "Could not reach AniList. Please check your connection and try again."
|
||||||
}
|
}
|
||||||
if login && (AniListJWT{}) != aniListJwt {
|
if login && (AniListJWT{}) != aniListJwt {
|
||||||
response.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
request.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
||||||
} else if login {
|
} else if login {
|
||||||
return nil, "Please login to AniList to make this request"
|
return nil, "Please login to AniList to make this request"
|
||||||
}
|
}
|
||||||
response.Header.Add("Content-Type", "application/json")
|
request.Header.Add("Content-Type", "application/json")
|
||||||
response.Header.Add("Accept", "application/json")
|
request.Header.Add("Accept", "application/json")
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
res, resErr := client.Do(response)
|
res, resErr := client.Do(request)
|
||||||
if resErr != nil {
|
if resErr != nil {
|
||||||
log.Printf("Failed at res, %s\n", err)
|
log.Printf("AniList request failed: network error\n")
|
||||||
|
return nil, "Could not reach AniList. Please check your connection and try again."
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
log.Printf("AniList request failed: empty response\n")
|
||||||
|
return nil, "Could not reach AniList. Please check your connection and try again."
|
||||||
|
}
|
||||||
|
if res.Body == nil {
|
||||||
|
log.Printf("AniList request failed: empty response body\n")
|
||||||
|
return nil, "Could not reach AniList. Please check your connection and try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
@@ -39,6 +55,37 @@ func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
|||||||
return returnedBody, res.Status
|
return returnedBody, res.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func aniListGraphQLErrorMessage(returnedBody json.RawMessage) string {
|
||||||
|
var gqlErr struct {
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(returnedBody, &gqlErr); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(gqlErr.Errors) > 0 && strings.TrimSpace(gqlErr.Errors[0].Message) != "" {
|
||||||
|
return strings.TrimSpace(gqlErr.Errors[0].Message)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func aniListSearchStatusError(status string, returnedBody json.RawMessage) error {
|
||||||
|
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
|
||||||
|
return fmt.Errorf("AniList search failed: %s", msg)
|
||||||
|
}
|
||||||
|
if strings.Contains(status, "429") {
|
||||||
|
return fmt.Errorf("AniList is rate-limiting search right now. Please wait a moment and try again.")
|
||||||
|
}
|
||||||
|
if strings.Contains(status, "500") || strings.Contains(status, "502") || strings.Contains(status, "503") || strings.Contains(status, "504") {
|
||||||
|
return fmt.Errorf("AniList is temporarily unavailable (%s). Please try again shortly.", status)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(status, "Could not") || strings.HasPrefix(status, "Please login") {
|
||||||
|
return fmt.Errorf("%s", status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("AniList search failed (%s). Please try again.", status)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
|
func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
|
||||||
user := a.GetAniListLoggedInUser()
|
user := a.GetAniListLoggedInUser()
|
||||||
|
|
||||||
@@ -336,14 +383,20 @@ func (a *App) AniListSearch(query string) (interface{}, error) {
|
|||||||
ListType: "ANIME",
|
ListType: "ANIME",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
returnedBody, status := AniListQuery(body, false)
|
returnedBody, status := AniListQuery(body, a.CheckIfAniListLoggedIn())
|
||||||
if status != "200 OK" {
|
if status != "200 OK" {
|
||||||
return nil, fmt.Errorf("API search failed with status: %s", status)
|
return nil, aniListSearchStatusError(status, returnedBody)
|
||||||
|
}
|
||||||
|
if len(returnedBody) == 0 {
|
||||||
|
return nil, fmt.Errorf("AniList returned an empty response. Please try again.")
|
||||||
|
}
|
||||||
|
if msg := aniListGraphQLErrorMessage(returnedBody); msg != "" {
|
||||||
|
return nil, fmt.Errorf("AniList search failed: %s", msg)
|
||||||
}
|
}
|
||||||
var post interface{}
|
var post interface{}
|
||||||
err := json.Unmarshal(returnedBody, &post)
|
err := json.Unmarshal(returnedBody, &post)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed at unmarshal, %s\n", err)
|
log.Printf("Failed at unmarshal search results\n")
|
||||||
return nil, fmt.Errorf("Failed to parse search results")
|
return nil, fmt.Errorf("Failed to parse search results")
|
||||||
}
|
}
|
||||||
return post, nil
|
return post, nil
|
||||||
@@ -558,7 +611,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (An
|
|||||||
return post, nil
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime {
|
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetSingleAnime, error) {
|
||||||
body := struct {
|
body := struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Variables AniListUpdateVariables `json:"variables"`
|
Variables AniListUpdateVariables `json:"variables"`
|
||||||
@@ -584,6 +637,10 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
|||||||
startedAt: $startedAt
|
startedAt: $startedAt
|
||||||
completedAt: $completedAt
|
completedAt: $completedAt
|
||||||
) {
|
) {
|
||||||
|
id
|
||||||
|
mediaId
|
||||||
|
userId
|
||||||
|
media {
|
||||||
id
|
id
|
||||||
idMal
|
idMal
|
||||||
title {
|
title {
|
||||||
@@ -661,7 +718,6 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
|||||||
}
|
}
|
||||||
isAdult
|
isAdult
|
||||||
}
|
}
|
||||||
|
|
||||||
status
|
status
|
||||||
startedAt {
|
startedAt {
|
||||||
year
|
year
|
||||||
@@ -700,22 +756,53 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
|||||||
Variables: updateBody,
|
Variables: updateBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
returnedBody, _ := AniListQuery(body, true)
|
returnedBody, status := AniListQuery(body, true)
|
||||||
|
|
||||||
|
var badPost struct {
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Locations []struct {
|
||||||
|
Line int `json:"line"`
|
||||||
|
Column int `json:"column"`
|
||||||
|
} `json:"locations"`
|
||||||
|
} `json:"errors"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "403 Forbidden" {
|
||||||
|
err := json.Unmarshal(returnedBody, &badPost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed at unmarshal, %s\n", err)
|
||||||
|
return AniListGetSingleAnime{}, fmt.Errorf("API authentication error")
|
||||||
|
}
|
||||||
|
return AniListGetSingleAnime{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
|
||||||
|
}
|
||||||
|
if status != "200 OK" {
|
||||||
|
log.Printf("AniListUpdateEntry failed with status: %s, body: %s\n", status, string(returnedBody))
|
||||||
|
return AniListGetSingleAnime{}, fmt.Errorf("API request failed with status: %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
var returnedJson AniListUpdateReturn
|
var returnedJson AniListUpdateReturn
|
||||||
err := json.Unmarshal(returnedBody, &returnedJson)
|
err := json.Unmarshal(returnedBody, &returnedJson)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed at unmarshal, %s\n", err)
|
log.Printf("AniListUpdateEntry failed to unmarshal response: %s, body: %s\n", err, string(returnedBody))
|
||||||
|
return AniListGetSingleAnime{}, fmt.Errorf("failed to parse AniList update response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if returnedJson.Data.SaveMediaListEntry.MediaID == 0 {
|
||||||
|
log.Printf("AniListUpdateEntry returned no saved entry, body: %s\n", string(returnedBody))
|
||||||
|
return AniListGetSingleAnime{}, fmt.Errorf("AniList returned no saved entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
var post AniListGetSingleAnime
|
var post AniListGetSingleAnime
|
||||||
|
|
||||||
post.Data.MediaList = returnedJson.Data.SaveMediaListEntry
|
post.Data.MediaList = returnedJson.Data.SaveMediaListEntry
|
||||||
|
|
||||||
return post
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn {
|
func (a *App) AniListDeleteEntry(mediaListId int) (DeleteAniListReturn, error) {
|
||||||
type Variables = struct {
|
type Variables = struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -740,15 +827,41 @@ func (a *App) AniListDeleteEntry(mediaListId int) DeleteAniListReturn {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
returnedBody, _ := AniListQuery(body, true)
|
returnedBody, status := AniListQuery(body, true)
|
||||||
|
|
||||||
|
var badPost struct {
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Locations []struct {
|
||||||
|
Line int `json:"line"`
|
||||||
|
Column int `json:"column"`
|
||||||
|
} `json:"locations"`
|
||||||
|
} `json:"errors"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "403 Forbidden" {
|
||||||
|
err := json.Unmarshal(returnedBody, &badPost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed at unmarshal, %s\n", err)
|
||||||
|
return DeleteAniListReturn{}, fmt.Errorf("API authentication error")
|
||||||
|
}
|
||||||
|
return DeleteAniListReturn{}, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
|
||||||
|
}
|
||||||
|
if status != "200 OK" {
|
||||||
|
log.Printf("AniListDeleteEntry failed with status: %s, body: %s\n", status, string(returnedBody))
|
||||||
|
return DeleteAniListReturn{}, fmt.Errorf("API request failed with status: %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
var post DeleteAniListReturn
|
var post DeleteAniListReturn
|
||||||
err := json.Unmarshal(returnedBody, &post)
|
err := json.Unmarshal(returnedBody, &post)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed at unmarshal, %s\n", err)
|
log.Printf("Failed at unmarshal, %s\n", err)
|
||||||
|
return DeleteAniListReturn{}, fmt.Errorf("failed to parse AniList delete response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return post
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AniListBrowse(
|
func (a *App) AniListBrowse(
|
||||||
|
|||||||
+39
-36
@@ -66,12 +66,7 @@ type AniListUpdateReturn struct {
|
|||||||
type Media struct {
|
type Media struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
IDMal int `json:"idMal"`
|
IDMal int `json:"idMal"`
|
||||||
Title struct {
|
Title MediaTitle `json:"title"`
|
||||||
UserPreferred string `json:"userPreferred"`
|
|
||||||
Romaji string `json:"romaji"`
|
|
||||||
English string `json:"english"`
|
|
||||||
Native string `json:"native"`
|
|
||||||
} `json:"title"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CoverImage struct {
|
CoverImage struct {
|
||||||
ExtraLarge string
|
ExtraLarge string
|
||||||
@@ -95,41 +90,15 @@ type Media struct {
|
|||||||
Trending int
|
Trending int
|
||||||
Favourites int
|
Favourites int
|
||||||
isFavourite bool
|
isFavourite bool
|
||||||
Relations struct {
|
Relations MediaRelations `json:"relations"`
|
||||||
nodes struct {
|
StartDate MediaFuzzyDate `json:"startDate"`
|
||||||
id int
|
EndDate MediaFuzzyDate `json:"endDate"`
|
||||||
Title struct {
|
|
||||||
UserPreferred string `json:"userPreferred"`
|
|
||||||
Romaji string `json:"romaji"`
|
|
||||||
English string `json:"english"`
|
|
||||||
Native string `json:"native"`
|
|
||||||
} `json:"title"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StartDate struct {
|
|
||||||
Year int
|
|
||||||
Month int
|
|
||||||
Day int
|
|
||||||
}
|
|
||||||
EndDate struct {
|
|
||||||
Year int
|
|
||||||
Month int
|
|
||||||
Day int
|
|
||||||
}
|
|
||||||
NextAiringEpisode struct {
|
NextAiringEpisode struct {
|
||||||
AiringAt int `json:"airingAt"`
|
AiringAt int `json:"airingAt"`
|
||||||
TimeUntilAiring int `json:"timeUntilAiring"`
|
TimeUntilAiring int `json:"timeUntilAiring"`
|
||||||
Episode int `json:"episode"`
|
Episode int `json:"episode"`
|
||||||
} `json:"nextAiringEpisode"`
|
} `json:"nextAiringEpisode"`
|
||||||
AiringSchedule struct {
|
AiringSchedule MediaAiringSchedule `json:"airingSchedule"`
|
||||||
Nodes struct {
|
|
||||||
Id int
|
|
||||||
AiringAt int
|
|
||||||
TimeUntilAiring int
|
|
||||||
Episode int
|
|
||||||
MediaId int
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Genres []string `json:"genres"`
|
Genres []string `json:"genres"`
|
||||||
Tags []struct {
|
Tags []struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
@@ -142,6 +111,40 @@ type Media struct {
|
|||||||
IsAdult bool `json:"isAdult"`
|
IsAdult bool `json:"isAdult"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaTitle struct {
|
||||||
|
UserPreferred string `json:"userPreferred"`
|
||||||
|
Romaji string `json:"romaji"`
|
||||||
|
English string `json:"english"`
|
||||||
|
Native string `json:"native"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaRelations struct {
|
||||||
|
Nodes []MediaRelation `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaRelation struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Title MediaTitle `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaFuzzyDate struct {
|
||||||
|
Year int `json:"year"`
|
||||||
|
Month int `json:"month"`
|
||||||
|
Day int `json:"day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAiringSchedule struct {
|
||||||
|
Nodes []AiringScheduleNode `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AiringScheduleNode struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
AiringAt int `json:"airingAt"`
|
||||||
|
TimeUntilAiring int `json:"timeUntilAiring"`
|
||||||
|
Episode int `json:"episode"`
|
||||||
|
MediaId int `json:"mediaId"`
|
||||||
|
}
|
||||||
|
|
||||||
type MediaList struct {
|
type MediaList struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
MediaID int `json:"mediaId"`
|
MediaID int `json:"mediaId"`
|
||||||
|
|||||||
+55
-20
@@ -19,18 +19,48 @@ import (
|
|||||||
|
|
||||||
var aniListJwt AniListJWT
|
var aniListJwt AniListJWT
|
||||||
|
|
||||||
var aniRing, _ = keyring.Open(keyring.Config{
|
var aniRing keyring.Keyring
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
aniRing, err = keyring.Open(keyring.Config{
|
||||||
ServiceName: "AniTrack",
|
ServiceName: "AniTrack",
|
||||||
KeychainName: "AniTrack",
|
KeychainName: "AniTrack",
|
||||||
KeychainSynchronizable: false,
|
KeychainSynchronizable: false,
|
||||||
KeychainTrustApplication: true,
|
KeychainTrustApplication: true,
|
||||||
KeychainAccessibleWhenUnlocked: true,
|
KeychainAccessibleWhenUnlocked: true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("anilist: secure storage unavailable: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aniRingReady() bool {
|
||||||
|
if aniRing == nil {
|
||||||
|
log.Println("anilist: secure storage unavailable")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func aniRingSet(key string, data []byte) error {
|
||||||
|
if !aniRingReady() {
|
||||||
|
return errors.New("anilist: secure storage unavailable")
|
||||||
|
}
|
||||||
|
if err := aniRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
|
||||||
|
log.Printf("anilist: save %s failed: %s", key, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var aniCtxShutdown, aniCancel = context.WithCancel(context.Background())
|
var aniCtxShutdown, aniCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
func (a *App) CheckIfAniListLoggedIn() bool {
|
func (a *App) CheckIfAniListLoggedIn() bool {
|
||||||
if (AniListJWT{} == aniListJwt) {
|
if (AniListJWT{} == aniListJwt) {
|
||||||
|
if !aniRingReady() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
||||||
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
||||||
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
||||||
@@ -38,10 +68,15 @@ func (a *App) CheckIfAniListLoggedIn() bool {
|
|||||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
|
var expiresInConvertErr error
|
||||||
aniListJwt.TokenType = string(tokenType.Data)
|
aniListJwt.TokenType = string(tokenType.Data)
|
||||||
aniListJwt.AccessToken = string(accessToken.Data)
|
aniListJwt.AccessToken = string(accessToken.Data)
|
||||||
aniListJwt.RefreshToken = string(refreshToken.Data)
|
aniListJwt.RefreshToken = string(refreshToken.Data)
|
||||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
|
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||||
|
if expiresInConvertErr != nil {
|
||||||
|
log.Printf("anilist: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -51,6 +86,10 @@ func (a *App) CheckIfAniListLoggedIn() bool {
|
|||||||
|
|
||||||
func (a *App) AniListLogin() {
|
func (a *App) AniListLogin() {
|
||||||
if (AniListJWT{} == aniListJwt) {
|
if (AniListJWT{} == aniListJwt) {
|
||||||
|
if !aniRingReady() {
|
||||||
|
log.Println("anilist: cannot check login, secure storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
||||||
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
||||||
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
||||||
@@ -64,10 +103,14 @@ func (a *App) AniListLogin() {
|
|||||||
a.handleAniListCallback(serverDone)
|
a.handleAniListCallback(serverDone)
|
||||||
serverDone.Wait()
|
serverDone.Wait()
|
||||||
} else {
|
} else {
|
||||||
|
var expiresInConvertErr error
|
||||||
aniListJwt.TokenType = string(tokenType.Data)
|
aniListJwt.TokenType = string(tokenType.Data)
|
||||||
aniListJwt.AccessToken = string(accessToken.Data)
|
aniListJwt.AccessToken = string(accessToken.Data)
|
||||||
aniListJwt.RefreshToken = string(refreshToken.Data)
|
aniListJwt.RefreshToken = string(refreshToken.Data)
|
||||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
|
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||||
|
if expiresInConvertErr != nil {
|
||||||
|
log.Printf("anilist: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,22 +128,10 @@ func (a *App) handleAniListCallback(wg *sync.WaitGroup) {
|
|||||||
content := r.FormValue("code")
|
content := r.FormValue("code")
|
||||||
if content != "" {
|
if content != "" {
|
||||||
aniListJwt = getAniListAuthorizationToken(content)
|
aniListJwt = getAniListAuthorizationToken(content)
|
||||||
_ = aniRing.Set(keyring.Item{
|
_ = aniRingSet("anilistTokenType", []byte(aniListJwt.TokenType))
|
||||||
Key: "anilistTokenType",
|
_ = aniRingSet("anilistTokenExpiresIn", []byte(strconv.Itoa(aniListJwt.ExpiresIn)))
|
||||||
Data: []byte(aniListJwt.TokenType),
|
_ = aniRingSet("anilistAccessToken", []byte(aniListJwt.AccessToken))
|
||||||
})
|
_ = aniRingSet("anilistRefreshToken", []byte(aniListJwt.RefreshToken))
|
||||||
_ = aniRing.Set(keyring.Item{
|
|
||||||
Key: "anilistTokenExpiresIn",
|
|
||||||
Data: []byte(strconv.Itoa(aniListJwt.ExpiresIn)),
|
|
||||||
})
|
|
||||||
_ = aniRing.Set(keyring.Item{
|
|
||||||
Key: "anilistAccessToken",
|
|
||||||
Data: []byte(aniListJwt.AccessToken),
|
|
||||||
})
|
|
||||||
_ = aniRing.Set(keyring.Item{
|
|
||||||
Key: "anilistRefreshToken",
|
|
||||||
Data: []byte(aniListJwt.RefreshToken),
|
|
||||||
})
|
|
||||||
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
||||||
Title: "AniList Authorization",
|
Title: "AniList Authorization",
|
||||||
Message: "It is now safe to close your browser tab",
|
Message: "It is now safe to close your browser tab",
|
||||||
@@ -208,12 +239,16 @@ func (a *App) GetAniListLoggedInUser() AniListUser {
|
|||||||
|
|
||||||
func (a *App) LogoutAniList() string {
|
func (a *App) LogoutAniList() string {
|
||||||
if (AniListJWT{} != aniListJwt) {
|
if (AniListJWT{} != aniListJwt) {
|
||||||
|
if !aniRingReady() {
|
||||||
|
aniListJwt = AniListJWT{}
|
||||||
|
return "AniList Logged Out Successfully"
|
||||||
|
}
|
||||||
typeErr := aniRing.Remove("anilistTokenType")
|
typeErr := aniRing.Remove("anilistTokenType")
|
||||||
expiresInErr := aniRing.Remove("anilistTokenExpiresIn")
|
expiresInErr := aniRing.Remove("anilistTokenExpiresIn")
|
||||||
accessTokenErr := aniRing.Remove("anilistAccessToken")
|
accessTokenErr := aniRing.Remove("anilistAccessToken")
|
||||||
refreshTokenErr := aniRing.Remove("anilistRefreshToken")
|
refreshTokenErr := aniRing.Remove("anilistRefreshToken")
|
||||||
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
|
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
|
||||||
fmt.Println("AniList Logout Failed")
|
log.Printf("anilist: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
|
||||||
}
|
}
|
||||||
aniListJwt = AniListJWT{}
|
aniListJwt = AniListJWT{}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-40
@@ -23,13 +23,40 @@ import (
|
|||||||
|
|
||||||
var myAnimeListJwt MyAnimeListJWT
|
var myAnimeListJwt MyAnimeListJWT
|
||||||
|
|
||||||
var myAnimeListRing, _ = keyring.Open(keyring.Config{
|
var myAnimeListRing keyring.Keyring
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
myAnimeListRing, err = keyring.Open(keyring.Config{
|
||||||
ServiceName: "AniTrack",
|
ServiceName: "AniTrack",
|
||||||
KeychainName: "AniTrack",
|
KeychainName: "AniTrack",
|
||||||
KeychainSynchronizable: false,
|
KeychainSynchronizable: false,
|
||||||
KeychainTrustApplication: true,
|
KeychainTrustApplication: true,
|
||||||
KeychainAccessibleWhenUnlocked: true,
|
KeychainAccessibleWhenUnlocked: true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mal: secure storage unavailable: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func malRingReady() bool {
|
||||||
|
if myAnimeListRing == nil {
|
||||||
|
log.Println("mal: secure storage unavailable")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func malRingSet(key string, data []byte) error {
|
||||||
|
if !malRingReady() {
|
||||||
|
return errors.New("mal: secure storage unavailable")
|
||||||
|
}
|
||||||
|
if err := myAnimeListRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
|
||||||
|
log.Printf("mal: save %s failed: %s", key, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var myAnimeListCtxShutdown, myAnimeListCancel = context.WithCancel(context.Background())
|
var myAnimeListCtxShutdown, myAnimeListCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
@@ -72,10 +99,13 @@ func (v *CodeVerifier) CodeChallengeS256() string {
|
|||||||
|
|
||||||
func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
||||||
if (MyAnimeListJWT{} == myAnimeListJwt) {
|
if (MyAnimeListJWT{} == myAnimeListJwt) {
|
||||||
|
if !malRingReady() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
@@ -83,7 +113,8 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
|||||||
myAnimeListJwt.TokenType = string(tokenType.Data)
|
myAnimeListJwt.TokenType = string(tokenType.Data)
|
||||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||||
if expiresInConvertErr != nil {
|
if expiresInConvertErr != nil {
|
||||||
fmt.Println("unable to convert string to int")
|
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
||||||
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
||||||
@@ -97,10 +128,14 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
|||||||
func (a *App) MyAnimeListLogin() {
|
func (a *App) MyAnimeListLogin() {
|
||||||
if !a.CheckIfMyAnimeListLoggedIn() {
|
if !a.CheckIfMyAnimeListLoggedIn() {
|
||||||
fmt.Println("check logged in function failed")
|
fmt.Println("check logged in function failed")
|
||||||
|
if !malRingReady() {
|
||||||
|
log.Println("mal: cannot check login, secure storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||||
verifier, _ := verifier()
|
verifier, _ := verifier()
|
||||||
getMyAnimeListCodeUrl := "https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=" + Environment.MAL_CLIENT_ID + "&redirect_uri=" + Environment.MAL_CALLBACK_URI + "&code_challenge=" + verifier.Value + "&code_challenge_method=plain"
|
getMyAnimeListCodeUrl := "https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=" + Environment.MAL_CLIENT_ID + "&redirect_uri=" + Environment.MAL_CALLBACK_URI + "&code_challenge=" + verifier.Value + "&code_challenge_method=plain"
|
||||||
@@ -114,7 +149,7 @@ func (a *App) MyAnimeListLogin() {
|
|||||||
myAnimeListJwt.TokenType = string(tokenType.Data)
|
myAnimeListJwt.TokenType = string(tokenType.Data)
|
||||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||||
if expiresInConvertErr != nil {
|
if expiresInConvertErr != nil {
|
||||||
fmt.Println("unable to convert string to int in Login function")
|
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
||||||
}
|
}
|
||||||
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
||||||
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
||||||
@@ -136,22 +171,10 @@ func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifi
|
|||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier)
|
myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier)
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
|
||||||
Key: "MyAnimeListTokenType",
|
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
|
||||||
Data: []byte(myAnimeListJwt.TokenType),
|
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
|
||||||
})
|
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListExpiresIn",
|
|
||||||
Data: []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)),
|
|
||||||
})
|
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListAccessToken",
|
|
||||||
Data: []byte(myAnimeListJwt.AccessToken),
|
|
||||||
})
|
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListRefreshToken",
|
|
||||||
Data: []byte(myAnimeListJwt.RefreshToken),
|
|
||||||
})
|
|
||||||
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
||||||
Title: "MyAnimeList Authorization",
|
Title: "MyAnimeList Authorization",
|
||||||
Message: "It is now safe to close your browser tab",
|
Message: "It is now safe to close your browser tab",
|
||||||
@@ -292,22 +315,10 @@ func refreshMyAnimeListAuthorizationToken() bool {
|
|||||||
|
|
||||||
myAnimeListJwt = refreshed
|
myAnimeListJwt = refreshed
|
||||||
|
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
|
||||||
Key: "MyAnimeListTokenType",
|
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
|
||||||
Data: []byte(myAnimeListJwt.TokenType),
|
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
|
||||||
})
|
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListExpiresIn",
|
|
||||||
Data: []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)),
|
|
||||||
})
|
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListAccessToken",
|
|
||||||
Data: []byte(myAnimeListJwt.AccessToken),
|
|
||||||
})
|
|
||||||
_ = myAnimeListRing.Set(keyring.Item{
|
|
||||||
Key: "MyAnimeListRefreshToken",
|
|
||||||
Data: []byte(myAnimeListJwt.RefreshToken),
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,12 +378,16 @@ func createUser() MyAnimeListUser {
|
|||||||
|
|
||||||
func (a *App) LogoutMyAnimeList() string {
|
func (a *App) LogoutMyAnimeList() string {
|
||||||
if (MyAnimeListJWT{} != myAnimeListJwt) {
|
if (MyAnimeListJWT{} != myAnimeListJwt) {
|
||||||
|
if !malRingReady() {
|
||||||
|
myAnimeListJwt = MyAnimeListJWT{}
|
||||||
|
return "MAL Logged Out Successfully"
|
||||||
|
}
|
||||||
typeErr := myAnimeListRing.Remove("MyAnimeListTokenType")
|
typeErr := myAnimeListRing.Remove("MyAnimeListTokenType")
|
||||||
expiresInErr := myAnimeListRing.Remove("MyAnimeListExpiresIn")
|
expiresInErr := myAnimeListRing.Remove("MyAnimeListExpiresIn")
|
||||||
accessTokenErr := myAnimeListRing.Remove("MyAnimeListAccessToken")
|
accessTokenErr := myAnimeListRing.Remove("MyAnimeListAccessToken")
|
||||||
refreshTokenErr := myAnimeListRing.Remove("MyAnimeListRefreshToken")
|
refreshTokenErr := myAnimeListRing.Remove("MyAnimeListRefreshToken")
|
||||||
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
|
if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil {
|
||||||
fmt.Println("MAL Logout Failed")
|
log.Printf("mal: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
|
||||||
}
|
}
|
||||||
myAnimeListJwt = MyAnimeListJWT{}
|
myAnimeListJwt = MyAnimeListJWT{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
TAGS := webkit2_41
|
TAGS := webkit2_41
|
||||||
|
|
||||||
.PHONY: dev build clean
|
.PHONY: dev build clean release
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
wails dev -tags $(TAGS)
|
wails dev -tags $(TAGS)
|
||||||
@@ -10,3 +10,42 @@ build:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build/bin/*
|
rm -rf build/bin/*
|
||||||
|
|
||||||
|
# Create a version commit (wails.json bump, committed and pushed) plus an
|
||||||
|
# annotated version tag carrying auto-generated release notes (git-cliff),
|
||||||
|
# and push both. The tag push triggers .gitea/workflows/release.yml, which
|
||||||
|
# verifies wails.json matches the tag, builds via `make build`, packages
|
||||||
|
# AniTrack-<VERSION>.tar.gz from build/, and publishes a Gitea Release
|
||||||
|
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
||||||
|
# from Conventional Commits — no hand-written message required.
|
||||||
|
#
|
||||||
|
# git-cliff's --latest needs the tag to exist to scope the notes, so we
|
||||||
|
# create a throwaway lightweight tag, generate the notes, replace it with
|
||||||
|
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
||||||
|
# group headers (git's default cleanup would strip lines starting with "#").
|
||||||
|
#
|
||||||
|
# Tags are plain versions (1.6.7) to match wails.json productVersion and the
|
||||||
|
# existing tag history. Pre-releases are created by tagging manually with a
|
||||||
|
# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
||||||
|
# those as prerelease. Use ./release as a shortcut.
|
||||||
|
#
|
||||||
|
# Requires git-cliff: https://git-cliff.org/install
|
||||||
|
# Usage: make release VERSION=1.6.7
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=1.6.7"; exit 1; }
|
||||||
|
@echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be plain semver like 1.6.7 (no v prefix, no AniTrack- prefix, no suffix)"; exit 1; }
|
||||||
|
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||||
|
@command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump wails.json"; exit 1; }
|
||||||
|
@git diff --quiet && git diff --cached --quiet || { echo "Working tree dirty — commit or stash first"; exit 1; }
|
||||||
|
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||||
|
@echo "Bumping wails.json to $(VERSION)..."
|
||||||
|
@python3 -c "import json; p='wails.json'; d=json.load(open(p)); d['info']['productVersion']='$(VERSION)'; json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')"
|
||||||
|
@git add wails.json
|
||||||
|
@git commit -m "chore(release): bump version to $(VERSION)"
|
||||||
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
|
@git tag "$(VERSION)" HEAD && \
|
||||||
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||||
|
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||||
|
@git push origin main "$(VERSION)"
|
||||||
|
@echo "Pushed $(VERSION) — Gitea Actions will build, package, and publish AniTrack-$(VERSION).tar.gz."
|
||||||
|
|||||||
+43
-14
@@ -17,18 +17,48 @@ import (
|
|||||||
|
|
||||||
var simklJwt SimklJWT
|
var simklJwt SimklJWT
|
||||||
|
|
||||||
var simklRing, _ = keyring.Open(keyring.Config{
|
var simklRing keyring.Keyring
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
simklRing, err = keyring.Open(keyring.Config{
|
||||||
ServiceName: "AniTrack",
|
ServiceName: "AniTrack",
|
||||||
KeychainName: "AniTrack",
|
KeychainName: "AniTrack",
|
||||||
KeychainSynchronizable: false,
|
KeychainSynchronizable: false,
|
||||||
KeychainTrustApplication: true,
|
KeychainTrustApplication: true,
|
||||||
KeychainAccessibleWhenUnlocked: true,
|
KeychainAccessibleWhenUnlocked: true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("simkl: secure storage unavailable: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simklRingReady() bool {
|
||||||
|
if simklRing == nil {
|
||||||
|
log.Println("simkl: secure storage unavailable")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func simklRingSet(key string, data []byte) error {
|
||||||
|
if !simklRingReady() {
|
||||||
|
return errors.New("simkl: secure storage unavailable")
|
||||||
|
}
|
||||||
|
if err := simklRing.Set(keyring.Item{Key: key, Data: data}); err != nil {
|
||||||
|
log.Printf("simkl: save %s failed: %s", key, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var simklCtxShutdown, simklCancel = context.WithCancel(context.Background())
|
var simklCtxShutdown, simklCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
func (a *App) CheckIfSimklLoggedIn() bool {
|
func (a *App) CheckIfSimklLoggedIn() bool {
|
||||||
if (SimklJWT{} == simklJwt) {
|
if (SimklJWT{} == simklJwt) {
|
||||||
|
if !simklRingReady() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
||||||
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
||||||
scope, scopeErr := simklRing.Get("SimklScope")
|
scope, scopeErr := simklRing.Get("SimklScope")
|
||||||
@@ -47,6 +77,10 @@ func (a *App) CheckIfSimklLoggedIn() bool {
|
|||||||
|
|
||||||
func (a *App) SimklLogin() {
|
func (a *App) SimklLogin() {
|
||||||
if !a.CheckIfSimklLoggedIn() {
|
if !a.CheckIfSimklLoggedIn() {
|
||||||
|
if !simklRingReady() {
|
||||||
|
log.Println("simkl: cannot check login, secure storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
||||||
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
||||||
scope, scopeErr := simklRing.Get("SimklScope")
|
scope, scopeErr := simklRing.Get("SimklScope")
|
||||||
@@ -80,18 +114,9 @@ func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
simklJwt = getSimklAuthorizationToken(content)
|
simklJwt = getSimklAuthorizationToken(content)
|
||||||
_ = simklRing.Set(keyring.Item{
|
_ = simklRingSet("SimklTokenType", []byte(simklJwt.TokenType))
|
||||||
Key: "SimklTokenType",
|
_ = simklRingSet("SimklAccessToken", []byte(simklJwt.AccessToken))
|
||||||
Data: []byte(simklJwt.TokenType),
|
_ = simklRingSet("SimklScope", []byte(simklJwt.Scope))
|
||||||
})
|
|
||||||
_ = simklRing.Set(keyring.Item{
|
|
||||||
Key: "SimklAccessToken",
|
|
||||||
Data: []byte(simklJwt.AccessToken),
|
|
||||||
})
|
|
||||||
_ = simklRing.Set(keyring.Item{
|
|
||||||
Key: "SimklScope",
|
|
||||||
Data: []byte(simklJwt.Scope),
|
|
||||||
})
|
|
||||||
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
||||||
Title: "Simkl Authorization",
|
Title: "Simkl Authorization",
|
||||||
Message: "It is now safe to close your browser tab",
|
Message: "It is now safe to close your browser tab",
|
||||||
@@ -218,12 +243,16 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
|
|||||||
|
|
||||||
func (a *App) LogoutSimkl() string {
|
func (a *App) LogoutSimkl() string {
|
||||||
if (SimklJWT{} != simklJwt) {
|
if (SimklJWT{} != simklJwt) {
|
||||||
|
if !simklRingReady() {
|
||||||
|
simklJwt = SimklJWT{}
|
||||||
|
return "Simkl Logged Out Successfully"
|
||||||
|
}
|
||||||
tokenTypeErr := simklRing.Remove("SimklTokenType")
|
tokenTypeErr := simklRing.Remove("SimklTokenType")
|
||||||
accessTokenErr := simklRing.Remove("SimklAccessToken")
|
accessTokenErr := simklRing.Remove("SimklAccessToken")
|
||||||
scopeErr := simklRing.Remove("SimklScope")
|
scopeErr := simklRing.Remove("SimklScope")
|
||||||
|
|
||||||
if tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil {
|
if tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil {
|
||||||
fmt.Println("Simkl Logout Failed")
|
log.Printf("simkl: logout cleanup failed (type=%v access=%v scope=%v)", tokenTypeErr, accessTokenErr, scopeErr)
|
||||||
}
|
}
|
||||||
simklJwt = SimklJWT{}
|
simklJwt = SimklJWT{}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# git-cliff configuration — generates the body of each Gitea Release from
|
||||||
|
# Conventional Commits accumulated since the previous tag. Invoked locally by
|
||||||
|
# `make release VERSION=...`; the annotated tag message becomes the Release
|
||||||
|
# body in .gitea/workflows/release.yml. Tags are plain versions (1.6.7).
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
Generated
+2717
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
aniListLoggedIn,
|
aniListLoggedIn,
|
||||||
malAnime,
|
malAnime,
|
||||||
malLoggedIn,
|
malLoggedIn,
|
||||||
|
setApiError,
|
||||||
simklAnime,
|
simklAnime,
|
||||||
simklLoggedIn,
|
simklLoggedIn,
|
||||||
watchlistNeedsRefresh,
|
watchlistNeedsRefresh,
|
||||||
@@ -207,10 +208,15 @@
|
|||||||
completedAt: convertDateToAniList(completedAtDate),
|
completedAt: convertDateToAniList(completedAtDate),
|
||||||
};
|
};
|
||||||
await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
|
await AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
|
||||||
value.data.MediaList.media.tags =
|
if (!value.data?.MediaList || value.data.MediaList.mediaId === 0) {
|
||||||
currentAniListAnime.data.MediaList.media.tags;
|
setApiError(
|
||||||
value.data.MediaList.media.genres =
|
"anilist",
|
||||||
currentAniListAnime.data.MediaList.media.genres;
|
"AniList update failed: no saved entry was returned",
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
aniListAnime.update((newValue) => {
|
aniListAnime.update((newValue) => {
|
||||||
newValue = value;
|
newValue = value;
|
||||||
return newValue;
|
return newValue;
|
||||||
@@ -233,7 +239,18 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Error submitting AniList changes:", error);
|
||||||
|
setApiError(
|
||||||
|
"anilist",
|
||||||
|
`Failed to sync AniList: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (malLoggedIn && currentMalAnime.id !== 0) {
|
if (malLoggedIn && currentMalAnime.id !== 0) {
|
||||||
let body: MALUploadStatus = {
|
let body: MALUploadStatus = {
|
||||||
status: submitData.status.mal,
|
status: submitData.status.mal,
|
||||||
@@ -286,7 +303,18 @@
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Error submitting MyAnimeList changes:", error);
|
||||||
|
setApiError(
|
||||||
|
"mal",
|
||||||
|
`Failed to sync MyAnimeList: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
|
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
|
||||||
if (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
|
if (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
|
||||||
await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
|
await SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
|
||||||
@@ -359,13 +387,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting changes:", error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
} finally {
|
console.error("Error submitting Simkl changes:", error);
|
||||||
|
setApiError(
|
||||||
|
"simkl",
|
||||||
|
`Failed to sync Simkl: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
submitting.set(false);
|
submitting.set(false);
|
||||||
submitSuccess.set(true);
|
submitSuccess.set(true);
|
||||||
watchlistNeedsRefresh.set(true);
|
watchlistNeedsRefresh.set(true);
|
||||||
setTimeout(() => submitSuccess.set(false), 2000);
|
setTimeout(() => submitSuccess.set(false), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEntries = async () => {
|
const deleteEntries = async () => {
|
||||||
@@ -389,6 +423,18 @@
|
|||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Error deleting AniList entry:", error);
|
||||||
|
setApiError(
|
||||||
|
"anilist",
|
||||||
|
`Failed to delete AniList entry: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (malLoggedIn && currentMalAnime.id !== 0) {
|
if (malLoggedIn && currentMalAnime.id !== 0) {
|
||||||
await DeleteMyAnimeListEntry(currentMalAnime.id);
|
await DeleteMyAnimeListEntry(currentMalAnime.id);
|
||||||
AddAnimeServiceToTable({
|
AddAnimeServiceToTable({
|
||||||
@@ -404,6 +450,18 @@
|
|||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Error deleting MyAnimeList entry:", error);
|
||||||
|
setApiError(
|
||||||
|
"mal",
|
||||||
|
`Failed to delete MyAnimeList entry: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
|
if (simklLoggedIn && currentSimklAnime.show.ids.simkl !== 0) {
|
||||||
await SimklSyncRemove(currentSimklAnime);
|
await SimklSyncRemove(currentSimklAnime);
|
||||||
AddAnimeServiceToTable({
|
AddAnimeServiceToTable({
|
||||||
@@ -420,13 +478,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting entries:", error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
} finally {
|
console.error("Error deleting Simkl entry:", error);
|
||||||
|
setApiError(
|
||||||
|
"simkl",
|
||||||
|
`Failed to delete Simkl entry: ${errorMsg}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
submitting.set(false);
|
submitting.set(false);
|
||||||
submitSuccess.set(true);
|
submitSuccess.set(true);
|
||||||
watchlistNeedsRefresh.set(true);
|
watchlistNeedsRefresh.set(true);
|
||||||
setTimeout(() => submitSuccess.set(false), 2000);
|
setTimeout(() => submitSuccess.set(false), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let max = 999;
|
let max = 999;
|
||||||
|
|||||||
@@ -5,23 +5,128 @@
|
|||||||
import {push} from "svelte-spa-router";
|
import {push} from "svelte-spa-router";
|
||||||
|
|
||||||
let aniSearch = ""
|
let aniSearch = ""
|
||||||
let aniListSearch: AniSearchList
|
let aniListSearch: AniSearchList | null = null
|
||||||
let aniListSearchActive = false
|
let dropdownOpen = false
|
||||||
|
let isSearching = false
|
||||||
|
let showSlowNotice = false
|
||||||
|
let searchError: string | null = null
|
||||||
|
let hasSearched = false
|
||||||
|
let searchRequestId = 0
|
||||||
|
|
||||||
function runAniListSearch(): void {
|
const SLOW_NOTICE_MS = 8000
|
||||||
AniListSearch(aniSearch).then(result => {
|
const SEARCH_TIMEOUT_MS = 30000
|
||||||
aniListSearch = result
|
|
||||||
aniListSearchActive = true
|
function openDropdown(): void {
|
||||||
})
|
dropdownOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchDropdown(): void {
|
function closeDropdown(): void {
|
||||||
let dropdown = document.querySelector("#aniListSearchDropdown")
|
dropdownOpen = false
|
||||||
dropdown.classList.toggle("hidden")
|
}
|
||||||
|
|
||||||
|
function displayTitle(media: { title?: { english?: string | null; romaji?: string | null; native?: string | null } }): string {
|
||||||
|
const english = media?.title?.english
|
||||||
|
if (english !== undefined && english !== null && english !== "") {
|
||||||
|
return english
|
||||||
|
}
|
||||||
|
const romaji = media?.title?.romaji
|
||||||
|
if (romaji !== undefined && romaji !== null && romaji !== "") {
|
||||||
|
return romaji
|
||||||
|
}
|
||||||
|
return media?.title?.native ?? "Unknown title"
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultCount(): number {
|
||||||
|
const media = aniListSearch?.data?.Page?.media
|
||||||
|
return Array.isArray(media) ? media.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAniListSearch(): Promise<void> {
|
||||||
|
const term = aniSearch.trim()
|
||||||
|
openDropdown()
|
||||||
|
if (term.length === 0) {
|
||||||
|
searchRequestId += 1
|
||||||
|
aniListSearch = null
|
||||||
|
searchError = null
|
||||||
|
hasSearched = false
|
||||||
|
isSearching = false
|
||||||
|
showSlowNotice = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSearching) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||||
|
aniListSearch = null
|
||||||
|
hasSearched = true
|
||||||
|
searchError = "You appear to be offline. Check your connection and try again."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSearching = true
|
||||||
|
showSlowNotice = false
|
||||||
|
searchError = null
|
||||||
|
hasSearched = true
|
||||||
|
const myRequest = searchRequestId + 1
|
||||||
|
searchRequestId = myRequest
|
||||||
|
let slowTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
try {
|
||||||
|
slowTimer = setTimeout(() => {
|
||||||
|
if (searchRequestId === myRequest && isSearching) {
|
||||||
|
showSlowNotice = true
|
||||||
|
}
|
||||||
|
}, SLOW_NOTICE_MS)
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timeoutTimer = setTimeout(() => {
|
||||||
|
reject(new Error("AniList is taking too long to respond. Please try again."))
|
||||||
|
}, SEARCH_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
const result = await Promise.race([AniListSearch(term), timeout])
|
||||||
|
if (searchRequestId !== myRequest) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aniListSearch = result as AniSearchList
|
||||||
|
if (!Array.isArray(aniListSearch?.data?.Page?.media)) {
|
||||||
|
aniListSearch = null
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (searchRequestId !== myRequest) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aniListSearch = null
|
||||||
|
searchError = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
if (slowTimer !== null) {
|
||||||
|
clearTimeout(slowTimer)
|
||||||
|
}
|
||||||
|
if (timeoutTimer !== null) {
|
||||||
|
clearTimeout(timeoutTimer)
|
||||||
|
}
|
||||||
|
if (searchRequestId === myRequest) {
|
||||||
|
isSearching = false
|
||||||
|
showSlowNotice = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToAnime(id: number): void {
|
||||||
|
closeDropdown()
|
||||||
|
push(`#/anime/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowClick(e: MouseEvent): void {
|
||||||
|
if (!dropdownOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const container = document.querySelector("#searchDropdown")
|
||||||
|
if (container && e.target instanceof Node && !container.contains(e.target)) {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleWindowClick} />
|
||||||
|
|
||||||
<div id="searchDropdown" class="relative w-64 md:w-48">
|
<div id="searchDropdown" class="relative w-64 md:w-48">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -33,16 +138,20 @@
|
|||||||
placeholder="Search for Anime"
|
placeholder="Search for Anime"
|
||||||
on:keypress={(e) => {
|
on:keypress={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
searchDropdown()
|
runAniListSearch()
|
||||||
if(aniSearch.length > 0) runAniListSearch()
|
}
|
||||||
|
}}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeDropdown()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
required/>
|
required/>
|
||||||
<button id="aniListSearchButton"
|
<button id="aniListSearchButton"
|
||||||
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800"
|
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium rounded-e-lg border focus:ring-4 focus:outline-none bg-blue-600 hover:bg-blue-700 focus:ring-blue-800 disabled:opacity-50"
|
||||||
|
disabled={isSearching}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
searchDropdown()
|
runAniListSearch()
|
||||||
if(aniSearch.length > 0) runAniListSearch()
|
|
||||||
}}>
|
}}>
|
||||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
viewBox="0 0 20 20">
|
viewBox="0 0 20 20">
|
||||||
@@ -54,31 +163,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="aniListSearchDropdown" class="z-10 absolute left-0 hidden bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700">
|
<div id="aniListSearchDropdown" class:hidden={!dropdownOpen} class="z-10 absolute left-0 bg-white rounded-lg shadow w-60 2xl:w-80 dark:bg-gray-700">
|
||||||
{#if aniListSearchActive}
|
{#if isSearching}
|
||||||
|
<div class="m-4 text-gray-700 dark:text-gray-200">{showSlowNotice ? "AniList is slow today, still trying..." : "Searching AniList..."}</div>
|
||||||
|
{:else if searchError}
|
||||||
|
<div class="m-4">
|
||||||
|
<p class="text-red-600 dark:text-red-400 font-medium">Search failed</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200">{searchError}</p>
|
||||||
|
<button class="mt-3 text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
on:click={() => runAniListSearch()}>
|
||||||
|
Retry search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if !hasSearched && aniSearch.trim().length === 0}
|
||||||
|
<div class="m-4 text-gray-700 dark:text-gray-200">Please enter a search term...</div>
|
||||||
|
{:else if resultCount() > 0 && aniListSearch}
|
||||||
<ul class="h-56 w-full py-2 overflow-y-auto text-gray-700 dark:text-gray-200"
|
<ul class="h-56 w-full py-2 overflow-y-auto text-gray-700 dark:text-gray-200"
|
||||||
aria-labelledby="aniListSearchButton">
|
aria-labelledby="aniListSearchButton">
|
||||||
{#each aniListSearch.data.Page.media as media}
|
{#each aniListSearch.data.Page.media as media (media.id)}
|
||||||
<li class="w-full">
|
<li class="w-full">
|
||||||
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
|
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
|
||||||
<button on:click={() => {
|
<button on:click={() => goToAnime(media.id)}
|
||||||
searchDropdown()
|
|
||||||
push(`#/anime/${media.id}`)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media.coverImage.large}
|
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media?.coverImage?.large}
|
||||||
alt="{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english} Cover">
|
alt="{displayTitle(media)} Cover">
|
||||||
</button>
|
</button>
|
||||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => {
|
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => goToAnime(media.id)} >{displayTitle(media)}</button>
|
||||||
searchDropdown()
|
|
||||||
push(`#/anime/${media.id}`)
|
|
||||||
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if aniSearch.length === 0}
|
{:else if hasSearched}
|
||||||
<div class="m-4">Please enter a search term...</div>
|
<div class="m-4 text-gray-700 dark:text-gray-200">No results found for "{aniSearch.trim()}". Try a different title.</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+161
-15
@@ -1,5 +1,25 @@
|
|||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class AiringScheduleNode {
|
||||||
|
id: number;
|
||||||
|
airingAt: number;
|
||||||
|
timeUntilAiring: number;
|
||||||
|
episode: number;
|
||||||
|
mediaId: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AiringScheduleNode(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.airingAt = source["airingAt"];
|
||||||
|
this.timeUntilAiring = source["timeUntilAiring"];
|
||||||
|
this.episode = source["episode"];
|
||||||
|
this.mediaId = source["mediaId"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AniListCurrentUserWatchList {
|
export class AniListCurrentUserWatchList {
|
||||||
data: struct { Page struct { PageInfo struct { Total int "json:\"total\""; PerPage int "json:\"perPage\""; CurrentPage int "json:\"currentPage\""; LastPage int "json:\"lastPage\""; HasNextPage bool "json:\"hasNextPage\"" } "json:\"pageInfo\""; MediaList []main.;
|
data: struct { Page struct { PageInfo struct { Total int "json:\"total\""; PerPage int "json:\"perPage\""; CurrentPage int "json:\"currentPage\""; LastPage int "json:\"lastPage\""; HasNextPage bool "json:\"hasNextPage\"" } "json:\"pageInfo\""; MediaList []main.;
|
||||||
|
|
||||||
@@ -381,11 +401,136 @@ export namespace main {
|
|||||||
this.isAdult = source["isAdult"];
|
this.isAdult = source["isAdult"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class MediaAiringSchedule {
|
||||||
|
nodes: AiringScheduleNode[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaAiringSchedule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.nodes = this.convertValues(source["nodes"], AiringScheduleNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaFuzzyDate {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaFuzzyDate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.year = source["year"];
|
||||||
|
this.month = source["month"];
|
||||||
|
this.day = source["day"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaRelation {
|
||||||
|
id: number;
|
||||||
|
title: MediaTitle;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaRelation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.title = this.convertValues(source["title"], MediaTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaRelations {
|
||||||
|
nodes: MediaRelation[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaRelations(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.nodes = this.convertValues(source["nodes"], MediaRelation);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaTitle {
|
||||||
|
userPreferred: string;
|
||||||
|
romaji: string;
|
||||||
|
english: string;
|
||||||
|
native: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaTitle(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.userPreferred = source["userPreferred"];
|
||||||
|
this.romaji = source["romaji"];
|
||||||
|
this.english = source["english"];
|
||||||
|
this.native = source["native"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Media {
|
export class Media {
|
||||||
id: number;
|
id: number;
|
||||||
idMal: number;
|
idMal: number;
|
||||||
// Go type: struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" }
|
title: MediaTitle;
|
||||||
title: any;
|
|
||||||
description: string;
|
description: string;
|
||||||
// Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string }
|
// Go type: struct { ExtraLarge string; Large string "json:\"large\""; Medium string; Color string }
|
||||||
coverImage: any;
|
coverImage: any;
|
||||||
@@ -404,16 +549,12 @@ export namespace main {
|
|||||||
Popularity: number;
|
Popularity: number;
|
||||||
Trending: number;
|
Trending: number;
|
||||||
Favourites: number;
|
Favourites: number;
|
||||||
// Go type: struct { nodes struct { id int; Title struct { UserPreferred string "json:\"userPreferred\""; Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } "json:\"title\"" } }
|
relations: MediaRelations;
|
||||||
Relations: any;
|
startDate: MediaFuzzyDate;
|
||||||
// Go type: struct { Year int; Month int; Day int }
|
endDate: MediaFuzzyDate;
|
||||||
StartDate: any;
|
|
||||||
// Go type: struct { Year int; Month int; Day int }
|
|
||||||
EndDate: any;
|
|
||||||
// Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" }
|
// Go type: struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" }
|
||||||
nextAiringEpisode: any;
|
nextAiringEpisode: any;
|
||||||
// Go type: struct { Nodes struct { Id int; AiringAt int; TimeUntilAiring int; Episode int; MediaId int } }
|
airingSchedule: MediaAiringSchedule;
|
||||||
AiringSchedule: any;
|
|
||||||
genres: string[];
|
genres: string[];
|
||||||
tags: [];
|
tags: [];
|
||||||
isAdult: boolean;
|
isAdult: boolean;
|
||||||
@@ -426,7 +567,7 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.id = source["id"];
|
this.id = source["id"];
|
||||||
this.idMal = source["idMal"];
|
this.idMal = source["idMal"];
|
||||||
this.title = this.convertValues(source["title"], Object);
|
this.title = this.convertValues(source["title"], MediaTitle);
|
||||||
this.description = source["description"];
|
this.description = source["description"];
|
||||||
this.coverImage = this.convertValues(source["coverImage"], Object);
|
this.coverImage = this.convertValues(source["coverImage"], Object);
|
||||||
this.BannerImage = source["BannerImage"];
|
this.BannerImage = source["BannerImage"];
|
||||||
@@ -444,11 +585,11 @@ export namespace main {
|
|||||||
this.Popularity = source["Popularity"];
|
this.Popularity = source["Popularity"];
|
||||||
this.Trending = source["Trending"];
|
this.Trending = source["Trending"];
|
||||||
this.Favourites = source["Favourites"];
|
this.Favourites = source["Favourites"];
|
||||||
this.Relations = this.convertValues(source["Relations"], Object);
|
this.relations = this.convertValues(source["relations"], MediaRelations);
|
||||||
this.StartDate = this.convertValues(source["StartDate"], Object);
|
this.startDate = this.convertValues(source["startDate"], MediaFuzzyDate);
|
||||||
this.EndDate = this.convertValues(source["EndDate"], Object);
|
this.endDate = this.convertValues(source["endDate"], MediaFuzzyDate);
|
||||||
this.nextAiringEpisode = this.convertValues(source["nextAiringEpisode"], Object);
|
this.nextAiringEpisode = this.convertValues(source["nextAiringEpisode"], Object);
|
||||||
this.AiringSchedule = this.convertValues(source["AiringSchedule"], Object);
|
this.airingSchedule = this.convertValues(source["airingSchedule"], MediaAiringSchedule);
|
||||||
this.genres = source["genres"];
|
this.genres = source["genres"];
|
||||||
this.tags = this.convertValues(source["tags"], );
|
this.tags = this.convertValues(source["tags"], );
|
||||||
this.isAdult = source["isAdult"];
|
this.isAdult = source["isAdult"];
|
||||||
@@ -472,6 +613,8 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class MediaList {
|
export class MediaList {
|
||||||
id: number;
|
id: number;
|
||||||
mediaId: number;
|
mediaId: number;
|
||||||
@@ -527,6 +670,9 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class MyAnimeListUser {
|
export class MyAnimeListUser {
|
||||||
id: id;
|
id: id;
|
||||||
name: name;
|
name: name;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Project-attached wrapper around `make release` so you can run:
|
||||||
|
# ./release 1.6.7 (or) ./release v1.6.7 (or) ./release AniTrack-1.6.7
|
||||||
|
# instead of:
|
||||||
|
# make release VERSION=1.6.7
|
||||||
|
# Lives in the repo (no machine-specific alias needed). Tags are plain
|
||||||
|
# versions (1.6.7) to match wails.json productVersion.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Normalize "v1.6.7" or "AniTrack-1.6.7" down to plain "1.6.7" (the Makefile
|
||||||
|
# rejects anything that is not plain semver, so this just saves typing).
|
||||||
|
VERSION="${1#v}"
|
||||||
|
VERSION="${VERSION#AniTrack-}"
|
||||||
|
|
||||||
|
exec make release "VERSION=${VERSION}"
|
||||||
+1
-1
@@ -12,6 +12,6 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "AniTrack",
|
"productName": "AniTrack",
|
||||||
"productVersion": "1.5.5"
|
"productVersion": "1.6.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user