Compare commits

...
10 Commits
Author SHA1 Message Date
John O'Keefe a5da544dd7 chore(release): bump version to 1.6.7
Release / release (push) Successful in 11m52s
2026-09-11 16:22:45 -04:00
John O'Keefe 270faeb507 feat(ci): automate Linux release builds and Gitea releases
Release / release (push) Successful in 13m50s
Local flow is now ./release 1.6.7 (or make release VERSION=1.6.7):
bump wails.json productVersion, commit the bump, generate git-cliff
notes into an annotated plain-version tag, and push commit plus tag.
The tag push fires the new Gitea Actions workflow.

- cliff.toml: git-cliff groups matching Bookhoard, tag_pattern for
  plain versions so --latest scopes correctly.
- Makefile release target: strict plain-semver validation, dirty-tree
  and existing-tag guards, python3 wails.json bump committed as
  chore(release), throwaway-tag notes generation, push of main + tag.
- release: wrapper normalizing v/AniTrack- prefixes to plain versions.
- .gitea/workflows/release.yml: version guard (wails.json vs tag,
  base-match for -suffix pre-releases), Go 1.25 / Node 20 / Wails
  v2.15.0 + webkit2_41 system deps, environment.go from Actions
  secrets, make build, AniTrack-<version>.tar.gz packaged from
  build/ (bin, icon, desktop, install script, README), idempotent
  Release create/update named AniTrack-<version> with archive
  attached via REGISTRY_TOKEN PAT.
2026-09-11 16:00:37 -04:00
John O'Keefe abba1803fc chore(release): bump version to 1.6.6
Update productVersion in wails.json from 1.6.5 to 1.6.6 to accompany
the MAL token variable fix and the authenticated AniList search fix.
2026-09-11 15:23:43 -04:00
John O'Keefe ce35a20eba fix(anilist): send auth token on search when logged in
AniListSearch hardcoded false for the authenticated flag to
AniListQuery, forcing anonymous searches even with a valid AniList
session. Pass a.CheckIfAniListLoggedIn() instead so the stored token
is attached when available, enabling user-specific data and
authenticated rate limits while keeping anonymous search as fallback.
2026-09-11 15:23:40 -04:00
John O'Keefe e5ce250392 fix(mal): correct swapped access and refresh token variables
In CheckIfMyAnimeListLoggedIn and MyAnimeListLogin the keyring lookups
assigned MyAnimeListAccessToken to refreshToken and
MyAnimeListRefreshToken to accessToken. Downstream code checks
len(accessToken.Data) for login state and assigns
accessToken.Data -> myAnimeListJwt.AccessToken, so the swap gated
login on the refresh token and stored each token in the wrong field.

Swap the variable names to match their keyring keys so login detection,
JWT restoration, and the empty-token fallback in MyAnimeListLogin use
the correct values.
2026-09-11 15:23:29 -04:00
john-okeefe 0cca48adb1 chore(release): bump version to 1.6.5 2026-09-03 19:12:18 -04:00
john-okeefe d2f2f8a618 fix(ui): make header search states explicit with slow-day timeouts
The search dropdown toggled blindly before the async call settled
and only handled success, so API failures left a blank dropdown
that looked like search did nothing.

- Replace toggle with explicit open/close, Escape and outside-click
  to dismiss, and disabled search button while a request is in flight
- Add isSearching/searchError/hasSearched states: loading text,
  failure panel with message plus Retry, empty prompt, and a
  no-results message for the searched term
- Guard null coverImage/title with a romaji/native fallback and a
  keyed each block; ignore stale late responses via request ids
- Tolerate slow AniList days: slow notice at 8s, 30s Promise.race
  fail-safe (backend allows 20s), and an immediate offline message
  via navigator.onLine so the UI never spins indefinitely
2026-09-03 19:09:44 -04:00
john-okeefe fcbcda7eac fix(anilist): harden AniList query transport and search errors
AniListQuery previously ignored json.Marshal/http.NewRequest errors,
logged the wrong variable on client.Do failure, and dereferenced a
nil response body, which could panic and leave callers hanging.

- Return early with user-safe messages on encode/request failures
- Add 20s http.Client timeout and nil guards for res/res.Body
- Keep logs generic so no auth material is ever printed
- Add aniListGraphQLErrorMessage/aniListSearchStatusError helpers
  mapping 429 to a rate-limit retry message, 5xx to a temporary
  outage message, and GraphQL errors[] (even on HTTP 200) to a
  surfaced message
- AniListSearch rejects empty bodies and unparseable payloads with
  actionable errors instead of failing silently downstream
2026-09-03 19:09:40 -04:00
john-okeefe 0a89ec3652 fix(anilist): decode array-shaped airingSchedule/relations and isolate per-service sync errors
The 1.6.1 fix exposed a latent decoding bug: AniList returns
airingSchedule.nodes and relations.nodes as arrays, but the Go Media
struct declared Nodes as single structs. Every full-media response
failed json.Unmarshal partway through - the update path turned that
into a rejected promise which skipped the MAL and Simkl syncs and all
table updates, while page loads silently continued with partially
decoded data (the reason the old tags/genres copy workaround existed).

- Media.AiringSchedule.Nodes is now []AiringScheduleNode and
  Media.Relations is an exported []MediaRelation (previously an
  unexported field silently dropped by encoding/json); title and
  fuzzy-date sub-structs promoted to named types.
- Regenerate wailsjs models for the new shapes.
- Anime.svelte: handleSubmit and deleteEntries now wrap each service
  in its own try/catch surfaced via setApiError/ErrorModal, so one
  service failing can no longer skip the others; removed the obsolete
  tags/genres copy workaround.
- AniListUpdateEntry/AniListDeleteEntry log HTTP status and response
  body on failure for terminal diagnostics.

Bump productVersion to 1.6.2.
2026-09-01 20:18:42 -04:00
john-okeefe 5a14863a8f fix(anilist): repair SaveMediaListEntry mutation and surface update failures
The AniListUpdateEntry mutation was mangled in 54c109a when the
standardized media field block was pasted in without the media { }
wrapper: media-level fields (idMal, title, ...) sat directly on
SaveMediaListEntry (which returns MediaList), the MediaList fields
(status, startedAt, ..., user) ended up at the Mutation root, and a
stray closing brace made the document a guaranteed 400. Because the
response status was discarded, the frontend received a zeroed
AniListGetSingleAnime and blanked the anime page after every submit,
making AniList appear logged out (and the change was never saved).

- Restore the mutation to match the tested bruno request: id/mediaId/
  userId, standard media block inside media { }, MediaList fields
  inside the selection, balanced braces.
- AniListUpdateEntry and AniListDeleteEntry now return an error on
  403/non-200/unparseable responses (mirroring
  GetAniListUserWatchingList) instead of silently returning zero
  values.
- Anime.svelte guards against replacing the page data with an empty
  response and raises the API error modal instead.

Bump productVersion to 1.6.1.
2026-09-01 19:49:47 -04:00
11 changed files with 1000 additions and 214 deletions
+251
View File
@@ -0,0 +1,251 @@
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: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- 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: Install Wails CLI
run: |
set -euo pipefail
go install github.com/wailsapp/wails/v2/cmd/wails@v2.15.0
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}"
+202 -89
View File
@@ -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,84 +637,87 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
startedAt: $startedAt startedAt: $startedAt
completedAt: $completedAt completedAt: $completedAt
) { ) {
id id
idMal mediaId
title { userId
userPreferred media {
romaji
english
native
}
description
coverImage {
extraLarge
large
medium
color
}
bannerImage
format
season
seasonYear
status
episodes
duration
countryOfOrigin
source
synonyms
averageScore
meanScore
popularity
trending
favourites
isFavourite
relations {
nodes {
id id
idMal
title { title {
userPreferred userPreferred
romaji romaji
english english
native native
} }
description
coverImage {
extraLarge
large
medium
color
}
bannerImage
format
season
seasonYear
status
episodes
duration
countryOfOrigin
source
synonyms
averageScore
meanScore
popularity
trending
favourites
isFavourite
relations {
nodes {
id
title {
userPreferred
romaji
english
native
}
}
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
airingSchedule {
nodes {
id
airingAt
timeUntilAiring
episode
mediaId
}
}
genres
tags {
id
name
description
rank
isMediaSpoiler
isAdult
}
isAdult
} }
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
airingSchedule {
nodes {
id
airingAt
timeUntilAiring
episode
mediaId
}
}
genres
tags {
id
name
description
rank
isMediaSpoiler
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(
+60 -57
View File
@@ -64,74 +64,43 @@ 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"` Description string `json:"description"`
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
Description string `json:"description"`
CoverImage struct { CoverImage struct {
ExtraLarge string ExtraLarge string
Large string `json:"large"` Large string `json:"large"`
Medium string Medium string
Color string Color string
} `json:"coverImage"` } `json:"coverImage"`
BannerImage string BannerImage string
Format string Format string
Season string `json:"season"` Season string `json:"season"`
SeasonYear int `json:"seasonYear"` SeasonYear int `json:"seasonYear"`
Status string `json:"status"` Status string `json:"status"`
Episodes int `json:"episodes"` Episodes int `json:"episodes"`
Duration int Duration int
CountryOfOrigin string CountryOfOrigin string
Source string Source string
Synonyms []string Synonyms []string
AverageScore int AverageScore int
MeanScore int MeanScore int
Popularity int Popularity int
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 { Genres []string `json:"genres"`
Id int Tags []struct {
AiringAt int
TimeUntilAiring int
Episode int
MediaId int
}
}
Genres []string `json:"genres"`
Tags []struct {
Id int `json:"id"` Id int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
@@ -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"`
+4 -4
View File
@@ -74,8 +74,8 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
if (MyAnimeListJWT{} == myAnimeListJwt) { if (MyAnimeListJWT{} == myAnimeListJwt) {
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 {
@@ -99,8 +99,8 @@ func (a *App) MyAnimeListLogin() {
fmt.Println("check logged in function failed") fmt.Println("check logged in function failed")
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"
+40 -1
View File
@@ -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."
+37
View File
@@ -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"
+80 -16
View File
@@ -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);
submitting.set(false); setApiError(
submitSuccess.set(true); "simkl",
watchlistNeedsRefresh.set(true); `Failed to sync Simkl: ${errorMsg}`,
setTimeout(() => submitSuccess.set(false), 2000); undefined,
true,
);
} }
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
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);
submitting.set(false); setApiError(
submitSuccess.set(true); "simkl",
watchlistNeedsRefresh.set(true); `Failed to delete Simkl entry: ${errorMsg}`,
setTimeout(() => submitSuccess.set(false), 2000); undefined,
true,
);
} }
submitting.set(false);
submitSuccess.set(true);
watchlistNeedsRefresh.set(true);
setTimeout(() => submitSuccess.set(false), 2000);
}; };
let max = 999; let max = 999;
+146 -30
View File
@@ -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
View File
@@ -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;
Executable
+17
View File
@@ -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
View File
@@ -12,6 +12,6 @@
}, },
"info": { "info": {
"productName": "AniTrack", "productName": "AniTrack",
"productVersion": "1.5.5" "productVersion": "1.6.7"
} }
} }