Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab5a080a9 | ||
|
|
60ff980809 | ||
|
|
e2c8ba10ce | ||
|
|
515678f035 | ||
|
|
e8a5768a2f | ||
|
|
2a6397b964 | ||
|
|
de808dbb67 | ||
|
|
f63563ce16 | ||
|
|
8d5d8ac2d7 | ||
|
|
59b81396f9 | ||
|
|
4087599629 | ||
|
|
577c7d0d5b | ||
|
|
5ce1094b90 | ||
|
|
0b9f19cb08 | ||
|
|
948c2d3960 | ||
|
|
0149b24a33 | ||
|
|
52fc656669 | ||
|
|
e9b04c0a84 | ||
|
|
bc1f4b1482 | ||
|
|
5036e34d3c | ||
|
|
8bae7f65f8 | ||
|
|
4d31edad4d | ||
|
|
ba2b49bedd | ||
|
|
4c34a57493 | ||
|
|
2881370acd | ||
|
|
e3eecc5b96 | ||
|
|
640e31f900 | ||
|
|
fc29c696ce | ||
|
|
1d4fa38b8e | ||
|
|
01299994b4 | ||
|
|
61813a4391 | ||
|
|
a8df8dcce7 | ||
|
|
2b4eb31495 | ||
|
|
2ab0843a0c | ||
|
|
1fa0e4afaa | ||
|
|
7d13b5d901 | ||
|
|
0642163844 | ||
|
|
850c1c6aee | ||
|
|
2bf8d3887e | ||
|
|
efe45f39e7 | ||
|
|
336a1f353f | ||
|
|
a19080ef39 | ||
|
|
2bddc6a224 | ||
|
|
3bf5d8290e | ||
|
|
a5da544dd7 | ||
|
|
270faeb507 | ||
|
|
abba1803fc | ||
|
|
ce35a20eba | ||
|
|
e5ce250392 | ||
|
|
0cca48adb1 | ||
|
|
d2f2f8a618 | ||
|
|
fcbcda7eac | ||
|
|
0a89ec3652 | ||
|
|
5a14863a8f | ||
|
|
a16c804692 | ||
|
|
d08756c36f | ||
|
|
b42456f255 | ||
|
|
49ac5bef03 | ||
|
|
b9f4c12839 | ||
|
|
54c109ae3b | ||
|
|
d3bc93f6c9 | ||
|
|
0384f570c5 | ||
|
|
6925cd4e08 | ||
|
|
23960e0613 | ||
|
|
669b41c62c | ||
|
|
c54a11e7bd | ||
|
|
b6bdee4df6 | ||
|
|
cbda217d18 | ||
|
|
ceb756a1a8 | ||
|
|
3621b66437 | ||
|
|
7f92b1714e | ||
|
|
cd142f7601 | ||
|
|
fe48e6a8c4 | ||
|
|
24d4d24edf | ||
|
|
b68abfc1c9 | ||
|
|
eadc96fb4f | ||
|
|
6a662cdf6e | ||
|
|
2a9777872f | ||
|
|
b03d4ab7e3 | ||
|
|
ff1b8fb742 | ||
|
|
cbd4c7cd01 | ||
|
|
335d757255 | ||
|
|
a3b4857175 | ||
|
|
6e4b9c041a | ||
|
|
ae54fd20dd | ||
|
|
2c7e2d0eff | ||
|
|
9a9f055c38 | ||
|
|
bc497521e7 | ||
|
|
48a6005725 | ||
|
|
93a94d0797 | ||
|
|
a2576b044c | ||
|
|
2ee2d85e9e | ||
|
|
1090f112f3 | ||
|
|
58c9f449e0 | ||
|
|
f016c90353 | ||
|
|
6bbe0f0f48 | ||
|
|
d841fee1e7 | ||
|
|
f29d8f378e | ||
|
|
35e93c0ca9 | ||
|
|
8c169d549a | ||
|
|
b2a8a504f3 | ||
|
|
c85a53a278 | ||
|
|
2cf3844e76 | ||
|
|
6ed5fe8b71 | ||
|
|
8a8baf7f8f | ||
|
|
ca8c8beaf3 | ||
|
|
3e7f7d1c95 | ||
|
|
b0ca864dfe | ||
|
|
5ed6dedeab | ||
|
|
3271af445a | ||
|
|
e7e9e5b826 | ||
|
|
5337758dee | ||
|
|
426793e56a | ||
|
|
a794b77654 | ||
|
|
c510c2a138 | ||
|
|
8cbf5cb20c | ||
|
|
54a8924384 | ||
|
|
d70153064f | ||
|
|
7960f8e26d | ||
|
|
2e5a4a4493 | ||
|
|
874b3952ee | ||
|
|
60eac10545 | ||
|
|
6d66d711ff | ||
|
|
cd62e6c658 | ||
|
|
063c5016f3 | ||
|
|
5c4caf68e6 | ||
|
|
c10e853564 | ||
|
|
cd043d093f | ||
|
|
6db01f7f9f | ||
|
|
8460d56d55 | ||
|
|
8fedbe4607 | ||
|
|
e069c47242 | ||
|
|
aba0f2d1d5 | ||
|
|
af6cb7f08a | ||
|
|
487e5ee5a8 | ||
|
|
631bd8b885 | ||
|
|
b35be6926a | ||
|
|
5a9f4391dc | ||
|
|
72004c98b4 | ||
|
|
3db25bc33a | ||
|
|
f3a1536953 | ||
|
|
dc01aa314c | ||
|
|
baed9a4a67 | ||
|
|
0e00120778 | ||
|
|
697692c277 | ||
|
|
3e82677c2c | ||
|
|
b81549e5a6 | ||
|
|
fd806df0a9 | ||
|
|
26f85dd412 | ||
|
|
4d9c54a116 | ||
|
|
068e568ec6 | ||
|
|
4e6f910e74 | ||
|
|
b39f19f03a | ||
|
|
1a083deb54 | ||
|
|
61f8f5dd84 | ||
|
|
4509e479bc | ||
|
|
ee8dd2e866 | ||
|
|
0c6a8a40c3 | ||
|
|
45845c2a69 | ||
|
|
3ec5eb1a03 | ||
|
|
a2aa90edec | ||
|
|
d08283bd2d | ||
|
|
73d349ee1a | ||
|
|
c9c6650829 | ||
|
|
896c6640e2 | ||
|
|
18c744c1cf | ||
|
|
0475d39c6c |
@@ -0,0 +1,316 @@
|
||||
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
|
||||
# build/config.yml (and the generated version.go), commits the bump,
|
||||
# creates an annotated tag carrying the git-cliff notes, and pushes
|
||||
# commit + tag. This workflow verifies build/config.yml 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 build/config.yml 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%%-*}"
|
||||
CFG_VER="$(python3 -c "import re; print(re.search(r'^ version: \"([^\"]*)\"', open('build/config.yml').read(), flags=re.M).group(1))")"
|
||||
if [ "${NORM}" = "${BASE}" ]; then
|
||||
if [ "${CFG_VER}" != "${NORM}" ]; then
|
||||
echo "::error::build/config.yml info.version (${CFG_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Pre-release (e.g. 1.6.7-rc1): build/config.yml must match the base version.
|
||||
if [ "${CFG_VER}" != "${BASE}" ]; then
|
||||
echo "::error::build/config.yml info.version (${CFG_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
||||
echo "Version guard passed: build/config.yml=${CFG_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
|
||||
# v3 desktop defaults to the GTK4/WebKitGTK 6.0 stack.
|
||||
sudo apt-get install -y \
|
||||
build-essential pkg-config \
|
||||
libgtk-4-dev libwebkitgtk-6.0-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/wails3
|
||||
key: wails3-v3.0.0-beta.22-${{ runner.os }}
|
||||
|
||||
- name: Install Wails CLI
|
||||
if: steps.wails-cli.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.22
|
||||
|
||||
- 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: Build updater asset and sign
|
||||
env:
|
||||
UPDATER_SIGNING_KEY: ${{ secrets.UPDATER_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${VERSION:?VERSION missing from version-guard step}"
|
||||
export PATH="${HOME}/go/bin:${PATH}"
|
||||
: "${UPDATER_SIGNING_KEY:?Missing secret UPDATER_SIGNING_KEY. Add the updater private key under Settings → Secrets → Actions.}"
|
||||
# Bare updater binary: the updater swaps os.Executable() in place,
|
||||
# so it takes the single binary, not the user tarball (whose
|
||||
# top-level directory would fail the single-entry rule).
|
||||
mkdir -p updater-dist
|
||||
cp build/bin/AniTrack updater-dist/AniTrack-linux-amd64
|
||||
printf '%s' "${UPDATER_SIGNING_KEY}" > updater-dist/updater.key
|
||||
chmod 600 updater-dist/updater.key
|
||||
wails3 updater sign -key updater-dist/updater.key updater-dist/AniTrack-linux-amd64 > updater-dist/sign.json
|
||||
# Split the sign output into the sidecar assets the Gitea provider
|
||||
# fetches: <name>.sha512 (sha512sum format) + <name>.sig (base64).
|
||||
python3 - <<'EOF'
|
||||
import base64, binascii, json
|
||||
entries = json.load(open('updater-dist/sign.json'))
|
||||
entry = next(e for e in entries if e['filename'].endswith('AniTrack-linux-amd64'))
|
||||
assert entry['digestAlgo'] == 'sha512', entry
|
||||
assert entry['signatureAlgo'] == 'ed25519ph', entry
|
||||
digest_hex = binascii.hexlify(base64.b64decode(entry['digest'])).decode()
|
||||
open('updater-dist/AniTrack-linux-amd64.sha512', 'w').write(f"{digest_hex} AniTrack-linux-amd64\n")
|
||||
open('updater-dist/AniTrack-linux-amd64.sig', 'w').write(entry['signature'].strip() + '\n')
|
||||
EOF
|
||||
shred -u updater-dist/updater.key
|
||||
ls -la updater-dist/
|
||||
|
||||
- name: Upload release assets
|
||||
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}"
|
||||
for ASSET in "${ARCHIVE}" updater-dist/AniTrack-linux-amd64 updater-dist/AniTrack-linux-amd64.sha512 updater-dist/AniTrack-linux-amd64.sig; do
|
||||
test -f "${ASSET}" || { echo "::error::${ASSET} 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.
|
||||
ANAME="$(basename "${ASSET}")"
|
||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ANAME}" '.[] | 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} (${ANAME})"
|
||||
fi
|
||||
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${ASSET}" "${API}/${RID}/assets?name=${ANAME}")"
|
||||
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 ${ANAME} to release id=${RID}"
|
||||
done
|
||||
+18
-2
@@ -23,10 +23,26 @@ go.work
|
||||
|
||||
# ---> Wails
|
||||
build/bin
|
||||
.task/
|
||||
node_modules
|
||||
frontend/dist
|
||||
package.json.md5
|
||||
package-lock.json
|
||||
frontend/package.json.md5
|
||||
# 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
|
||||
.env
|
||||
environment.go
|
||||
# Updater signing private key. The public half (updater.pub) is committed and
|
||||
# embedded; the private key lives in the password manager + CI secrets only.
|
||||
updater.key
|
||||
|
||||
# REST (http files)
|
||||
http-client.private.env.json
|
||||
|
||||
# Build artifacts
|
||||
*.tar
|
||||
*.tar.gz
|
||||
/AniTrack
|
||||
# Updater CI staging (bare binary + sidecars, runner-ephemeral)
|
||||
updater-dist/
|
||||
|
||||
+685
-57
@@ -3,29 +3,46 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
||||
reader, _ := json.Marshal(body)
|
||||
response, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(reader))
|
||||
reader, err := json.Marshal(body)
|
||||
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 {
|
||||
response.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
||||
request.Header.Add("Authorization", "Bearer "+aniListJwt.AccessToken)
|
||||
} else if login {
|
||||
return nil, "Please login to AniList to make this request"
|
||||
}
|
||||
response.Header.Add("Content-Type", "application/json")
|
||||
response.Header.Add("Accept", "application/json")
|
||||
request.Header.Add("Content-Type", "application/json")
|
||||
request.Header.Add("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, resErr := client.Do(response)
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
res, resErr := client.Do(request)
|
||||
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()
|
||||
@@ -38,6 +55,37 @@ func AniListQuery(body interface{}, login bool) (json.RawMessage, string) {
|
||||
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 {
|
||||
user := a.GetAniListLoggedInUser()
|
||||
|
||||
@@ -76,24 +124,71 @@ func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
extraLarge
|
||||
large
|
||||
medium
|
||||
color
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
bannerImage
|
||||
format
|
||||
season
|
||||
seasonYear
|
||||
status
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
source
|
||||
synonyms
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
trending
|
||||
favourites
|
||||
isFavourite
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
nextAiringEpisode {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
tags{
|
||||
airingSchedule {
|
||||
nodes {
|
||||
id
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
mediaId
|
||||
}
|
||||
}
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
description
|
||||
@@ -182,7 +277,7 @@ func (a *App) GetAniListItem(aniId int, login bool) AniListGetSingleAnime {
|
||||
return post
|
||||
}
|
||||
|
||||
func (a *App) AniListSearch(query string) any {
|
||||
func (a *App) AniListSearch(query string) (interface{}, error) {
|
||||
type Variables struct {
|
||||
Search string `json:"search"`
|
||||
ListType string `json:"listType"`
|
||||
@@ -205,23 +300,70 @@ func (a *App) AniListSearch(query string) any {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
@@ -232,6 +374,7 @@ func (a *App) AniListSearch(query string) any {
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -240,18 +383,26 @@ func (a *App) AniListSearch(query string) any {
|
||||
ListType: "ANIME",
|
||||
},
|
||||
}
|
||||
returnedBody, _ := AniListQuery(body, false)
|
||||
|
||||
returnedBody, status := AniListQuery(body, a.CheckIfAniListLoggedIn())
|
||||
if status != "200 OK" {
|
||||
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{}
|
||||
err := json.Unmarshal(returnedBody, &post)
|
||||
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 post
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) AniListCurrentUserWatchList {
|
||||
func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) (AniListCurrentUserWatchList, error) {
|
||||
user := a.GetAniListLoggedInUser()
|
||||
type Variables struct {
|
||||
Page int `json:"page"`
|
||||
@@ -266,14 +417,14 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
Variables Variables `json:"variables"`
|
||||
}{
|
||||
Query: `
|
||||
query(
|
||||
query (
|
||||
$page: Int
|
||||
$perPage: Int
|
||||
$userId: Int
|
||||
$listType: MediaType
|
||||
$status: MediaListStatus
|
||||
$sort:[MediaListSort]
|
||||
) {
|
||||
$sort: [MediaListSort]
|
||||
) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
total
|
||||
@@ -290,23 +441,70 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
@@ -317,6 +515,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
|
||||
status
|
||||
startedAt {
|
||||
year
|
||||
@@ -335,7 +534,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatar{
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
@@ -351,7 +550,7 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
Variables: Variables{
|
||||
Page: page,
|
||||
@@ -401,39 +600,43 @@ func (a *App) GetAniListUserWatchingList(page int, perPage int, sort string) Ani
|
||||
err := json.Unmarshal(returnedBody, &badPost)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return post, fmt.Errorf("API authentication error")
|
||||
}
|
||||
log.Fatal(badPost.Errors[0].Message)
|
||||
return post, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
|
||||
}
|
||||
if status != "200 OK" {
|
||||
return post, fmt.Errorf("API request failed with status: %s", status)
|
||||
}
|
||||
|
||||
return post
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSingleAnime {
|
||||
func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) (AniListGetSingleAnime, error) {
|
||||
body := struct {
|
||||
Query string `json:"query"`
|
||||
Variables AniListUpdateVariables `json:"variables"`
|
||||
}{
|
||||
Query: `
|
||||
mutation(
|
||||
$mediaId:Int,
|
||||
$progress:Int,
|
||||
$status:MediaListStatus,
|
||||
$score:Float,
|
||||
$repeat:Int,
|
||||
$notes:String,
|
||||
$startedAt:FuzzyDateInput,
|
||||
$completedAt:FuzzyDateInput,
|
||||
){
|
||||
mutation (
|
||||
$mediaId: Int
|
||||
$progress: Int
|
||||
$status: MediaListStatus
|
||||
$score: Float
|
||||
$repeat: Int
|
||||
$notes: String
|
||||
$startedAt: FuzzyDateInput
|
||||
$completedAt: FuzzyDateInput
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
mediaId:$mediaId,
|
||||
progress:$progress,
|
||||
status:$status,
|
||||
score:$score,
|
||||
repeat:$repeat,
|
||||
notes:$notes,
|
||||
startedAt:$startedAt
|
||||
completedAt:$completedAt
|
||||
){
|
||||
mediaId: $mediaId
|
||||
progress: $progress
|
||||
status: $status
|
||||
score: $score
|
||||
repeat: $repeat
|
||||
notes: $notes
|
||||
startedAt: $startedAt
|
||||
completedAt: $completedAt
|
||||
) {
|
||||
id
|
||||
mediaId
|
||||
userId
|
||||
@@ -441,32 +644,87 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
}
|
||||
status
|
||||
startedAt{
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt{
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
@@ -478,14 +736,14 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatar{
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
statistics{
|
||||
anime{
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
statuses{
|
||||
statuses {
|
||||
status
|
||||
count
|
||||
}
|
||||
@@ -493,27 +751,58 @@ func (a *App) AniListUpdateEntry(updateBody AniListUpdateVariables) AniListGetSi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
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
|
||||
err := json.Unmarshal(returnedBody, &returnedJson)
|
||||
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
|
||||
|
||||
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 {
|
||||
Id int `json:"id"`
|
||||
}
|
||||
@@ -538,13 +827,352 @@ 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
|
||||
err := json.Unmarshal(returnedBody, &post)
|
||||
if err != nil {
|
||||
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(
|
||||
page int,
|
||||
perPage int,
|
||||
id int,
|
||||
isAdult bool,
|
||||
search string,
|
||||
format []string,
|
||||
status string,
|
||||
countryOfOrigin string,
|
||||
source string,
|
||||
season string,
|
||||
seasonYear int,
|
||||
year string,
|
||||
onList bool,
|
||||
yearLesser int,
|
||||
yearGreater int,
|
||||
episodeLesser int,
|
||||
episodeGreater int,
|
||||
durationLesser int,
|
||||
durationGreater int,
|
||||
chapterLesser int,
|
||||
chapterGreater int,
|
||||
volumeLesser int,
|
||||
volumeGreater int,
|
||||
licensedBy []int,
|
||||
isLicensed bool,
|
||||
genres []string,
|
||||
excludedGenres []string,
|
||||
tags []string,
|
||||
excludedTags []string,
|
||||
minimumTagRank int,
|
||||
sort []string) (AniListCurrentUserWatchList, error) {
|
||||
// user := a.GetAniListLoggedInUser()
|
||||
type Variables struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
Id int `json:"id"`
|
||||
IsAdult bool `json:"isAdult"`
|
||||
Search string `json:"search"`
|
||||
Format []string `json:"format"`
|
||||
Status string `json:"status"`
|
||||
CountryOfOrigin string `json:"countryOfOrigin"`
|
||||
Source string `json:"source"`
|
||||
Season string `json:"season"`
|
||||
SeasonYear int `json:"seasonYear"`
|
||||
Year string `json:"year"`
|
||||
OnList bool `json:"onList"`
|
||||
YearLesser int `json:"yearLesser"`
|
||||
YearGreater int `json:"yearGreater"`
|
||||
EpisodeLesser int `json:"episodeLesser"`
|
||||
EpisodeGreater int `json:"episodeGreater"`
|
||||
DurationLesser int `json:"durationLesser"`
|
||||
DurationGreater int `json:"durationGreater"`
|
||||
ChapterLesser int `json:"chapterLesser"`
|
||||
ChapterGreater int `json:"chapterGreater"`
|
||||
VolumeLesser int `json:"volumeLesser"`
|
||||
VolumeGreater int `json:"volumeGreater"`
|
||||
LicensedBy []int `json:"licensedBy"`
|
||||
IsLicensed bool `json:"isLicensed"`
|
||||
Genres []string `json:"genres"`
|
||||
ExcludedGenres []string `json:"excludedGenres"`
|
||||
Tags []string `json:"tags"`
|
||||
ExcludedTags []string `json:"excludedTags"`
|
||||
MinimumTagRank int `json:"minimumTagRank"`
|
||||
Sort []string `json:"sort"`
|
||||
}
|
||||
body := struct {
|
||||
Query string `json:"query"`
|
||||
Variables Variables `json:"variables"`
|
||||
}{
|
||||
Query: `
|
||||
query (
|
||||
$page: Int = 1
|
||||
$perPage: Int = 20
|
||||
$id: Int
|
||||
$isAdult: Boolean = false
|
||||
$search: String
|
||||
$format: [MediaFormat]
|
||||
$status: MediaStatus
|
||||
$countryOfOrigin: CountryCode
|
||||
$source: MediaSource
|
||||
$season: MediaSeason
|
||||
$seasonYear: Int
|
||||
$year: String
|
||||
$onList: Boolean
|
||||
$yearLesser: FuzzyDateInt
|
||||
$yearGreater: FuzzyDateInt
|
||||
$episodeLesser: Int
|
||||
$episodeGreater: Int
|
||||
$durationLesser: Int
|
||||
$durationGreater: Int
|
||||
$chapterLesser: Int
|
||||
$chapterGreater: Int
|
||||
$volumeLesser: Int
|
||||
$volumeGreater: Int
|
||||
$licensedBy: [Int]
|
||||
$isLicensed: Boolean
|
||||
$genres: [String]
|
||||
$excludedGenres: [String]
|
||||
$tags: [String]
|
||||
$excludedTags: [String]
|
||||
$minimumTagRank: Int
|
||||
$sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]
|
||||
) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
lastPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
id: $id
|
||||
type: ANIME
|
||||
season: $season
|
||||
format_in: $format
|
||||
status: $status
|
||||
countryOfOrigin: $countryOfOrigin
|
||||
source: $source
|
||||
search: $search
|
||||
onList: $onList
|
||||
seasonYear: $seasonYear
|
||||
startDate_like: $year
|
||||
startDate_lesser: $yearLesser
|
||||
startDate_greater: $yearGreater
|
||||
episodes_lesser: $episodeLesser
|
||||
episodes_greater: $episodeGreater
|
||||
duration_lesser: $durationLesser
|
||||
duration_greater: $durationGreater
|
||||
chapters_lesser: $chapterLesser
|
||||
chapters_greater: $chapterGreater
|
||||
volumes_lesser: $volumeLesser
|
||||
volumes_greater: $volumeGreater
|
||||
licensedById_in: $licensedBy
|
||||
isLicensed: $isLicensed
|
||||
genre_in: $genres
|
||||
genre_not_in: $excludedGenres
|
||||
tag_in: $tags
|
||||
tag_not_in: $excludedTags
|
||||
minimumTagRank: $minimumTagRank
|
||||
sort: $sort
|
||||
isAdult: $isAdult
|
||||
) {
|
||||
id
|
||||
mediaId
|
||||
userId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
`,
|
||||
Variables: Variables{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Id: id,
|
||||
IsAdult: isAdult,
|
||||
Search: search,
|
||||
Format: format,
|
||||
Status: status,
|
||||
CountryOfOrigin: countryOfOrigin,
|
||||
Source: source,
|
||||
Season: season,
|
||||
SeasonYear: seasonYear,
|
||||
Year: year,
|
||||
OnList: onList,
|
||||
YearLesser: yearLesser,
|
||||
YearGreater: yearGreater,
|
||||
EpisodeLesser: episodeLesser,
|
||||
EpisodeGreater: episodeGreater,
|
||||
DurationLesser: durationLesser,
|
||||
DurationGreater: durationGreater,
|
||||
ChapterLesser: chapterLesser,
|
||||
ChapterGreater: chapterGreater,
|
||||
VolumeLesser: volumeLesser,
|
||||
VolumeGreater: volumeGreater,
|
||||
LicensedBy: licensedBy,
|
||||
IsLicensed: isLicensed,
|
||||
Genres: genres,
|
||||
ExcludedGenres: excludedGenres,
|
||||
Tags: tags,
|
||||
ExcludedTags: excludedTags,
|
||||
MinimumTagRank: minimumTagRank,
|
||||
Sort: sort,
|
||||
},
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
var post AniListCurrentUserWatchList
|
||||
if status == "200 OK" {
|
||||
err := json.Unmarshal(returnedBody, &post)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
}
|
||||
// Getting the real total, finding the real last page and storing that in the Page info
|
||||
statuses := post.Data.Page.MediaList[0].User.Statistics.Anime.Statuses
|
||||
var total int
|
||||
for _, status := range statuses {
|
||||
if status.Status == "CURRENT" {
|
||||
total = status.Count
|
||||
}
|
||||
}
|
||||
|
||||
lastPage := total / perPage
|
||||
|
||||
post.Data.Page.PageInfo.Total = total
|
||||
post.Data.Page.PageInfo.LastPage = lastPage
|
||||
}
|
||||
|
||||
if status == "403 Forbidden" {
|
||||
err := json.Unmarshal(returnedBody, &badPost)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return post, fmt.Errorf("API authentication error")
|
||||
}
|
||||
return post, fmt.Errorf("AniList API error: %s", badPost.Errors[0].Message)
|
||||
}
|
||||
if status != "200 OK" {
|
||||
return post, fmt.Errorf("API request failed with status: %s", status)
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
+77
-11
@@ -37,6 +37,20 @@ type AniListCurrentUserWatchList struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type AniListBrowseList struct {
|
||||
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"`
|
||||
Media []Media `json:"mediaList"`
|
||||
} `json:"Page"`
|
||||
} `json:"data"`
|
||||
}
|
||||
type AniListGetSingleAnime struct {
|
||||
Data struct {
|
||||
MediaList MediaList `json:"MediaList"`
|
||||
@@ -49,31 +63,43 @@ type AniListUpdateReturn struct {
|
||||
}
|
||||
}
|
||||
|
||||
type MediaList struct {
|
||||
ID int `json:"id"`
|
||||
MediaID int `json:"mediaId"`
|
||||
UserID int `json:"userId"`
|
||||
Media struct {
|
||||
type Media struct {
|
||||
ID int `json:"id"`
|
||||
IDMal int `json:"idMal"`
|
||||
Title struct {
|
||||
Romaji string `json:"romaji"`
|
||||
English string `json:"english"`
|
||||
Native string `json:"native"`
|
||||
} `json:"title"`
|
||||
Title MediaTitle `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverImage struct {
|
||||
ExtraLarge string
|
||||
Large string `json:"large"`
|
||||
Medium string
|
||||
Color string
|
||||
} `json:"coverImage"`
|
||||
BannerImage string
|
||||
Format string
|
||||
Season string `json:"season"`
|
||||
SeasonYear int `json:"seasonYear"`
|
||||
Status string `json:"status"`
|
||||
Episodes int `json:"episodes"`
|
||||
Duration int
|
||||
CountryOfOrigin string
|
||||
Source string
|
||||
Synonyms []string
|
||||
AverageScore int
|
||||
MeanScore int
|
||||
Popularity int
|
||||
Trending int
|
||||
Favourites int
|
||||
isFavourite bool
|
||||
Relations MediaRelations `json:"relations"`
|
||||
StartDate MediaFuzzyDate `json:"startDate"`
|
||||
EndDate MediaFuzzyDate `json:"endDate"`
|
||||
NextAiringEpisode struct {
|
||||
AiringAt int `json:"airingAt"`
|
||||
TimeUntilAiring int `json:"timeUntilAiring"`
|
||||
Episode int `json:"episode"`
|
||||
} `json:"nextAiringEpisode"`
|
||||
AiringSchedule MediaAiringSchedule `json:"airingSchedule"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -83,8 +109,48 @@ type MediaList struct {
|
||||
IsAdult bool `json:"isAdult"`
|
||||
} `json:"tags"`
|
||||
IsAdult bool `json:"isAdult"`
|
||||
} `json:"media"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID int `json:"id"`
|
||||
MediaID int `json:"mediaId"`
|
||||
UserID int `json:"userId"`
|
||||
Status string `json:"status"`
|
||||
Media Media `json:"media"`
|
||||
StartedAt struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
|
||||
+57
-58
@@ -13,35 +13,43 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
var aniListJwt AniListJWT
|
||||
|
||||
var aniRing, _ = keyring.Open(keyring.Config{
|
||||
ServiceName: "AniTrack",
|
||||
KeychainName: "AniTrack",
|
||||
KeychainSynchronizable: false,
|
||||
KeychainTrustApplication: true,
|
||||
KeychainAccessibleWhenUnlocked: true,
|
||||
})
|
||||
// aniKeyringService is the OS keyring service name all AniTrack secrets
|
||||
// live under (same wallet the 99designs/keyring build used).
|
||||
const aniKeyringService = "AniTrack"
|
||||
|
||||
func aniRingSet(key string, data []byte) error {
|
||||
if err := keyring.Set(aniKeyringService, key, string(data)); err != nil {
|
||||
log.Printf("anilist: save %s failed: %s", key, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var aniCtxShutdown, aniCancel = context.WithCancel(context.Background())
|
||||
|
||||
func (a *App) CheckIfAniListLoggedIn() bool {
|
||||
if (AniListJWT{} == aniListJwt) {
|
||||
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
||||
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
||||
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
||||
accessToken, accessTokenErr := aniRing.Get("anilistAccessToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenErr := keyring.Get(aniKeyringService, "anilistTokenType")
|
||||
expiresIn, expiresInErr := keyring.Get(aniKeyringService, "anilistTokenExpiresIn")
|
||||
refreshToken, refreshTokenErr := keyring.Get(aniKeyringService, "anilistRefreshToken")
|
||||
accessToken, accessTokenErr := keyring.Get(aniKeyringService, "anilistAccessToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 {
|
||||
return false
|
||||
} else {
|
||||
aniListJwt.TokenType = string(tokenType.Data)
|
||||
aniListJwt.AccessToken = string(accessToken.Data)
|
||||
aniListJwt.RefreshToken = string(refreshToken.Data)
|
||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
|
||||
var expiresInConvertErr error
|
||||
aniListJwt.TokenType = tokenType
|
||||
aniListJwt.AccessToken = accessToken
|
||||
aniListJwt.RefreshToken = refreshToken
|
||||
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
log.Printf("anilist: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -51,23 +59,30 @@ func (a *App) CheckIfAniListLoggedIn() bool {
|
||||
|
||||
func (a *App) AniListLogin() {
|
||||
if (AniListJWT{} == aniListJwt) {
|
||||
tokenType, tokenErr := aniRing.Get("anilistTokenType")
|
||||
expiresIn, expiresInErr := aniRing.Get("anilistTokenExpiresIn")
|
||||
refreshToken, refreshTokenErr := aniRing.Get("anilistRefreshToken")
|
||||
accessToken, accessTokenErr := aniRing.Get("anilistAccessToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenErr := keyring.Get(aniKeyringService, "anilistTokenType")
|
||||
expiresIn, expiresInErr := keyring.Get(aniKeyringService, "anilistTokenExpiresIn")
|
||||
refreshToken, refreshTokenErr := keyring.Get(aniKeyringService, "anilistRefreshToken")
|
||||
accessToken, accessTokenErr := keyring.Get(aniKeyringService, "anilistAccessToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 {
|
||||
getAniListCodeUrl := "https://anilist.co/api/v2/oauth/authorize?client_id=" + Environment.ANILIST_APP_ID + "&redirect_uri=" + Environment.ANILIST_CALLBACK_URI + "&response_type=code"
|
||||
runtime.BrowserOpenURL(*wailsContext, getAniListCodeUrl)
|
||||
if err := a.app.Browser.OpenURL(getAniListCodeUrl); err != nil {
|
||||
log.Printf("anilist: failed to open browser: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
serverDone := &sync.WaitGroup{}
|
||||
serverDone.Add(1)
|
||||
a.handleAniListCallback(serverDone)
|
||||
serverDone.Wait()
|
||||
} else {
|
||||
aniListJwt.TokenType = string(tokenType.Data)
|
||||
aniListJwt.AccessToken = string(accessToken.Data)
|
||||
aniListJwt.RefreshToken = string(refreshToken.Data)
|
||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data))
|
||||
var expiresInConvertErr error
|
||||
aniListJwt.TokenType = tokenType
|
||||
aniListJwt.AccessToken = accessToken
|
||||
aniListJwt.RefreshToken = refreshToken
|
||||
aniListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
log.Printf("anilist: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,33 +100,17 @@ func (a *App) handleAniListCallback(wg *sync.WaitGroup) {
|
||||
content := r.FormValue("code")
|
||||
if content != "" {
|
||||
aniListJwt = getAniListAuthorizationToken(content)
|
||||
_ = aniRing.Set(keyring.Item{
|
||||
Key: "anilistTokenType",
|
||||
Data: []byte(aniListJwt.TokenType),
|
||||
})
|
||||
_ = 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{
|
||||
Title: "AniList Authorization",
|
||||
Message: "It is now safe to close your browser tab",
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
_ = aniRingSet("anilistTokenType", []byte(aniListJwt.TokenType))
|
||||
_ = aniRingSet("anilistTokenExpiresIn", []byte(strconv.Itoa(aniListJwt.ExpiresIn)))
|
||||
_ = aniRingSet("anilistAccessToken", []byte(aniListJwt.AccessToken))
|
||||
_ = aniRingSet("anilistRefreshToken", []byte(aniListJwt.RefreshToken))
|
||||
a.app.Dialog.Info().
|
||||
SetTitle("AniList Authorization").
|
||||
SetMessage("It is now safe to close your browser tab").
|
||||
Show()
|
||||
fmt.Println("Shutting down...")
|
||||
aniCancel()
|
||||
err = srv.Shutdown(context.Background())
|
||||
if err != nil {
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
log.Println("server.Shutdown:", err)
|
||||
}
|
||||
} else {
|
||||
@@ -208,12 +207,12 @@ func (a *App) GetAniListLoggedInUser() AniListUser {
|
||||
|
||||
func (a *App) LogoutAniList() string {
|
||||
if (AniListJWT{} != aniListJwt) {
|
||||
typeErr := aniRing.Remove("anilistTokenType")
|
||||
expiresInErr := aniRing.Remove("anilistTokenExpiresIn")
|
||||
accessTokenErr := aniRing.Remove("anilistAccessToken")
|
||||
refreshTokenErr := aniRing.Remove("anilistRefreshToken")
|
||||
typeErr := keyring.Delete(aniKeyringService, "anilistTokenType")
|
||||
expiresInErr := keyring.Delete(aniKeyringService, "anilistTokenExpiresIn")
|
||||
accessTokenErr := keyring.Delete(aniKeyringService, "anilistAccessToken")
|
||||
refreshTokenErr := keyring.Delete(aniKeyringService, "anilistRefreshToken")
|
||||
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{}
|
||||
}
|
||||
|
||||
+72
-25
@@ -11,10 +11,34 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage, string) {
|
||||
// Unmarshalling accidental numbers received from MAL to strings
|
||||
func (f *FlexString) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
*f = FlexString(s)
|
||||
return nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
*f = FlexString(string(n))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("FlexString: invalid value")
|
||||
}
|
||||
|
||||
func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage, string, error) {
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest(method, malUrl, strings.NewReader(body.Encode()))
|
||||
req, err := http.NewRequest(method, malUrl, strings.NewReader(body.Encode()))
|
||||
|
||||
if err != nil {
|
||||
message, _ := json.Marshal(struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "Failed to create request: " + err.Error(),
|
||||
})
|
||||
return message, "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("Authorization", "Bearer "+myAnimeListJwt.AccessToken)
|
||||
@@ -22,47 +46,57 @@ func MALHelper(method string, malUrl string, body url.Values) (json.RawMessage,
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Errored when sending request to the server")
|
||||
message, _ := json.Marshal(struct {
|
||||
Message string `json:"message" ts_type:"message"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "Errored when sending request to the server" + err.Error(),
|
||||
Message: "Network error: " + err.Error(),
|
||||
})
|
||||
|
||||
return message, resp.Status
|
||||
return message, "", fmt.Errorf("network error: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return nil, resp.Status, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Status == "401 Unauthorized" {
|
||||
refreshMyAnimeListAuthorizationToken()
|
||||
MALHelper(method, malUrl, body)
|
||||
return MALHelper(method, malUrl, body)
|
||||
}
|
||||
|
||||
return respBody, resp.Status
|
||||
if resp.Status != "200 OK" && resp.Status != "201 Created" && resp.Status != "202 Accepted" {
|
||||
return respBody, resp.Status, fmt.Errorf("API returned status: %s", resp.Status)
|
||||
}
|
||||
return respBody, resp.Status, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMyAnimeList(count int) MALWatchlist {
|
||||
func (a *App) GetMyAnimeList(count int) (MALWatchlist, error) {
|
||||
limit := strconv.Itoa(count)
|
||||
user := a.GetMyAnimeListLoggedInUser()
|
||||
malUrl := "https://api.myanimelist.net/v2/users/" + user.Name + "/animelist?fields=list_status&status=watching&limit=" + limit
|
||||
|
||||
var malList MALWatchlist
|
||||
|
||||
respBody, resStatus := MALHelper("GET", malUrl, nil)
|
||||
respBody, resStatus, err := MALHelper("GET", malUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return malList, fmt.Errorf("failed to get MAL watchlist: %w", err)
|
||||
}
|
||||
|
||||
if resStatus == "200 OK" {
|
||||
err := json.Unmarshal(respBody, &malList)
|
||||
if err != nil {
|
||||
log.Printf("Failed to unmarshal json response, %s\n", err)
|
||||
return malList, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return malList
|
||||
return malList, nil
|
||||
}
|
||||
|
||||
func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) MalListStatus {
|
||||
func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) (MalListStatus, error) {
|
||||
if update.NumTimesRewatched >= 1 {
|
||||
update.IsRewatching = true
|
||||
} else {
|
||||
@@ -78,39 +112,52 @@ func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) MalListS
|
||||
|
||||
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(anime.Id) + "/my_list_status"
|
||||
var status MalListStatus
|
||||
respBody, respStatus := MALHelper("PATCH", malUrl, body)
|
||||
respBody, respStatus, err := MALHelper("PATCH", malUrl, body)
|
||||
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("failed to update MAL entry: %w", err)
|
||||
}
|
||||
|
||||
if respStatus == "200 OK" {
|
||||
err := json.Unmarshal(respBody, &status)
|
||||
if err != nil {
|
||||
log.Printf("Failed to unmarshal json response, %s\n", err)
|
||||
return status, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMyAnimeListAnime(id int) MALAnime {
|
||||
func (a *App) GetMyAnimeListAnime(id int) (MALAnime, error) {
|
||||
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,genres,created_at,updated_at,media_type,status,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,recommendations,studios,statistics"
|
||||
respBody, respStatus := MALHelper("GET", malUrl, nil)
|
||||
var malAnime MALAnime
|
||||
respBody, respStatus, err := MALHelper("GET", malUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return malAnime, fmt.Errorf("failed to get MAL anime: %w", err)
|
||||
}
|
||||
|
||||
if respStatus == "200 OK" {
|
||||
err := json.Unmarshal(respBody, &malAnime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to unmarshal json response, %s\n", err)
|
||||
return malAnime, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return malAnime
|
||||
return malAnime, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteMyAnimeListEntry(id int) bool {
|
||||
func (a *App) DeleteMyAnimeListEntry(id int) (bool, error) {
|
||||
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "/my_list_status"
|
||||
_, respStatus := MALHelper("DELETE", malUrl, nil)
|
||||
_, respStatus, err := MALHelper("DELETE", malUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to delete MAL entry: %w", err)
|
||||
}
|
||||
|
||||
if respStatus == "200 OK" {
|
||||
return true
|
||||
return true, nil
|
||||
} else {
|
||||
return false
|
||||
return false, fmt.Errorf("delete failed with status: %s", respStatus)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type FlexString string
|
||||
|
||||
type MyAnimeListJWT struct {
|
||||
TokenType string `json:"token_type"`
|
||||
@@ -129,11 +133,11 @@ type MALAnime struct {
|
||||
Statistics struct {
|
||||
NumListUsers int `json:"num_list_users" ts_type:"numListUsers"`
|
||||
Status struct {
|
||||
Watching string `json:"watching" ts_type:"watching"`
|
||||
Completed string `json:"completed" ts_type:"completed"`
|
||||
OnHold string `json:"on_hold" ts_type:"onHold"`
|
||||
Dropped string `json:"dropped" ts_type:"dropped"`
|
||||
PlanToWatch string `json:"plan_to_watch" ts_type:"planToWatch"`
|
||||
Watching FlexString `json:"watching" ts_type:"string"`
|
||||
Completed FlexString `json:"completed" ts_type:"string"`
|
||||
OnHold FlexString `json:"on_hold" ts_type:"string"`
|
||||
Dropped FlexString `json:"dropped" ts_type:"string"`
|
||||
PlanToWatch FlexString `json:"plan_to_watch" ts_type:"string"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-88
@@ -17,19 +17,22 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
var myAnimeListJwt MyAnimeListJWT
|
||||
|
||||
var myAnimeListRing, _ = keyring.Open(keyring.Config{
|
||||
ServiceName: "AniTrack",
|
||||
KeychainName: "AniTrack",
|
||||
KeychainSynchronizable: false,
|
||||
KeychainTrustApplication: true,
|
||||
KeychainAccessibleWhenUnlocked: true,
|
||||
})
|
||||
// malKeyringService is the OS keyring service name all AniTrack secrets
|
||||
// live under (same wallet the 99designs/keyring build used).
|
||||
const malKeyringService = "AniTrack"
|
||||
|
||||
func malRingSet(key string, data []byte) error {
|
||||
if err := keyring.Set(malKeyringService, key, string(data)); err != nil {
|
||||
log.Printf("mal: save %s failed: %s", key, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var myAnimeListCtxShutdown, myAnimeListCancel = context.WithCancel(context.Background())
|
||||
|
||||
@@ -71,23 +74,23 @@ func (v *CodeVerifier) CodeChallengeS256() string {
|
||||
}
|
||||
|
||||
func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
||||
fmt.Println("check function reached")
|
||||
if (MyAnimeListJWT{} == myAnimeListJwt) {
|
||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenErr := keyring.Get(malKeyringService, "MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := keyring.Get(malKeyringService, "MyAnimeListExpiresIn")
|
||||
accessToken, accessTokenErr := keyring.Get(malKeyringService, "MyAnimeListAccessToken")
|
||||
refreshToken, refreshTokenErr := keyring.Get(malKeyringService, "MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 {
|
||||
return false
|
||||
} else {
|
||||
var expiresInConvertErr error
|
||||
myAnimeListJwt.TokenType = string(tokenType.Data)
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||
myAnimeListJwt.TokenType = tokenType
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
fmt.Println("unable to convert string to int")
|
||||
log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
return false
|
||||
}
|
||||
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
||||
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
||||
myAnimeListJwt.AccessToken = accessToken
|
||||
myAnimeListJwt.RefreshToken = refreshToken
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -96,30 +99,32 @@ func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
||||
}
|
||||
|
||||
func (a *App) MyAnimeListLogin() {
|
||||
fmt.Println("login function reached")
|
||||
if !a.CheckIfMyAnimeListLoggedIn() {
|
||||
fmt.Println("check logged in function failed")
|
||||
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
||||
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
||||
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenErr := keyring.Get(malKeyringService, "MyAnimeListTokenType")
|
||||
expiresIn, expiresInErr := keyring.Get(malKeyringService, "MyAnimeListExpiresIn")
|
||||
accessToken, accessTokenErr := keyring.Get(malKeyringService, "MyAnimeListAccessToken")
|
||||
refreshToken, refreshTokenErr := keyring.Get(malKeyringService, "MyAnimeListRefreshToken")
|
||||
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 {
|
||||
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"
|
||||
runtime.BrowserOpenURL(*wailsContext, getMyAnimeListCodeUrl)
|
||||
if err := a.app.Browser.OpenURL(getMyAnimeListCodeUrl); err != nil {
|
||||
log.Printf("mal: failed to open browser: %s", err)
|
||||
return
|
||||
}
|
||||
serverDone := &sync.WaitGroup{}
|
||||
serverDone.Add(1)
|
||||
a.handleMyAnimeListCallback(serverDone, verifier)
|
||||
serverDone.Wait()
|
||||
} else {
|
||||
var expiresInConvertErr error
|
||||
myAnimeListJwt.TokenType = string(tokenType.Data)
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
||||
myAnimeListJwt.TokenType = tokenType
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
fmt.Println("unable to convert string to int in Login function")
|
||||
log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
}
|
||||
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
||||
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
||||
myAnimeListJwt.AccessToken = accessToken
|
||||
myAnimeListJwt.RefreshToken = refreshToken
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,33 +143,17 @@ func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifi
|
||||
|
||||
if content != "" {
|
||||
myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier)
|
||||
_ = myAnimeListRing.Set(keyring.Item{
|
||||
Key: "MyAnimeListTokenType",
|
||||
Data: []byte(myAnimeListJwt.TokenType),
|
||||
})
|
||||
_ = 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{
|
||||
Title: "MyAnimeList Authorization",
|
||||
Message: "It is now safe to close your browser tab",
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
|
||||
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
|
||||
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
|
||||
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
|
||||
a.app.Dialog.Info().
|
||||
SetTitle("MyAnimeList Authorization").
|
||||
SetMessage("It is now safe to close your browser tab").
|
||||
Show()
|
||||
fmt.Println("Shutting down...")
|
||||
myAnimeListCancel()
|
||||
err = srv.Shutdown(context.Background())
|
||||
if err != nil {
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
log.Println("server.Shutdown:", err)
|
||||
}
|
||||
} else {
|
||||
@@ -178,7 +167,7 @@ func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifi
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
log.Printf("Server error: %s\n", err)
|
||||
}
|
||||
fmt.Println("Shutting down...")
|
||||
}()
|
||||
@@ -237,7 +226,7 @@ func getMyAnimeListAuthorizationToken(content string, verifier *CodeVerifier) My
|
||||
return post
|
||||
}
|
||||
|
||||
func refreshMyAnimeListAuthorizationToken() {
|
||||
func refreshMyAnimeListAuthorizationToken() bool {
|
||||
dataForURLs := struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -262,13 +251,15 @@ func refreshMyAnimeListAuthorizationToken() {
|
||||
response, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
log.Printf("Failed at response, %s\n", err)
|
||||
return false
|
||||
}
|
||||
response.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{}
|
||||
res, resErr := client.Do(response)
|
||||
if resErr != nil {
|
||||
log.Printf("Failed at res, %s\n", err)
|
||||
log.Printf("Failed at res, %s\n", resErr)
|
||||
return false
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
@@ -276,41 +267,54 @@ func refreshMyAnimeListAuthorizationToken() {
|
||||
returnedBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Printf("Could not read returned body, %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal(returnedBody, &myAnimeListJwt)
|
||||
var refreshed MyAnimeListJWT
|
||||
err = json.Unmarshal(returnedBody, &refreshed)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
_ = myAnimeListRing.Set(keyring.Item{
|
||||
Key: "MyAnimeListTokenType",
|
||||
Data: []byte(myAnimeListJwt.TokenType),
|
||||
})
|
||||
_ = 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{
|
||||
Title: "MyAnimeList Authorization",
|
||||
Message: "It is now safe to close your browser tab",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
if refreshed.AccessToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
myAnimeListJwt = refreshed
|
||||
|
||||
_ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType))
|
||||
_ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)))
|
||||
_ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken))
|
||||
_ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken))
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) GetMyAnimeListLoggedInUser() MyAnimeListUser {
|
||||
a.MyAnimeListLogin()
|
||||
user := createUser()
|
||||
if user.Name == "" && !a.refreshMyAnimeListAndGetUser(&user) {
|
||||
a.LogoutMyAnimeList()
|
||||
a.MyAnimeListLogin()
|
||||
user = createUser()
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (a *App) refreshMyAnimeListAndGetUser(user *MyAnimeListUser) bool {
|
||||
if !refreshMyAnimeListAuthorizationToken() {
|
||||
return false
|
||||
}
|
||||
freshUser := createUser()
|
||||
if freshUser.Name == "" {
|
||||
return false
|
||||
}
|
||||
*user = freshUser
|
||||
return true
|
||||
}
|
||||
|
||||
func createUser() MyAnimeListUser {
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://api.myanimelist.net/v2/users/@me?fields=anime_statistics", nil)
|
||||
@@ -337,16 +341,17 @@ func (a *App) GetMyAnimeListLoggedInUser() MyAnimeListUser {
|
||||
}
|
||||
|
||||
return user
|
||||
|
||||
}
|
||||
|
||||
func (a *App) LogoutMyAnimeList() string {
|
||||
if (MyAnimeListJWT{} != myAnimeListJwt) {
|
||||
typeErr := myAnimeListRing.Remove("MyAnimeListTokenType")
|
||||
expiresInErr := myAnimeListRing.Remove("MyAnimeListExpiresIn")
|
||||
accessTokenErr := myAnimeListRing.Remove("MyAnimeListAccessToken")
|
||||
refreshTokenErr := myAnimeListRing.Remove("MyAnimeListRefreshToken")
|
||||
typeErr := keyring.Delete(malKeyringService, "MyAnimeListTokenType")
|
||||
expiresInErr := keyring.Delete(malKeyringService, "MyAnimeListExpiresIn")
|
||||
accessTokenErr := keyring.Delete(malKeyringService, "MyAnimeListAccessToken")
|
||||
refreshTokenErr := keyring.Delete(malKeyringService, "MyAnimeListRefreshToken")
|
||||
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{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.PHONY: dev build clean release
|
||||
|
||||
# v3 uses the GTK4/WebKitGTK 6.0 stack by default (same 2.52.x engine
|
||||
# generation as the v2 webkit2_41 build), so no build tags are needed.
|
||||
dev:
|
||||
wails3 dev -port 5173
|
||||
|
||||
build:
|
||||
wails3 build
|
||||
|
||||
clean:
|
||||
rm -rf build/bin/*
|
||||
|
||||
# Create a version commit (version 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 build/config.yml 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.
|
||||
#
|
||||
# The single version source is build/config.yml info.version. `make release`
|
||||
# bumps it and regenerates version.go (the compiled-in copy) from it, so the
|
||||
# two can never drift; both ride the same bump commit.
|
||||
#
|
||||
# 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 build/config.yml info.version 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 versions"; 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 build/config.yml to $(VERSION)..."
|
||||
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); m=re.search(r'^ version: \"([^\"]*)\"', s, flags=re.M); assert m, 'info.version not found'; print('build/config.yml', m.group(1), '-> $(VERSION)' if m.group(1) != '$(VERSION)' else '(already at version)'); open(p,'w').write(re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M))"
|
||||
@echo "Regenerating version.go from build/config.yml..."
|
||||
@python3 -c "import re; v=re.search(r'^ version: \"([^\"]*)\"', open('build/config.yml').read(), flags=re.M).group(1); open('version.go','w').write('package main\n\n// Code generated by \`make release\` from build/config.yml info.version.\n// DO NOT EDIT.\nconst appVersionString = \"%s\"\n' % v)"
|
||||
@git add build/config.yml version.go
|
||||
@git diff --cached --quiet && echo "versions already at $(VERSION), skipping bump commit" || 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."
|
||||
@@ -8,6 +8,14 @@ This has been built with the official Wails Svelte-TS template.
|
||||
|
||||
To run as is, please feel free to download a binary from the releases page.
|
||||
|
||||
> **To run:** install the WebKitGTK 6 runtime first — it is not preinstalled
|
||||
> on most distros, and the app will not start without it
|
||||
> (`error while loading shared libraries: libwebkitgtk-6.0.so.4`).
|
||||
> The release tarball's `install_linux.sh` installs it automatically
|
||||
> (Arch, Debian/Ubuntu, Fedora, Void, OpenMandriva). Manual fallback —
|
||||
> Arch: `sudo pacman -S webkitgtk-6.0` ·
|
||||
> Debian/Ubuntu: `sudo apt install libwebkitgtk-6.0-4`.
|
||||
|
||||
If you are getting too many errors due to api usage, please build from source.
|
||||
|
||||
## Build from Source
|
||||
@@ -29,15 +37,33 @@ Simkl: [Simkl Developer](https://simkl.com/settings/developer/)
|
||||
Once you have the IDs, Keys, and Secrets create an environment.go file based on the environment.go.example and fill in the fields.
|
||||
|
||||
### Install Wails and Dependencies
|
||||
Please follow the instructions [here](https://wails.io/docs/gettingstarted/installation) to get Wails up and running and follow the instructions below.
|
||||
Please follow the instructions [here](https://v3.wails.io/getting-started/your-first-app/) to get Wails v3 up and running and follow the instructions below.
|
||||
|
||||
System packages for building (the WebKitGTK 6 runtime above, plus
|
||||
compilers and headers) — Arch: `sudo pacman -S base-devel gtk4 webkitgtk-6.0 go nodejs npm` ·
|
||||
Debian/Ubuntu: `sudo apt install build-essential pkg-config libgtk-4-dev libwebkitgtk-6.0-dev golang nodejs npm`,
|
||||
then install the pinned CLI with `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20`.
|
||||
|
||||
> **Versioning note:** `1.99.x` is the v3-migration beta series — Wails v3
|
||||
> underneath, Svelte 4/Vite 4 toolchain refresh still pending. `2.0.0` is
|
||||
> reserved for the finished article. See `docs/V3_MIGRATION.md`.
|
||||
|
||||
## Live Development
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
To run in live development mode, run `make dev` in the project directory (it runs `wails3 dev`). This starts a Vite development
|
||||
server on http://localhost:5173 with very fast hot reload of your frontend changes, alongside the desktop app window.
|
||||
|
||||
## Migrating from v2
|
||||
|
||||
The `wailsv3` branch moves AniTrack from Wails `v2` → `v3` and from `99designs/keyring` → `zalando/go-keyring`. Both versions can coexist: on Linux, `v2` tokens live in an `AniTrack` Secret Service collection while `v3` uses the `login` collection, so one does not overwrite the other. After you log into AniList / MAL / Simkl once on `v3`, the `v2` entries become stale but harmless orphans — leave them if you still run both versions, or clear the old collection when you’ve fully moved over. See `docs/V3_MIGRATION.md` for the full migration summary and next steps (including planned Android support).
|
||||
|
||||
## Building
|
||||
|
||||
To build a redistributable, production mode package, use `wails build --clean`.
|
||||
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
||||
|
||||
## Updates
|
||||
|
||||
Desktop Linux releases self-update: the app checks for a newer release at
|
||||
startup and offers it in-app (signed, verified before install). Only the
|
||||
binary updates itself — icons and the `.desktop` file still come from the
|
||||
release tarball. Details in `docs/V3_MIGRATION.md: Self-updates`.
|
||||
|
||||
+92
-50
@@ -14,16 +14,24 @@ import (
|
||||
|
||||
var SimklWatchList SimklWatchListType
|
||||
|
||||
func SimklHelper(method string, url string, body interface{}) json.RawMessage {
|
||||
reader, _ := json.Marshal(body)
|
||||
func SimklHelper(method string, url string, body interface{}) (json.RawMessage, error) {
|
||||
reader, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal body: %w", err)
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
if body != nil {
|
||||
req, _ = http.NewRequest(method, url, bytes.NewBuffer(reader))
|
||||
req, err = http.NewRequest(method, url, bytes.NewBuffer(reader))
|
||||
} else {
|
||||
req, _ = http.NewRequest(method, url, nil)
|
||||
req, err = http.NewRequest(method, url, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
@@ -32,41 +40,45 @@ func SimklHelper(method string, url string, body interface{}) json.RawMessage {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("Errored when sending request to the server")
|
||||
message, _ := json.Marshal(struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "Errored when sending request to the server" + err.Error(),
|
||||
})
|
||||
|
||||
return message
|
||||
return nil, fmt.Errorf("network error: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
return respBody
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return respBody, fmt.Errorf("API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklGetUserWatchlist() SimklWatchListType {
|
||||
func (a *App) SimklGetUserWatchlist() (SimklWatchListType, error) {
|
||||
method := "GET"
|
||||
url := "https://api.simkl.com/sync/all-items/anime"
|
||||
|
||||
respBody := SimklHelper(method, url, nil)
|
||||
respBody, err := SimklHelper(method, url, nil)
|
||||
|
||||
if err != nil {
|
||||
return SimklWatchListType{}, fmt.Errorf("failed to get Simkl watchlist: %w", err)
|
||||
}
|
||||
|
||||
var errCheck struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(respBody, &errCheck)
|
||||
err = json.Unmarshal(respBody, &errCheck)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return SimklWatchListType{}, fmt.Errorf("failed to parse error response: %w", err)
|
||||
}
|
||||
|
||||
if errCheck.Error != "" {
|
||||
a.LogoutSimkl()
|
||||
return SimklWatchListType{}
|
||||
return SimklWatchListType{}, fmt.Errorf("Simkl API error: %s", errCheck.Message)
|
||||
}
|
||||
|
||||
var watchlist SimklWatchListType
|
||||
@@ -74,14 +86,15 @@ func (a *App) SimklGetUserWatchlist() SimklWatchListType {
|
||||
err = json.Unmarshal(respBody, &watchlist)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return SimklWatchListType{}, fmt.Errorf("failed to parse watchlist: %w", err)
|
||||
}
|
||||
|
||||
SimklWatchList = watchlist
|
||||
|
||||
return watchlist
|
||||
return watchlist, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime {
|
||||
func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) (SimklAnime, error) {
|
||||
var episodes []Episode
|
||||
var url string
|
||||
var shows []SimklPostShow
|
||||
@@ -112,13 +125,19 @@ func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime {
|
||||
|
||||
simklSync := SimklSyncHistoryType{shows}
|
||||
|
||||
respBody := SimklHelper("POST", url, simklSync)
|
||||
respBody, err := SimklHelper("POST", url, simklSync)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to sync episodes: %s\n", err)
|
||||
return anime, fmt.Errorf("failed to sync episodes: %w", err)
|
||||
}
|
||||
|
||||
var success interface{}
|
||||
|
||||
err := json.Unmarshal(respBody, &success)
|
||||
err = json.Unmarshal(respBody, &success)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return anime, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
for i, simklAnime := range SimklWatchList.Anime {
|
||||
@@ -131,10 +150,10 @@ func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) SimklAnime {
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime {
|
||||
func (a *App) SimklSyncRating(anime SimklAnime, rating int) (SimklAnime, error) {
|
||||
var url string
|
||||
showWithRating := ShowWithRating{
|
||||
Title: anime.Show.Title,
|
||||
@@ -169,13 +188,17 @@ func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime {
|
||||
Shows []interface{} `json:"shows" ts_type:"shows"`
|
||||
}{shows}
|
||||
|
||||
respBody := SimklHelper("POST", url, simklSync)
|
||||
|
||||
respBody, err := SimklHelper("POST", url, simklSync)
|
||||
if err != nil {
|
||||
log.Printf("Failed to sync rating: %s\n", err)
|
||||
return anime, fmt.Errorf("failed to sync rating: %w", err)
|
||||
}
|
||||
var success interface{}
|
||||
|
||||
err := json.Unmarshal(respBody, &success)
|
||||
err = json.Unmarshal(respBody, &success)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return anime, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
for i, simklAnime := range SimklWatchList.Anime {
|
||||
@@ -188,10 +211,10 @@ func (a *App) SimklSyncRating(anime SimklAnime, rating int) SimklAnime {
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime {
|
||||
func (a *App) SimklSyncStatus(anime SimklAnime, status string) (SimklAnime, error) {
|
||||
url := "https://api.simkl.com/sync/add-to-list"
|
||||
show := SimklShowStatus{
|
||||
Title: anime.Show.Title,
|
||||
@@ -211,13 +234,17 @@ func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime {
|
||||
Shows []SimklShowStatus `json:"shows" ts_type:"shows"`
|
||||
}{shows}
|
||||
|
||||
respBody := SimklHelper("POST", url, simklSync)
|
||||
|
||||
respBody, err := SimklHelper("POST", url, simklSync)
|
||||
if err != nil {
|
||||
log.Printf("Failed to sync status: %s\n", err)
|
||||
return anime, fmt.Errorf("failed to sync status: %w", err)
|
||||
}
|
||||
var success interface{}
|
||||
|
||||
err := json.Unmarshal(respBody, &success)
|
||||
err = json.Unmarshal(respBody, &success)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return anime, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
for i, simklAnime := range SimklWatchList.Anime {
|
||||
@@ -230,15 +257,20 @@ func (a *App) SimklSyncStatus(anime SimklAnime, status string) SimklAnime {
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime {
|
||||
func (a *App) SimklSearch(aniListAnime MediaList) (SimklAnime, error) {
|
||||
var result SimklAnime
|
||||
|
||||
if reflect.DeepEqual(SimklWatchList, SimklWatchListType{}) {
|
||||
fmt.Println("Watchlist empty. Calling...")
|
||||
SimklWatchList = a.SimklGetUserWatchlist()
|
||||
watchlist, err := a.SimklGetUserWatchlist()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get watchlist: %s\n", err)
|
||||
return result, fmt.Errorf("failed to load watchlist for search: %w", err)
|
||||
}
|
||||
SimklWatchList = watchlist
|
||||
}
|
||||
|
||||
for _, anime := range SimklWatchList.Anime {
|
||||
@@ -255,23 +287,30 @@ func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime {
|
||||
var anime SimklSearchType
|
||||
url := "https://api.simkl.com/search/id?anilist=" + strconv.Itoa(aniListAnime.Media.ID)
|
||||
|
||||
respBody := SimklHelper("GET", url, nil)
|
||||
|
||||
err := json.Unmarshal(respBody, &anime)
|
||||
respBody, err := SimklHelper("GET", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to search Simkl: %s\n", err)
|
||||
return result, fmt.Errorf("failed to search Simkl by AniList ID: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(respBody, &anime)
|
||||
|
||||
if len(anime) == 0 {
|
||||
url = "https://api.simkl.com/search/id?mal=" + strconv.Itoa(aniListAnime.Media.IDMal)
|
||||
respBody = SimklHelper("GET", url, nil)
|
||||
fmt.Println(string(respBody))
|
||||
respBody, err = SimklHelper("GET", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to search Simkl by MAL ID: %s\n", err)
|
||||
return result, fmt.Errorf("failed to search by MAL ID: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(respBody, &anime)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return result, fmt.Errorf("failed to parse search results: %w", err)
|
||||
}
|
||||
|
||||
if len(anime) == 0 {
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, watchListAnime := range SimklWatchList.Anime {
|
||||
@@ -289,10 +328,10 @@ func (a *App) SimklSearch(aniListAnime MediaList) SimklAnime {
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncRemove(anime SimklAnime) bool {
|
||||
func (a *App) SimklSyncRemove(anime SimklAnime) (bool, error) {
|
||||
url := "https://api.simkl.com/sync/history/remove"
|
||||
var showArray []SimklShowStatus
|
||||
|
||||
@@ -313,25 +352,28 @@ func (a *App) SimklSyncRemove(anime SimklAnime) bool {
|
||||
Shows: showArray,
|
||||
}
|
||||
|
||||
respBody := SimklHelper("POST", url, show)
|
||||
respBody, err := SimklHelper("POST", url, show)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to sync remove: %s\n", err)
|
||||
return false, fmt.Errorf("failed to sync remove: %w", err)
|
||||
}
|
||||
var success SimklDeleteType
|
||||
|
||||
err := json.Unmarshal(respBody, &success)
|
||||
err = json.Unmarshal(respBody, &success)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return false, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if success.Deleted.Shows >= 1 {
|
||||
for i, simklAnime := range SimklWatchList.Anime {
|
||||
if simklAnime.Show.Ids.Simkl == anime.Show.Ids.Simkl {
|
||||
SimklWatchList.Anime = slices.Delete(SimklWatchList.Anime, i, i+1)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("no shows were deleted")
|
||||
}
|
||||
|
||||
func WatchListUpdate(anime SimklAnime) {
|
||||
|
||||
+45
-51
@@ -11,33 +11,36 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
var simklJwt SimklJWT
|
||||
|
||||
var simklRing, _ = keyring.Open(keyring.Config{
|
||||
ServiceName: "AniTrack",
|
||||
KeychainName: "AniTrack",
|
||||
KeychainSynchronizable: false,
|
||||
KeychainTrustApplication: true,
|
||||
KeychainAccessibleWhenUnlocked: true,
|
||||
})
|
||||
// simklKeyringService is the OS keyring service name all AniTrack secrets
|
||||
// live under (same wallet the 99designs/keyring build used).
|
||||
const simklKeyringService = "AniTrack"
|
||||
|
||||
func simklRingSet(key string, data []byte) error {
|
||||
if err := keyring.Set(simklKeyringService, key, string(data)); err != nil {
|
||||
log.Printf("simkl: save %s failed: %s", key, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var simklCtxShutdown, simklCancel = context.WithCancel(context.Background())
|
||||
|
||||
func (a *App) CheckIfSimklLoggedIn() bool {
|
||||
if (SimklJWT{} == simklJwt) {
|
||||
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
||||
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
||||
scope, scopeErr := simklRing.Get("SimklScope")
|
||||
if (tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenTypeErr := keyring.Get(simklKeyringService, "SimklTokenType")
|
||||
accessToken, accessTokenErr := keyring.Get(simklKeyringService, "SimklAccessToken")
|
||||
scope, scopeErr := keyring.Get(simklKeyringService, "SimklScope")
|
||||
if (tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil) || len(accessToken) == 0 {
|
||||
return false
|
||||
} else {
|
||||
simklJwt.TokenType = string(tokenType.Data)
|
||||
simklJwt.AccessToken = string(accessToken.Data)
|
||||
simklJwt.Scope = string(scope.Data)
|
||||
simklJwt.TokenType = tokenType
|
||||
simklJwt.AccessToken = accessToken
|
||||
simklJwt.Scope = scope
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -47,21 +50,24 @@ func (a *App) CheckIfSimklLoggedIn() bool {
|
||||
|
||||
func (a *App) SimklLogin() {
|
||||
if !a.CheckIfSimklLoggedIn() {
|
||||
tokenType, tokenTypeErr := simklRing.Get("SimklTokenType")
|
||||
accessToken, accessTokenErr := simklRing.Get("SimklAccessToken")
|
||||
scope, scopeErr := simklRing.Get("SimklScope")
|
||||
if (tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil) || len(accessToken.Data) == 0 {
|
||||
tokenType, tokenTypeErr := keyring.Get(simklKeyringService, "SimklTokenType")
|
||||
accessToken, accessTokenErr := keyring.Get(simklKeyringService, "SimklAccessToken")
|
||||
scope, scopeErr := keyring.Get(simklKeyringService, "SimklScope")
|
||||
if (tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil) || len(accessToken) == 0 {
|
||||
getSimklCodeUrl := "https://simkl.com/oauth/authorize?response_type=code&client_id=" + Environment.SIMKL_CLIENT_ID + "&redirect_uri=" + Environment.SIMKL_CALLBACK_URI
|
||||
runtime.BrowserOpenURL(*wailsContext, getSimklCodeUrl)
|
||||
if err := a.app.Browser.OpenURL(getSimklCodeUrl); err != nil {
|
||||
log.Printf("simkl: failed to open browser: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
serverDone := &sync.WaitGroup{}
|
||||
serverDone.Add(1)
|
||||
a.handleSimklCallback(serverDone)
|
||||
serverDone.Wait()
|
||||
} else {
|
||||
simklJwt.TokenType = string(tokenType.Data)
|
||||
simklJwt.AccessToken = string(accessToken.Data)
|
||||
simklJwt.Scope = string(scope.Data)
|
||||
simklJwt.TokenType = tokenType
|
||||
simklJwt.AccessToken = accessToken
|
||||
simklJwt.Scope = scope
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,29 +86,16 @@ func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
|
||||
|
||||
if content != "" {
|
||||
simklJwt = getSimklAuthorizationToken(content)
|
||||
_ = simklRing.Set(keyring.Item{
|
||||
Key: "SimklTokenType",
|
||||
Data: []byte(simklJwt.TokenType),
|
||||
})
|
||||
_ = 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{
|
||||
Title: "Simkl Authorization",
|
||||
Message: "It is now safe to close your browser tab",
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
_ = simklRingSet("SimklTokenType", []byte(simklJwt.TokenType))
|
||||
_ = simklRingSet("SimklAccessToken", []byte(simklJwt.AccessToken))
|
||||
_ = simklRingSet("SimklScope", []byte(simklJwt.Scope))
|
||||
a.app.Dialog.Info().
|
||||
SetTitle("Simkl Authorization").
|
||||
SetMessage("It is now safe to close your browser tab").
|
||||
Show()
|
||||
fmt.Println("Shutting down...")
|
||||
simklCancel()
|
||||
err = srv.Shutdown(context.Background())
|
||||
if err != nil {
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
log.Println("server.Shutdown:", err)
|
||||
}
|
||||
} else {
|
||||
@@ -116,7 +109,7 @@ func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
log.Printf("Server error: %s\n", err)
|
||||
}
|
||||
fmt.Println("Shutting down...")
|
||||
}()
|
||||
@@ -138,7 +131,8 @@ func getSimklAuthorizationToken(content string) SimklJWT {
|
||||
}
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Printf("Failed to marshal data: %s\n", err)
|
||||
return SimklJWT{}
|
||||
}
|
||||
|
||||
response, err := http.NewRequest("POST", "https://api.simkl.com/oauth/token", bytes.NewBuffer(jsonData))
|
||||
@@ -217,12 +211,12 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
|
||||
|
||||
func (a *App) LogoutSimkl() string {
|
||||
if (SimklJWT{} != simklJwt) {
|
||||
tokenTypeErr := simklRing.Remove("SimklTokenType")
|
||||
accessTokenErr := simklRing.Remove("SimklAccessToken")
|
||||
scopeErr := simklRing.Remove("SimklScope")
|
||||
tokenTypeErr := keyring.Delete(simklKeyringService, "SimklTokenType")
|
||||
accessTokenErr := keyring.Delete(simklKeyringService, "SimklAccessToken")
|
||||
scopeErr := keyring.Delete(simklKeyringService, "SimklScope")
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
APP_NAME: "AniTrack"
|
||||
# build/bin keeps the v2 output path so the release packaging is unchanged.
|
||||
BIN_DIR: "build/bin"
|
||||
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 5173}}'
|
||||
# Target OS for build/package/run. Defaults to the host OS.
|
||||
GOOS: '{{.GOOS | default OS}}'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:build"
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
@@ -1,45 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed wails.json
|
||||
var wailsJSON string
|
||||
|
||||
var wailsContext *context.Context
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
app *application.App
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
func NewApp(app *application.App) *App {
|
||||
return &App{app: app}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
version := gjson.Get(wailsJSON, "info.productVersion")
|
||||
wailsContext = &ctx
|
||||
runtime.WindowSetTitle(ctx, "AniTrack "+version.String())
|
||||
//runtime.WindowMaximise(ctx)
|
||||
// appVersion is the release version, stamped into version.go by
|
||||
// `make release` from build/config.yml.
|
||||
func appVersion() string {
|
||||
return appVersionString
|
||||
}
|
||||
|
||||
func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) {
|
||||
var secondInstanceArgs = secondInstanceData.Args
|
||||
// appTitle is the window title: "AniTrack <version>".
|
||||
func appTitle() string {
|
||||
return "AniTrack " + appVersion()
|
||||
}
|
||||
|
||||
println("user opened second instance", strings.Join(secondInstanceData.Args, ","))
|
||||
println("user opened second from", secondInstanceData.WorkingDirectory)
|
||||
runtime.WindowUnminimise(*wailsContext)
|
||||
runtime.Show(*wailsContext)
|
||||
go runtime.EventsEmit(*wailsContext, "launchArgs", secondInstanceArgs)
|
||||
func (a *App) onSecondInstanceLaunch(data application.SecondInstanceData) {
|
||||
println("user opened second instance", strings.Join(data.Args, ","))
|
||||
println("user opened second from", data.WorkingDir)
|
||||
if w := a.app.Window.Current(); w != nil {
|
||||
w.Restore()
|
||||
w.Focus()
|
||||
}
|
||||
go a.app.Event.Emit("launchArgs", data.Args)
|
||||
}
|
||||
|
||||
func (a *App) ShowVersion() {
|
||||
a.app.Dialog.Info().
|
||||
SetTitle("Version").
|
||||
SetMessage("AniTrack Version: " + appVersion()).
|
||||
Show()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ body:graphql {
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
|
||||
@@ -33,6 +33,7 @@ body:graphql {
|
||||
english
|
||||
native
|
||||
}
|
||||
genre
|
||||
description
|
||||
coverImage {
|
||||
large
|
||||
|
||||
@@ -16,7 +16,6 @@ headers {
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
# Write your query or mutation here
|
||||
query (
|
||||
$page: Int
|
||||
$perPage: Int
|
||||
@@ -41,23 +40,79 @@ body:graphql {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
}
|
||||
status
|
||||
startedAt {
|
||||
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
headers {
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
@@ -20,7 +20,7 @@ body:form-urlencoded {
|
||||
client_id: {{ANILIST_APP_ID}}
|
||||
client_secret: {{ANILIST_SECRET_TOKEN}}
|
||||
redirect_uri: http://localhost:6734/callback
|
||||
code: {{code}}
|
||||
code: {{ANILIST_CODE}}
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
|
||||
@@ -17,63 +17,161 @@ headers {
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
mutation(
|
||||
$mediaId:Int,
|
||||
$progress:Int,
|
||||
$status:MediaListStatus,
|
||||
$score:Float,
|
||||
$repeat:Int,
|
||||
$notes:String,
|
||||
$startedAt:FuzzyDateInput,
|
||||
$completedAt:FuzzyDateInput,
|
||||
){
|
||||
mutation (
|
||||
$mediaId: Int
|
||||
$progress: Int
|
||||
$status: MediaListStatus
|
||||
$score: Float
|
||||
$repeat: Int
|
||||
$notes: String
|
||||
$startedAt: FuzzyDateInput
|
||||
$completedAt: FuzzyDateInput
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
mediaId:$mediaId,
|
||||
progress:$progress,
|
||||
status:$status,
|
||||
score:$score,
|
||||
repeat:$repeat,
|
||||
notes:$notes,
|
||||
startedAt:$startedAt
|
||||
completedAt:$completedAt
|
||||
){
|
||||
mediaId: $mediaId
|
||||
progress: $progress
|
||||
status: $status
|
||||
score: $score
|
||||
repeat: $repeat
|
||||
notes: $notes
|
||||
startedAt: $startedAt
|
||||
completedAt: $completedAt
|
||||
) {
|
||||
id
|
||||
mediaId
|
||||
progress
|
||||
userId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
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
|
||||
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
|
||||
}
|
||||
status
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
notes
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
notes
|
||||
startedAt{
|
||||
year
|
||||
month
|
||||
day
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
completedAt{
|
||||
year
|
||||
month
|
||||
day
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
statuses {
|
||||
status
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"mediaId":170998,
|
||||
"progress":5,
|
||||
"status":"CURRENT",
|
||||
"score":9.0,
|
||||
"repeat":0,
|
||||
"notes":",malSync::eyJ1IjoiaHR0cHM6Ly93d3cuY3J1bmNoeXJvbGwuY29tL3Nlcmllcy9HVkRIWDg1Wk4vI3NlYXNvbj1HNjNWQzJHUUsiLCJwIjoiIn0=::",
|
||||
"startedAt":{
|
||||
"year":2024,
|
||||
"month":7,
|
||||
"day":10
|
||||
"mediaId": 170998,
|
||||
"progress": 5,
|
||||
"status": "CURRENT",
|
||||
"score": 9,
|
||||
"repeat": 0,
|
||||
"notes": ",malSync::eyJ1IjoiaHR0cHM6Ly93d3cuY3J1bmNoeXJvbGwuY29tL3Nlcmllcy9HVkRIWDg1Wk4vI3NlYXNvbj1HNjNWQzJHUUsiLCJwIjoiIn0=::",
|
||||
"startedAt": {
|
||||
"year": 2024,
|
||||
"month": 7,
|
||||
"day": 10
|
||||
},
|
||||
"completedAt":{
|
||||
"completedAt": {
|
||||
"year": 0,
|
||||
"month":0,
|
||||
"day":0
|
||||
"month": 0,
|
||||
"day": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
meta {
|
||||
name: SeasonBasedBrowse
|
||||
type: graphql
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://graphql.anilist.co
|
||||
body: graphql
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
query (
|
||||
$page: Int = 1
|
||||
$perPage: Int = 20
|
||||
$id: Int
|
||||
$type: MediaType
|
||||
$isAdult: Boolean = false
|
||||
$search: String
|
||||
$format: [MediaFormat]
|
||||
$status: MediaStatus
|
||||
$countryOfOrigin: CountryCode
|
||||
$source: MediaSource
|
||||
$season: MediaSeason
|
||||
$seasonYear: Int
|
||||
$year: String
|
||||
$onList: Boolean
|
||||
$yearLesser: FuzzyDateInt
|
||||
$yearGreater: FuzzyDateInt
|
||||
$episodeLesser: Int
|
||||
$episodeGreater: Int
|
||||
$durationLesser: Int
|
||||
$durationGreater: Int
|
||||
$chapterLesser: Int
|
||||
$chapterGreater: Int
|
||||
$volumeLesser: Int
|
||||
$volumeGreater: Int
|
||||
$licensedBy: [Int]
|
||||
$isLicensed: Boolean
|
||||
$genres: [String]
|
||||
$excludedGenres: [String]
|
||||
$tags: [String]
|
||||
$excludedTags: [String]
|
||||
$minimumTagRank: Int
|
||||
$sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]
|
||||
) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
lastPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
id: $id
|
||||
type: $type
|
||||
season: $season
|
||||
format_in: $format
|
||||
status: $status
|
||||
countryOfOrigin: $countryOfOrigin
|
||||
source: $source
|
||||
search: $search
|
||||
onList: $onList
|
||||
seasonYear: $seasonYear
|
||||
startDate_like: $year
|
||||
startDate_lesser: $yearLesser
|
||||
startDate_greater: $yearGreater
|
||||
episodes_lesser: $episodeLesser
|
||||
episodes_greater: $episodeGreater
|
||||
duration_lesser: $durationLesser
|
||||
duration_greater: $durationGreater
|
||||
chapters_lesser: $chapterLesser
|
||||
chapters_greater: $chapterGreater
|
||||
volumes_lesser: $volumeLesser
|
||||
volumes_greater: $volumeGreater
|
||||
licensedById_in: $licensedBy
|
||||
isLicensed: $isLicensed
|
||||
genre_in: $genres
|
||||
genre_not_in: $excludedGenres
|
||||
tag_in: $tags
|
||||
tag_not_in: $excludedTags
|
||||
minimumTagRank: $minimumTagRank
|
||||
sort: $sort
|
||||
isAdult: $isAdult
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
extraLarge
|
||||
large
|
||||
medium
|
||||
color
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
bannerImage
|
||||
format
|
||||
season
|
||||
seasonYear
|
||||
status
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
source
|
||||
synonyms
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
trending
|
||||
favourites
|
||||
isFavourite
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
nextAiringEpisode {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
airingSchedule {
|
||||
nodes {
|
||||
id
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
mediaId
|
||||
}
|
||||
}
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
description
|
||||
rank
|
||||
isMediaSpoiler
|
||||
isAdult
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"page": 1,
|
||||
"perPage": 20,
|
||||
"season": "SUMMER",
|
||||
"seasonYear": 2026,
|
||||
"type": "ANIME",
|
||||
"excludedTags": [
|
||||
"Ecchi",
|
||||
"LGBTQ+ Themes",
|
||||
"Yuri"
|
||||
],
|
||||
"excludedGenres": [
|
||||
"Boy's Love"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: AniListCalendar
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -9,7 +9,7 @@ vars {
|
||||
}
|
||||
vars:secret [
|
||||
ANILIST_ACCESS_TOKEN,
|
||||
code,
|
||||
ANILIST_CODE,
|
||||
SIMKL_AUTH_TOKEN,
|
||||
MAL_CODE,
|
||||
MAL_VERIFIER,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,398 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
go:mod:tidy:
|
||||
summary: Runs `go mod tidy`
|
||||
internal: true
|
||||
# Universal/multi-arch builds invoke this concurrently from parallel deps;
|
||||
# two `go mod tidy` processes racing on go.mod can corrupt it (#4637).
|
||||
run: once
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
install:frontend:deps:
|
||||
summary: Install frontend dependencies
|
||||
run: once
|
||||
cmds:
|
||||
- task: install:frontend:deps:{{.PACKAGE_MANAGER}}
|
||||
|
||||
install:frontend:deps:npm:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: npm version
|
||||
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
|
||||
cmds:
|
||||
- npm install
|
||||
|
||||
install:frontend:deps:bun:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- bun.lock
|
||||
- bun.lockb
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: bun --version
|
||||
msg: "bun not found"
|
||||
cmds:
|
||||
- bun install
|
||||
|
||||
install:frontend:deps:pnpm:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: pnpm --version
|
||||
msg: "pnpm not found"
|
||||
cmds:
|
||||
- pnpm install
|
||||
|
||||
install:frontend:deps:yarn:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
status:
|
||||
- test -d node_modules || test -f .pnp.cjs
|
||||
preconditions:
|
||||
- sh: yarn --version
|
||||
msg: "yarn not found"
|
||||
cmds:
|
||||
- yarn install
|
||||
|
||||
build:frontend:
|
||||
label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
|
||||
summary: Build the frontend project
|
||||
# darwin:build:universal runs its per-arch builds as parallel deps, each of
|
||||
# which depends on this task. Without run:once the two executions race:
|
||||
# one regenerates frontend/bindings (-clean deletes it first) while the
|
||||
# other's bundler is reading it, failing intermittently with
|
||||
# 'Could not resolve "./bindings/<pkg>"' (#4637).
|
||||
run: once
|
||||
dir: frontend
|
||||
sources:
|
||||
- "**/*"
|
||||
- exclude: node_modules/**/*
|
||||
generates:
|
||||
- dist/**/*
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
- task: generate:bindings
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
cmds:
|
||||
- task: frontend:run
|
||||
vars:
|
||||
SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||
env:
|
||||
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||
|
||||
frontend:run:
|
||||
summary: Run a frontend script with selected runner
|
||||
cmds:
|
||||
- task: frontend:run:{{.PACKAGE_MANAGER}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:npm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- npm run {{.SCRIPT}} -q
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:yarn:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:pnpm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm run {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:bun:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- bun run {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:vendor:puppertino:
|
||||
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
|
||||
sources:
|
||||
- frontend/public/puppertino/puppertino.css
|
||||
generates:
|
||||
- frontend/public/puppertino/puppertino.css
|
||||
cmds:
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/public/puppertino
|
||||
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
|
||||
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
|
||||
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
|
||||
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
|
||||
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
|
||||
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
|
||||
else
|
||||
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
|
||||
fi
|
||||
else
|
||||
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
|
||||
fi
|
||||
# Ensure index.html includes Puppertino CSS and button classes
|
||||
INDEX_HTML=frontend/index.html
|
||||
if [ -f "$INDEX_HTML" ]; then
|
||||
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
|
||||
# Insert Puppertino link tag after style.css link
|
||||
awk '
|
||||
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
|
||||
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
|
||||
fi
|
||||
# Replace default .btn with Puppertino primary button classes if present
|
||||
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
generate:bindings:
|
||||
summary: Generates bindings for the frontend
|
||||
run: once
|
||||
deps:
|
||||
- task: go:mod:tidy
|
||||
sources:
|
||||
- "**/*.[jt]s"
|
||||
- exclude: frontend/**/*
|
||||
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
generates:
|
||||
- frontend/bindings/**/*
|
||||
cmds:
|
||||
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true{{if eq .OBFUSCATED "true"}} -obfuscated{{end}} -ts -i
|
||||
|
||||
generate:icons:
|
||||
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
|
||||
run: once
|
||||
dir: build
|
||||
sources:
|
||||
- "appicon.png"
|
||||
- "appicon.icon"
|
||||
generates:
|
||||
- "darwin/icons.icns"
|
||||
- "windows/icon.ico"
|
||||
cmds:
|
||||
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
|
||||
|
||||
dev:frontend:
|
||||
summary: Runs the frontend in development mode
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- task: frontend:dev:{{.PACKAGE_MANAGER}}
|
||||
|
||||
frontend:dev:npm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- npm run dev -- --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:yarn:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:pnpm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:bun:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- bun run dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
update:build-assets:
|
||||
summary: Updates the build assets
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
desc: |
|
||||
Builds a production server binary by default: -tags server,production,
|
||||
-trimpath and a stripped binary, mirroring the desktop `build` task.
|
||||
Server mode runs as a pure HTTP server without native GUI dependencies.
|
||||
|
||||
Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2]
|
||||
DEV=true development server (-tags server, no strip, inlining kept)
|
||||
OBFUSCATED=true obfuscated build via garble (requires garble installed)
|
||||
EXTRA_TAGS additional comma-separated build tags
|
||||
deps:
|
||||
- task: build:frontend
|
||||
vars:
|
||||
DEV:
|
||||
ref: .DEV
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
preconditions:
|
||||
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
|
||||
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
|
||||
cmds:
|
||||
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
|
||||
vars:
|
||||
BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||
|
||||
run:server:
|
||||
summary: Builds and runs a development server (DEV=true)
|
||||
deps:
|
||||
- task: build:server
|
||||
vars:
|
||||
DEV: "true"
|
||||
cmds:
|
||||
- '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
desc: |
|
||||
Creates a minimal Docker image containing the production server binary.
|
||||
Defaults to a pure-Go static binary on a distroless/static base. The
|
||||
production frontend is built first so the embedded assets are current.
|
||||
|
||||
Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...]
|
||||
For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.:
|
||||
task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12
|
||||
deps:
|
||||
# Build the production frontend so frontend/dist (embedded by the Go build
|
||||
# inside the image) is present and current in the Docker build context.
|
||||
# Pass the server,production tags so binding generation analyses the same
|
||||
# build the Docker image compiles, not the default-tag build.
|
||||
- task: build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS: "-tags server,production"
|
||||
cmds:
|
||||
- >-
|
||||
docker build
|
||||
--build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}}
|
||||
--build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}}
|
||||
--build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}}
|
||||
-t {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||
-f build/docker/Dockerfile.server .
|
||||
vars:
|
||||
TAG: "{{.TAG}}"
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required. Please install Docker first."
|
||||
- sh: test -f build/docker/Dockerfile.server
|
||||
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
desc: |
|
||||
Builds the Docker image and runs it, exposing port 8080.
|
||||
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
|
||||
Note: The internal container port is always 8080. The PORT variable
|
||||
only changes the host port mapping. Ensure your app uses port 8080
|
||||
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||
deps:
|
||||
- task: build:docker
|
||||
vars:
|
||||
TAG:
|
||||
ref: .TAG
|
||||
cmds:
|
||||
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||
vars:
|
||||
TAG: "{{.TAG}}"
|
||||
PORT: "{{.PORT}}"
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
desc: |
|
||||
Builds the Docker image needed for cross-compiling to any platform.
|
||||
Run this once to enable cross-platform builds from any OS.
|
||||
cmds:
|
||||
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required. Please install Docker first."
|
||||
|
||||
ios:device:list:
|
||||
summary: Lists connected iOS devices (UDIDs)
|
||||
cmds:
|
||||
- xcrun xcdevice list
|
||||
|
||||
ios:run:device:
|
||||
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
|
||||
vars:
|
||||
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
|
||||
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
|
||||
CONFIG: '{{.CONFIG | default "Debug"}}'
|
||||
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
|
||||
UDID: '{{.UDID}}' # from `task ios:device:list`
|
||||
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
|
||||
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
|
||||
preconditions:
|
||||
- sh: xcrun -f xcodebuild
|
||||
msg: "xcodebuild not found. Please install Xcode."
|
||||
- sh: xcrun -f devicectl
|
||||
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
|
||||
- sh: test -n '{{.PROJECT}}'
|
||||
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
|
||||
- sh: test -n '{{.SCHEME}}'
|
||||
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
|
||||
- sh: test -n '{{.UDID}}'
|
||||
msg: "Set UDID to your device UDID (see: task ios:device:list)."
|
||||
- sh: test -n '{{.BUNDLE_ID}}'
|
||||
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
|
||||
cmds:
|
||||
- |
|
||||
set -euo pipefail
|
||||
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
|
||||
XCB_ARGS=(
|
||||
-project "{{.PROJECT}}"
|
||||
-scheme "{{.SCHEME}}"
|
||||
-configuration "{{.CONFIG}}"
|
||||
-destination "id={{.UDID}}"
|
||||
-derivedDataPath "{{.DERIVED}}"
|
||||
-allowProvisioningUpdates
|
||||
-allowProvisioningDeviceRegistration
|
||||
)
|
||||
# Optionally inject signing identifiers if provided
|
||||
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
|
||||
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
|
||||
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
|
||||
# If xcpretty isn't installed, run without it
|
||||
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||
xcodebuild "${XCB_ARGS[@]}" build
|
||||
fi
|
||||
# Find built .app
|
||||
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing: $APP_PATH"
|
||||
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
|
||||
echo "Launching: {{.BUNDLE_ID}}"
|
||||
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Wails v3 project configuration.
|
||||
# NOTE: `info.version` below is the single version source: `make release`
|
||||
# bumps it and regenerates version.go (the compiled-in copy) from it.
|
||||
# Never touch the top-level `version: '3'` — that is the config file schema,
|
||||
# not the app version.
|
||||
version: '3'
|
||||
|
||||
info:
|
||||
companyName: "John O'Keefe"
|
||||
productName: "AniTrack"
|
||||
productIdentifier: "com.linuxhg.anitrack"
|
||||
description: "Track anime watchlists across AniList, MyAnimeList, and Simkl"
|
||||
copyright: "John O'Keefe"
|
||||
version: "2.0.0"
|
||||
|
||||
# Dev mode configuration
|
||||
dev_mode:
|
||||
root_path: .
|
||||
log_level: warn
|
||||
debounce: 1000
|
||||
ignore:
|
||||
dir:
|
||||
- .git
|
||||
- node_modules
|
||||
- frontend
|
||||
- bin
|
||||
file:
|
||||
- .DS_Store
|
||||
- .gitignore
|
||||
- .gitkeep
|
||||
- "*_test.go"
|
||||
watched_extension:
|
||||
- "*.go"
|
||||
- "*.js"
|
||||
- "*.ts"
|
||||
git_ignore: true
|
||||
executes:
|
||||
- cmd: wails3 build DEV=true
|
||||
type: blocking
|
||||
- cmd: wails3 task common:dev:frontend
|
||||
type: background
|
||||
- cmd: wails3 task run
|
||||
type: primary
|
||||
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true />
|
||||
</dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
|
||||
# Step 0: WebKitGTK 6 runtime ...................................................
|
||||
# The release binary dynamically links libwebkitgtk-6.0 and libgtk-4, which are
|
||||
# not preinstalled on most distros and are not shipped in the tarball. Install
|
||||
# them first so the app can start; abort before copying anything otherwise.
|
||||
#
|
||||
# OS_RELEASE_FILE override exists for testing the distro mapping only.
|
||||
|
||||
print_manual_instructions() {
|
||||
cat >&2 <<'EOF'
|
||||
Install the WebKitGTK 6 runtime for your distribution, then re-run this script:
|
||||
Arch / Manjaro / EndeavourOS: sudo pacman -S webkitgtk-6.0
|
||||
Debian / Ubuntu / Mint / Pop: sudo apt install libwebkitgtk-6.0-4
|
||||
Fedora / Nobara: sudo dnf install webkitgtk6.0
|
||||
OpenMandriva: sudo dnf install lib64webkit2gtk6.0
|
||||
Void (glibc): sudo xbps-install -S libwebkitgtk60
|
||||
Without it the app fails at launch: error while loading shared libraries: libwebkitgtk-6.0.so.4
|
||||
EOF
|
||||
}
|
||||
|
||||
install_webkit_runtime() {
|
||||
# 0a. musl guard: the prebuilt binary is glibc-linked and cannot run on
|
||||
# musl systems (Alpine, Void-musl). No package fixes that — refuse loudly.
|
||||
if ldd --version 2>&1 | grep -qi musl; then
|
||||
echo "ERROR: AniTrack ships a glibc-linked binary, but this system uses musl libc." >&2
|
||||
echo "The prebuilt release cannot run here. Build from source instead (see README)." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 0b. Every package manager below needs privilege.
|
||||
local sudo_cmd=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "ERROR: root privileges are required to install system packages," >&2
|
||||
echo "but sudo is not available. Install the WebKitGTK 6 runtime manually:" >&2
|
||||
print_manual_instructions
|
||||
return 1
|
||||
fi
|
||||
sudo_cmd="sudo"
|
||||
fi
|
||||
|
||||
# 0c. Distro mapping. ID_LIKE catches derivatives (Mint/Pop -> ubuntu,
|
||||
# Manjaro/EndeavourOS -> arch, Nobara -> fedora) without enumerating them.
|
||||
local os_release="${OS_RELEASE_FILE:-/etc/os-release}"
|
||||
local dist_id="" dist_like=""
|
||||
if [ -r "$os_release" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$os_release"
|
||||
dist_id="${ID:-}"
|
||||
dist_like="${ID_LIKE:-}"
|
||||
fi
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
local ids=" ${dist_id} ${dist_like} "
|
||||
case "$ids" in
|
||||
*" arch "*|*" manjaro "*|*" endeavouros "*|*" garuda "*|*" cachyos "*)
|
||||
need_cmd pacman || { echo "ERROR: pacman not found on Arch-family system." >&2; return 1; }
|
||||
$sudo_cmd pacman -S --needed --noconfirm webkitgtk-6.0 || return 1
|
||||
;;
|
||||
*" debian "*|*" ubuntu "*|*" pop "*|*" linuxmint "*)
|
||||
need_cmd apt-get || { echo "ERROR: apt-get not found on Debian-family system." >&2; return 1; }
|
||||
$sudo_cmd apt-get update && $sudo_cmd apt-get install -y libwebkitgtk-6.0-4 || return 1
|
||||
;;
|
||||
*" fedora "*|*" nobara "*)
|
||||
need_cmd dnf || { echo "ERROR: dnf not found on Fedora-family system." >&2; return 1; }
|
||||
$sudo_cmd dnf install -y webkitgtk6.0 || return 1
|
||||
;;
|
||||
*" openmandriva "*)
|
||||
need_cmd dnf || { echo "ERROR: dnf not found on OpenMandriva system." >&2; return 1; }
|
||||
$sudo_cmd dnf install -y lib64webkit2gtk6.0 || return 1
|
||||
;;
|
||||
*" void "*)
|
||||
need_cmd xbps-install || { echo "ERROR: xbps-install not found on Void system." >&2; return 1; }
|
||||
$sudo_cmd xbps-install -Sy libwebkitgtk60 || return 1
|
||||
;;
|
||||
*" alpine "*)
|
||||
echo "ERROR: Alpine Linux uses musl libc, which the prebuilt glibc-linked binary cannot run on." >&2
|
||||
echo "Build from source instead (see README)." >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unsupported or unrecognised distribution (ID='${dist_id}' ID_LIKE='${dist_like}')." >&2
|
||||
print_manual_instructions
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
unset -f need_cmd
|
||||
}
|
||||
|
||||
install_webkit_runtime || exit 1
|
||||
|
||||
# copy desktop file
|
||||
if [ ! -f "$HOME/.local/share/applications/AniTrack.desktop" ]; then
|
||||
if [ -d "~/.local/share/applications/" ]; then
|
||||
echo "Copying desktop file..."
|
||||
cp ./AniTrack.desktop ~/.local/share/applications/
|
||||
else
|
||||
mkdir -p ~/.local/share/applications/
|
||||
echo "Copying desktop file..."
|
||||
cp ./AniTrack.desktop ~/.local/share/applications/
|
||||
fi
|
||||
else
|
||||
echo "Desktop file already installed..."
|
||||
fi
|
||||
|
||||
# copy icons to xdg folders
|
||||
for size in 32 48 64 128; do
|
||||
if [ ! -f $HOME/.local/share/icons/hicolor/${size}x${size}/apps/AniTrack.png ]; then
|
||||
echo "Installing ${size} icon size..."
|
||||
xdg-icon-resource install --novendor --context apps --size $size ./icon/$size/AniTrack.png AniTrack
|
||||
else
|
||||
echo "${size} icon size already exists..."
|
||||
fi
|
||||
done
|
||||
|
||||
# copy AniTrack Binary to $HOME/Applications/
|
||||
if ! [ -d "$HOME/Applications" ]; then
|
||||
mkdir -p ~/Applications
|
||||
echo "Installing app to ~/Applications..."
|
||||
cp ./bin/AniTrack ~/Applications/
|
||||
elif ! [[ -e $HOME/Applications/AniTrack ]]; then
|
||||
echo "Installing app to ~/Applications"
|
||||
cp ./bin/AniTrack ~/Applications/
|
||||
else
|
||||
echo "AniTrack already in Applications..."
|
||||
fi
|
||||
|
||||
echo "AniTrack has been successfully installed."
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=AniTrack
|
||||
Exec=AniTrack
|
||||
Icon=AniTrack
|
||||
Categories=Development;
|
||||
Terminal=false
|
||||
Keywords=wails
|
||||
Version=1.0
|
||||
StartupNotify=false
|
||||
@@ -0,0 +1,222 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
# Signing configuration - edit these values for your project
|
||||
# PGP_KEY: "path/to/signing-key.asc"
|
||||
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
|
||||
#
|
||||
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||
|
||||
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
|
||||
CROSS_IMAGE: wails-cross
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application for Linux
|
||||
cmds:
|
||||
# Linux requires CGO - use Docker when:
|
||||
# 1. Cross-compiling from non-Linux, OR
|
||||
# 2. No C compiler is available, OR
|
||||
# 3. Target architecture differs from host architecture (cross-arch compilation)
|
||||
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||
vars:
|
||||
ARCH: '{{.ARCH}}'
|
||||
DEV: '{{.DEV}}'
|
||||
OUTPUT: '{{.OUTPUT}}'
|
||||
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||
OBFUSCATED: '{{.OBFUSCATED}}'
|
||||
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
|
||||
vars:
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
# Determine target architecture (defaults to host ARCH if not specified)
|
||||
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||
# Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
|
||||
HAS_CC:
|
||||
sh: 'wails3 tool has "gcc|clang"'
|
||||
|
||||
build:native:
|
||||
summary: Builds the application natively on Linux
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
DEV:
|
||||
ref: .DEV
|
||||
# common:generate:icons intentionally unwired: it generates macOS/Windows
|
||||
# icons from build/appicon.png, which this Linux-only project does not
|
||||
# have (icons ship via build/icon + the release tarball instead).
|
||||
- task: generate:dotdesktop
|
||||
preconditions:
|
||||
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
|
||||
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
|
||||
cmds:
|
||||
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
build:docker:
|
||||
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
- task: common:generate:icons
|
||||
- task: generate:dotdesktop
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
|
||||
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||
msg: |
|
||||
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||
Build it first: wails3 task setup:docker
|
||||
cmds:
|
||||
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
|
||||
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||
platforms: [linux, darwin]
|
||||
- mkdir -p {{.BIN_DIR}}
|
||||
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||
vars:
|
||||
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
# Generate Docker volume mounts: Go module cache + go.mod replace directives
|
||||
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
|
||||
DOCKER_MOUNTS:
|
||||
sh: 'wails3 tool docker-mounts'
|
||||
|
||||
package:
|
||||
summary: Packages the application for Linux
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: create:appimage
|
||||
- task: create:deb
|
||||
- task: create:rpm
|
||||
- task: create:aur
|
||||
|
||||
create:appimage:
|
||||
summary: Creates an AppImage
|
||||
dir: build/linux/appimage
|
||||
deps:
|
||||
- task: build
|
||||
- task: generate:dotdesktop
|
||||
cmds:
|
||||
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
|
||||
- cp ../../appicon.png "{{.APP_NAME}}.png"
|
||||
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
|
||||
vars:
|
||||
APP_NAME: '{{.APP_NAME}}'
|
||||
APP_BINARY: '../../../bin/{{.APP_NAME}}'
|
||||
ICON: '{{.APP_NAME}}.png'
|
||||
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
|
||||
OUTPUT_DIR: '../../../bin'
|
||||
|
||||
create:deb:
|
||||
summary: Creates a deb package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:deb
|
||||
|
||||
create:rpm:
|
||||
summary: Creates a rpm package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:rpm
|
||||
|
||||
create:aur:
|
||||
summary: Creates a arch linux packager package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:aur
|
||||
|
||||
generate:deb:
|
||||
summary: Creates a deb package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:rpm:
|
||||
summary: Creates a rpm package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:aur:
|
||||
summary: Creates a arch linux packager package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:dotdesktop:
|
||||
summary: Generates a `.desktop` file
|
||||
dir: build
|
||||
cmds:
|
||||
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
||||
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
|
||||
vars:
|
||||
APP_NAME: '{{.APP_NAME}}'
|
||||
EXEC: '{{.APP_NAME}}'
|
||||
ICON: '{{.APP_NAME}}'
|
||||
CATEGORIES: 'Development;'
|
||||
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
|
||||
|
||||
run:
|
||||
cmds:
|
||||
- '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
|
||||
sign:deb:
|
||||
summary: Signs the DEB package
|
||||
desc: |
|
||||
Signs the .deb package with a PGP key.
|
||||
Set PGP_KEY in the vars section to override the key configured globally via
|
||||
`wails3 setup` (~/.config/wails/defaults.yaml).
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
deps:
|
||||
- task: create:deb
|
||||
cmds:
|
||||
# PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key
|
||||
# configured globally via `wails3 setup`.
|
||||
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}}
|
||||
|
||||
sign:rpm:
|
||||
summary: Signs the RPM package
|
||||
desc: |
|
||||
Signs the .rpm package with a PGP key.
|
||||
Set PGP_KEY in the vars section to override the key configured globally via
|
||||
`wails3 setup` (~/.config/wails/defaults.yaml).
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
deps:
|
||||
- task: create:rpm
|
||||
cmds:
|
||||
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}}
|
||||
|
||||
sign:packages:
|
||||
summary: Signs all Linux packages (DEB and RPM)
|
||||
desc: |
|
||||
Signs both .deb and .rpm packages with a PGP key.
|
||||
Set PGP_KEY in the vars section to override the key configured globally via
|
||||
`wails3 setup` (~/.config/wails/defaults.yaml).
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
cmds:
|
||||
- task: sign:deb
|
||||
- task: sign:rpm
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -1,249 +0,0 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "{{.Name}}"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
File "..\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||
|
||||
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||
{{end}}
|
||||
!macroend
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
+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"
|
||||
@@ -0,0 +1,66 @@
|
||||
# AniTrack v3 Migration — What Changed and What's Next
|
||||
|
||||
This document summarizes the `wailsv3` branch (Wails `v2.15.0` → `v3.0.0-beta.20`, desktop-only) and the plan going forward. It’s written for a future me and for anyone following the migration.
|
||||
|
||||
## What we did (desktop, no mobile yet)
|
||||
|
||||
**Goal:** get the existing app running on Wails v3 with the same structure and behavior — no rewrites of AniList / MAL / Simkl logic, no mobile, no stores.
|
||||
|
||||
**Backend — single `App` service preserved:**
|
||||
- `main.go`: `wails.Run(options.App{ Bind: app })` → `application.New( Services: [app] )` + `Window.NewWithOptions(...)` + `app.Run()`. Same window (1024×768, title `AniTrack <version>`, RGBA background, Linux `ProgramName`/`Icon`/`GpuPolicyNever`), same `SingleInstance` lock/ID and `onSecondInstanceLaunch` (now `SecondInstanceData{ Args, WorkingDir }` + `Window.Current().Restore/Focus` + `Event.Emit("launchArgs")`).
|
||||
- `app.go`: `App` now holds `*application.App` instead of a `context`. `startup(ctx)` / global `wailsContext` removed; version title moved into the window options. `ShowVersion` and the three OAuth callbacks now use `app.Dialog.Info()` / `app.Browser.OpenURL()` (errors logged, not ignored).
|
||||
- `AniListUserFunctions.go` / `MALUserFunctions.go` / `SimklUserFunctions.go`: 11 runtime calls migrated (`BrowserOpenURL` ×3, `MessageDialog` ×3/4, `EventsEmit` ×1, window calls) to `v3` managers (`Browser`, `Dialog`, `Event`, `Window`). OAuth servers on `:6734/callback` unchanged. Per-file key constants unchanged.
|
||||
|
||||
**Secrets — `99designs/keyring` → `zalando/go-keyring`:**
|
||||
- `go.mod`: `wails/v2` + `99designs v1.2.2` dropped (stale `replace` removed), `wails/v3 v3.0.0-beta.20` (pinned to the local `wails3` CLI) + `zalando v0.2.8` added — what `v3` itself depends on.
|
||||
- Same service name `AniTrack` and same 11 key names (`anilist*`, `MyAnimeList*`, `Simkl*`), so intent is the same wallet. On Linux the underlying collection differs (`AniTrack` vs `login`) — see “Stale keys” below — so first `v3` run requires re-logging into the 3 services once.
|
||||
|
||||
**Frontend — bindings + runtime:**
|
||||
- `wails3 generate bindings` → `frontend/bindings/AniTrack/*.ts` (1 service, 29 methods, 25 models), verified byte-deterministic. `frontend/wailsjs/` removed. 10 Svelte files updated: `wailsjs/go/main/App` → `bindings/AniTrack` (`App.*` call prefixes), `WebsiteLink.svelte` → `Browser.OpenURL`, `AvatarMenu.svelte` → `Application.Quit`, both from `@wailsio/runtime` `3.0.0-beta.20`.
|
||||
- `frontend/vite.config.ts`: added `@wailsio/runtime` vite plugin (`wails('./bindings')`) + `server.host/port/strictPort` (5173). `frontend/package.json`: added `build:dev` (`vite build --minify false --mode development`, required by the `v3` dev taskflow) and `@wailsio/runtime` dep.
|
||||
|
||||
**Build + CI:**
|
||||
- `Taskfile.yml` + `build/Taskfile.yml` (stock `common`) + `build/linux/Taskfile.yml` (stock minus `common:generate:icons` — macOS/Windows icon generation targets `build/appicon.png` which this Linux-only project doesn’t have) + `build/config.yml` (AniTrack metadata, `Version 1.6.8`). Root output stays `build/bin/AniTrack` so release packaging is untouched. `wails.json` → `v3` `frontend` block, `info.productVersion` retained.
|
||||
- `Makefile`: `wails dev/build -tags webkit2_41` → `wails3 dev -port 5173` / `wails3 build` (no tags; `v3` defaults to `GTK4/WebKitGTK 6.0`, same `2.52.x` engine generation). Added `.task/` to `.gitignore`. Removed `build/darwin/`, `build/windows/`, `frontend/package.json.md5`. Version flow: single source `build/config.yml:info.version`, `make release` bumps it and regenerates `version.go` (compiled-in copy) in the same commit — `wails.json` retired entirely (proof: `wails3 build` + `wails3 dev` both green without it; a first-boot `dev` failure turned out to be Vite 8 cold-start vs the app retry budget, fixed by rerunning warm, not by the file).
|
||||
- `.gitea/workflows/release.yml`: Wails CLI `wails@v2.15.0` → `wails3@v3.0.0-beta.20` (path + cache key), apt `libgtk-3-dev/libwebkit2gtk-4.1-dev` → `libgtk-4-dev/libwebkitgtk-6.0-dev`, `make build` unchanged. Go/npm/go-build/Wails-CLI caches remain valid (npm cache now resolves since `frontend/package-lock.json` is committed).
|
||||
|
||||
**Verification:** `go vet` clean, `wails3 build` green (`build/bin/AniTrack` ~13 MB), `wails3 dev` boots end-to-end (bindings regen → `build:dev` → vite on `:5173` → `Connected to frontend dev server` → `AniTrack 1.6.8` window), second instance exits 0 via the lock. `svelte-check` was already red on `main` (`wailsjs/go/models.ts` anonymous structs) and remains so — not introduced by the migration.
|
||||
|
||||
**Commits on `wailsv3`:** `chore(wailsv3): migrate Go backend…`, `chore(wailsv3): migrate frontend…`, `chore(wailsv3): add v3 Taskfile…`, `ci(release): build releases with the Wails v3 toolchain`, plus `chore(wailsv3): v3 conformance pass…`.
|
||||
|
||||
## Stale keys — what a v2 user will see
|
||||
|
||||
On Linux, `v2` tokens lived in an `AniTrack` Secret Service collection as labeled JSON items; `v3` (zalando) uses the `login` collection with `service`/`username` attributes. Same OS keyring, different collections — so after migrating, the old `AniTrack` collection is orphaned.
|
||||
|
||||
In practice: running both versions side-by-side works fine — each reads its own collection, neither overwrites the other. If you fully move to `v3`, just log into AniList / MAL / Simkl once there; the `v2` entries become stale but harmless (11 small items). See the notice in `README.md: Migrating from v2` — no automatic deletion, on purpose.
|
||||
|
||||
## Self-updates (desktop Linux, 1.99.x)
|
||||
|
||||
Releases self-update in place via the Wails v3 updater (`app.Updater`): a
|
||||
custom Gitea provider (`updater/gitea` — Wails ships none for Gitea) checks
|
||||
the Gitea releases API, and CI publishes a signed bare-binary asset
|
||||
(`AniTrack-linux-amd64` + `.sha512`/`.sig` sidecars from
|
||||
`wails3 updater sign`) next to the user tarball. Verification is fail-closed;
|
||||
a release missing any of the three files is skipped. Gated to desktop
|
||||
production builds (`application.System.IsDesktop()` + `-tags production`) —
|
||||
mobile stays on Obtainium, dev builds never check. The startup check is
|
||||
headless; the builtin window opens only when an update is found.
|
||||
`ANITRACK_UPDATER_CHANNEL=beta` includes `-rc` pre-releases (testing only).
|
||||
Only the binary self-swaps; icons/`.desktop` still come from the tarball's
|
||||
`install_linux.sh`. Signing: public half `updater.pub` is embedded; the
|
||||
private key lives in the password manager + the `UPDATER_SIGNING_KEY` CI
|
||||
secret, never in the repo (see `.gitignore`).
|
||||
|
||||
## Future plans on v3
|
||||
|
||||
**Not yet done — staged after the desktop migration, in order** (each with its home branch, so nothing lands in the wrong place):
|
||||
|
||||
1. **Stabilize desktop `v3`:** real-world dogfooding of `main` (logins, watchlist sync, error modals). Merged to `main` as the `1.99.x` beta series while Wails `v3` is still beta upstream.
|
||||
2. **Frontend toolchain refresh:** done on `wailsv3` — Svelte 4.2 → 5.57, Vite 4.5 → 8.3, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 (CSS-first), flowbite-svelte 0.46 → 1.33, router 4 → 5. `svelte-check` went 453 errors → 0/0; stores retyped to the generated bindings models; AnimeTable rewritten dependency-free (`svelte-headless-table` peers Svelte 4 only). Deliberately held: TypeScript at 5.9 (`svelte-check` peers `^5||^6`), `@wailsio/runtime` pinned to the Go framework version (lockstep upgrades only).
|
||||
3. **Android (short-lived feature branch off `wailsv3`, e.g. `wailsv3-android`, merged back):** personal sideload, no Play/official F-Droid. Per-file `//go:build android` shims — `zalando` → `Android.Secure*` (`EncryptedSharedPreferences`), and `localhost:6734` OAuth callbacks → deep-link `anitrack://callback` + intent filter; start with one provider (AniList) to prove the pattern. Responsive / safe-area polish. Distribution via `adb install` + GitHub Releases + Obtainium. Kept off `wailsv3` proper so the mobile scaffolding (`build/android/`, manifests, gradle files) doesn't pollute the desktop-to-`main` merge.
|
||||
4. **CI follow-ups (wherever the work they support lives):** none required for desktop; the current 4 caches + the fixed runner cache backend already bring releases from ~12 min to ~3 min. Builder image only if the 2 min apt floor becomes annoying.
|
||||
|
||||
## Branch map
|
||||
|
||||
- `main` — v3 desktop, ships `1.99.x` beta-series releases.
|
||||
- `wailsv3` — retained as an alias tracking `main` for now.
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* App struct
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function AniListBrowse(page: number, perPage: number, id: number, isAdult: boolean, search: string, format: string[] | null, status: string, countryOfOrigin: string, source: string, season: string, seasonYear: number, year: string, onList: boolean, yearLesser: number, yearGreater: number, episodeLesser: number, episodeGreater: number, durationLesser: number, durationGreater: number, chapterLesser: number, chapterGreater: number, volumeLesser: number, volumeGreater: number, licensedBy: number[] | null, isLicensed: boolean, genres: string[] | null, excludedGenres: string[] | null, tags: string[] | null, excludedTags: string[] | null, minimumTagRank: number, sort: string[] | null): $CancellablePromise<$models.AniListCurrentUserWatchList> {
|
||||
return $Call.ByID(509958107, page, perPage, id, isAdult, search, format, status, countryOfOrigin, source, season, seasonYear, year, onList, yearLesser, yearGreater, episodeLesser, episodeGreater, durationLesser, durationGreater, chapterLesser, chapterGreater, volumeLesser, volumeGreater, licensedBy, isLicensed, genres, excludedGenres, tags, excludedTags, minimumTagRank, sort);
|
||||
}
|
||||
|
||||
export function AniListDeleteEntry(mediaListId: number): $CancellablePromise<$models.DeleteAniListReturn> {
|
||||
return $Call.ByID(814044062, mediaListId);
|
||||
}
|
||||
|
||||
export function AniListLogin(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1289244998);
|
||||
}
|
||||
|
||||
export function AniListSearch(query: string): $CancellablePromise<any> {
|
||||
return $Call.ByID(535361453, query);
|
||||
}
|
||||
|
||||
export function AniListUpdateEntry(updateBody: $models.AniListUpdateVariables): $CancellablePromise<$models.AniListGetSingleAnime> {
|
||||
return $Call.ByID(1288135876, updateBody);
|
||||
}
|
||||
|
||||
export function CheckIfAniListLoggedIn(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3851355);
|
||||
}
|
||||
|
||||
export function CheckIfMyAnimeListLoggedIn(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3447028267);
|
||||
}
|
||||
|
||||
export function CheckIfSimklLoggedIn(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(2483871029);
|
||||
}
|
||||
|
||||
export function DeleteMyAnimeListEntry(id: number): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3393515640, id);
|
||||
}
|
||||
|
||||
export function GetAniListItem(aniId: number, login: boolean): $CancellablePromise<$models.AniListGetSingleAnime> {
|
||||
return $Call.ByID(2336493728, aniId, login);
|
||||
}
|
||||
|
||||
export function GetAniListLoggedInUser(): $CancellablePromise<$models.AniListUser> {
|
||||
return $Call.ByID(448499589);
|
||||
}
|
||||
|
||||
export function GetAniListUserWatchingList(page: number, perPage: number, sort: string): $CancellablePromise<$models.AniListCurrentUserWatchList> {
|
||||
return $Call.ByID(3978330877, page, perPage, sort);
|
||||
}
|
||||
|
||||
export function GetMyAnimeList(count: number): $CancellablePromise<$models.MALWatchlist> {
|
||||
return $Call.ByID(1771759135, count);
|
||||
}
|
||||
|
||||
export function GetMyAnimeListAnime(id: number): $CancellablePromise<$models.MALAnime> {
|
||||
return $Call.ByID(1368244377, id);
|
||||
}
|
||||
|
||||
export function GetMyAnimeListLoggedInUser(): $CancellablePromise<$models.MyAnimeListUser> {
|
||||
return $Call.ByID(768446973);
|
||||
}
|
||||
|
||||
export function GetSimklLoggedInUser(): $CancellablePromise<$models.SimklUser> {
|
||||
return $Call.ByID(2872164207);
|
||||
}
|
||||
|
||||
export function LogoutAniList(): $CancellablePromise<string> {
|
||||
return $Call.ByID(1911309547);
|
||||
}
|
||||
|
||||
export function LogoutMyAnimeList(): $CancellablePromise<string> {
|
||||
return $Call.ByID(2568421227);
|
||||
}
|
||||
|
||||
export function LogoutSimkl(): $CancellablePromise<string> {
|
||||
return $Call.ByID(734133957);
|
||||
}
|
||||
|
||||
export function MyAnimeListLogin(): $CancellablePromise<void> {
|
||||
return $Call.ByID(439153478);
|
||||
}
|
||||
|
||||
export function MyAnimeListUpdate(anime: $models.MALAnime, update: $models.MALUploadStatus): $CancellablePromise<$models.MalListStatus> {
|
||||
return $Call.ByID(2544871568, anime, update);
|
||||
}
|
||||
|
||||
export function ShowVersion(): $CancellablePromise<void> {
|
||||
return $Call.ByID(4089383544);
|
||||
}
|
||||
|
||||
export function SimklGetUserWatchlist(): $CancellablePromise<$models.SimklWatchListType> {
|
||||
return $Call.ByID(3479741957);
|
||||
}
|
||||
|
||||
export function SimklLogin(): $CancellablePromise<void> {
|
||||
return $Call.ByID(4146248);
|
||||
}
|
||||
|
||||
export function SimklSearch(aniListAnime: $models.MediaList): $CancellablePromise<$models.SimklAnime> {
|
||||
return $Call.ByID(3130411731, aniListAnime);
|
||||
}
|
||||
|
||||
export function SimklSyncEpisodes(anime: $models.SimklAnime, progress: number): $CancellablePromise<$models.SimklAnime> {
|
||||
return $Call.ByID(3607297770, anime, progress);
|
||||
}
|
||||
|
||||
export function SimklSyncRating(anime: $models.SimklAnime, rating: number): $CancellablePromise<$models.SimklAnime> {
|
||||
return $Call.ByID(2909509273, anime, rating);
|
||||
}
|
||||
|
||||
export function SimklSyncRemove(anime: $models.SimklAnime): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(1794667404, anime);
|
||||
}
|
||||
|
||||
export function SimklSyncStatus(anime: $models.SimklAnime, status: string): $CancellablePromise<$models.SimklAnime> {
|
||||
return $Call.ByID(2561714086, anime, status);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as App from "./app.js";
|
||||
export {
|
||||
App
|
||||
};
|
||||
|
||||
export type {
|
||||
AiringScheduleNode,
|
||||
AniListCurrentUserWatchList,
|
||||
AniListGetSingleAnime,
|
||||
AniListUpdateVariables,
|
||||
AniListUser,
|
||||
AnimeStatistics,
|
||||
CompletedAt,
|
||||
DeleteAniListReturn,
|
||||
FlexString,
|
||||
MALAnime,
|
||||
MALUploadStatus,
|
||||
MALWatchlist,
|
||||
MalListStatus,
|
||||
Media,
|
||||
MediaAiringSchedule,
|
||||
MediaFuzzyDate,
|
||||
MediaList,
|
||||
MediaRelation,
|
||||
MediaRelations,
|
||||
MediaTitle,
|
||||
MyAnimeListUser,
|
||||
SimklAnime,
|
||||
SimklUser,
|
||||
SimklWatchListType,
|
||||
StartedAt
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,240 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export interface AiringScheduleNode {
|
||||
"id": number;
|
||||
"airingAt": number;
|
||||
"timeUntilAiring": number;
|
||||
"episode": number;
|
||||
"mediaId": number;
|
||||
}
|
||||
|
||||
export interface AniListCurrentUserWatchList {
|
||||
"data": {"Page": {"pageInfo": {"total": number, "perPage": number, "currentPage": number, "lastPage": number, "hasNextPage": boolean}, "mediaList": MediaList[] | null}};
|
||||
}
|
||||
|
||||
export interface AniListGetSingleAnime {
|
||||
"data": {"MediaList": MediaList};
|
||||
}
|
||||
|
||||
export interface AniListUpdateVariables {
|
||||
"mediaId": number;
|
||||
"progress": number;
|
||||
"status": string;
|
||||
"score": number;
|
||||
"repeat": number;
|
||||
"notes": string;
|
||||
"startedAt": StartedAt;
|
||||
"completedAt": CompletedAt;
|
||||
}
|
||||
|
||||
export interface AniListUser {
|
||||
"data": {"Viewer": {"id": number, "name": string, "avatar": {"large": string, "medium": string}, "bannerImage": string, "siteUrl": string}};
|
||||
}
|
||||
|
||||
export interface AnimeStatistics {
|
||||
"num_items_watching": number;
|
||||
"num_items_completed": number;
|
||||
"num_items_on_hold": number;
|
||||
"num_items_dropped": number;
|
||||
"num_items_plan_to_watch": number;
|
||||
"num_items": number;
|
||||
"num_days_watched": number;
|
||||
"num_days_watching": number;
|
||||
"num_days_completed": number;
|
||||
"num_days_on_hold": number;
|
||||
"num_days_dropped": number;
|
||||
"num_days": number;
|
||||
"num_episodes": number;
|
||||
"num_times_rewatched": number;
|
||||
"mean_score": number;
|
||||
}
|
||||
|
||||
export interface CompletedAt {
|
||||
"year": number;
|
||||
"month": number;
|
||||
"day": number;
|
||||
}
|
||||
|
||||
export interface DeleteAniListReturn {
|
||||
"data": {"DeleteMediaListEntry": {"deleted": boolean}};
|
||||
}
|
||||
|
||||
export type FlexString = string;
|
||||
|
||||
export interface MALAnime {
|
||||
"id": number;
|
||||
"title": string;
|
||||
"main_picture": {"large": string, "medium": string};
|
||||
"alternative_titles": {"synonyms": string[] | null, "en": string, "ja": string};
|
||||
"start_date": string;
|
||||
"end_date": string;
|
||||
"synopsis": string;
|
||||
"mean": number;
|
||||
"rank": number;
|
||||
"popularity": number;
|
||||
"num_list_users": number;
|
||||
"num_scoring_users": number;
|
||||
"nsfw": string;
|
||||
"genres": {"id": number, "name": string}[] | null;
|
||||
"created_at": string;
|
||||
"updated_at": string;
|
||||
"media_type": string;
|
||||
"status": string;
|
||||
"my_list_status": MalListStatus;
|
||||
"num_episodes": number;
|
||||
"start_season": {"year": number, "season": string};
|
||||
"broadcast": {"day_of_the_week": string, "start_time": string};
|
||||
"source": string;
|
||||
"average_episode_duration": number;
|
||||
"rating": string;
|
||||
"studios": {"id": number, "name": string}[] | null;
|
||||
"pictures": {"large": string, "medium": string}[] | null;
|
||||
"background": string;
|
||||
"related_anime": {"node": MALAnime, "relation_type": string, "relation_type_formatted": string}[] | null;
|
||||
"recommendations": {"node": MALAnime, "num_recommendations": number}[] | null;
|
||||
"Statistics": {"num_list_users": number, "Status": {"watching": FlexString, "completed": FlexString, "on_hold": FlexString, "dropped": FlexString, "plan_to_watch": FlexString}};
|
||||
}
|
||||
|
||||
export interface MALUploadStatus {
|
||||
"status": string;
|
||||
"is_rewatching": boolean;
|
||||
"score": number;
|
||||
"num_watched_episodes": number;
|
||||
"num_times_rewatched": number;
|
||||
"comments": string;
|
||||
}
|
||||
|
||||
export interface MALWatchlist {
|
||||
"data": {"node": {"id": number, "title": string, "main_picture": {"medium": string, "large": string}}, "list_status": {"status": string, "score": number, "num_episodes_watched": number, "is_rewatching": boolean, "updated_at": string, "start_date": string, "finish_date": string}}[] | null;
|
||||
"paging": {"previous": string, "next": string};
|
||||
}
|
||||
|
||||
export interface MalListStatus {
|
||||
"status": string;
|
||||
"score": number;
|
||||
"num_episodes_watched": number;
|
||||
"is_rewatching": boolean;
|
||||
"start_date": string;
|
||||
"finish_date": string;
|
||||
"priority": number;
|
||||
"num_times_rewatched": number;
|
||||
"rewatch_value": number;
|
||||
"tags": string[] | null;
|
||||
"comments": string;
|
||||
"updated_at": string;
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
"id": number;
|
||||
"idMal": number;
|
||||
"title": MediaTitle;
|
||||
"description": string;
|
||||
"coverImage": {"ExtraLarge": string, "large": string, "Medium": string, "Color": string};
|
||||
"BannerImage": string;
|
||||
"Format": string;
|
||||
"season": string;
|
||||
"seasonYear": number;
|
||||
"status": string;
|
||||
"episodes": number;
|
||||
"Duration": number;
|
||||
"CountryOfOrigin": string;
|
||||
"Source": string;
|
||||
"Synonyms": string[] | null;
|
||||
"AverageScore": number;
|
||||
"MeanScore": number;
|
||||
"Popularity": number;
|
||||
"Trending": number;
|
||||
"Favourites": number;
|
||||
"relations": MediaRelations;
|
||||
"startDate": MediaFuzzyDate;
|
||||
"endDate": MediaFuzzyDate;
|
||||
"nextAiringEpisode": {"airingAt": number, "timeUntilAiring": number, "episode": number};
|
||||
"airingSchedule": MediaAiringSchedule;
|
||||
"genres": string[] | null;
|
||||
"tags": {"id": number, "name": string, "description": string, "rank": number, "isMediaSpoiler": boolean, "isAdult": boolean}[] | null;
|
||||
"isAdult": boolean;
|
||||
}
|
||||
|
||||
export interface MediaAiringSchedule {
|
||||
"nodes": AiringScheduleNode[] | null;
|
||||
}
|
||||
|
||||
export interface MediaFuzzyDate {
|
||||
"year": number;
|
||||
"month": number;
|
||||
"day": number;
|
||||
}
|
||||
|
||||
export interface MediaList {
|
||||
"id": number;
|
||||
"mediaId": number;
|
||||
"userId": number;
|
||||
"status": string;
|
||||
"media": Media;
|
||||
"startedAt": {"year": number, "month": number, "day": number};
|
||||
"completedAt": {"year": number, "month": number, "day": number};
|
||||
"notes": string;
|
||||
"progress": number;
|
||||
"score": number;
|
||||
"repeat": number;
|
||||
"user": {"id": number, "name": string, "avatar": {"large": string, "medium": string}, "statistics": {"anime": {"count": number, "statuses": {"status": string, "count": number}[] | null}}};
|
||||
}
|
||||
|
||||
export interface MediaRelation {
|
||||
"id": number;
|
||||
"title": MediaTitle;
|
||||
}
|
||||
|
||||
export interface MediaRelations {
|
||||
"nodes": MediaRelation[] | null;
|
||||
}
|
||||
|
||||
export interface MediaTitle {
|
||||
"userPreferred": string;
|
||||
"romaji": string;
|
||||
"english": string;
|
||||
"native": string;
|
||||
}
|
||||
|
||||
export interface MyAnimeListUser {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"picture": string;
|
||||
"gender": string;
|
||||
"birthday": string;
|
||||
"location": string;
|
||||
"joined_at": string;
|
||||
"anime_statistics": AnimeStatistics;
|
||||
"time_zone": string;
|
||||
"is_supporter": boolean;
|
||||
}
|
||||
|
||||
export interface SimklAnime {
|
||||
"last_watched_at": string;
|
||||
"status": string;
|
||||
"user_rating": number;
|
||||
"last_watched": string;
|
||||
"next_to_watch": string;
|
||||
"watched_episodes_count": number;
|
||||
"total_episodes_count": number;
|
||||
"not_aired_episodes_count": number;
|
||||
"show": {"title": string, "poster": string, "ids": {"simkl": number, "slug": string, "offjp": string, "tw": string, "ann": string, "mal": string, "wikien": string, "wikijp": string, "allcin": string, "imdb": string, "tmdb": string, "offen": string, "crunchyroll": string, "tvdbslug": string, "anilist": string, "animeplanet": string, "anisearch": string, "kitsu": string, "livechart": string, "traktslug": string, "anidb": string}};
|
||||
"anime_type": string;
|
||||
}
|
||||
|
||||
export interface SimklUser {
|
||||
"user": {"name": string, "joined_at": string, "gender": string, "avatar": string, "bio": string, "loc": string, "age": string};
|
||||
"account": {"id": number, "timezone": string, "type": string};
|
||||
"connections": {"facebook": boolean};
|
||||
}
|
||||
|
||||
export interface SimklWatchListType {
|
||||
"anime": SimklAnime[] | null;
|
||||
}
|
||||
|
||||
export interface StartedAt {
|
||||
"year": number;
|
||||
"month": number;
|
||||
"day": number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
Object.freeze($Create.Events);
|
||||
@@ -0,0 +1,2 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
+9
-10
@@ -1,13 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>AniTrack</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.ts" type="module"></script>
|
||||
<script src="./node_modules/flowbite/dist/flowbite.js"></script>
|
||||
</body>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2182
File diff suppressed because it is too large
Load Diff
+16
-17
@@ -5,30 +5,29 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:dev": "vite build --minify false --mode development",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.1",
|
||||
"@tsconfig/svelte": "^4.0.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte-check": "^3.4.3",
|
||||
"svelte-headless-table": "^0.18.2",
|
||||
"svelte-preprocess": "^5.0.3",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.5.5"
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"svelte-spa-router": "^5.1.1",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"flowbite": "^2.5.1",
|
||||
"flowbite-svelte": "^0.46.16",
|
||||
"moment": "^2.30.1"
|
||||
"@wailsio/runtime": "3.0.0-beta.22",
|
||||
"flowbite": "^4.0.2",
|
||||
"flowbite-svelte": "^1.33.1",
|
||||
"moment": "^2.31.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+65
-30
@@ -1,44 +1,79 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListAnime,
|
||||
GetAnimeSingleItem,
|
||||
aniListLoggedIn,
|
||||
malLoggedIn,
|
||||
simklLoggedIn,
|
||||
watchlistNeedsRefresh,
|
||||
aniListPrimary,
|
||||
malPrimary,
|
||||
simklPrimary,
|
||||
malWatchList,
|
||||
simklWatchList,
|
||||
serviceLoggingIn,
|
||||
} from "./helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {onMount} from "svelte";
|
||||
import Router from "svelte-spa-router"
|
||||
import { onMount } from "svelte";
|
||||
import Router from "svelte-spa-router";
|
||||
import Home from "./routes/Home.svelte";
|
||||
import {wrap} from "svelte-spa-router/wrap";
|
||||
import { wrap } from "svelte-spa-router/wrap";
|
||||
import Spinner from "./helperComponents/Spinner.svelte";
|
||||
import Header from "./helperComponents/Header.svelte";
|
||||
import {CheckIfAniListLoggedInAndLoadWatchList} from "./helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "./helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import { CheckIfMALLoggedInAndSetUser } from "./helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
||||
import {CheckIfSimklLoggedInAndSetUser} from "./helperModules/CheckIsSimklLoggedIn.svelte"
|
||||
import {CheckIfAniListLoggedIn} from "../wailsjs/go/main/App";
|
||||
import {AniListGetSingleAnimeDefaultData} from "./helperDefaults/AniListGetSingleAnime";
|
||||
import { CheckIfSimklLoggedInAndSetUser } from "./helperModules/CheckIsSimklLoggedIn.svelte";
|
||||
import {App} from "../bindings/AniTrack";
|
||||
import ErrorModal from "./helperComponents/ErrorModal.svelte";
|
||||
|
||||
onMount(async () => {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList()
|
||||
await CheckIfMALLoggedInAndSetUser()
|
||||
await CheckIfSimklLoggedInAndSetUser()
|
||||
})
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
|
||||
serviceLoggingIn.set(["anilist", "mal", "simkl"]);
|
||||
|
||||
!isAniListLoggedIn && (await CheckIfAniListLoggedInAndLoadWatchList());
|
||||
!isMALLoggedIn && (await CheckIfMALLoggedInAndSetUser());
|
||||
!isSimklLoggedIn && (await CheckIfSimklLoggedInAndSetUser());
|
||||
});
|
||||
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// Reloads all watchlists when returning home with the refresh flag set
|
||||
// (e.g. after saving changes on a detail page). Driven by the router's
|
||||
// onRouteLoaded callback: the old `$: ... router.location ...` reactive
|
||||
// statement did not reliably re-fire on navigation under Svelte 5,
|
||||
// so homecoming refreshes silently never ran.
|
||||
async function refreshHomeWatchlists(location: string): Promise<void> {
|
||||
if (location !== "/" || !get(watchlistNeedsRefresh)) return;
|
||||
if (get(aniListLoggedIn) && get(aniListPrimary)) {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
}
|
||||
if (get(malLoggedIn) && get(malPrimary)) {
|
||||
await App.GetMyAnimeList(1000).then((w) => malWatchList.set(w));
|
||||
}
|
||||
if (get(simklLoggedIn) && get(simklPrimary)) {
|
||||
await App.SimklGetUserWatchlist().then((w) => simklWatchList.set(w));
|
||||
}
|
||||
|
||||
watchlistNeedsRefresh.set(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
<Router routes={{
|
||||
'/': Home,
|
||||
'/anime/:id': wrap({
|
||||
asyncComponent: () => import('./routes/AnimeRoutePage.svelte'),
|
||||
conditions: [
|
||||
async () => await CheckIfAniListLoggedIn(),
|
||||
async (detail) => {
|
||||
aniListAnime.update(value => {
|
||||
value = AniListGetSingleAnimeDefaultData
|
||||
return value
|
||||
})
|
||||
await GetAnimeSingleItem(Number(detail.params.id), true)
|
||||
return Object.keys($aniListAnime).length!==0
|
||||
},
|
||||
],
|
||||
loadingComponent: Spinner
|
||||
<ErrorModal />
|
||||
<Router
|
||||
onRouteLoaded={(detail) => {
|
||||
void refreshHomeWatchlists(detail.location);
|
||||
}}
|
||||
routes={{
|
||||
"/": Home,
|
||||
"/anime/:id": wrap({
|
||||
asyncComponent: () => import("./routes/AnimeRoutePage.svelte"),
|
||||
conditions: [async () => await App.CheckIfAniListLoggedIn()],
|
||||
loadingComponent: Spinner,
|
||||
}),
|
||||
// '*': "Not Found"
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,79 +2,93 @@ export interface AniListCurrentUserWatchList {
|
||||
data: {
|
||||
Page: {
|
||||
pageInfo: {
|
||||
total: number
|
||||
perPage: number
|
||||
currentPage: number
|
||||
lastPage: number
|
||||
hasNextPage: boolean
|
||||
},
|
||||
mediaList: MediaList[]
|
||||
}
|
||||
}
|
||||
total: number;
|
||||
perPage: number;
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
mediaList: MediaList[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AniListGetSingleAnime {
|
||||
data: {
|
||||
MediaList: MediaList
|
||||
}
|
||||
MediaList: MediaList;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MediaList {
|
||||
id: number
|
||||
mediaId: number
|
||||
userId: number
|
||||
id: number;
|
||||
mediaId: number;
|
||||
userId: number;
|
||||
media: {
|
||||
id: number
|
||||
idMal: number
|
||||
id: number;
|
||||
idMal: number;
|
||||
title: {
|
||||
romaji: string
|
||||
english?: string
|
||||
native: string
|
||||
}
|
||||
description: string
|
||||
romaji: string;
|
||||
english?: string;
|
||||
native: string;
|
||||
};
|
||||
description: string;
|
||||
coverImage: {
|
||||
large: string
|
||||
}
|
||||
season: string
|
||||
seasonYear: number
|
||||
status: string
|
||||
episodes?: number
|
||||
large: string;
|
||||
};
|
||||
season: string;
|
||||
seasonYear: number;
|
||||
status: string;
|
||||
episodes?: number;
|
||||
nextAiringEpisode?: {
|
||||
airingAt: number
|
||||
timeUntilAiring: number
|
||||
episode: number
|
||||
}
|
||||
}
|
||||
status: string
|
||||
airingAt: number;
|
||||
timeUntilAiring: number;
|
||||
episode: number;
|
||||
};
|
||||
genres: string[];
|
||||
tags: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
rank: number;
|
||||
isMediaSpoiler: boolean;
|
||||
isAdult: boolean;
|
||||
},
|
||||
];
|
||||
isAdult: boolean;
|
||||
};
|
||||
status: string;
|
||||
startedAt: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
completedAt: {
|
||||
year?: number
|
||||
month?: number
|
||||
day?: number
|
||||
}
|
||||
notes?: string
|
||||
progress: number
|
||||
score: number
|
||||
repeat: number
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
};
|
||||
notes?: string;
|
||||
progress: number;
|
||||
score: number;
|
||||
repeat: number;
|
||||
user: {
|
||||
id: number
|
||||
name: string
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: {
|
||||
large: string
|
||||
medium: string
|
||||
}
|
||||
large: string;
|
||||
medium: string;
|
||||
};
|
||||
statistics: {
|
||||
anime: {
|
||||
count: number
|
||||
statuses: [{
|
||||
status: string
|
||||
count: number
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
count: number;
|
||||
statuses: [
|
||||
{
|
||||
status: string;
|
||||
count: number;
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,47 +4,43 @@
|
||||
aniListLoggedIn,
|
||||
malAnime,
|
||||
malLoggedIn,
|
||||
setApiError,
|
||||
simklAnime,
|
||||
simklLoggedIn,
|
||||
watchlistNeedsRefresh,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { Button } from "flowbite-svelte";
|
||||
import type { AniListGetSingleAnime } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import Rating from "./Rating.svelte";
|
||||
import convertAniListDateToString from "../helperFunctions/convertAniListDateToString";
|
||||
import AnimeTable from "./AnimeTable.svelte";
|
||||
import WebsiteLink from "./WebsiteLink.svelte";
|
||||
import type {
|
||||
AniListGetSingleAnime,
|
||||
AniListUpdateVariables,
|
||||
MALAnime,
|
||||
MalListStatus,
|
||||
MALUploadStatus,
|
||||
} from "../mal/types/MALTypes";
|
||||
import type { SimklAnime } from "../simkl/types/simklTypes";
|
||||
import { writable } from "svelte/store";
|
||||
import type {
|
||||
StatusOption,
|
||||
StatusOptions,
|
||||
} from "../helperTypes/StatusTypes";
|
||||
import type { AniListUpdateVariables } from "../anilist/types/AniListTypes";
|
||||
import convertDateStringToAniList from "../helperFunctions/convertDateStringToAniList";
|
||||
SimklAnime,
|
||||
} from "../../bindings/AniTrack/models";
|
||||
import Rating from "./Rating.svelte";
|
||||
import {
|
||||
AniListDeleteEntry,
|
||||
AniListUpdateEntry,
|
||||
DeleteMyAnimeListEntry,
|
||||
MyAnimeListUpdate,
|
||||
SimklSyncEpisodes,
|
||||
SimklSyncRating,
|
||||
SimklSyncRemove,
|
||||
SimklSyncStatus,
|
||||
} from "../../wailsjs/go/main/App";
|
||||
convertAniListDateToString,
|
||||
convertAniListDateToDate,
|
||||
} from "../helperFunctions/convertAniListDateIn";
|
||||
import AnimeTable from "./AnimeTable.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { StatusOption, StatusOptions } from "../helperTypes/StatusTypes";
|
||||
import { convertDateToAniList } from "../helperFunctions/convertDateToAniList";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import { AddAnimeServiceToTable } from "../helperModules/AddAnimeServiceToTable.svelte";
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import Datepicker from "./Datepicker.svelte";
|
||||
import { Badge, Tooltip } from "flowbite-svelte";
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isMalLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let currentAniListAnime: AniListGetSingleAnime;
|
||||
let currentMalAnime: MALAnime;
|
||||
let currentSimklAnime: SimklAnime;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isMalLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let currentAniListAnime!: AniListGetSingleAnime;
|
||||
let currentMalAnime!: MALAnime;
|
||||
let currentSimklAnime!: SimklAnime;
|
||||
let submitting = writable(false);
|
||||
let isSubmitting: boolean;
|
||||
let submitSuccess = writable(false);
|
||||
@@ -75,13 +71,12 @@
|
||||
{ id: 5, aniList: "REPEATING", mal: "rewatching", simkl: "watching" },
|
||||
];
|
||||
let startingAnilistStatusOption: StatusOption = statusOptions.filter(
|
||||
(option) =>
|
||||
currentAniListAnime.data.MediaList.status === option.aniList,
|
||||
(option) => currentAniListAnime.data.MediaList.status === option.aniList,
|
||||
)[0];
|
||||
const startedAtDate = convertAniListDateToString(
|
||||
let startedAtDate: Date | null = convertAniListDateToDate(
|
||||
currentAniListAnime.data.MediaList.startedAt,
|
||||
);
|
||||
const completedAtDate = convertAniListDateToString(
|
||||
let completedAtDate: Date | null = convertAniListDateToDate(
|
||||
currentAniListAnime.data.MediaList.completedAt,
|
||||
);
|
||||
|
||||
@@ -103,19 +98,34 @@
|
||||
notes: currentAniListAnime.data.MediaList.notes,
|
||||
});
|
||||
|
||||
if (isMalLoggedIn)
|
||||
if (isMalLoggedIn) {
|
||||
let startDate = "";
|
||||
let finishDate = "";
|
||||
if (currentMalAnime.my_list_status.start_date !== "") {
|
||||
const startArray = re.exec(currentMalAnime.my_list_status.start_date);
|
||||
if (startArray) {
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(currentMalAnime.my_list_status.finish_date);
|
||||
if (finishArray) {
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
}
|
||||
}
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
title: currentMalAnime.title,
|
||||
service: "MyAnimeList",
|
||||
progress: currentMalAnime.my_list_status.num_episodes_watched,
|
||||
status: currentMalAnime.my_list_status.status,
|
||||
startedAt: currentMalAnime.my_list_status.start_date,
|
||||
completedAt: currentMalAnime.my_list_status.finish_date,
|
||||
startedAt: startDate,
|
||||
completedAt: finishDate,
|
||||
score: currentMalAnime.my_list_status.score,
|
||||
repeat: currentMalAnime.my_list_status.num_times_rewatched,
|
||||
notes: currentMalAnime.my_list_status.comments,
|
||||
});
|
||||
}
|
||||
|
||||
if (isSimklLoggedIn && Object.keys(currentSimklAnime).length > 0)
|
||||
AddAnimeServiceToTable({
|
||||
@@ -137,8 +147,8 @@
|
||||
rating: number;
|
||||
episodes: number;
|
||||
status: StatusOption;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
repeat: number;
|
||||
notes: string;
|
||||
} = {
|
||||
@@ -150,8 +160,8 @@
|
||||
mal: "",
|
||||
simkl: "",
|
||||
},
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
};
|
||||
@@ -159,7 +169,9 @@
|
||||
for (let field of formData) {
|
||||
const [key, value] = field;
|
||||
if (key === "rating") {
|
||||
submitData.rating = Number(value) * 2;
|
||||
// Form value is already 0-10 (StarInput works in backend units).
|
||||
// The old * 2 belonged to the retired 0-5 slider scale.
|
||||
submitData.rating = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (key === "episodes") {
|
||||
@@ -174,9 +186,14 @@
|
||||
submitData.status = startingAnilistStatusOption;
|
||||
continue;
|
||||
}
|
||||
submitData[key] = value;
|
||||
// The only remaining form field is "notes" (string); anything else
|
||||
// is ignored rather than assigned into a mistyped slot.
|
||||
if (key === "notes" && typeof value === "string") {
|
||||
submitData.notes = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
isAniListLoggedIn &&
|
||||
currentAniListAnime.data.MediaList.mediaId !== 0
|
||||
@@ -188,14 +205,19 @@
|
||||
score: submitData.rating,
|
||||
repeat: submitData.repeat,
|
||||
notes: submitData.notes,
|
||||
startedAt: convertDateStringToAniList(submitData.startedAt),
|
||||
completedAt: convertDateStringToAniList(submitData.completedAt),
|
||||
startedAt: convertDateToAniList(startedAtDate),
|
||||
completedAt: convertDateToAniList(completedAtDate),
|
||||
};
|
||||
await AniListUpdateEntry(body).then(
|
||||
(value: AniListGetSingleAnime) => {
|
||||
/* TODO in future when you inevitably add tags to typescript, until Anilist fixes the api bug
|
||||
where tags break the SaveMediaListEntry return, you'll want to use this delete line
|
||||
delete value.data.MediaList.media.tags */
|
||||
await App.AniListUpdateEntry(body).then((value: AniListGetSingleAnime) => {
|
||||
if (!value.data?.MediaList || value.data.MediaList.mediaId === 0) {
|
||||
setApiError(
|
||||
"anilist",
|
||||
"AniList update failed: no saved entry was returned",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
aniListAnime.update((newValue) => {
|
||||
newValue = value;
|
||||
return newValue;
|
||||
@@ -216,10 +238,20 @@
|
||||
repeat: currentAniListAnime.data.MediaList.repeat,
|
||||
notes: currentAniListAnime.data.MediaList.notes,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
} 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) {
|
||||
let body: MALUploadStatus = {
|
||||
status: submitData.status.mal,
|
||||
@@ -230,12 +262,11 @@
|
||||
comments: submitData.notes,
|
||||
};
|
||||
|
||||
await MyAnimeListUpdate(currentMalAnime, body).then(
|
||||
await App.MyAnimeListUpdate(currentMalAnime, body).then(
|
||||
(malAnimeReturn: MalListStatus) => {
|
||||
malAnime.update((value) => {
|
||||
value.my_list_status.status = malAnimeReturn.status;
|
||||
value.my_list_status.is_rewatching =
|
||||
malAnimeReturn.is_rewatching;
|
||||
value.my_list_status.is_rewatching = malAnimeReturn.is_rewatching;
|
||||
value.my_list_status.score = malAnimeReturn.score;
|
||||
value.my_list_status.num_episodes_watched =
|
||||
malAnimeReturn.num_episodes_watched;
|
||||
@@ -244,32 +275,55 @@
|
||||
value.my_list_status.comments = malAnimeReturn.comments;
|
||||
return value;
|
||||
});
|
||||
let startDate = "";
|
||||
let finishDate = "";
|
||||
if (currentMalAnime.my_list_status.start_date !== "") {
|
||||
const startArray = re.exec(
|
||||
currentMalAnime.my_list_status.start_date,
|
||||
);
|
||||
if (startArray) {
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(
|
||||
currentMalAnime.my_list_status.finish_date,
|
||||
);
|
||||
if (finishArray) {
|
||||
finishDate = `${finishArray[2]}-${finishArray[3]}-${finishArray[1]}`;
|
||||
}
|
||||
}
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
title: currentMalAnime.title,
|
||||
service: "MyAnimeList",
|
||||
progress:
|
||||
currentMalAnime.my_list_status.num_episodes_watched,
|
||||
progress: currentMalAnime.my_list_status.num_episodes_watched,
|
||||
status: currentMalAnime.my_list_status.status,
|
||||
startedAt: currentMalAnime.my_list_status.start_date,
|
||||
completedAt: currentMalAnime.my_list_status.finish_date,
|
||||
startedAt: startDate,
|
||||
completedAt: finishDate,
|
||||
score: currentMalAnime.my_list_status.score,
|
||||
repeat: currentMalAnime.my_list_status
|
||||
.num_times_rewatched,
|
||||
repeat: currentMalAnime.my_list_status.num_times_rewatched,
|
||||
notes: currentMalAnime.my_list_status.comments,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
} 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 (
|
||||
currentSimklAnime.watched_episodes_count !== submitData.episodes
|
||||
) {
|
||||
await SimklSyncEpisodes(
|
||||
currentSimklAnime,
|
||||
submitData.episodes,
|
||||
).then((value: SimklAnime) => {
|
||||
if (currentSimklAnime.watched_episodes_count !== submitData.episodes) {
|
||||
await App.SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
|
||||
(value: SimklAnime) => {
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${value.show.ids.simkl}`,
|
||||
title: value.show.title,
|
||||
@@ -286,14 +340,13 @@
|
||||
newValue = value;
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (currentSimklAnime.user_rating !== submitData.rating) {
|
||||
await SimklSyncRating(
|
||||
currentSimklAnime,
|
||||
submitData.rating,
|
||||
).then((value) => {
|
||||
await App.SimklSyncRating(currentSimklAnime, submitData.rating).then(
|
||||
(value) => {
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${value.show.ids.simkl}`,
|
||||
title: value.show.title,
|
||||
@@ -310,11 +363,12 @@
|
||||
newValue = value;
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (currentSimklAnime.status !== submitData.status.simkl) {
|
||||
await SimklSyncStatus(
|
||||
await App.SimklSyncStatus(
|
||||
currentSimklAnime,
|
||||
submitData.status.simkl,
|
||||
).then((value) => {
|
||||
@@ -337,19 +391,30 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("Error submitting Simkl changes:", error);
|
||||
setApiError(
|
||||
"simkl",
|
||||
`Failed to sync Simkl: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
submitting.set(false);
|
||||
submitSuccess.set(true);
|
||||
watchlistNeedsRefresh.set(true);
|
||||
setTimeout(() => submitSuccess.set(false), 2000);
|
||||
};
|
||||
|
||||
const deleteEntries = async () => {
|
||||
submitting.set(true);
|
||||
try {
|
||||
if (
|
||||
isAniListLoggedIn &&
|
||||
currentAniListAnime.data.MediaList.mediaId !== 0
|
||||
) {
|
||||
await AniListDeleteEntry(currentAniListAnime.data.MediaList.id);
|
||||
await App.AniListDeleteEntry(currentAniListAnime.data.MediaList.id);
|
||||
AddAnimeServiceToTable({
|
||||
id: `a-${currentAniListAnime.data.MediaList.mediaId}`,
|
||||
title,
|
||||
@@ -363,8 +428,20 @@
|
||||
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) {
|
||||
await DeleteMyAnimeListEntry(currentMalAnime.id);
|
||||
await App.DeleteMyAnimeListEntry(currentMalAnime.id);
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
title: currentMalAnime.title,
|
||||
@@ -378,8 +455,20 @@
|
||||
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) {
|
||||
await SimklSyncRemove(currentSimklAnime);
|
||||
await App.SimklSyncRemove(currentSimklAnime);
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${currentSimklAnime.show.ids.simkl}`,
|
||||
title: currentSimklAnime.show.title,
|
||||
@@ -393,8 +482,19 @@
|
||||
notes: "",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("Error deleting Simkl entry:", error);
|
||||
setApiError(
|
||||
"simkl",
|
||||
`Failed to delete Simkl entry: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
submitting.set(false);
|
||||
submitSuccess.set(true);
|
||||
watchlistNeedsRefresh.set(true);
|
||||
setTimeout(() => submitSuccess.set(false), 2000);
|
||||
};
|
||||
|
||||
@@ -408,8 +508,7 @@
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode !== 0
|
||||
) {
|
||||
max =
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode -
|
||||
1;
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode - 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -418,7 +517,7 @@
|
||||
{title}
|
||||
</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-10 grid-flow-col gap-4">
|
||||
<div class="md:col-span-2 space-y-3">
|
||||
<div class="md:col-span-2 space-y-3 flex flex-col items-center">
|
||||
<img
|
||||
class="rounded-lg"
|
||||
src={currentAniListAnime.data.MediaList.media.coverImage.large}
|
||||
@@ -440,14 +539,27 @@
|
||||
<div class="relative flex items-center max-w-[8rem]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease episode progress"
|
||||
id="decrement-button"
|
||||
data-input-counter-decrement="quantity-input"
|
||||
on:click={() =>
|
||||
(currentAniListAnime.data.MediaList.progress -= 1)}
|
||||
class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-s-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
on:click={() => {
|
||||
currentAniListAnime.data.MediaList.progress -= 1;
|
||||
if (
|
||||
currentAniListAnime.data.MediaList.progress <
|
||||
currentAniListAnime.data.MediaList.media.episodes
|
||||
) {
|
||||
startingAnilistStatusOption = statusOptions[0];
|
||||
if (currentAniListAnime.data.MediaList.repeat === 0)
|
||||
completedAtDate = null;
|
||||
}
|
||||
}}
|
||||
disabled={currentAniListAnime.data.MediaList.progress <= 0}
|
||||
class={currentAniListAnime.data.MediaList.progress <= 0
|
||||
? "border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-gray-900 dark:text-white"
|
||||
class="w-3 h-3 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -470,33 +582,63 @@
|
||||
id="episodes"
|
||||
class="border border-x-0 p-2.5 h-11 text-center text-sm block w-full placeholder-gray-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none
|
||||
{currentAniListAnime.data.MediaList.progress < 0 ||
|
||||
(currentAniListAnime.data.MediaList.media.episodes >
|
||||
0 &&
|
||||
(currentAniListAnime.data.MediaList.media.episodes > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >
|
||||
currentAniListAnime.data.MediaList.media
|
||||
.episodes) ||
|
||||
(currentAniListAnime.data.MediaList.media
|
||||
.nextAiringEpisode.episode > 0 &&
|
||||
currentAniListAnime.data.MediaList.media.episodes) ||
|
||||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >
|
||||
currentAniListAnime.data.MediaList.media
|
||||
.nextAiringEpisode.episode -
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode -
|
||||
1)
|
||||
? 'border-red-500 border-[2px] text-rose-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'bg-gray-700 hover:bg-gray-600 border-gray-600 text-white focus:ring-blue-500 focus:border-blue-500'} w-24"
|
||||
bind:value={currentAniListAnime.data.MediaList
|
||||
.progress}
|
||||
bind:value={currentAniListAnime.data.MediaList.progress}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase episode progress"
|
||||
id="increment-button"
|
||||
data-input-counter-increment="quantity-input"
|
||||
on:click={() =>
|
||||
(currentAniListAnime.data.MediaList.progress += 1)}
|
||||
class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-e-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
on:click={() => {
|
||||
currentAniListAnime.data.MediaList.progress += 1;
|
||||
if (
|
||||
currentAniListAnime.data.MediaList.media.episodes ===
|
||||
currentAniListAnime.data.MediaList.progress
|
||||
) {
|
||||
startingAnilistStatusOption = statusOptions[2];
|
||||
completedAtDate = new Date();
|
||||
}
|
||||
if (currentAniListAnime.data.MediaList.progress - 1 === 0) {
|
||||
startingAnilistStatusOption = statusOptions[0];
|
||||
if (startedAtDate === null) startedAtDate = new Date();
|
||||
}
|
||||
}}
|
||||
disabled={(currentAniListAnime.data.MediaList.media.episodes >
|
||||
0 &&
|
||||
currentAniListAnime.data.MediaList.progress >=
|
||||
currentAniListAnime.data.MediaList.media.episodes) ||
|
||||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode -
|
||||
2)}
|
||||
class={(currentAniListAnime.data.MediaList.media.episodes > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >=
|
||||
currentAniListAnime.data.MediaList.media.episodes) ||
|
||||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode -
|
||||
2)
|
||||
? "border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-gray-900 dark:text-white"
|
||||
class="w-3 h-3 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -513,24 +655,22 @@
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
/ {currentAniListAnime.data.MediaList.media
|
||||
.nextAiringEpisode.episode !== 0
|
||||
? currentAniListAnime.data.MediaList.media
|
||||
.nextAiringEpisode.episode - 1
|
||||
/ {currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode !== 0
|
||||
? currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode - 1
|
||||
: currentAniListAnime.data.MediaList.media.episodes}
|
||||
</div>
|
||||
{#if currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode !== 0}
|
||||
<div>
|
||||
of {currentAniListAnime.data.MediaList.media
|
||||
.episodes}
|
||||
of {currentAniListAnime.data.MediaList.media.episodes}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="status"
|
||||
class="text-left block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Status</label
|
||||
>
|
||||
<select
|
||||
@@ -556,33 +696,16 @@
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Date Started</label
|
||||
>
|
||||
<div class="relative max-w-sm">
|
||||
<div
|
||||
class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z"
|
||||
<Datepicker
|
||||
bind:value={startedAtDate}
|
||||
color="gray"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
showActionButtons
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="startedAt"
|
||||
type="date"
|
||||
name="startedAt"
|
||||
class="border text-sm rounded-lg
|
||||
focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 bg-gray-700 border-gray-600
|
||||
placeholder-gray-400 text-white"
|
||||
value={startedAtDate}
|
||||
placeholder="Date Started"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
@@ -590,33 +713,16 @@
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Date Completed</label
|
||||
>
|
||||
<div class="relative max-w-sm">
|
||||
<div
|
||||
class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z"
|
||||
<Datepicker
|
||||
bind:value={completedAtDate}
|
||||
color="gray"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
showActionButtons
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="completedAt"
|
||||
type="date"
|
||||
name="completedAt"
|
||||
class="border text-sm rounded-lg
|
||||
block w-full ps-10 p-2.5 bg-gray-700 border-gray-600
|
||||
placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
value={completedAtDate}
|
||||
placeholder="Date Completed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
@@ -629,8 +735,7 @@
|
||||
name="repeat"
|
||||
min="0"
|
||||
id="repeat"
|
||||
class="border {currentAniListAnime.data.MediaList
|
||||
.repeat < 0
|
||||
class="border {currentAniListAnime.data.MediaList.repeat < 0
|
||||
? 'border-red-500 border-[2px] text-rose-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-500 text-white focus:ring-blue-500 focus:border-blue-500'} text-sm rounded-lg block w-24 p-2.5 bg-gray-600 placeholder-gray-400 text-white"
|
||||
bind:value={currentAniListAnime.data.MediaList.repeat}
|
||||
@@ -659,63 +764,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="external-data">
|
||||
<div
|
||||
id="anilist-data"
|
||||
class="flex flex-col md:flex-row md:pl-10 md:pr-10 pt-5 pb-5 justify-center md:gap-x-16 lg:gap-x-36 group"
|
||||
>
|
||||
<h2 class="text-left mb-1 text-base font-semibold text-white">
|
||||
AniList
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimeTable />
|
||||
|
||||
<div class="flex rounded-lg shadow max-w-4-4 bg-gray-800">
|
||||
<div
|
||||
class="w-full mx-auto max-w-screen-xl p-4 md:flex md:items-center md:justify-start"
|
||||
>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
id="delete-button"
|
||||
class="text-white bg-red-700 {$submitSuccess
|
||||
? 'bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 focus:ring-4 focus:ring-green-800 dark:focus:ring-green-800'
|
||||
: 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-700 focus:ring-4 focus:ring-red-800 dark:focus:ring-red-800'} font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none"
|
||||
on:click={deleteEntries}
|
||||
>
|
||||
<svg
|
||||
id="submit-loader"
|
||||
aria-hidden="true"
|
||||
role="status"
|
||||
class="{isSubmitting
|
||||
? 'inline'
|
||||
: 'hidden'} w-4 h-4 me-3 text-white animate-spin"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="#E5E7EB"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Delete Entries
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex mb-4 rounded-lg shadow max-w-4-4 bg-gray-800">
|
||||
<div
|
||||
class="w-full mx-auto max-w-screen-xl p-4 md:flex md:items-center md:justify-end"
|
||||
>
|
||||
<Button
|
||||
<button
|
||||
disabled={isSubmitting}
|
||||
id="sync-button"
|
||||
class="text-white {$submitSuccess
|
||||
? 'bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 focus:ring-4 focus:ring-green-800 dark:focus:ring-green-800'
|
||||
: 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:ring-4 focus:ring-blue-800 dark:focus:ring-blue-800'} font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none"
|
||||
? 'bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-800'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-800'} font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none"
|
||||
type="submit"
|
||||
>
|
||||
<svg
|
||||
@@ -739,23 +797,145 @@
|
||||
/>
|
||||
</svg>
|
||||
Sync Changes
|
||||
</Button>
|
||||
<Button
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-gray-800 border border-gray-600 focus:outline-none hover:bg-gray-700 focus:ring-4
|
||||
focus:ring-gray-700 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white
|
||||
dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
|
||||
focus:ring-gray-700 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2
|
||||
hover:border-gray-600"
|
||||
on:click={async () => {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
return push("/");
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimeTable />
|
||||
|
||||
<div class="flex rounded-lg shadow max-w-4-4 bg-gray-800">
|
||||
<div
|
||||
class="w-full mx-auto max-w-screen-xl p-4 md:flex md:items-center md:justify-start"
|
||||
>
|
||||
<button
|
||||
disabled={isSubmitting}
|
||||
id="delete-button"
|
||||
class="text-white {$submitSuccess
|
||||
? 'bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-800'
|
||||
: 'bg-red-600 hover:bg-red-700 focus:ring-4 focus:ring-red-800'} font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none"
|
||||
on:click={deleteEntries}
|
||||
>
|
||||
<svg
|
||||
id="submit-loader"
|
||||
aria-hidden="true"
|
||||
role="status"
|
||||
class="{isSubmitting
|
||||
? 'inline'
|
||||
: 'hidden'} w-4 h-4 me-3 text-white animate-spin"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="#E5E7EB"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Delete Entries
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="w-full mx-auto max-w-screen-xl p-4 md:flex md:items-center md:justify-end"
|
||||
>
|
||||
<button
|
||||
disabled={isSubmitting}
|
||||
id="sync-button"
|
||||
class="text-white {$submitSuccess
|
||||
? 'bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-800'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-800'} font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none"
|
||||
type="submit"
|
||||
>
|
||||
<svg
|
||||
id="submit-loader"
|
||||
aria-hidden="true"
|
||||
role="status"
|
||||
class="{isSubmitting
|
||||
? 'inline'
|
||||
: 'hidden'} w-4 h-4 me-3 text-white animate-spin"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="#E5E7EB"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Sync Changes
|
||||
</button>
|
||||
<button
|
||||
class="text-white bg-gray-800 border border-gray-600 focus:outline-none hover:bg-gray-700 focus:ring-4
|
||||
focus:ring-gray-700 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2
|
||||
hover:border-gray-600"
|
||||
on:click={async () => {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
return push("/");
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex m-5">
|
||||
<div>
|
||||
<h3 class="text-2xl">Genres</h3>
|
||||
{#each currentAniListAnime.data.MediaList.media.genres || [] as genre}
|
||||
<div>
|
||||
<Badge large border color="blue" class="m-1 w-52">
|
||||
<div>
|
||||
<WebsiteLink
|
||||
id={genre}
|
||||
url="https://anilist.co/search/anime/{genre}"
|
||||
/>
|
||||
</div>
|
||||
</Badge>
|
||||
<Tooltip>{genre}</Tooltip>
|
||||
</div>
|
||||
{/each}
|
||||
<h3 class="text-2xl">Tags</h3>
|
||||
<div class="mt-2">
|
||||
{#each currentAniListAnime.data.MediaList.media.tags as tag}
|
||||
<div>
|
||||
<Badge large border color="blue" class="m-1 w-52">
|
||||
<div>
|
||||
<WebsiteLink
|
||||
id={tag.name}
|
||||
url="https://anilist.co/search/anime/?genres={tag.name}"
|
||||
/>
|
||||
<span class="text-xs">({tag.rank}%)</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Tooltip>{tag.description}</Tooltip>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-5">
|
||||
<h3 class="text-2xl">Summary</h3>
|
||||
<p>{@html currentAniListAnime.data.MediaList.media.description}</p>
|
||||
<p class="rounded border border-gray-700 p-2 mt-2">
|
||||
{@html currentAniListAnime.data.MediaList.media.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,118 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createRender,
|
||||
createTable,
|
||||
Render,
|
||||
Subscribe,
|
||||
} from "svelte-headless-table"
|
||||
// @ts-ignore
|
||||
import { addSortBy } from "svelte-headless-table/plugins"
|
||||
import { tableItems } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte"
|
||||
import WebsiteLink from "./WebsiteLink.svelte"
|
||||
import { tableItems } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import type { TableItem } from "../helperTypes/TableTypes";
|
||||
import WebsiteLink from "./WebsiteLink.svelte";
|
||||
|
||||
//when adding sort here is code { sort: addSortBy() }
|
||||
const table = createTable(tableItems, { sort: addSortBy() })
|
||||
type SortKey = keyof TableItem;
|
||||
type SortOrder = "asc" | "desc" | undefined;
|
||||
|
||||
const columns = table.createColumns([
|
||||
table.column({
|
||||
header: "Service Id",
|
||||
cell: ({ value }) => createRender(WebsiteLink, {id: value}),
|
||||
accessor: "id",
|
||||
}),
|
||||
table.column({
|
||||
header: "Anime Title",
|
||||
accessor: "title",
|
||||
}),
|
||||
table.column({
|
||||
header: "Service",
|
||||
accessor: "service",
|
||||
}),
|
||||
table.column({
|
||||
header: "Episode Progress",
|
||||
accessor: "progress",
|
||||
}),
|
||||
table.column({
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
}),
|
||||
table.column({
|
||||
header: "Started At",
|
||||
accessor: "startedAt",
|
||||
}),
|
||||
table.column({
|
||||
header: "Completed At",
|
||||
accessor: "completedAt",
|
||||
}),
|
||||
table.column({
|
||||
header: "Rating",
|
||||
accessor: "score",
|
||||
}),
|
||||
table.column({
|
||||
header: "Repeat",
|
||||
accessor: "repeat",
|
||||
}),
|
||||
table.column({
|
||||
header: "Notes",
|
||||
accessor: "notes",
|
||||
}),
|
||||
])
|
||||
const columns: { key: SortKey; header: string }[] = [
|
||||
{ key: "id", header: "Service Id" },
|
||||
{ key: "title", header: "Anime Title" },
|
||||
{ key: "service", header: "Service" },
|
||||
{ key: "progress", header: "Episode Progress" },
|
||||
{ key: "status", header: "Status" },
|
||||
{ key: "startedAt", header: "Started At" },
|
||||
{ key: "completedAt", header: "Completed At" },
|
||||
{ key: "score", header: "Rating" },
|
||||
{ key: "repeat", header: "Repeat" },
|
||||
{ key: "notes", header: "Notes" },
|
||||
];
|
||||
|
||||
//add pluginStates when add sort back
|
||||
const { headerRows, rows, tableAttrs, tableBodyAttrs } =
|
||||
table.createViewModel(columns)
|
||||
let sortKey = $state<SortKey | undefined>(undefined);
|
||||
let sortOrder = $state<SortOrder>(undefined);
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey !== key) {
|
||||
sortKey = key;
|
||||
sortOrder = "asc";
|
||||
} else if (sortOrder === "asc") {
|
||||
sortOrder = "desc";
|
||||
} else if (sortOrder === "desc") {
|
||||
sortKey = undefined;
|
||||
sortOrder = undefined;
|
||||
} else {
|
||||
sortOrder = "asc";
|
||||
}
|
||||
}
|
||||
|
||||
function compareItems(a: TableItem, b: TableItem): number {
|
||||
if (sortKey === undefined || sortOrder === undefined) return 0;
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
let result: number;
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
result = av - bv;
|
||||
} else {
|
||||
result = String(av ?? "").localeCompare(String(bv ?? ""));
|
||||
}
|
||||
return sortOrder === "asc" ? result : -result;
|
||||
}
|
||||
|
||||
let sortedRows = $derived(
|
||||
sortKey === undefined ? $tableItems : [...$tableItems].sort(compareItems),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="relative overflow-x-auto rounded-lg mb-5">
|
||||
<table
|
||||
class="w-full text-sm text-left rtl:text-right text-gray-400"
|
||||
{...$tableAttrs}
|
||||
>
|
||||
<table class="w-full text-sm text-left rtl:text-right text-gray-400">
|
||||
<thead class="text-xs uppercase bg-gray-700 text-gray-400">
|
||||
{#each $headerRows as headerRow (headerRow.id)}
|
||||
<Subscribe attrs={headerRow.attrs()} let:attrs>
|
||||
<tr {...attrs}>
|
||||
{#each headerRow.cells as cell (cell.id)}
|
||||
<Subscribe
|
||||
attrs={cell.attrs()}
|
||||
let:attrs
|
||||
props={cell.props()}
|
||||
let:props
|
||||
>
|
||||
<tr>
|
||||
{#each columns as column (column.key)}
|
||||
<th
|
||||
{...attrs}
|
||||
on:click={props.sort.toggle}
|
||||
class:sorted={props.sort.order !==
|
||||
undefined}
|
||||
class="px-6 py-3"
|
||||
onclick={() => toggleSort(column.key)}
|
||||
class:sorted={sortKey === column.key &&
|
||||
sortOrder !== undefined}
|
||||
class="px-6 py-3 cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<Render of={cell.render()} />
|
||||
{#if props.sort.order === "asc"}
|
||||
{column.header}
|
||||
{#if sortKey === column.key && sortOrder === "asc"}
|
||||
⬇️
|
||||
{:else if props.sort.order === "desc"}
|
||||
{:else if sortKey === column.key && sortOrder === "desc"}
|
||||
⬆️
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tr>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</thead>
|
||||
<tbody {...$tableBodyAttrs}>
|
||||
{#each $rows as row (row.id)}
|
||||
<Subscribe attrs={row.attrs()} let:attrs>
|
||||
<tr {...attrs} class="bg-gray-800 border-gray-700">
|
||||
{#each row.cells as cell (cell.id)}
|
||||
<Subscribe attrs={cell.attrs()} let:attrs>
|
||||
<td {...attrs} class="px-6 py-4">
|
||||
<Render of={cell.render()} />
|
||||
<tbody>
|
||||
{#each sortedRows as row (row.id + row.service)}
|
||||
<tr class="bg-gray-800 border-gray-700">
|
||||
{#each columns as column (column.key)}
|
||||
<td class="px-6 py-4">
|
||||
{#if column.key === "id"}
|
||||
<WebsiteLink id={row.id} />
|
||||
{:else}
|
||||
{row[column.key]}
|
||||
{/if}
|
||||
</td>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tr>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "flowbite-svelte";
|
||||
import type { AniListUser } from "../anilist/types/AniListTypes";
|
||||
import type { AniListUser, MyAnimeListUser, SimklUser } from "../../bindings/AniTrack/models";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListUser,
|
||||
@@ -14,28 +14,49 @@
|
||||
logoutOfAniList,
|
||||
logoutOfMAL,
|
||||
logoutOfSimkl,
|
||||
serviceLoggingIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import * as runtime from "../../wailsjs/runtime";
|
||||
import type {MyAnimeListUser} from "../mal/types/MALTypes";
|
||||
import type {SimklUser} from "../simkl/types/simklTypes";
|
||||
import {Application} from "@wailsio/runtime";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
let currentAniListUser: AniListUser;
|
||||
let currentMALUser: MyAnimeListUser;
|
||||
let currentSimklUser: SimklUser;
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
let isMALLoggedIn: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let loggingIn: string[] = [];
|
||||
|
||||
aniListUser.subscribe((value) => (currentAniListUser = value));
|
||||
malUser.subscribe((value) => (currentMALUser = value))
|
||||
simklUser.subscribe(value => currentSimklUser = value)
|
||||
malUser.subscribe((value) => (currentMALUser = value));
|
||||
simklUser.subscribe((value) => (currentSimklUser = value));
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||
serviceLoggingIn.subscribe((value) => (loggingIn = value));
|
||||
|
||||
function dropdownUser(): void {
|
||||
let dropdown = document.querySelector("#userDropdown");
|
||||
const dropdown = document.querySelector("#userDropdown");
|
||||
if (!dropdown) return;
|
||||
dropdown.classList.toggle("hidden");
|
||||
|
||||
if (!dropdown.classList.contains("hidden")) {
|
||||
document.addEventListener("click", clickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(event: Event): void {
|
||||
const dropdown = document.querySelector("#userDropdown");
|
||||
const toggleBtn = document.querySelector("#userDropdownButton");
|
||||
if (!dropdown || !toggleBtn) return;
|
||||
|
||||
if (
|
||||
!dropdown.contains(event.target as Node) &&
|
||||
!toggleBtn.contains(event.target as Node)
|
||||
) {
|
||||
dropdown.classList.add("hidden");
|
||||
document.removeEventListener("click", clickOutside);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -45,7 +66,9 @@
|
||||
<Avatar
|
||||
src={currentAniListUser.data.Viewer.avatar.medium}
|
||||
class="cursor-pointer"
|
||||
dot={{ color: "green" }}
|
||||
dot={isAniListLoggedIn && isMALLoggedIn && isSimklLoggedIn
|
||||
? { color: "green" }
|
||||
: { color: "yellow" }}
|
||||
/>
|
||||
{:else}
|
||||
<Avatar class="cursor-pointer" dot={{ color: "red" }} />
|
||||
@@ -72,15 +95,30 @@
|
||||
on:click={logoutOfAniList}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-green-800 hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg text-green-200 mr-4">A</span>Logout {currentAniListUser.data.Viewer.name}
|
||||
<span class="maple-font text-lg text-green-200 mr-4">A</span>Logout {currentAniListUser
|
||||
.data.Viewer.name}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={loginToAniList}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
|
||||
{#if loggingIn.includes("anilist")}
|
||||
<span class="flex items-center px-4 py-2 w-full truncate">
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span class="maple-font text-lg mr-4">A</span>Checking AniList
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => {
|
||||
dropdownUser();
|
||||
loginToAniList();
|
||||
}}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg mr-4">A</span>Login to AniList
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{#if isMALLoggedIn}
|
||||
@@ -94,10 +132,24 @@
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={loginToMAL}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
|
||||
{#if loggingIn.includes("mal")}
|
||||
<span class="flex items-center px-4 py-2 w-full truncate">
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span class="maple-font text-lg mr-4">M</span>Checking MyAnimeList
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => {
|
||||
dropdownUser();
|
||||
loginToMAL();
|
||||
}}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg mr-4">M</span>Login to MyAnimeList
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{#if isSimklLoggedIn}
|
||||
@@ -106,21 +158,45 @@
|
||||
on:click={logoutOfSimkl}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-indigo-800 hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg text-indigo-200 mr-4">S</span>Logout {currentSimklUser.user.name}
|
||||
<span class="maple-font text-lg text-indigo-200 mr-4">S</span>Logout {currentSimklUser
|
||||
.user.name}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={loginToSimkl}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white">
|
||||
{#if loggingIn.includes("simkl")}
|
||||
<span class="flex items-center px-4 py-2 w-full truncate">
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span class="maple-font text-lg mr-4">S</span>Checking Simkl
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => {
|
||||
dropdownUser();
|
||||
loginToSimkl();
|
||||
}}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg mr-4">S</span>Login to Simkl
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
<div class="py-2">
|
||||
<button
|
||||
on:click={() => runtime.Quit()}
|
||||
on:click={() => {
|
||||
dropdownUser();
|
||||
App.ShowVersion();
|
||||
}}
|
||||
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
|
||||
>
|
||||
Version
|
||||
</button>
|
||||
<button
|
||||
on:click={() => Application.Quit()}
|
||||
class="block px-4 py-2 w-full text-sm hover:bg-gray-600 text-gray-200 over:text-white"
|
||||
>
|
||||
Exit Application
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Button } from "flowbite-svelte";
|
||||
import type { ButtonProps } from "flowbite-svelte";
|
||||
|
||||
export let value: Date | null = null;
|
||||
export let defaultDate: Date | null = null;
|
||||
export let range: boolean = false;
|
||||
export let rangeFrom: Date | null = null;
|
||||
export let rangeTo: Date | null = null;
|
||||
export let locale: string = "default";
|
||||
export let firstDayOfWeek: number = 0; // 0 = Monday, 6 = Sunday
|
||||
export let dateFormat: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
export let placeholder: string = "Select date";
|
||||
export let disabled: boolean = false;
|
||||
export let required: boolean = false;
|
||||
export let inputClass: string = "";
|
||||
export let color: ButtonProps["color"] = "primary";
|
||||
export let inline: boolean = false;
|
||||
export let autohide: boolean = true;
|
||||
export let showActionButtons: boolean = false;
|
||||
export let title: string = "";
|
||||
|
||||
// Internal state
|
||||
const dispatch = createEventDispatcher();
|
||||
let isOpen: boolean = inline;
|
||||
let inputElement: HTMLInputElement;
|
||||
let datepickerContainerElement: HTMLDivElement;
|
||||
let currentMonth: Date = value || defaultDate || new Date();
|
||||
let focusedDate: Date | null = null;
|
||||
let calendarRef: HTMLDivElement;
|
||||
|
||||
$: daysInMonth = getDaysInMonth(currentMonth);
|
||||
$: weekdays = getWeekdays();
|
||||
|
||||
onMount(() => {
|
||||
if (!inline) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Color handling functions
|
||||
function getFocusRingClass(color: ButtonProps["color"]): string {
|
||||
switch (color) {
|
||||
case "primary":
|
||||
return "focus:ring-2 focus:ring-primary-400";
|
||||
case "blue":
|
||||
return "focus:ring-2 focus:ring-blue-400";
|
||||
case "red":
|
||||
return "focus:ring-2 focus:ring-red-400";
|
||||
case "green":
|
||||
return "focus:ring-2 focus:ring-green-400";
|
||||
case "yellow":
|
||||
return "focus:ring-2 focus:ring-yellow-400";
|
||||
case "purple":
|
||||
return "focus:ring-2 focus:ring-purple-400";
|
||||
case "gray":
|
||||
return "focus:ring-2 focus:ring-gray-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getRangeBackgroundClass(color: ButtonProps["color"]): string {
|
||||
switch (color) {
|
||||
case "primary":
|
||||
return "bg-primary-900";
|
||||
case "blue":
|
||||
return "bg-blue-900";
|
||||
case "red":
|
||||
return "bg-red-900";
|
||||
case "green":
|
||||
return "bg-green-900";
|
||||
case "yellow":
|
||||
return "bg-yellow-900";
|
||||
case "purple":
|
||||
return "bg-purple-900";
|
||||
case "gray":
|
||||
return "bg-gray-900";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getDaysInMonth(date: Date): Date[] {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 0);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysArray: Date[] = [];
|
||||
|
||||
// Add days from previous month to fill the first week
|
||||
let start = firstDay.getDay() - firstDayOfWeek;
|
||||
if (start < 0) start += 7;
|
||||
for (let i = 0; i < start; i++) {
|
||||
daysArray.unshift(new Date(year, month, -i));
|
||||
}
|
||||
|
||||
// Add days of the current month
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
daysArray.push(new Date(year, month, i));
|
||||
}
|
||||
|
||||
// Add days from next month to fill the last week
|
||||
const remainingDays = 7 - (daysArray.length % 7);
|
||||
if (remainingDays < 7) {
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
daysArray.push(new Date(year, month + 1, i));
|
||||
}
|
||||
}
|
||||
|
||||
return daysArray;
|
||||
}
|
||||
|
||||
function getWeekdays(): string[] {
|
||||
const weekdays = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(2021, 5, i + firstDayOfWeek);
|
||||
weekdays.push(day.toLocaleString(locale, { weekday: "short" }));
|
||||
}
|
||||
return weekdays;
|
||||
}
|
||||
|
||||
function changeMonth(increment: number) {
|
||||
currentMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + increment,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function handleDaySelect(day: Date) {
|
||||
if (range) {
|
||||
if (!rangeFrom || (rangeFrom && rangeTo)) {
|
||||
rangeFrom = day;
|
||||
rangeTo = null;
|
||||
} else if (day < rangeFrom) {
|
||||
rangeTo = rangeFrom;
|
||||
rangeFrom = day;
|
||||
} else {
|
||||
rangeTo = day;
|
||||
}
|
||||
dispatch("select", { from: rangeFrom, to: rangeTo });
|
||||
} else {
|
||||
value = day;
|
||||
dispatch("select", value);
|
||||
if (autohide && !inline) isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
const date = new Date(inputElement.value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
handleDaySelect(date);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
isOpen &&
|
||||
datepickerContainerElement &&
|
||||
!datepickerContainerElement.contains(event.target as Node)
|
||||
) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString(locale, dateFormat);
|
||||
}
|
||||
|
||||
function isSameDate(date1: Date | null, date2: Date | null): boolean {
|
||||
if (!date1 || !date2) return false;
|
||||
return date1.toDateString() === date2.toDateString();
|
||||
}
|
||||
|
||||
$: isSelected = (day: Date): boolean => {
|
||||
if (range) {
|
||||
return isSameDate(day, rangeFrom) || isSameDate(day, rangeTo);
|
||||
}
|
||||
return isSameDate(day, value);
|
||||
};
|
||||
|
||||
function isInRange(day: Date): boolean {
|
||||
if (!range || !rangeFrom || !rangeTo) return false;
|
||||
return day > rangeFrom && day < rangeTo;
|
||||
}
|
||||
|
||||
function isToday(day: Date): boolean {
|
||||
const today = new Date();
|
||||
return day.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function handleCalendarKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (!focusedDate) {
|
||||
focusedDate = value || new Date();
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
focusedDate = new Date(
|
||||
focusedDate.getFullYear(),
|
||||
focusedDate.getMonth(),
|
||||
focusedDate.getDate() - 1,
|
||||
);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
focusedDate = new Date(
|
||||
focusedDate.getFullYear(),
|
||||
focusedDate.getMonth(),
|
||||
focusedDate.getDate() + 1,
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
focusedDate = new Date(
|
||||
focusedDate.getFullYear(),
|
||||
focusedDate.getMonth(),
|
||||
focusedDate.getDate() - 7,
|
||||
);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
focusedDate = new Date(
|
||||
focusedDate.getFullYear(),
|
||||
focusedDate.getMonth(),
|
||||
focusedDate.getDate() + 7,
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
handleDaySelect(focusedDate);
|
||||
break;
|
||||
case "Escape":
|
||||
isOpen = false;
|
||||
inputElement.focus();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (focusedDate.getMonth() !== currentMonth.getMonth()) {
|
||||
currentMonth = new Date(
|
||||
focusedDate.getFullYear(),
|
||||
focusedDate.getMonth(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Focus the button for the focused date
|
||||
setTimeout(() => {
|
||||
const focusedButton = calendarRef.querySelector(
|
||||
`button[aria-label="${focusedDate!.toLocaleDateString(locale, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}"]`,
|
||||
) as HTMLButtonElement | null;
|
||||
focusedButton?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
function handleToday() {
|
||||
handleDaySelect(new Date());
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
value = null;
|
||||
rangeFrom = null;
|
||||
rangeTo = null;
|
||||
dispatch("clear");
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
dispatch("apply", range ? { from: rangeFrom, to: rangeTo } : value);
|
||||
if (!inline) isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={datepickerContainerElement}
|
||||
class="relative {inline ? 'inline-block' : ''}"
|
||||
>
|
||||
{#if !inline}
|
||||
<div class="relative">
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
class="w-full px-4 py-3 text-sm border rounded-md focus:outline-none bg-gray-700 text-white border-gray-600 {getFocusRingClass(
|
||||
color,
|
||||
)} {inputClass}"
|
||||
{placeholder}
|
||||
value={range
|
||||
? `${formatDate(rangeFrom)} - ${formatDate(rangeTo)}`
|
||||
: formatDate(value)}
|
||||
on:focus={() => (isOpen = true)}
|
||||
on:input={handleInputChange}
|
||||
on:keydown={handleInputKeydown}
|
||||
{disabled}
|
||||
{required}
|
||||
aria-haspopup="dialog"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 focus:outline-none"
|
||||
on:click={() => (isOpen = !isOpen)}
|
||||
{disabled}
|
||||
aria-label={isOpen ? "Close date picker" : "Open date picker"}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isOpen || inline}
|
||||
<div
|
||||
bind:this={calendarRef}
|
||||
id="datepicker-dropdown"
|
||||
class="
|
||||
{inline ? '' : 'absolute z-10 mt-1'}
|
||||
bg-gray-800 rounded-md shadow-lg"
|
||||
transition:fade={{ duration: 100 }}
|
||||
role="dialog"
|
||||
aria-label="Calendar"
|
||||
>
|
||||
<div class="p-4" role="application">
|
||||
{#if title}
|
||||
<h2 class="text-lg font-semibold mb-4 text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
onclick={() => changeMonth(-1)}
|
||||
{color}
|
||||
size="sm"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 rtl:rotate-180 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 10"
|
||||
><path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 5H1m0 0 4 4M1 5l4-4"
|
||||
></path></svg
|
||||
>
|
||||
</Button>
|
||||
<h3
|
||||
class="text-lg font-semibold text-white"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentMonth.toLocaleString(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
onclick={() => changeMonth(1)}
|
||||
{color}
|
||||
size="sm"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 rtl:rotate-180 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 10"
|
||||
><path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M1 5h12m0 0L9 1m4 4L9 9"
|
||||
></path></svg
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1" role="grid">
|
||||
{#each weekdays as day}
|
||||
<div
|
||||
class="text-center text-sm font-medium text-gray-400"
|
||||
role="columnheader"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
{/each}
|
||||
{#each daysInMonth as day}
|
||||
<Button
|
||||
color={isSelected(day) ? color : "alternative"}
|
||||
size="sm"
|
||||
class="w-full h-8 {day.getMonth() !==
|
||||
currentMonth.getMonth()
|
||||
? 'text-gray-600'
|
||||
: ''} {isToday(day)
|
||||
? 'font-bold'
|
||||
: ''} {isInRange(day)
|
||||
? getRangeBackgroundClass(color)
|
||||
: ''}"
|
||||
onclick={() => handleDaySelect(day)}
|
||||
onkeydown={handleCalendarKeydown}
|
||||
aria-label={day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
aria-selected={isSelected(day)}
|
||||
role="gridcell"
|
||||
>
|
||||
{day.getDate()}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showActionButtons}
|
||||
<div class="mt-4 flex justify-between">
|
||||
<Button onclick={handleToday} {color} size="sm"
|
||||
>Today</Button
|
||||
>
|
||||
<Button onclick={handleClear} color="red" size="sm"
|
||||
>Clear</Button
|
||||
>
|
||||
<Button onclick={handleApply} {color} size="sm"
|
||||
>Apply</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@component
|
||||
[Go to docs](https://flowbite-svelte.com/)
|
||||
## Props
|
||||
@prop export let value: Date | null = null;
|
||||
@prop export let defaultDate: Date | null = null;
|
||||
@prop export let range: boolean = false;
|
||||
@prop export let rangeFrom: Date | null = null;
|
||||
@prop export let rangeTo: Date | null = null;
|
||||
@prop export let locale: string = 'default';
|
||||
@prop export let firstDayOfWeek: number = 0;
|
||||
@prop export let dateFormat: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
@prop export let placeholder: string = 'Select date';
|
||||
@prop export let disabled: boolean = false;
|
||||
@prop export let required: boolean = false;
|
||||
@prop export let inputClass: string = '';
|
||||
@prop export let color: Button['color'] = 'primary';
|
||||
@prop export let inline: boolean = false;
|
||||
@prop export let autohide: boolean = true;
|
||||
@prop export let showActionButtons: boolean = false;
|
||||
@prop export let title: string = '';
|
||||
-->
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
apiError,
|
||||
isApiDown,
|
||||
clearApiError,
|
||||
setApiError,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import { CheckIfMALLoggedInAndSetUser } from "../helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
||||
import { CheckIfSimklLoggedInAndSetUser } from "../helperModules/CheckIsSimklLoggedIn.svelte";
|
||||
import { Modal, Button } from "flowbite-svelte";
|
||||
let showModal = false;
|
||||
$: if ($apiError) {
|
||||
showModal = true;
|
||||
}
|
||||
async function handleRetry() {
|
||||
const service = $apiError?.service;
|
||||
clearApiError();
|
||||
|
||||
try {
|
||||
if (service === "anilist") {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
} else if (service === "mal") {
|
||||
await CheckIfMALLoggedInAndSetUser();
|
||||
} else if (service === "simkl") {
|
||||
await CheckIfSimklLoggedInAndSetUser();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
setApiError(
|
||||
service || "unknown",
|
||||
`Retry failed: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
function handleDismiss() {
|
||||
clearApiError();
|
||||
showModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showModal && $apiError}
|
||||
<Modal
|
||||
open={showModal}
|
||||
title="{$apiError.service.toUpperCase()} API Error"
|
||||
size="md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-red-800 font-medium">{$apiError.message}</p>
|
||||
{#if $apiError.statusCode}
|
||||
<p class="text-red-600 text-sm mt-2">
|
||||
Status: {$apiError.statusCode}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-gray-600">
|
||||
The application will remain open. You can retry the connection or
|
||||
dismiss this message to continue with limited functionality.
|
||||
</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<div class="flex gap-3 justify-end">
|
||||
{#if $apiError.canRetry}
|
||||
<Button onclick={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
||||
Retry Connection
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={handleDismiss} color="alternative">Dismiss</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Search from "./Search.svelte"
|
||||
import Search from "./Search.svelte";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
loginToAniList,
|
||||
@@ -7,70 +7,127 @@
|
||||
loginToSimkl,
|
||||
malLoggedIn,
|
||||
simklLoggedIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte"
|
||||
serviceLoggingIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import AvatarMenu from "./AvatarMenu.svelte";
|
||||
import logo from "../assets/images/AniTrackLogo.svg"
|
||||
import logo from "../assets/images/AniTrackLogo.svg";
|
||||
import { link } from "svelte-spa-router";
|
||||
|
||||
let isAniListLoggedIn: boolean
|
||||
let isSimklLoggedIn: boolean
|
||||
let isMALLoggedIn: boolean
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
let isMALLoggedIn!: boolean;
|
||||
let loggingIn: string[] = [];
|
||||
|
||||
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value)
|
||||
simklLoggedIn.subscribe((value) => isSimklLoggedIn = value)
|
||||
malLoggedIn.subscribe((value) => isMALLoggedIn = value)
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||
serviceLoggingIn.subscribe((value) => (loggingIn = value));
|
||||
</script>
|
||||
|
||||
<nav class="border-gray-200 bg-gray-900">
|
||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<div
|
||||
class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"
|
||||
>
|
||||
<div class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<a href="/"><img src={logo} class="h-8" alt="AniTrack Logo"/></a>
|
||||
<a href="/" use:link
|
||||
><img src={logo} class="h-8" alt="AniTrack Logo" /></a
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center min-[950px]:order-2 space-x-3 min-[950px]:space-x-0 rtl:space-x-reverse">
|
||||
<div class="min-[950px]:block min-[950px]:mr-4">
|
||||
<div
|
||||
class="flex items-center sm:order-2 space-x-3 sm:space-x-0 rtl:space-x-reverse"
|
||||
>
|
||||
<div class="hidden sm:block sm:mr-4">
|
||||
<Search />
|
||||
</div>
|
||||
<AvatarMenu/>
|
||||
<button on:click={() => {
|
||||
let menu = document.querySelector("#navbar-user")
|
||||
menu.classList.toggle("hidden")
|
||||
}} type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm rounded-lg min-[950px]:hidden focus:outline-none focus:ring-2 text-gray-400 hover:bg-gray-700 focus:ring-gray-600"
|
||||
aria-controls="navbar-user" aria-expanded="false">
|
||||
<AvatarMenu />
|
||||
<button
|
||||
on:click={() => {
|
||||
const menu = document.querySelector("#navbar-user");
|
||||
if (!menu) return;
|
||||
menu.classList.toggle("hidden");
|
||||
}}
|
||||
type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm rounded-lg sm:hidden focus:outline-none focus:ring-2 text-gray-400 hover:bg-gray-700 focus:ring-gray-600"
|
||||
aria-controls="navbar-user"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 17 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M1 1h15M1 7h15M1 13h15"/>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 17 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M1 1h15M1 7h15M1 13h15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden items-center justify-between w-full pb-4 min-[950px]:pb-0 min-[950px]:flex min-[950px]:w-auto min-[950px]:order-1 border border-gray-700 min-[950px]:border-0 bg-gray-800 min-[950px]:bg-transparent rounded-lg" id="navbar-user">
|
||||
<ul class="flex flex-col font-medium pb-6 min-[950px]:p-0 mt-4 min-[950px]:space-x-8 rtl:space-x-reverse min-[950px]:flex-row min-[950px]:mt-0">
|
||||
<div
|
||||
class="hidden items-center justify-between w-full pb-4 sm:pb-0 sm:flex sm:w-auto sm:order-1 border border-gray-700 sm:border-0 bg-gray-800 sm:bg-transparent rounded-lg"
|
||||
id="navbar-user"
|
||||
>
|
||||
<ul
|
||||
class="flex flex-col font-medium pb-6 sm:p-0 mt-4 sm:space-x-8 rtl:space-x-reverse sm:flex-row sm:mt-0"
|
||||
>
|
||||
<li>
|
||||
{#if !isAniListLoggedIn}
|
||||
<button on:click={loginToAniList}>
|
||||
<!-- class="block py-2 px-3 w-full min-[950px]:w-auto rounded text-gray-300 min-[950px]:hover:text-blue-500 hover:bg-gray-700 hover:text-white min-[950px]:hover:bg-transparent border-gray-700">-->
|
||||
<button
|
||||
disabled={loggingIn.includes("anilist")}
|
||||
on:click={loginToAniList}
|
||||
>
|
||||
{#if loggingIn.includes("anilist")}
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-2 border-2 border-gray-300 border-t-transparent rounded-full animate-spin align-middle"
|
||||
></span
|
||||
>Checking AniList
|
||||
{:else}
|
||||
AniList Login
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isMALLoggedIn}
|
||||
<button on:click={loginToMAL}>
|
||||
<!-- class="block py-2 px-3 w-full min-[950px]:w-auto rounded min-[950px]:p-0 text-gray-300 min-[950px]:hover:text-blue-500 hover:bg-gray-700 hover:text-white min-[950px]:hover:bg-transparent border-gray-700">-->
|
||||
<button
|
||||
disabled={loggingIn.includes("mal")}
|
||||
on:click={loginToMAL}
|
||||
>
|
||||
{#if loggingIn.includes("mal")}
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-2 border-2 border-gray-300 border-t-transparent rounded-full animate-spin align-middle"
|
||||
></span
|
||||
>Checking MAL
|
||||
{:else}
|
||||
MyAnimeList Login
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
<li>
|
||||
{#if !isSimklLoggedIn}
|
||||
<button on:click={loginToSimkl}>
|
||||
<!-- class="block py-2 px-3 w-full min-[950px]:w-auto rounded min-[950px]:p-0 text-gray-300 min-[950px]:hover:text-blue-500 hover:bg-gray-700 hover:text-white min-[950px]:hover:bg-transparent border-gray-700">-->
|
||||
<button
|
||||
disabled={loggingIn.includes("simkl")}
|
||||
on:click={loginToSimkl}
|
||||
>
|
||||
{#if loggingIn.includes("simkl")}
|
||||
<span
|
||||
class="inline-block w-4 h-4 mr-2 border-2 border-gray-300 border-t-transparent rounded-full animate-spin align-middle"
|
||||
></span
|
||||
>Checking Simkl
|
||||
{:else}
|
||||
Simkl Login
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-center min-[950px]:hidden">
|
||||
<Search/>
|
||||
<div class="flex justify-center sm:hidden">
|
||||
<Search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListSort,
|
||||
aniListWatchlist,
|
||||
animePerPage,
|
||||
watchListPage,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
import type {AniListCurrentUserWatchList} from "../anilist/types/AniListCurrentUserWatchListType"
|
||||
import {GetAniListUserWatchingList} from "../../wailsjs/go/main/App";
|
||||
import {MediaListSort} from "../anilist/types/AniListTypes";
|
||||
import type { AniListCurrentUserWatchList } from "../../bindings/AniTrack/models";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList
|
||||
let page: number
|
||||
let perPage: number
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort!: string;
|
||||
|
||||
watchListPage.subscribe(value => page = value)
|
||||
animePerPage.subscribe(value => perPage = value)
|
||||
aniListWatchlist.subscribe((value) => aniListWatchListLoaded = value)
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
animePerPage.subscribe((value) => (perPage = value));
|
||||
aniListWatchlist.subscribe((value) => (aniListWatchListLoaded = value));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
const perPageOptions = [10, 20, 50]
|
||||
const perPageOptions = [10, 20, 50];
|
||||
|
||||
function ChangeWatchListPage(newPage: number) {
|
||||
GetAniListUserWatchingList(newPage, perPage, MediaListSort.UpdatedTimeDesc).then((result) => {
|
||||
watchListPage.set(newPage)
|
||||
aniListWatchlist.set(result)
|
||||
aniListLoggedIn.set(true)
|
||||
})
|
||||
App.GetAniListUserWatchingList(newPage, perPage, sort).then((result) => {
|
||||
watchListPage.set(newPage);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
function changePage(e): void {
|
||||
if ((e.key === "Enter" || e.key === "Tab") && Number(e.target.value) !== page) ChangeWatchListPage(Number(e.target.value))
|
||||
function changePage(
|
||||
e: KeyboardEvent & { currentTarget: HTMLInputElement },
|
||||
): void {
|
||||
if (
|
||||
(e.key === "Enter" || e.key === "Tab") &&
|
||||
Number(e.currentTarget.value) !== page
|
||||
)
|
||||
ChangeWatchListPage(Number(e.currentTarget.value));
|
||||
}
|
||||
|
||||
function changeCountPerPage(e): void {
|
||||
GetAniListUserWatchingList(1, Number(e.target.value), MediaListSort.UpdatedTimeDesc).then((result) => {
|
||||
animePerPage.set(Number(e.target.value))
|
||||
watchListPage.set(1)
|
||||
aniListWatchlist.set(result)
|
||||
aniListLoggedIn.set(true)
|
||||
})
|
||||
function changeCountPerPage(
|
||||
e: Event & { currentTarget: HTMLSelectElement },
|
||||
): void {
|
||||
// Read synchronously: Svelte 5 nulls currentTarget after dispatch.
|
||||
const count = Number(e.currentTarget.value);
|
||||
App.GetAniListUserWatchingList(1, count, sort).then((result) => {
|
||||
animePerPage.set(count);
|
||||
watchListPage.set(1);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="mb-8">
|
||||
@@ -49,43 +60,57 @@
|
||||
<ul class="inline-flex -space-x-px text-base h-10">
|
||||
{#if page === 1}
|
||||
<li>
|
||||
<button disabled
|
||||
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 cursor-default">
|
||||
<button
|
||||
disabled
|
||||
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 cursor-default"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={() => ChangeWatchListPage(page-1)}
|
||||
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white">
|
||||
<button
|
||||
on:click={() => ChangeWatchListPage(page - 1)}
|
||||
class="flex items-center justify-center px-4 h-10 ms-0 leading-tight border border-e-0 rounded-s-lg border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#each {length: aniListWatchListLoaded.data.Page.pageInfo.lastPage} as _, i}
|
||||
{#each { length: aniListWatchListLoaded.data.Page.pageInfo.lastPage } as _, i}
|
||||
{#if i + 1 === page}
|
||||
<li>
|
||||
<button on:click={() => ChangeWatchListPage(i+1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border bg-gray-100 border-gray-700 bg-gray-700 text-white">{i + 1}</button>
|
||||
<button
|
||||
on:click={() => ChangeWatchListPage(i + 1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border hover:bg-gray-100 border-gray-700 bg-gray-700 text-white"
|
||||
>{i + 1}</button
|
||||
>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={() => ChangeWatchListPage(i+1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white">{i + 1}</button>
|
||||
<button
|
||||
on:click={() => ChangeWatchListPage(i + 1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
|
||||
>{i + 1}</button
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if page === aniListWatchListLoaded.data.Page.pageInfo.lastPage}
|
||||
<li>
|
||||
<button disabled
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 cursor-default">
|
||||
<button
|
||||
disabled
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 cursor-default"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button on:click={() => ChangeWatchListPage(page+1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white">
|
||||
<button
|
||||
on:click={() => ChangeWatchListPage(page + 1)}
|
||||
class="flex items-center justify-center px-4 h-10 leading-tight border rounded-e-lg dark border-gray-700 text-gray-400 hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
@@ -93,10 +118,14 @@
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
<div class="flex mt-5">
|
||||
<div class="flex">
|
||||
<div class="w-20 mx-auto">
|
||||
<select bind:value={perPage} on:change={(e) => changeCountPerPage(e)} id="countPerPage"
|
||||
class="border text-sm rounded-lg block w-full p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500">
|
||||
<select
|
||||
bind:value={perPage}
|
||||
on:change={(e) => changeCountPerPage(e)}
|
||||
id="countPerPage"
|
||||
class="border text-sm rounded-lg block w-full p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{#each perPageOptions as option}
|
||||
<option value={option}>
|
||||
{option}
|
||||
@@ -108,35 +137,83 @@
|
||||
<div>
|
||||
<div>Total Anime: {aniListWatchListLoaded.data.Page.pageInfo.total}</div>
|
||||
{#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12}
|
||||
<div class="md:hidden">Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}</div>
|
||||
<div class="md:hidden">
|
||||
Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}
|
||||
</div>
|
||||
{:else}
|
||||
<div>Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}</div>
|
||||
<div>
|
||||
Page: {page} of {aniListWatchListLoaded.data.Page.pageInfo.lastPage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-w-xs mx-auto">
|
||||
<div class="relative flex items-center max-w-[11rem]">
|
||||
<button type="button" id="decrement-button" on:click={() => ChangeWatchListPage(page-1)}
|
||||
class="bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none">
|
||||
<svg class="w-3 h-3 text-white" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 2">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M1 1h16"/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
id="decrement-button"
|
||||
on:click={() => ChangeWatchListPage(page - 1)}
|
||||
class={page <= 1
|
||||
? "border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-s-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 18 2"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M1 1h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="number" min="1" max="{aniListWatchListLoaded.data.Page.pageInfo.lastPage}"
|
||||
on:keydown={changePage} id="page-counter"
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={aniListWatchListLoaded.data.Page.pageInfo.lastPage}
|
||||
on:keydown={changePage}
|
||||
id="page-counter"
|
||||
class="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none border-x-0 h-11 font-medium text-center text-sm block w-full pb-6 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
value={page} required/>
|
||||
<div class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse">
|
||||
value={page}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse"
|
||||
>
|
||||
<span>Page #</span>
|
||||
</div>
|
||||
<button type="button" id="increment-button" on:click={() => ChangeWatchListPage(page+1)}
|
||||
class="hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none">
|
||||
<svg class="w-3 h-3 text-white" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 1v16M1 9h16"/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
id="increment-button"
|
||||
on:click={() => ChangeWatchListPage(page + 1)}
|
||||
class={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage
|
||||
? "border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
: "bg-gray-700 hover:bg-gray-600 border-gray-600 border rounded-e-lg p-3 h-11 focus:ring-gray-700 focus:ring-2 focus:outline-none"}
|
||||
disabled={page >= aniListWatchListLoaded.data.Page.pageInfo.lastPage}
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 18 18"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 1v16M1 9h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
<script lang="ts">
|
||||
import StarRatting from "../star-rating/Stars.svelte";
|
||||
import StarInput from "../star-rating/StarInput.svelte";
|
||||
|
||||
export let score
|
||||
let { score = $bindable(0) }: { score: number } = $props();
|
||||
|
||||
let config = {
|
||||
readOnly: false,
|
||||
countStars: 5,
|
||||
range: {
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5
|
||||
},
|
||||
score: score / 2,
|
||||
showScore: false,
|
||||
name: "rating",
|
||||
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
|
||||
starConfig: {
|
||||
size: 32,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: "#e2c714",
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
}
|
||||
}
|
||||
|
||||
const ratingInWords = {
|
||||
const ratingInWords: Record<number, string> = {
|
||||
0: "Not Reviewed",
|
||||
1: "Appalling",
|
||||
2: "Horrible",
|
||||
@@ -37,14 +16,10 @@
|
||||
9: "Great",
|
||||
10: "Masterpiece",
|
||||
}
|
||||
|
||||
const changeRating = (e: any) => {
|
||||
score = e.target.valueAsNumber * 2
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<StarRatting bind:config on:change={changeRating}/>
|
||||
<p>Rating: {config.score * 2}</p>
|
||||
<p>{ratingInWords[config.score * 2]}</p>
|
||||
<StarInput bind:value={score} min={0} max={10} step={1} name="rating" />
|
||||
<p>Rating: {score}</p>
|
||||
<p>{ratingInWords[score]}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import { loading } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="py-2 px-4 mt-4 mr-4 text-nowrap bg-gray-700 rounded-lg"
|
||||
on:click={async () => {
|
||||
loading.set(true);
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
loading.set(false);
|
||||
}}
|
||||
>
|
||||
Refresh WatchList
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,27 +1,132 @@
|
||||
<script lang="ts">
|
||||
|
||||
import {AniListSearch} from "../../wailsjs/go/main/App";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import type {AniSearchList} from "../anilist/types/AniListTypes";
|
||||
import {push} from "svelte-spa-router";
|
||||
|
||||
let aniSearch = ""
|
||||
let aniListSearch: AniSearchList
|
||||
let aniListSearchActive = false
|
||||
let aniListSearch: AniSearchList | null = null
|
||||
let dropdownOpen = false
|
||||
let isSearching = false
|
||||
let showSlowNotice = false
|
||||
let searchError: string | null = null
|
||||
let hasSearched = false
|
||||
let searchRequestId = 0
|
||||
|
||||
function runAniListSearch(): void {
|
||||
AniListSearch(aniSearch).then(result => {
|
||||
aniListSearch = result
|
||||
aniListSearchActive = true
|
||||
})
|
||||
const SLOW_NOTICE_MS = 8000
|
||||
const SEARCH_TIMEOUT_MS = 30000
|
||||
|
||||
function openDropdown(): void {
|
||||
dropdownOpen = true
|
||||
}
|
||||
|
||||
function searchDropdown(): void {
|
||||
let dropdown = document.querySelector("#aniListSearchDropdown")
|
||||
dropdown.classList.toggle("hidden")
|
||||
function closeDropdown(): void {
|
||||
dropdownOpen = false
|
||||
}
|
||||
|
||||
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([App.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>
|
||||
|
||||
<svelte:window on:click={handleWindowClick} />
|
||||
|
||||
<div id="searchDropdown" class="relative w-64 md:w-48">
|
||||
<div class="flex">
|
||||
@@ -33,16 +138,20 @@
|
||||
placeholder="Search for Anime"
|
||||
on:keypress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
searchDropdown()
|
||||
if(aniSearch.length > 0) runAniListSearch()
|
||||
runAniListSearch()
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
closeDropdown()
|
||||
}
|
||||
}}
|
||||
required/>
|
||||
<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={() => {
|
||||
searchDropdown()
|
||||
if(aniSearch.length > 0) runAniListSearch()
|
||||
runAniListSearch()
|
||||
}}>
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
@@ -54,31 +163,38 @@
|
||||
</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">
|
||||
{#if aniListSearchActive}
|
||||
<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 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"
|
||||
aria-labelledby="aniListSearchButton">
|
||||
{#each aniListSearch.data.Page.media as media}
|
||||
{#each aniListSearch.data.Page.media as media (media.id)}
|
||||
<li class="w-full">
|
||||
<div class="flex w-full items-start p-1 hover:bg-gray-600 hover:text-white rounded-lg">
|
||||
<button on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}}
|
||||
<button on:click={() => goToAnime(media.id)}
|
||||
>
|
||||
<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">
|
||||
<img class="rounded-bl-lg rounded-tl-lg max-w-24 max-h-24" src={media?.coverImage?.large}
|
||||
alt="{displayTitle(media)} Cover">
|
||||
</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => {
|
||||
searchDropdown()
|
||||
push(`#/anime/${media.id}`)
|
||||
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => goToAnime(media.id)} >{displayTitle(media)}</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if aniSearch.length === 0}
|
||||
<div class="m-4">Please enter a search term...</div>
|
||||
{:else if hasSearched}
|
||||
<div class="m-4 text-gray-700 dark:text-gray-200">No results found for "{aniSearch.trim()}". Try a different title.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListSort,
|
||||
watchListPage,
|
||||
animePerPage,
|
||||
aniListWatchlist,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { MediaListSort } from "../anilist/types/AniListTypes";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
const sortTypes = [
|
||||
{ value: MediaListSort.MediaId, name: "Media Id Asc" },
|
||||
{ value: MediaListSort.MediaIdDesc, name: "Media Id Desc" },
|
||||
{ value: MediaListSort.Score, name: "Score Asc" },
|
||||
{ value: MediaListSort.ScoreDesc, name: "Score Desc" },
|
||||
{ value: MediaListSort.Status, name: "Status Asc" },
|
||||
{ value: MediaListSort.StatusDesc, name: "Status Desc" },
|
||||
{ value: MediaListSort.Progress, name: "Progress Asc" },
|
||||
{ value: MediaListSort.ProgressDesc, name: "Progress Desc" },
|
||||
{ value: MediaListSort.ProgressVolumes, name: "Progress Valumes Asc" },
|
||||
{ value: MediaListSort.ProgressVolumesDesc, name: "Progress Valumes Desc" },
|
||||
{ value: MediaListSort.Repeat, name: "Repeat Asc" },
|
||||
{ value: MediaListSort.RepeatDesc, name: "Repeat Desc" },
|
||||
{ value: MediaListSort.Priority, name: "Priority Asc" },
|
||||
{ value: MediaListSort.PriorityDesc, name: "Priority Desc" },
|
||||
{ value: MediaListSort.StartedOn, name: "Started On Asc" },
|
||||
{ value: MediaListSort.StartedOnDesc, name: "Started On Desc" },
|
||||
{ value: MediaListSort.FinishedOn, name: "Finished On Asc" },
|
||||
{ value: MediaListSort.FinishedOnDesc, name: "Finished On Desc" },
|
||||
{ value: MediaListSort.AddedTime, name: "Added Time Asc" },
|
||||
{ value: MediaListSort.AddedTimeDesc, name: "Added Time Desc" },
|
||||
{ value: MediaListSort.UpdatedTime, name: "Updated Time Asc" },
|
||||
{ value: MediaListSort.UpdatedTimeDesc, name: "Updated Time Desc" },
|
||||
{ value: MediaListSort.MediaTitleRomaji, name: "Media Title Romaji Asc" },
|
||||
{
|
||||
value: MediaListSort.MediaTitleRomajiDesc,
|
||||
name: "Media Title Romaji Desc",
|
||||
},
|
||||
{ value: MediaListSort.MediaTitleEnglish, name: "Media Title English Asc" },
|
||||
{
|
||||
value: MediaListSort.MediaTitleEnglishDesc,
|
||||
name: "Media Title English Desc",
|
||||
},
|
||||
{ value: MediaListSort.MediaTitleNative, name: "Media Title Native Asc" },
|
||||
{
|
||||
value: MediaListSort.MediaTitleNativeDesc,
|
||||
name: "Media Title Native Desc",
|
||||
},
|
||||
{ value: MediaListSort.MediaPopularity, name: "Media Popularity Asc" },
|
||||
{ value: MediaListSort.MediaPopularityDesc, name: "Media Popularity Desc" },
|
||||
];
|
||||
|
||||
let sort!: string;
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
console.log(sort);
|
||||
|
||||
async function changeWatchListSort() {
|
||||
const result = await App.GetAniListUserWatchingList(
|
||||
$watchListPage,
|
||||
$animePerPage,
|
||||
$aniListSort,
|
||||
);
|
||||
aniListWatchlist.set(result);
|
||||
}
|
||||
</script>
|
||||
|
||||
<select
|
||||
id="sort"
|
||||
class="border rounded-lg block max-h-10 mt-4 mx-2 p-1.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
bind:value={$aniListSort}
|
||||
on:change={() => changeWatchListSort()}
|
||||
>
|
||||
<option value="" disabled>Sort WatchList</option>
|
||||
{#each sortTypes as sort}
|
||||
<option value={sort.value}>{sort.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -5,31 +5,46 @@
|
||||
GetAnimeSingleItem,
|
||||
loading,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {push} from "svelte-spa-router";
|
||||
import type {AniListCurrentUserWatchList} from "../anilist/types/AniListCurrentUserWatchListType"
|
||||
import {Rating} from "flowbite-svelte";
|
||||
import loader from '../helperFunctions/loader'
|
||||
import { push } from "svelte-spa-router";
|
||||
import type { AniListCurrentUserWatchList } from "../../bindings/AniTrack/models";
|
||||
import { Rating } from "flowbite-svelte";
|
||||
import loader from "../helperFunctions/loader";
|
||||
import { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import Sort from "../helperComponents/Sort.svelte";
|
||||
import RefreshWatchListButton from "./RefreshWatchListButton.svelte";
|
||||
|
||||
let isAniListLoggedIn!: boolean;
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||
|
||||
let isAniListLoggedIn: boolean
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList
|
||||
|
||||
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value)
|
||||
aniListWatchlist.subscribe((value) => aniListWatchListLoaded = value)
|
||||
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
aniListWatchlist.subscribe((value) => (aniListWatchListLoaded = value));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if isAniListLoggedIn}
|
||||
<div class="mx-auto max-w-2xl p-4 sm:p-6 lg:max-w-7xl lg:px-8 relative items-center">
|
||||
<h1 class="text-left text-xl font-bold mb-4">Your AniList WatchList</h1>
|
||||
<div
|
||||
class="mx-auto max-w-2xl p-4 sm:p-6 lg:max-w-7xl lg:px-8 relative items-center"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-left text-xl font-bold">Your AniList WatchList</h1>
|
||||
<div class="flex">
|
||||
<Sort />
|
||||
<RefreshWatchListButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
||||
>
|
||||
{#each aniListWatchListLoaded.data.Page.mediaList as media}
|
||||
<div use:loader={loading} class="aspect-h-1 aspect-w-1 w-full overflow-hidden rounded-lg xl:aspect-h-8 xl:aspect-w-7">
|
||||
<div
|
||||
use:loader={loading}
|
||||
class="aspect-h-1 aspect-w-1 w-full overflow-hidden rounded-lg xl:aspect-h-8 xl:aspect-w-7"
|
||||
>
|
||||
<div class="flex flex-col items-center group">
|
||||
<button on:click={() => {
|
||||
push(`#/anime/${media.media.id}`)
|
||||
<button
|
||||
on:click={() => {
|
||||
push(`#/anime/${media.media.id}`);
|
||||
// loading.set(true)
|
||||
// GetAniListSingleItem(media.media.id, true).then(() => {
|
||||
// loading.set(false)
|
||||
@@ -37,27 +52,38 @@
|
||||
// })
|
||||
}}
|
||||
>
|
||||
<img class="rounded-lg" src={media.media.coverImage.large} alt={
|
||||
media.media.title.english === "" ?
|
||||
media.media.title.romaji :
|
||||
media.media.title.english
|
||||
}/>
|
||||
<img
|
||||
class="rounded-lg w-[230px] h-[330px] object-cover"
|
||||
src={media.media.coverImage.large}
|
||||
alt={media.media.title.english === ""
|
||||
? media.media.title.romaji
|
||||
: media.media.title.english}
|
||||
/>
|
||||
</button>
|
||||
<Rating id="anime-rating" total={5} size={35} rating={media.score/2.0}/>
|
||||
<button class="mt-4 text-md font-semibold text-white-700"
|
||||
on:click={() => GetAnimeSingleItem(media.media.id, true)}>
|
||||
{
|
||||
media.media.title.english === "" ?
|
||||
media.media.title.romaji :
|
||||
media.media.title.english
|
||||
}
|
||||
<Rating
|
||||
id="anime-rating"
|
||||
total={5}
|
||||
size={35}
|
||||
rating={media.score / 2.0}
|
||||
/>
|
||||
<button
|
||||
class="mt-4 text-md font-semibold text-white-700"
|
||||
on:click={() => GetAnimeSingleItem(media.media.id, true)}
|
||||
>
|
||||
{media.media.title.english === ""
|
||||
? media.media.title.romaji
|
||||
: media.media.title.english}
|
||||
</button>
|
||||
<p class="mt-1 text-lg font-medium text-white-900">{media.progress}
|
||||
/ {media.media.nextAiringEpisode.episode !== 0 ?
|
||||
media.media.nextAiringEpisode.episode - 1 : media.media.episodes}</p>
|
||||
<p class="mt-1 text-lg font-medium text-white-900">
|
||||
{media.progress}
|
||||
/ {media.media.nextAiringEpisode.episode !== 0
|
||||
? media.media.nextAiringEpisode.episode - 1
|
||||
: media.media.episodes}
|
||||
</p>
|
||||
{#if media.media.episodes > 0}
|
||||
<p class="mt-1 text-lg font-medium text-white-900">Total
|
||||
Episodes: {media.media.episodes}</p>
|
||||
<p class="mt-1 text-lg font-medium text-white-900">
|
||||
Total Episodes: {media.media.episodes}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {BrowserOpenURL} from "../../wailsjs/runtime"
|
||||
import {Browser} from "@wailsio/runtime";
|
||||
|
||||
export let id: string
|
||||
let url = ""
|
||||
let isAniList = false
|
||||
let isMAL = false
|
||||
let isSimkl = false
|
||||
let newId = id
|
||||
let re = /[ams]?-?(.*)/
|
||||
export let id: string;
|
||||
export let url = "";
|
||||
let isAniList = false;
|
||||
let isMAL = false;
|
||||
let isSimkl = false;
|
||||
let newId = id;
|
||||
let re = /[ams]?-?(.*)/;
|
||||
if (id !== undefined && id.length > 0) {
|
||||
isAniList = id.includes("a-")
|
||||
isMAL = id.includes("m-")
|
||||
isSimkl = id.includes("s-")
|
||||
newId = id.match(re)[1]
|
||||
isAniList = id.includes("a-");
|
||||
isMAL = id.includes("m-");
|
||||
isSimkl = id.includes("s-");
|
||||
if (isAniList || isMAL || isSimkl) newId = id.match(re)![1];
|
||||
else newId = id;
|
||||
}
|
||||
|
||||
|
||||
if (isAniList) url = `https://anilist.co/anime/${newId}`
|
||||
if (isMAL) url = `https://myanimelist.net/anime/${newId}`
|
||||
if (isSimkl) url = `https://simkl.com/anime/${newId}`
|
||||
if (isAniList) url = `https://anilist.co/anime/${newId}`;
|
||||
if (isMAL) url = `https://myanimelist.net/anime/${newId}`;
|
||||
if (isSimkl) url = `https://simkl.com/anime/${newId}`;
|
||||
</script>
|
||||
|
||||
{#if url.length > 0}
|
||||
<button class="underline underline-offset-2 px-4 py-1" on:click={() => BrowserOpenURL(url)}>{newId}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="underline underline-offset-2 px-4 py-1"
|
||||
on:click={() => Browser.OpenURL(url)}>{newId}</button
|
||||
>
|
||||
{:else}
|
||||
{id}
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {AniListGetSingleAnime} from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import type {AniListGetSingleAnime} from "../../bindings/AniTrack/models";
|
||||
|
||||
export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||
data: {
|
||||
@@ -10,23 +10,57 @@ export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||
id: 0,
|
||||
idMal: 0,
|
||||
title: {
|
||||
userPreferred: "",
|
||||
romaji: "",
|
||||
english: "",
|
||||
native: "",
|
||||
},
|
||||
description: "",
|
||||
coverImage: {
|
||||
ExtraLarge: "",
|
||||
large: "",
|
||||
Medium: "",
|
||||
Color: "",
|
||||
},
|
||||
BannerImage: "",
|
||||
Format: "",
|
||||
season: "",
|
||||
seasonYear: 0,
|
||||
status: "",
|
||||
episodes: 0,
|
||||
Duration: 0,
|
||||
CountryOfOrigin: "",
|
||||
Source: "",
|
||||
Synonyms: null,
|
||||
AverageScore: 0,
|
||||
MeanScore: 0,
|
||||
Popularity: 0,
|
||||
Trending: 0,
|
||||
Favourites: 0,
|
||||
relations: {
|
||||
nodes: null,
|
||||
},
|
||||
startDate: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
endDate: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
nextAiringEpisode: {
|
||||
airingAt: 0,
|
||||
timeUntilAiring: 0,
|
||||
episode: 0,
|
||||
}
|
||||
},
|
||||
airingSchedule: {
|
||||
nodes: null,
|
||||
},
|
||||
genres: [],
|
||||
tags: [],
|
||||
isAdult: false,
|
||||
},
|
||||
status: "",
|
||||
startedAt: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import moment from "moment";
|
||||
|
||||
const convertAniListDateToString = (date: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
}): string => {
|
||||
if (
|
||||
date.year === undefined ||
|
||||
(date.year === 0 && date.month === undefined) ||
|
||||
(date.month === 0 && date.day === undefined) ||
|
||||
date.day === 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const newISODate = new Date(date.year, date.month! - 1, date.day);
|
||||
const newMoment = moment(newISODate);
|
||||
return newMoment.format("MM-DD-YYYY");
|
||||
};
|
||||
|
||||
const convertAniListDateToDate = (date: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
}): Date | null => {
|
||||
if (
|
||||
date.year === undefined ||
|
||||
(date.year === 0 && date.month === undefined) ||
|
||||
(date.month === 0 && date.day === undefined) ||
|
||||
date.day === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return new Date(date.year, date.month! - 1, date.day);
|
||||
};
|
||||
|
||||
export { convertAniListDateToString, convertAniListDateToDate };
|
||||
@@ -1,17 +0,0 @@
|
||||
import moment from "moment";
|
||||
|
||||
export default (date: {
|
||||
year?: number,
|
||||
month?: number,
|
||||
day?: number,
|
||||
}): string => {
|
||||
if (date.year === undefined || date.year === 0
|
||||
&& date.month === undefined || date.month === 0
|
||||
&& date.day === undefined || date.day === 0
|
||||
) {
|
||||
return ""
|
||||
}
|
||||
const newISODate = new Date(date.year, date.month - 1, date.day)
|
||||
const newMoment = moment(newISODate)
|
||||
return newMoment.format('YYYY-MM-DD')
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
type Date = {
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
}
|
||||
|
||||
export default (date: string): Date => {
|
||||
if (date === "") {
|
||||
return {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
}
|
||||
}
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/
|
||||
const newDate = re.exec(date)
|
||||
return {
|
||||
year: Number(newDate[1]),
|
||||
month: Number(newDate[2]),
|
||||
day: Number(newDate[3])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
type AnilistDate = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
|
||||
const convertDateStringToAniList = (date: string): AnilistDate => {
|
||||
if (date === "") {
|
||||
return {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
};
|
||||
}
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/;
|
||||
const newDate = re.exec(date);
|
||||
if (newDate === null) {
|
||||
return {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
year: Number(newDate[1]),
|
||||
month: Number(newDate[2]),
|
||||
day: Number(newDate[3]),
|
||||
};
|
||||
};
|
||||
|
||||
const convertDateToAniList = (date: Date | null): AnilistDate => {
|
||||
if (date === null) {
|
||||
return {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
year: Number(date.getFullYear()),
|
||||
month: Number(date.getMonth()) + 1,
|
||||
day: Number(date.getDate()),
|
||||
};
|
||||
};
|
||||
|
||||
export { convertDateStringToAniList, convertDateToAniList };
|
||||
@@ -1,18 +1,29 @@
|
||||
import { mount, unmount } from 'svelte';
|
||||
import Spinner from '../helperComponents/Spinner.svelte';
|
||||
|
||||
export default (node: any, loading: any) => {
|
||||
let Spin: any
|
||||
loading.subscribe((loading: any) => {
|
||||
if(loading){
|
||||
Spin = new Spinner({
|
||||
let app: any;
|
||||
const unsubscribe = loading.subscribe((isLoading: any) => {
|
||||
if (isLoading && !app) {
|
||||
app = mount(Spinner, {
|
||||
target: node,
|
||||
intro: true
|
||||
})
|
||||
} else {
|
||||
if(Spin){
|
||||
Spin?.$destroy?.()
|
||||
Spin = undefined;
|
||||
});
|
||||
} else if (!isLoading && app) {
|
||||
const current = app;
|
||||
app = undefined;
|
||||
// Teardown must never break the caller.
|
||||
void unmount(current).catch(() => {});
|
||||
}
|
||||
});
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
if (app) {
|
||||
const current = app;
|
||||
app = undefined;
|
||||
void unmount(current).catch(() => {});
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -3,19 +3,19 @@
|
||||
import { tableItems } from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
|
||||
export function AddAnimeServiceToTable(animeItem: TableItem) {
|
||||
// Always return a NEW array: in-place mutation with the same
|
||||
// reference does not reliably invalidate $derived consumers
|
||||
// under Svelte 5, which left the table stale after submits.
|
||||
tableItems.update((table) => {
|
||||
if (table.length === 0) {
|
||||
table.push(animeItem)
|
||||
} else {
|
||||
for (const [index, tableItem] of table.entries()) {
|
||||
if(tableItem.service === animeItem.service) {
|
||||
table[index] = animeItem
|
||||
return table
|
||||
const index = table.findIndex(
|
||||
(tableItem) => tableItem.service === animeItem.service,
|
||||
);
|
||||
if (index === -1) {
|
||||
return [...table, animeItem];
|
||||
}
|
||||
}
|
||||
table.push(animeItem)
|
||||
}
|
||||
return table
|
||||
return table.map((tableItem, i) =>
|
||||
i === index ? animeItem : tableItem,
|
||||
);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,34 +1,81 @@
|
||||
<script lang="ts" context="module">
|
||||
import {CheckIfAniListLoggedIn, GetAniListLoggedInUser, GetAniListUserWatchingList} from "../../wailsjs/go/main/App";
|
||||
import {MediaListSort} from "../anilist/types/AniListTypes";
|
||||
import { aniListUser, watchListPage, animePerPage, aniListPrimary, aniListLoggedIn, aniListWatchlist } from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import {
|
||||
aniListUser,
|
||||
watchListPage,
|
||||
animePerPage,
|
||||
aniListPrimary,
|
||||
aniListLoggedIn,
|
||||
aniListWatchlist,
|
||||
aniListSort,
|
||||
clearApiError,
|
||||
setApiError,
|
||||
serviceLoggingIn,
|
||||
} from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
let isAniListPrimary: boolean
|
||||
let page: number
|
||||
let perPage: number
|
||||
let isAniListPrimary: boolean;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort!: string;
|
||||
|
||||
aniListPrimary.subscribe(value => isAniListPrimary = value)
|
||||
watchListPage.subscribe(value => page = value)
|
||||
animePerPage.subscribe(value => perPage = value)
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
animePerPage.subscribe((value) => (perPage = value));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
export const LoadAniListUser = async () => {
|
||||
await GetAniListLoggedInUser().then(user => {
|
||||
aniListUser.set(user)
|
||||
})
|
||||
try {
|
||||
await App.GetAniListLoggedInUser().then((user) => {
|
||||
aniListUser.set(user);
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
setApiError(
|
||||
"anilist",
|
||||
`Failed to load user: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const LoadAniListWatchList = async () => {
|
||||
await GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then((watchList) => {
|
||||
aniListWatchlist.set(watchList)
|
||||
})
|
||||
try {
|
||||
const watchList = await App.GetAniListUserWatchingList(page, perPage, sort);
|
||||
aniListWatchlist.set(watchList);
|
||||
clearApiError();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
setApiError(
|
||||
"anilist",
|
||||
`Failed to load watch list: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
};
|
||||
export const CheckIfAniListLoggedInAndLoadWatchList = async () => {
|
||||
const loggedIn = await CheckIfAniListLoggedIn()
|
||||
serviceLoggingIn.update((s) => [...s, "anilist"]);
|
||||
try {
|
||||
const loggedIn = await App.CheckIfAniListLoggedIn();
|
||||
if (loggedIn) {
|
||||
await LoadAniListUser()
|
||||
if (isAniListPrimary) await LoadAniListWatchList()
|
||||
await LoadAniListUser();
|
||||
if (isAniListPrimary) await LoadAniListWatchList();
|
||||
}
|
||||
aniListLoggedIn.set(loggedIn)
|
||||
aniListLoggedIn.set(loggedIn);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
setApiError(
|
||||
"anilist",
|
||||
`Authentication failed: ${errorMsg}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
aniListLoggedIn.set(false);
|
||||
} finally {
|
||||
serviceLoggingIn.update((s) => s.filter((item) => item !== "anilist"));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,17 +1,24 @@
|
||||
<script lang="ts" context="module">
|
||||
import {CheckIfMyAnimeListLoggedIn, GetMyAnimeList, GetMyAnimeListLoggedInUser} from "../../wailsjs/go/main/App";
|
||||
import {malUser, malPrimary, malWatchList, malLoggedIn} from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import {malUser, malPrimary, malWatchList, malLoggedIn, serviceLoggingIn} from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
import type { MyAnimeListUser } from "../../bindings/AniTrack/models";
|
||||
|
||||
let isMalPrimary: boolean
|
||||
malPrimary.subscribe(value => isMalPrimary = value)
|
||||
|
||||
export const CheckIfMALLoggedInAndSetUser = async () => {
|
||||
await CheckIfMyAnimeListLoggedIn().then(loggedIn => {
|
||||
serviceLoggingIn.update((s) => [...s, "mal"])
|
||||
await App.CheckIfMyAnimeListLoggedIn().then(loggedIn => {
|
||||
if (loggedIn) {
|
||||
GetMyAnimeListLoggedInUser().then(user => {
|
||||
App.GetMyAnimeListLoggedInUser().then(user => {
|
||||
if (!user.name) {
|
||||
malUser.set({} as MyAnimeListUser)
|
||||
malLoggedIn.set(false)
|
||||
return
|
||||
}
|
||||
malUser.set(user)
|
||||
if (isMalPrimary) {
|
||||
GetMyAnimeList(1000).then(watchList => {
|
||||
App.GetMyAnimeList(1000).then(watchList => {
|
||||
malWatchList.set(watchList)
|
||||
malLoggedIn.set(loggedIn)
|
||||
})
|
||||
@@ -20,6 +27,8 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
serviceLoggingIn.update((s) => s.filter(item => item !== "mal"))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,20 +1,21 @@
|
||||
<script lang="ts" context="module">
|
||||
import {CheckIfSimklLoggedIn, GetSimklLoggedInUser, SimklGetUserWatchlist} from "../../wailsjs/go/main/App";
|
||||
import { simklLoggedIn, simklUser, simklPrimary, simklWatchList } from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import { simklLoggedIn, simklUser, simklPrimary, simklWatchList, serviceLoggingIn } from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
let isSimklPrimary: boolean
|
||||
simklPrimary.subscribe(value => isSimklPrimary = value)
|
||||
|
||||
export const CheckIfSimklLoggedInAndSetUser = async () => {
|
||||
await CheckIfSimklLoggedIn().then(loggedIn => {
|
||||
serviceLoggingIn.update((s) => [...s, "simkl"])
|
||||
await App.CheckIfSimklLoggedIn().then(loggedIn => {
|
||||
if (loggedIn) {
|
||||
GetSimklLoggedInUser().then(user => {
|
||||
App.GetSimklLoggedInUser().then(user => {
|
||||
if (Object.keys(user).length === 0) {
|
||||
simklLoggedIn.set(false)
|
||||
} else {
|
||||
simklUser.set(user)
|
||||
if (isSimklPrimary) {
|
||||
SimklGetUserWatchlist().then(result => {
|
||||
App.SimklGetUserWatchlist().then(result => {
|
||||
simklWatchList.set(result)
|
||||
simklLoggedIn.set(loggedIn)
|
||||
})
|
||||
@@ -24,6 +25,8 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
serviceLoggingIn.update((s) => s.filter(item => item !== "simkl"))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,162 +1,226 @@
|
||||
<script lang="ts" context="module">
|
||||
import {
|
||||
GetAniListItem,
|
||||
GetAniListLoggedInUser,
|
||||
GetAniListUserWatchingList,
|
||||
GetMyAnimeListAnime,
|
||||
GetMyAnimeListLoggedInUser,
|
||||
GetSimklLoggedInUser,
|
||||
LogoutAniList,
|
||||
LogoutMyAnimeList,
|
||||
LogoutSimkl,
|
||||
SimklGetUserWatchlist,
|
||||
SimklSearch
|
||||
} from "../../wailsjs/go/main/App";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import type {
|
||||
AniListCurrentUserWatchList,
|
||||
AniListGetSingleAnime
|
||||
} from "../anilist/types/AniListCurrentUserWatchListType.js";
|
||||
import {writable} from 'svelte/store'
|
||||
import type {SimklAnime, SimklUser, SimklWatchList} from "../simkl/types/simklTypes";
|
||||
import {type AniListUser, MediaListSort} from "../anilist/types/AniListTypes";
|
||||
import type {MALAnime, MALWatchlist, MyAnimeListUser} from "../mal/types/MALTypes";
|
||||
import type {TableItems} from "../helperTypes/TableTypes";
|
||||
import {AniListGetSingleAnimeDefaultData} from "../helperDefaults/AniListGetSingleAnime";
|
||||
AniListGetSingleAnime,
|
||||
AniListUser,
|
||||
MALAnime,
|
||||
MALWatchlist,
|
||||
MyAnimeListUser,
|
||||
SimklAnime,
|
||||
SimklUser,
|
||||
SimklWatchListType,
|
||||
} from "../../bindings/AniTrack/models";
|
||||
import { writable } from "svelte/store";
|
||||
import { MediaListSort } from "../anilist/types/AniListTypes";
|
||||
import type { TableItems } from "../helperTypes/TableTypes";
|
||||
import { AniListGetSingleAnimeDefaultData } from "../helperDefaults/AniListGetSingleAnime";
|
||||
|
||||
export let aniListAnime = writable(AniListGetSingleAnimeDefaultData)
|
||||
export let title = writable("")
|
||||
export let aniListLoggedIn = writable(false)
|
||||
export let simklLoggedIn = writable(false)
|
||||
export let malLoggedIn = writable(false)
|
||||
export let simklWatchList = writable({} as SimklWatchList)
|
||||
export let aniListPrimary = writable(true)
|
||||
export let simklPrimary = writable(false)
|
||||
export let malPrimary = writable(false)
|
||||
export let simklUser = writable({} as SimklUser)
|
||||
export let aniListUser = writable({} as AniListUser)
|
||||
export let malUser = writable({} as MyAnimeListUser)
|
||||
export let aniListWatchlist = writable({} as AniListCurrentUserWatchList)
|
||||
export let malWatchList = writable({} as MALWatchlist)
|
||||
export let malAnime = writable({} as MALAnime)
|
||||
export let simklAnime = writable({} as SimklAnime)
|
||||
export let loading = writable(false)
|
||||
export let tableItems = writable([] as TableItems)
|
||||
export let aniListAnime = writable(AniListGetSingleAnimeDefaultData);
|
||||
export let title = writable("");
|
||||
export let aniListLoggedIn = writable(false);
|
||||
export let simklLoggedIn = writable(false);
|
||||
export let malLoggedIn = writable(false);
|
||||
export const serviceLoggingIn = writable([] as string[]);
|
||||
export let simklWatchList = writable({} as SimklWatchListType);
|
||||
export let aniListPrimary = writable(true);
|
||||
export let simklPrimary = writable(false);
|
||||
export let malPrimary = writable(false);
|
||||
export let simklUser = writable({} as SimklUser);
|
||||
export let aniListUser = writable({} as AniListUser);
|
||||
export let malUser = writable({} as MyAnimeListUser);
|
||||
export let aniListWatchlist = writable({} as AniListCurrentUserWatchList);
|
||||
export let malWatchList = writable({} as MALWatchlist);
|
||||
export let malAnime = writable({} as MALAnime);
|
||||
export let simklAnime = writable({} as SimklAnime);
|
||||
export let loading = writable(false);
|
||||
export let tableItems = writable([] as TableItems);
|
||||
export let watchlistNeedsRefresh = writable(false);
|
||||
export let aniListSort = writable(MediaListSort.UpdatedTimeDesc);
|
||||
|
||||
export let watchListPage = writable(1)
|
||||
export let animePerPage = writable(20)
|
||||
export let watchListPage = writable(1);
|
||||
export let animePerPage = writable(20);
|
||||
|
||||
let isAniListPrimary: boolean
|
||||
let page: number
|
||||
let perPage: number
|
||||
let aniWatchlist: AniListCurrentUserWatchList
|
||||
let currentAniListAnime: AniListGetSingleAnime
|
||||
let isAniListPrimary: boolean;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort!: string;
|
||||
let aniWatchlist: AniListCurrentUserWatchList;
|
||||
let currentAniListAnime!: AniListGetSingleAnime;
|
||||
|
||||
let isMalLoggedIn: boolean
|
||||
let isSimklLoggedIn: boolean
|
||||
let isMalLoggedIn!: boolean;
|
||||
let isSimklLoggedIn!: boolean;
|
||||
|
||||
aniListPrimary.subscribe(value => isAniListPrimary = value)
|
||||
watchListPage.subscribe(value => page = value)
|
||||
animePerPage.subscribe(value => perPage = value)
|
||||
aniListWatchlist.subscribe(value => aniWatchlist = value)
|
||||
malLoggedIn.subscribe(value => isMalLoggedIn = value)
|
||||
simklLoggedIn.subscribe(value => isSimklLoggedIn = value)
|
||||
aniListAnime.subscribe(value => currentAniListAnime = value)
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
animePerPage.subscribe((value) => (perPage = value));
|
||||
aniListWatchlist.subscribe((value) => (aniWatchlist = value));
|
||||
malLoggedIn.subscribe((value) => (isMalLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
aniListAnime.subscribe((value) => (currentAniListAnime = value));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
|
||||
export async function GetAnimeSingleItem(aniId: number, login: boolean): Promise<""> {
|
||||
await GetAniListItem(aniId, login).then(aniListResult => {
|
||||
let finalResult: AniListGetSingleAnime
|
||||
finalResult = aniListResult
|
||||
if (login === false) {
|
||||
finalResult.data.MediaList.status = ""
|
||||
finalResult.data.MediaList.score = 0
|
||||
finalResult.data.MediaList.progress = 0
|
||||
finalResult.data.MediaList.notes = ""
|
||||
finalResult.data.MediaList.repeat = 0
|
||||
finalResult.data.MediaList.startedAt.day = 0
|
||||
finalResult.data.MediaList.startedAt.month = 0
|
||||
finalResult.data.MediaList.startedAt.year = 0
|
||||
finalResult.data.MediaList.completedAt.day = 0
|
||||
finalResult.data.MediaList.completedAt.month = 0
|
||||
finalResult.data.MediaList.completedAt.year = 0
|
||||
export interface ApiError {
|
||||
service: string;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
canRetry: boolean;
|
||||
}
|
||||
aniListAnime.set(finalResult)
|
||||
title.set(currentAniListAnime.data.MediaList.media.title.english === "" ?
|
||||
currentAniListAnime.data.MediaList.media.title.romaji :
|
||||
currentAniListAnime.data.MediaList.media.title.english)
|
||||
})
|
||||
export const apiError = writable<ApiError | null>(null);
|
||||
export const isApiDown = writable(false);
|
||||
export function setApiError(
|
||||
service: string,
|
||||
message: string,
|
||||
statusCode?: string,
|
||||
canRetry: boolean = true,
|
||||
) {
|
||||
apiError.set({
|
||||
service,
|
||||
message,
|
||||
statusCode,
|
||||
canRetry,
|
||||
});
|
||||
isApiDown.set(true);
|
||||
}
|
||||
export function clearApiError() {
|
||||
apiError.set(null);
|
||||
isApiDown.set(false);
|
||||
}
|
||||
|
||||
export async function GetAnimeSingleItem(
|
||||
aniId: number,
|
||||
login: boolean,
|
||||
): Promise<""> {
|
||||
await App.GetAniListItem(aniId, login).then((aniListResult) => {
|
||||
let finalResult: AniListGetSingleAnime;
|
||||
finalResult = aniListResult;
|
||||
if (login === false) {
|
||||
finalResult.data.MediaList.status = "";
|
||||
finalResult.data.MediaList.score = 0;
|
||||
finalResult.data.MediaList.progress = 0;
|
||||
finalResult.data.MediaList.notes = "";
|
||||
finalResult.data.MediaList.repeat = 0;
|
||||
finalResult.data.MediaList.startedAt.day = 0;
|
||||
finalResult.data.MediaList.startedAt.month = 0;
|
||||
finalResult.data.MediaList.startedAt.year = 0;
|
||||
finalResult.data.MediaList.completedAt.day = 0;
|
||||
finalResult.data.MediaList.completedAt.month = 0;
|
||||
finalResult.data.MediaList.completedAt.year = 0;
|
||||
}
|
||||
aniListAnime.set(finalResult);
|
||||
title.set(
|
||||
currentAniListAnime.data.MediaList.media.title.english === ""
|
||||
? currentAniListAnime.data.MediaList.media.title.romaji
|
||||
: currentAniListAnime.data.MediaList.media.title.english,
|
||||
);
|
||||
});
|
||||
if (isMalLoggedIn) {
|
||||
await GetMyAnimeListAnime(currentAniListAnime.data.MediaList.media.idMal).then(malResult => {
|
||||
malAnime.set(malResult)
|
||||
})
|
||||
await App.GetMyAnimeListAnime(
|
||||
currentAniListAnime.data.MediaList.media.idMal,
|
||||
).then((malResult) => {
|
||||
malAnime.set(malResult);
|
||||
});
|
||||
}
|
||||
if (isSimklLoggedIn) {
|
||||
await SimklSearch(currentAniListAnime.data.MediaList).then((value: SimklAnime) => {
|
||||
simklAnime.set(value)
|
||||
})
|
||||
await App.SimklSearch(currentAniListAnime.data.MediaList).then(
|
||||
(value: SimklAnime) => {
|
||||
simklAnime.set(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
|
||||
export function setServiceLoggingIn(service: string, isLoggingIn: boolean): void {
|
||||
serviceLoggingIn.update((services) => {
|
||||
if (isLoggingIn) {
|
||||
return services.includes(service) ? services : [...services, service];
|
||||
}
|
||||
return services.filter((s) => s !== service);
|
||||
});
|
||||
}
|
||||
|
||||
export function isServiceLoggingIn(service: string): boolean {
|
||||
let loggingIn = false;
|
||||
serviceLoggingIn.subscribe((services) => {
|
||||
loggingIn = services.includes(service);
|
||||
})();
|
||||
return loggingIn;
|
||||
}
|
||||
|
||||
export function loginToSimkl(): void {
|
||||
GetSimklLoggedInUser().then(user => {
|
||||
setServiceLoggingIn("simkl", true);
|
||||
App.GetSimklLoggedInUser()
|
||||
.then((user) => {
|
||||
if (Object.keys(user).length === 0) {
|
||||
simklLoggedIn.set(false)
|
||||
simklLoggedIn.set(false);
|
||||
} else {
|
||||
simklUser.set(user)
|
||||
SimklGetUserWatchlist().then(result => {
|
||||
simklWatchList.set(result)
|
||||
simklLoggedIn.set(true)
|
||||
})
|
||||
simklUser.set(user);
|
||||
App.SimklGetUserWatchlist().then((result) => {
|
||||
simklWatchList.set(result);
|
||||
simklLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => setServiceLoggingIn("simkl", false));
|
||||
}
|
||||
|
||||
export function loginToAniList(): void {
|
||||
GetAniListLoggedInUser().then(result => {
|
||||
aniListUser.set(result)
|
||||
setServiceLoggingIn("anilist", true);
|
||||
App.GetAniListLoggedInUser()
|
||||
.then((result) => {
|
||||
aniListUser.set(result);
|
||||
if (isAniListPrimary) {
|
||||
GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then((result) => {
|
||||
aniListWatchlist.set(result)
|
||||
aniListLoggedIn.set(true)
|
||||
})
|
||||
App.GetAniListUserWatchingList(page, perPage, sort).then((result) => {
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
} else {
|
||||
aniListLoggedIn.set(true)
|
||||
aniListLoggedIn.set(true);
|
||||
}
|
||||
})
|
||||
.finally(() => setServiceLoggingIn("anilist", false));
|
||||
}
|
||||
|
||||
export function loginToMAL(): void {
|
||||
GetMyAnimeListLoggedInUser().then(result => {
|
||||
malUser.set(result)
|
||||
malLoggedIn.set(true)
|
||||
setServiceLoggingIn("mal", true);
|
||||
App.GetMyAnimeListLoggedInUser()
|
||||
.then((result) => {
|
||||
if (!result.name) {
|
||||
malUser.set({} as MyAnimeListUser);
|
||||
malLoggedIn.set(false);
|
||||
return;
|
||||
}
|
||||
malUser.set(result);
|
||||
malLoggedIn.set(true);
|
||||
})
|
||||
.finally(() => setServiceLoggingIn("mal", false));
|
||||
}
|
||||
|
||||
export function logoutOfAniList(): void {
|
||||
LogoutAniList().then(result => {
|
||||
console.log(result)
|
||||
App.LogoutAniList().then((result) => {
|
||||
console.log(result);
|
||||
if (Object.keys(aniWatchlist).length !== 0) {
|
||||
aniListWatchlist.set({} as AniListCurrentUserWatchList)
|
||||
aniListWatchlist.set({} as AniListCurrentUserWatchList);
|
||||
}
|
||||
aniListUser.set({} as AniListUser)
|
||||
aniListLoggedIn.set(false)
|
||||
})
|
||||
aniListUser.set({} as AniListUser);
|
||||
aniListLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutOfMAL(): void {
|
||||
LogoutMyAnimeList().then(result => {
|
||||
console.log(result)
|
||||
malUser.set({} as MyAnimeListUser)
|
||||
malLoggedIn.set(false)
|
||||
})
|
||||
App.LogoutMyAnimeList().then((result) => {
|
||||
console.log(result);
|
||||
malUser.set({} as MyAnimeListUser);
|
||||
malLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutOfSimkl(): void {
|
||||
LogoutSimkl().then(result => {
|
||||
console.log(result)
|
||||
simklUser.set({} as SimklUser)
|
||||
simklLoggedIn.set(false)
|
||||
})
|
||||
App.LogoutSimkl().then((result) => {
|
||||
console.log(result);
|
||||
simklUser.set({} as SimklUser);
|
||||
simklLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,9 @@
|
||||
import './style.css'
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { aniListAnime, GetAnimeSingleItem } from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import Anime from "../helperComponents/Anime.svelte"
|
||||
import { AniListGetSingleAnimeDefaultData } from "../helperDefaults/AniListGetSingleAnime";
|
||||
import Spinner from "../helperComponents/Spinner.svelte";
|
||||
|
||||
export let params: Record<string, string>
|
||||
let loadPromise = load(params.id)
|
||||
$: loadPromise = load(params.id)
|
||||
|
||||
async function load(id: string) {
|
||||
aniListAnime.update(() => AniListGetSingleAnimeDefaultData)
|
||||
await GetAnimeSingleItem(Number(id), true)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key params.id}
|
||||
{#await loadPromise}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
<Anime />
|
||||
{/await}
|
||||
{/key}
|
||||
@@ -1,25 +1,56 @@
|
||||
<script lang="ts">
|
||||
import Pagination from "../helperComponents/Pagination.svelte";
|
||||
import WatchList from "../helperComponents/WatchList.svelte";
|
||||
import {
|
||||
import Pagination from "../helperComponents/Pagination.svelte";
|
||||
import WatchList from "../helperComponents/WatchList.svelte";
|
||||
import RefreshWatchListButton from "../helperComponents/RefreshWatchListButton.svelte";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListPrimary,
|
||||
loading,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import loader from '../helperFunctions/loader'
|
||||
isApiDown,
|
||||
apiError,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import loader from "../helperFunctions/loader";
|
||||
|
||||
let isAniListPrimary: boolean
|
||||
let isAniListLoggedIn: boolean
|
||||
let isAniListPrimary: boolean;
|
||||
let isAniListLoggedIn!: boolean;
|
||||
|
||||
aniListPrimary.subscribe((value) => isAniListPrimary = value)
|
||||
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value)
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
</script>
|
||||
{#if isAniListLoggedIn && isAniListPrimary}
|
||||
<div class="container py-10">
|
||||
|
||||
{#if $isApiDown}
|
||||
<div class="container py-10">
|
||||
<div
|
||||
class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-yellow-600 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-xl font-semibold text-yellow-900 mb-2">
|
||||
API Unavailable
|
||||
</h2>
|
||||
<p class="text-yellow-700 mb-4">
|
||||
The {$apiError?.service || "service"} is currently unavailable. The app will
|
||||
remain open, and you can retry when the service is back online.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isAniListLoggedIn && isAniListPrimary}
|
||||
<div class="container py-10">
|
||||
<Pagination />
|
||||
<WatchList />
|
||||
<Pagination />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div use:loader={loading}></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- Dumb reusable star-rating input. No stores, no backend knowledge:
|
||||
`value` is written live on hover (reverted on leave unless committed)
|
||||
and set on click/keyboard; the parent owns what the value means
|
||||
(words, persistence). Reuses the Star leaf, so output is pixel-identical
|
||||
to the old overlay-slider version without the overlay. -->
|
||||
<script lang="ts">
|
||||
import Star from './components/Star.svelte';
|
||||
|
||||
type StarColors = {
|
||||
size: number;
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
unfilledColor: string;
|
||||
strokeUnfilledColor: string;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(0),
|
||||
min = 0,
|
||||
max = 5,
|
||||
step = 0.5,
|
||||
count = 5,
|
||||
disabled = false,
|
||||
name,
|
||||
starConfig = {
|
||||
size: 32,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: '#BB8511',
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
},
|
||||
onchange,
|
||||
}: {
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
// When set, renders a hidden input so the value submits with a
|
||||
// surrounding <form> via FormData (the old slider exposed
|
||||
// name="rating" the same way).
|
||||
name?: string;
|
||||
starConfig?: StarColors;
|
||||
onchange?: (value: number) => void;
|
||||
} = $props();
|
||||
|
||||
// Value at hover entry; restored on leave unless committed. Null = idle.
|
||||
let baseline: number | null = $state(null);
|
||||
|
||||
function clamp(v: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function snap(v: number): number {
|
||||
const snapped = min + Math.round((v - min) / step) * step;
|
||||
return clamp(Math.round(snapped * 1e6) / 1e6);
|
||||
}
|
||||
|
||||
// Continuous value for cursor position within star `index`.
|
||||
function valueAt(index: number, fraction: number): number {
|
||||
if (index === 0 && fraction < 0.2) return min;
|
||||
const f = fraction >= 0.5 ? 1 : 0.5;
|
||||
return snap(min + ((index + f) / count) * (max - min));
|
||||
}
|
||||
|
||||
function fractionOf(e: MouseEvent, el: HTMLElement): number {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0) return 1;
|
||||
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
function fillOf(index: number): number {
|
||||
const per = (max - min) / count;
|
||||
return Math.min(1, Math.max(0, (value - min - index * per) / per));
|
||||
}
|
||||
|
||||
function onHoverMove(e: MouseEvent, el: HTMLElement, index: number) {
|
||||
if (disabled) return;
|
||||
if (baseline === null) baseline = value;
|
||||
value = valueAt(index, fractionOf(e, el));
|
||||
}
|
||||
|
||||
function onHoverLeave() {
|
||||
if (disabled) return;
|
||||
if (baseline !== null) {
|
||||
value = baseline;
|
||||
baseline = null;
|
||||
}
|
||||
}
|
||||
|
||||
function commit(v: number) {
|
||||
if (disabled) return;
|
||||
value = clamp(v);
|
||||
baseline = value;
|
||||
onchange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="stars-container"
|
||||
role="group"
|
||||
aria-label="Star rating"
|
||||
onmouseleave={onHoverLeave}
|
||||
>
|
||||
<div class="stars">
|
||||
{#if name !== undefined}
|
||||
<input type="hidden" name={name} value={value} />
|
||||
{/if}
|
||||
{#each Array(count) as _, i}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-0 border-0 bg-transparent"
|
||||
disabled={disabled}
|
||||
aria-label={`Rate ${i + 1} out of ${count}`}
|
||||
onmousemove={(e) => onHoverMove(e, e.currentTarget, i)}
|
||||
onclick={() => commit(value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
commit(value + step);
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
commit(value - step);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
id={`star-${i}`}
|
||||
readOnly={disabled}
|
||||
starConfig={starConfig}
|
||||
fillPercentage={fillOf(i)}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
</style>
|
||||
@@ -1,63 +0,0 @@
|
||||
<!-- Originally from @ernane/svelte-star-rating. Wanted to give credit but could not use from the library without causing program crash. -->
|
||||
|
||||
<script>
|
||||
import Star from './components/Star.svelte';
|
||||
export let config = {
|
||||
readOnly: false,
|
||||
countStars: 5,
|
||||
range: { min: 0, max: 5, step: 0.001 },
|
||||
score: 0.0,
|
||||
showScore: true,
|
||||
name: "stars",
|
||||
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
|
||||
starConfig: {
|
||||
size: 30,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: "#BB8511",
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="stars-container">
|
||||
<div class="range-stars">
|
||||
<div class="stars">
|
||||
{#each Array(config.countStars) as star, id}
|
||||
{#if Math.floor(config.score) === id}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={config.score - Math.floor(config.score)}/>
|
||||
{:else if Math.floor(config.score) > id}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={1}/>
|
||||
{:else}
|
||||
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={0}/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<input name={config.name}
|
||||
class="slider"
|
||||
type="range"
|
||||
min={config.readOnly ? config.score : config.range.min}
|
||||
max={config.readOnly ? config.score : config.range.max}
|
||||
step="{config.range.step}" bind:value={config.score}
|
||||
on:change
|
||||
on:click
|
||||
>
|
||||
</div>
|
||||
{#if config.showScore}
|
||||
<span class="show-score" style="font-size: {config.starConfig.size/2}px;">
|
||||
{#if config.scoreFormat}
|
||||
{config.scoreFormat()}
|
||||
{:else}
|
||||
({((config.score/config.countStars)*100).toFixed(2)}%)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.range-stars{ position: relative; }
|
||||
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
|
||||
.slider{ opacity: 0; cursor: pointer; position: absolute; top: 0; left: 0; right: 0; height: 100%; }
|
||||
.show-score{ user-select: none; color: #888 }
|
||||
</style>
|
||||
+57
-3
@@ -1,6 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin 'flowbite/plugin';
|
||||
|
||||
@source "../node_modules/flowbite-svelte/dist";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #FFF5F2;
|
||||
--color-primary-100: #FFF1EE;
|
||||
--color-primary-200: #FFE4DE;
|
||||
--color-primary-300: #FFD5CC;
|
||||
--color-primary-400: #FFBCAD;
|
||||
--color-primary-500: #FE795D;
|
||||
--color-primary-600: #EF562F;
|
||||
--color-primary-700: #EB4F27;
|
||||
--color-primary-800: #CC4522;
|
||||
--color-primary-900: #A5371B;
|
||||
}
|
||||
|
||||
@utility container {
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
max-width: theme(--breakpoint-sm);
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-md)) {
|
||||
max-width: theme(--breakpoint-md);
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-lg)) {
|
||||
max-width: theme(--breakpoint-lg);
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-xl)) {
|
||||
max-width: theme(--breakpoint-xl);
|
||||
padding-inline: 5rem;
|
||||
}
|
||||
|
||||
@media (width >= theme(--breakpoint-2xl)) {
|
||||
max-width: theme(--breakpoint-2xl);
|
||||
padding-inline: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* Tailwind v4 removed the v3 preflight rule that gave buttons a
|
||||
pointer cursor. Restore it (disabled buttons keep the default). */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import sveltePreprocess from 'svelte-preprocess'
|
||||
|
||||
export default {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: sveltePreprocess()
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import flowbitePlugin from 'flowbite/plugin'
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
||||
"./node_modules/flowbite/**/*.{html,js,svelte,ts}",
|
||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}",
|
||||
],
|
||||
plugins: [ flowbitePlugin ],
|
||||
|
||||
darkMode: 'media',
|
||||
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: {
|
||||
DEFAULT: '1rem',
|
||||
sm: '2rem',
|
||||
lg: '4rem',
|
||||
xl: '5rem',
|
||||
'2xl': '6rem',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// flowbite-svelte
|
||||
primary: {
|
||||
50: '#FFF5F2',
|
||||
100: '#FFF1EE',
|
||||
200: '#FFE4DE',
|
||||
300: '#FFD5CC',
|
||||
400: '#FFBCAD',
|
||||
500: '#FE795D',
|
||||
600: '#EF562F',
|
||||
700: '#EB4F27',
|
||||
800: '#CC4522',
|
||||
900: '#A5371B'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import {svelte} from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import wails from '@wailsio/runtime/plugins/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()]
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.WAILS_VITE_PORT) || 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [tailwindcss(), svelte(), wails('./bindings')]
|
||||
})
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// vite.config.ts
|
||||
import { defineConfig } from "file:///home/nymusicman/Code/AniTrack/frontend/node_modules/vite/dist/node/index.js";
|
||||
import { svelte } from "file:///home/nymusicman/Code/AniTrack/frontend/node_modules/@sveltejs/vite-plugin-svelte/src/index.js";
|
||||
var vite_config_default = defineConfig({
|
||||
plugins: [svelte()]
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9ueW11c2ljbWFuL0NvZGUvQW5pVHJhY2svZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL255bXVzaWNtYW4vQ29kZS9BbmlUcmFjay9mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9ueW11c2ljbWFuL0NvZGUvQW5pVHJhY2svZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQge2RlZmluZUNvbmZpZ30gZnJvbSAndml0ZSdcbmltcG9ydCB7c3ZlbHRlfSBmcm9tICdAc3ZlbHRlanMvdml0ZS1wbHVnaW4tc3ZlbHRlJ1xuXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW3N2ZWx0ZSgpXVxufSlcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBdVMsU0FBUSxvQkFBbUI7QUFDbFUsU0FBUSxjQUFhO0FBR3JCLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxPQUFPLENBQUM7QUFDcEIsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {main} from '../models';
|
||||
|
||||
export function AniListDeleteEntry(arg1:number):Promise<main.DeleteAniListReturn>;
|
||||
|
||||
export function AniListLogin():Promise<void>;
|
||||
|
||||
export function AniListSearch(arg1:string):Promise<any>;
|
||||
|
||||
export function AniListUpdateEntry(arg1:main.AniListUpdateVariables):Promise<main.AniListGetSingleAnime>;
|
||||
|
||||
export function CheckIfAniListLoggedIn():Promise<boolean>;
|
||||
|
||||
export function CheckIfMyAnimeListLoggedIn():Promise<boolean>;
|
||||
|
||||
export function CheckIfSimklLoggedIn():Promise<boolean>;
|
||||
|
||||
export function DeleteMyAnimeListEntry(arg1:number):Promise<boolean>;
|
||||
|
||||
export function GetAniListItem(arg1:number,arg2:boolean):Promise<main.AniListGetSingleAnime>;
|
||||
|
||||
export function GetAniListLoggedInUser():Promise<main.AniListUser>;
|
||||
|
||||
export function GetAniListUserWatchingList(arg1:number,arg2:number,arg3:string):Promise<main.AniListCurrentUserWatchList>;
|
||||
|
||||
export function GetMyAnimeList(arg1:number):Promise<main.MALWatchlist>;
|
||||
|
||||
export function GetMyAnimeListAnime(arg1:number):Promise<main.MALAnime>;
|
||||
|
||||
export function GetMyAnimeListLoggedInUser():Promise<main.MyAnimeListUser>;
|
||||
|
||||
export function GetSimklLoggedInUser():Promise<main.SimklUser>;
|
||||
|
||||
export function LogoutAniList():Promise<string>;
|
||||
|
||||
export function LogoutMyAnimeList():Promise<string>;
|
||||
|
||||
export function LogoutSimkl():Promise<string>;
|
||||
|
||||
export function MyAnimeListLogin():Promise<void>;
|
||||
|
||||
export function MyAnimeListUpdate(arg1:main.MALAnime,arg2:main.MALUploadStatus):Promise<main.MalListStatus>;
|
||||
|
||||
export function SimklGetUserWatchlist():Promise<main.SimklWatchListType>;
|
||||
|
||||
export function SimklLogin():Promise<void>;
|
||||
|
||||
export function SimklSearch(arg1:main.MediaList):Promise<main.SimklAnime>;
|
||||
|
||||
export function SimklSyncEpisodes(arg1:main.SimklAnime,arg2:number):Promise<main.SimklAnime>;
|
||||
|
||||
export function SimklSyncRating(arg1:main.SimklAnime,arg2:number):Promise<main.SimklAnime>;
|
||||
|
||||
export function SimklSyncRemove(arg1:main.SimklAnime):Promise<boolean>;
|
||||
|
||||
export function SimklSyncStatus(arg1:main.SimklAnime,arg2:string):Promise<main.SimklAnime>;
|
||||
@@ -1,111 +0,0 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AniListDeleteEntry(arg1) {
|
||||
return window['go']['main']['App']['AniListDeleteEntry'](arg1);
|
||||
}
|
||||
|
||||
export function AniListLogin() {
|
||||
return window['go']['main']['App']['AniListLogin']();
|
||||
}
|
||||
|
||||
export function AniListSearch(arg1) {
|
||||
return window['go']['main']['App']['AniListSearch'](arg1);
|
||||
}
|
||||
|
||||
export function AniListUpdateEntry(arg1) {
|
||||
return window['go']['main']['App']['AniListUpdateEntry'](arg1);
|
||||
}
|
||||
|
||||
export function CheckIfAniListLoggedIn() {
|
||||
return window['go']['main']['App']['CheckIfAniListLoggedIn']();
|
||||
}
|
||||
|
||||
export function CheckIfMyAnimeListLoggedIn() {
|
||||
return window['go']['main']['App']['CheckIfMyAnimeListLoggedIn']();
|
||||
}
|
||||
|
||||
export function CheckIfSimklLoggedIn() {
|
||||
return window['go']['main']['App']['CheckIfSimklLoggedIn']();
|
||||
}
|
||||
|
||||
export function DeleteMyAnimeListEntry(arg1) {
|
||||
return window['go']['main']['App']['DeleteMyAnimeListEntry'](arg1);
|
||||
}
|
||||
|
||||
export function GetAniListItem(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetAniListItem'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAniListLoggedInUser() {
|
||||
return window['go']['main']['App']['GetAniListLoggedInUser']();
|
||||
}
|
||||
|
||||
export function GetAniListUserWatchingList(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['GetAniListUserWatchingList'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function GetMyAnimeList(arg1) {
|
||||
return window['go']['main']['App']['GetMyAnimeList'](arg1);
|
||||
}
|
||||
|
||||
export function GetMyAnimeListAnime(arg1) {
|
||||
return window['go']['main']['App']['GetMyAnimeListAnime'](arg1);
|
||||
}
|
||||
|
||||
export function GetMyAnimeListLoggedInUser() {
|
||||
return window['go']['main']['App']['GetMyAnimeListLoggedInUser']();
|
||||
}
|
||||
|
||||
export function GetSimklLoggedInUser() {
|
||||
return window['go']['main']['App']['GetSimklLoggedInUser']();
|
||||
}
|
||||
|
||||
export function LogoutAniList() {
|
||||
return window['go']['main']['App']['LogoutAniList']();
|
||||
}
|
||||
|
||||
export function LogoutMyAnimeList() {
|
||||
return window['go']['main']['App']['LogoutMyAnimeList']();
|
||||
}
|
||||
|
||||
export function LogoutSimkl() {
|
||||
return window['go']['main']['App']['LogoutSimkl']();
|
||||
}
|
||||
|
||||
export function MyAnimeListLogin() {
|
||||
return window['go']['main']['App']['MyAnimeListLogin']();
|
||||
}
|
||||
|
||||
export function MyAnimeListUpdate(arg1, arg2) {
|
||||
return window['go']['main']['App']['MyAnimeListUpdate'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SimklGetUserWatchlist() {
|
||||
return window['go']['main']['App']['SimklGetUserWatchlist']();
|
||||
}
|
||||
|
||||
export function SimklLogin() {
|
||||
return window['go']['main']['App']['SimklLogin']();
|
||||
}
|
||||
|
||||
export function SimklSearch(arg1) {
|
||||
return window['go']['main']['App']['SimklSearch'](arg1);
|
||||
}
|
||||
|
||||
export function SimklSyncEpisodes(arg1, arg2) {
|
||||
return window['go']['main']['App']['SimklSyncEpisodes'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SimklSyncRating(arg1, arg2) {
|
||||
return window['go']['main']['App']['SimklSyncRating'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SimklSyncRemove(arg1) {
|
||||
return window['go']['main']['App']['SimklSyncRemove'](arg1);
|
||||
}
|
||||
|
||||
export function SimklSyncStatus(arg1, arg2) {
|
||||
return window['go']['main']['App']['SimklSyncStatus'](arg1, arg2);
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
export namespace main {
|
||||
|
||||
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.;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AniListCurrentUserWatchList(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = this.convertValues(source["data"], Object);
|
||||
}
|
||||
|
||||
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 AniListGetSingleAnime {
|
||||
data: struct { MediaList main.;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AniListGetSingleAnime(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = this.convertValues(source["data"], Object);
|
||||
}
|
||||
|
||||
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 CompletedAt {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CompletedAt(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 StartedAt {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StartedAt(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 AniListUpdateVariables {
|
||||
mediaId: number;
|
||||
progress: number;
|
||||
status: string;
|
||||
score: number;
|
||||
repeat: number;
|
||||
notes: string;
|
||||
startedAt: StartedAt;
|
||||
completedAt: CompletedAt;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AniListUpdateVariables(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.mediaId = source["mediaId"];
|
||||
this.progress = source["progress"];
|
||||
this.status = source["status"];
|
||||
this.score = source["score"];
|
||||
this.repeat = source["repeat"];
|
||||
this.notes = source["notes"];
|
||||
this.startedAt = this.convertValues(source["startedAt"], StartedAt);
|
||||
this.completedAt = this.convertValues(source["completedAt"], CompletedAt);
|
||||
}
|
||||
|
||||
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 AniListUser {
|
||||
// Go type: struct { Viewer struct { ID int "json:\"id\""; Name string "json:\"name\""; Avatar struct { Large string "json:\"large\""; Medium string "json:\"medium\"" } "json:\"avatar\""; BannerImage string "json:\"bannerImage\""; SiteUrl string "json:\"siteUrl\"" } "json:\"Viewer\"" }
|
||||
data: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AniListUser(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = this.convertValues(source["data"], Object);
|
||||
}
|
||||
|
||||
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 DeleteAniListReturn {
|
||||
// Go type: struct { DeleteMediaListEntry struct { Deleted bool "json:\"deleted\"" } "json:\"DeleteMediaListEntry\"" }
|
||||
data: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DeleteAniListReturn(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = this.convertValues(source["data"], Object);
|
||||
}
|
||||
|
||||
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 MALAnime {
|
||||
id: id;
|
||||
title: title;
|
||||
main_picture: mainPicture;
|
||||
alternative_titles: alternativeTitles;
|
||||
start_date: startDate;
|
||||
end_date: endDate;
|
||||
synopsis: synopsis;
|
||||
mean: mean;
|
||||
rank: rank;
|
||||
popularity: popularity;
|
||||
num_list_users: numListUsers;
|
||||
num_scoring_users: numScoringUsers;
|
||||
nsfw: nsfw;
|
||||
genres: genres;
|
||||
created_at: createdAt;
|
||||
updated_at: updatedAt;
|
||||
media_type: mediaType;
|
||||
status: status;
|
||||
my_list_status: MalListStatus;
|
||||
num_episodes: numEpisodes;
|
||||
start_season: startSeason;
|
||||
broadcast: broadcast;
|
||||
source: source;
|
||||
average_episode_duration: averageEpisodeDuration;
|
||||
rating: rating;
|
||||
studios: studios;
|
||||
pictures: pictures;
|
||||
background: background;
|
||||
related_anime: relatedAnime;
|
||||
recommendations: recommendations;
|
||||
// Go type: struct { NumListUsers int "json:\"num_list_users\" ts_type:\"numListUsers\""; Status struct { Watching string "json:\"watching\" ts_type:\"watching\""; Completed string "json:\"completed\" ts_type:\"completed\""; OnHold string "json:\"on_hold\" ts_type:\"onHold\""; Dropped string "json:\"dropped\" ts_type:\"dropped\""; PlanToWatch string "json:\"plan_to_watch\" ts_type:\"planToWatch\"" } }
|
||||
Statistics: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MALAnime(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.title = source["title"];
|
||||
this.main_picture = source["main_picture"];
|
||||
this.alternative_titles = source["alternative_titles"];
|
||||
this.start_date = source["start_date"];
|
||||
this.end_date = source["end_date"];
|
||||
this.synopsis = source["synopsis"];
|
||||
this.mean = source["mean"];
|
||||
this.rank = source["rank"];
|
||||
this.popularity = source["popularity"];
|
||||
this.num_list_users = source["num_list_users"];
|
||||
this.num_scoring_users = source["num_scoring_users"];
|
||||
this.nsfw = source["nsfw"];
|
||||
this.genres = source["genres"];
|
||||
this.created_at = source["created_at"];
|
||||
this.updated_at = source["updated_at"];
|
||||
this.media_type = source["media_type"];
|
||||
this.status = source["status"];
|
||||
this.my_list_status = source["my_list_status"];
|
||||
this.num_episodes = source["num_episodes"];
|
||||
this.start_season = source["start_season"];
|
||||
this.broadcast = source["broadcast"];
|
||||
this.source = source["source"];
|
||||
this.average_episode_duration = source["average_episode_duration"];
|
||||
this.rating = source["rating"];
|
||||
this.studios = source["studios"];
|
||||
this.pictures = source["pictures"];
|
||||
this.background = source["background"];
|
||||
this.related_anime = source["related_anime"];
|
||||
this.recommendations = source["recommendations"];
|
||||
this.Statistics = this.convertValues(source["Statistics"], Object);
|
||||
}
|
||||
|
||||
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 MALUploadStatus {
|
||||
status: string;
|
||||
is_rewatching: boolean;
|
||||
score: number;
|
||||
num_watched_episodes: number;
|
||||
num_times_rewatched: number;
|
||||
comments: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MALUploadStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.status = source["status"];
|
||||
this.is_rewatching = source["is_rewatching"];
|
||||
this.score = source["score"];
|
||||
this.num_watched_episodes = source["num_watched_episodes"];
|
||||
this.num_times_rewatched = source["num_times_rewatched"];
|
||||
this.comments = source["comments"];
|
||||
}
|
||||
}
|
||||
export class MALWatchlist {
|
||||
data: data;
|
||||
paging: paging;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MALWatchlist(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.data = source["data"];
|
||||
this.paging = source["paging"];
|
||||
}
|
||||
}
|
||||
export class MalListStatus {
|
||||
status: status;
|
||||
score: score;
|
||||
num_episodes_watched: numEpisodesWatched;
|
||||
is_rewatching: isRewatching;
|
||||
start_date: startDate;
|
||||
finish_date: finishDate;
|
||||
priority: priority;
|
||||
num_times_rewatched: numTimesRewatched;
|
||||
rewatch_value: rewatchValue;
|
||||
tags: tags;
|
||||
comments: comments;
|
||||
updated_at: updatedAt;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MalListStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.status = source["status"];
|
||||
this.score = source["score"];
|
||||
this.num_episodes_watched = source["num_episodes_watched"];
|
||||
this.is_rewatching = source["is_rewatching"];
|
||||
this.start_date = source["start_date"];
|
||||
this.finish_date = source["finish_date"];
|
||||
this.priority = source["priority"];
|
||||
this.num_times_rewatched = source["num_times_rewatched"];
|
||||
this.rewatch_value = source["rewatch_value"];
|
||||
this.tags = source["tags"];
|
||||
this.comments = source["comments"];
|
||||
this.updated_at = source["updated_at"];
|
||||
}
|
||||
}
|
||||
export class MediaList {
|
||||
id: number;
|
||||
mediaId: number;
|
||||
userId: number;
|
||||
// Go type: struct { ID int "json:\"id\""; IDMal int "json:\"idMal\""; Title struct { Romaji string "json:\"romaji\""; English string "json:\"english\""; Native string "json:\"native\"" } "json:\"title\""; Description string "json:\"description\""; CoverImage struct { Large string "json:\"large\"" } "json:\"coverImage\""; Season string "json:\"season\""; SeasonYear int "json:\"seasonYear\""; Status string "json:\"status\""; Episodes int "json:\"episodes\""; NextAiringEpisode struct { AiringAt int "json:\"airingAt\""; TimeUntilAiring int "json:\"timeUntilAiring\""; Episode int "json:\"episode\"" } "json:\"nextAiringEpisode\""; Tags []struct { Id int "json:\"id\""; Name string "json:\"name\""; Description string "json:\"description\""; Rank int "json:\"rank\""; IsMediaSpoiler bool "json:\"isMediaSpoiler\""; IsAdult bool "json:\"isAdult\"" } "json:\"tags\""; IsAdult bool "json:\"isAdult\"" }
|
||||
media: any;
|
||||
status: string;
|
||||
// Go type: struct { Year int "json:\"year\""; Month int "json:\"month\""; Day int "json:\"day\"" }
|
||||
startedAt: any;
|
||||
// Go type: struct { Year int "json:\"year\""; Month int "json:\"month\""; Day int "json:\"day\"" }
|
||||
completedAt: any;
|
||||
notes: string;
|
||||
progress: number;
|
||||
score: number;
|
||||
repeat: number;
|
||||
// Go type: struct { ID int "json:\"id\""; Name string "json:\"name\""; Avatar struct { Large string "json:\"large\""; Medium string "json:\"medium\"" } "json:\"avatar\""; Statistics struct { Anime struct { Count int "json:\"count\""; Statuses []struct { Status string "json:\"status\""; Count int "json:\"count\"" } "json:\"statuses\"" } "json:\"anime\"" } "json:\"statistics\"" }
|
||||
user: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MediaList(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.mediaId = source["mediaId"];
|
||||
this.userId = source["userId"];
|
||||
this.media = this.convertValues(source["media"], Object);
|
||||
this.status = source["status"];
|
||||
this.startedAt = this.convertValues(source["startedAt"], Object);
|
||||
this.completedAt = this.convertValues(source["completedAt"], Object);
|
||||
this.notes = source["notes"];
|
||||
this.progress = source["progress"];
|
||||
this.score = source["score"];
|
||||
this.repeat = source["repeat"];
|
||||
this.user = this.convertValues(source["user"], Object);
|
||||
}
|
||||
|
||||
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 MyAnimeListUser {
|
||||
id: id;
|
||||
name: name;
|
||||
picture: picture;
|
||||
gender: gender;
|
||||
birthday: birthday;
|
||||
location: location;
|
||||
joined_at: joinedAt;
|
||||
num_items_watching: numItemsWatching;
|
||||
num_items_completed: numItemsCompleted;
|
||||
num_items_on_hold: numItemsOnHold;
|
||||
num_items_dropped: numItemsDropped;
|
||||
num_items_plan_to_watch: numItemsPlanToWatch;
|
||||
num_items: numItems;
|
||||
num_days_watched: numDaysWatched;
|
||||
num_days_watching: numDaysWatching;
|
||||
num_days_completed: numDaysCompleted;
|
||||
num_days_on_hold: numDaysOnHold;
|
||||
num_days_dropped: numDaysDropped;
|
||||
num_days: numDays;
|
||||
num_episodes: numEpisodes;
|
||||
num_times_rewatched: numTimesRewatched;
|
||||
mean_score: meanScore;
|
||||
time_zone: timeZone;
|
||||
is_supporter: isSupporter;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MyAnimeListUser(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.picture = source["picture"];
|
||||
this.gender = source["gender"];
|
||||
this.birthday = source["birthday"];
|
||||
this.location = source["location"];
|
||||
this.joined_at = source["joined_at"];
|
||||
this.num_items_watching = source["num_items_watching"];
|
||||
this.num_items_completed = source["num_items_completed"];
|
||||
this.num_items_on_hold = source["num_items_on_hold"];
|
||||
this.num_items_dropped = source["num_items_dropped"];
|
||||
this.num_items_plan_to_watch = source["num_items_plan_to_watch"];
|
||||
this.num_items = source["num_items"];
|
||||
this.num_days_watched = source["num_days_watched"];
|
||||
this.num_days_watching = source["num_days_watching"];
|
||||
this.num_days_completed = source["num_days_completed"];
|
||||
this.num_days_on_hold = source["num_days_on_hold"];
|
||||
this.num_days_dropped = source["num_days_dropped"];
|
||||
this.num_days = source["num_days"];
|
||||
this.num_episodes = source["num_episodes"];
|
||||
this.num_times_rewatched = source["num_times_rewatched"];
|
||||
this.mean_score = source["mean_score"];
|
||||
this.time_zone = source["time_zone"];
|
||||
this.is_supporter = source["is_supporter"];
|
||||
}
|
||||
}
|
||||
export class SimklAnime {
|
||||
last_watched_at: last_watched_at;
|
||||
status: status;
|
||||
user_rating: user_rating;
|
||||
last_watched: last_watched;
|
||||
next_to_watch: next_to_watch;
|
||||
watched_episodes_count: watched_episodes_count;
|
||||
total_episodes_count: total_episodes_count;
|
||||
not_aired_episodes_count: not_aired_episodes_count;
|
||||
show: show;
|
||||
anime_type: anime_type;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SimklAnime(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.last_watched_at = source["last_watched_at"];
|
||||
this.status = source["status"];
|
||||
this.user_rating = source["user_rating"];
|
||||
this.last_watched = source["last_watched"];
|
||||
this.next_to_watch = source["next_to_watch"];
|
||||
this.watched_episodes_count = source["watched_episodes_count"];
|
||||
this.total_episodes_count = source["total_episodes_count"];
|
||||
this.not_aired_episodes_count = source["not_aired_episodes_count"];
|
||||
this.show = source["show"];
|
||||
this.anime_type = source["anime_type"];
|
||||
}
|
||||
}
|
||||
export class SimklUser {
|
||||
user: user;
|
||||
account: account;
|
||||
connections: connections;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SimklUser(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.user = source["user"];
|
||||
this.account = source["account"];
|
||||
this.connections = source["connections"];
|
||||
}
|
||||
}
|
||||
export class SimklWatchListType {
|
||||
anime: anime;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SimklWatchListType(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.anime = source["anime"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace struct { MediaList main {
|
||||
|
||||
export class {
|
||||
MediaList: main.MediaList;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new (source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.MediaList = this.convertValues(source["MediaList"], main.MediaList);
|
||||
}
|
||||
|
||||
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 namespace struct { Node main {
|
||||
|
||||
export class {
|
||||
node: node;
|
||||
num_recommendations: numRecommendations;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new (source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.node = source["node"];
|
||||
this.num_recommendations = source["num_recommendations"];
|
||||
}
|
||||
}
|
||||
export class {
|
||||
node: node;
|
||||
relation_type: relationType;
|
||||
relation_type_formatted: relationTypeFormatted;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new (source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.node = source["node"];
|
||||
this.relation_type = source["relation_type"];
|
||||
this.relation_type_formatted = source["relation_type_formatted"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace struct { Node struct { Id int "json:\"id\" ts_type:\"id\""; Title string "json:\"title\" ts_type:\"title\""; MainPicture struct { Medium string "json:\"medium\" ts_type:\"medium\""; Large string "json:\"large\" ts_type:\"large\"" } "json:\"main_picture\" ts_type:\"mainPicture\"" } "json:\"node\" ts_type:\"node\""; ListStatus struct { Status string "json:\"status\" ts_type:\"status\""; Score int "json:\"score\" ts_type:\"score\""; NumEpisodesWatched int "json:\"num_episodes_watched\" ts_type:\"numEpisodesWatched\""; IsRewatching bool "json:\"is_rewatching\" ts_type:\"isRewatching\""; UpdatedAt time {
|
||||
|
||||
export class {
|
||||
node: node;
|
||||
list_status: listStatus;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new (source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.node = source["node"];
|
||||
this.list_status = source["list_status"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace struct { Status string "json:\"status\" ts_type:\"status\""; Score int "json:\"score\" ts_type:\"score\""; NumEpisodesWatched int "json:\"num_episodes_watched\" ts_type:\"numEpisodesWatched\""; IsRewatching bool "json:\"is_rewatching\" ts_type:\"isRewatching\""; UpdatedAt time {
|
||||
|
||||
export class {
|
||||
status: status;
|
||||
score: score;
|
||||
num_episodes_watched: numEpisodesWatched;
|
||||
is_rewatching: isRewatching;
|
||||
updated_at: updatedAt;
|
||||
start_date: startDate;
|
||||
finish_date: finishDate;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new (source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.status = source["status"];
|
||||
this.score = source["score"];
|
||||
this.num_episodes_watched = source["num_episodes_watched"];
|
||||
this.is_rewatching = source["is_rewatching"];
|
||||
this.updated_at = source["updated_at"];
|
||||
this.start_date = source["start_date"];
|
||||
this.finish_date = source["finish_date"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width : number
|
||||
height : number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||
export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): Promise<Size>;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||
export function WindowIsMaximised(): Promise<boolean>;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||
export function WindowIsMinimised(): Promise<boolean>;
|
||||
|
||||
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||
export function WindowIsNormal(): Promise<boolean>;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
|
||||
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||
// Returns the current text stored on clipboard
|
||||
export function ClipboardGetText(): Promise<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||
|
||||
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
export function OnFileDropOff() :void
|
||||
|
||||
// Check if the file path resolver is available
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowIsFullscreen() {
|
||||
return window.runtime.WindowIsFullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowIsMaximised() {
|
||||
return window.runtime.WindowIsMaximised();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function WindowIsMinimised() {
|
||||
return window.runtime.WindowIsMinimised();
|
||||
}
|
||||
|
||||
export function WindowIsNormal() {
|
||||
return window.runtime.WindowIsNormal();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
|
||||
export function ClipboardGetText() {
|
||||
return window.runtime.ClipboardGetText();
|
||||
}
|
||||
|
||||
export function ClipboardSetText(text) {
|
||||
return window.runtime.ClipboardSetText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
*
|
||||
* @export
|
||||
* @callback OnFileDropCallback
|
||||
* @param {number} x - x coordinate of the drop
|
||||
* @param {number} y - y coordinate of the drop
|
||||
* @param {string[]} paths - A list of file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
*
|
||||
* @export
|
||||
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||
*/
|
||||
export function OnFileDrop(callback, useDropTarget) {
|
||||
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
*/
|
||||
export function OnFileDropOff() {
|
||||
return window.runtime.OnFileDropOff();
|
||||
}
|
||||
|
||||
export function CanResolveFilePaths() {
|
||||
return window.runtime.CanResolveFilePaths();
|
||||
}
|
||||
|
||||
export function ResolveFilePaths(files) {
|
||||
return window.runtime.ResolveFilePaths(files);
|
||||
}
|
||||
@@ -1,49 +1,19 @@
|
||||
module AniTrack
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/99designs/keyring v1.2.2
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/wailsapp/wails/v2 v2.9.2
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.2 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.8.0 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.47.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.19 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
|
||||
// replace github.com/wailsapp/wails/v2 v2.9.1 => /home/nymusicman/go/pkg/mod
|
||||
|
||||
@@ -1,109 +1,37 @@
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
|
||||
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA=
|
||||
github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
|
||||
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
|
||||
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
|
||||
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22 h1:GsdqwRB7nESQyeZKVv16DLIS30xCLMlZP/8UhgKnHYc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.22/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,45 +2,64 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
// Create an instance of the app structure
|
||||
app := NewApp()
|
||||
// The service is created after the application (it needs the app
|
||||
// reference), so the single-instance callback closes over the variable
|
||||
// and resolves it at call time. A second instance can only launch once
|
||||
// this one is running, by which point svc is always set.
|
||||
var svc *App
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "AniTrack",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
app := application.New(application.Options{
|
||||
Name: "AniTrack",
|
||||
Description: "Track anime watchlists across AniList, MyAnimeList, and Simkl",
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(assets),
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
SingleInstanceLock: &options.SingleInstanceLock{
|
||||
UniqueId: "49c93b6d-663d-4b7a-9cb0-8a469ea9182b",
|
||||
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
|
||||
SingleInstance: &application.SingleInstanceOptions{
|
||||
UniqueID: "49c93b6d-663d-4b7a-9cb0-8a469ea9182b",
|
||||
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
|
||||
svc.onSecondInstanceLaunch(data)
|
||||
},
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
Linux: &linux.Options{
|
||||
Icon: []byte("./build/AniTrack.png"),
|
||||
WindowIsTranslucent: false,
|
||||
WebviewGpuPolicy: linux.WebviewGpuPolicyNever,
|
||||
Linux: application.LinuxOptions{
|
||||
ProgramName: "AniTrack",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
|
||||
svc = NewApp(app)
|
||||
app.RegisterService(application.NewService(svc))
|
||||
|
||||
// Self-updates: desktop production builds only (no-op elsewhere).
|
||||
maybeEnableUpdater(app)
|
||||
|
||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: appTitle(),
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
BackgroundColour: application.NewRGBA(27, 38, 54, 1),
|
||||
URL: "/",
|
||||
Linux: application.LinuxWindow{
|
||||
Icon: []byte("./build/AniTrack.png"),
|
||||
WindowIsTranslucent: false,
|
||||
// OnDemand: GPU for scrolling/compositing on AMD/Intel Mesa,
|
||||
// software fallback otherwise. Never (the old Nvidia/X11
|
||||
// blank-window workaround) forces CPU raster on GTK4/WebKit 6
|
||||
// and makes scrolling janky. All target machines here are
|
||||
// AMD or Intel, so the Nvidia DMABUF workaround is not needed.
|
||||
WebviewGpuPolicy: application.WebviewGpuPolicyOnDemand,
|
||||
},
|
||||
})
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 build/config.yml info.version.
|
||||
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}"
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||
|
||||
giteaprovider "AniTrack/updater/gitea"
|
||||
)
|
||||
|
||||
//go:embed updater.pub
|
||||
var updaterPublicKey []byte
|
||||
|
||||
// maybeEnableUpdater wires self-updates: desktop production builds only.
|
||||
// Mobile stays on Obtainium; dev builds never phone home. The startup check
|
||||
// is headless — the builtin window opens only when an update is found, via a
|
||||
// second CheckAndInstall (one redundant index round-trip, negligible).
|
||||
func maybeEnableUpdater(app *application.App) {
|
||||
if !updaterAutoCheck {
|
||||
return
|
||||
}
|
||||
if !application.System.IsDesktop() {
|
||||
return
|
||||
}
|
||||
gh, err := giteaprovider.New(giteaprovider.Config{
|
||||
BaseURL: "https://git.linuxhg.com",
|
||||
Owner: "john-okeefe",
|
||||
Repo: "Anitrack",
|
||||
AssetName: "AniTrack-linux-amd64",
|
||||
// Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta.
|
||||
AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("updater: %s", err)
|
||||
return
|
||||
}
|
||||
if err := app.Updater.Init(updater.Config{
|
||||
CurrentVersion: appVersion(),
|
||||
Providers: []updater.Provider{gh},
|
||||
PublicKey: updaterPublicKey,
|
||||
Window: &updater.BuiltinWindow{
|
||||
CSS: ":root { --accent: #4d9fff; }",
|
||||
},
|
||||
}); err != nil {
|
||||
log.Printf("updater: init: %s", err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
rel, err := app.Updater.Check(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("updater: check: %s", err)
|
||||
return
|
||||
}
|
||||
if rel == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("updater: %s available, opening installer", rel.Version)
|
||||
if err := app.Updater.CheckAndInstall(context.Background()); err != nil {
|
||||
log.Printf("updater: install: %s", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAWWF3cDxJKM668FqWsFsA9e7r7aXU2uDTUF+ZKKcxLP0=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package gitea implements updater.Provider for Gitea releases.
|
||||
//
|
||||
// Releases live on a self-hosted Gitea instance, for which Wails ships no
|
||||
// in-tree provider, so this mirrors the in-tree github provider against
|
||||
// Gitea's releases API. Everything else (verification, staging, swap,
|
||||
// restart, window) stays the framework's job.
|
||||
//
|
||||
// Asset layout per release (see .gitea/workflows/release.yml):
|
||||
//
|
||||
// AniTrack-linux-amd64 bare updater binary, exact name match
|
||||
// AniTrack-linux-amd64.sha512 "<hex> <filename>" (sha512sum format)
|
||||
// AniTrack-linux-amd64.sig base64 ed25519ph signature from
|
||||
// `wails3 updater sign`
|
||||
//
|
||||
// A release missing any of the three is skipped: verification fails closed.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/updater"
|
||||
)
|
||||
|
||||
// Config configures the Gitea provider.
|
||||
type Config struct {
|
||||
// BaseURL is the instance root, e.g. https://git.linuxhg.com.
|
||||
BaseURL string
|
||||
// Owner and Repo identify the repository, e.g. john-okeefe/Anitrack.
|
||||
Owner string
|
||||
Repo string
|
||||
// AssetName is the exact updater artifact name, e.g. AniTrack-linux-amd64.
|
||||
AssetName string
|
||||
// HTTPClient overrides the default client (30s timeout). Optional.
|
||||
HTTPClient *http.Client
|
||||
// AllowPrerelease includes pre-release-tagged releases. Default false:
|
||||
// -rc tags stay invisible (stable channel). Beta/test flows opt in.
|
||||
AllowPrerelease bool
|
||||
}
|
||||
|
||||
// Provider is an updater.Provider backed by Gitea releases.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New validates config and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/")
|
||||
if cfg.BaseURL == "" || cfg.Owner == "" || cfg.Repo == "" || cfg.AssetName == "" {
|
||||
return nil, fmt.Errorf("gitea: BaseURL, Owner, Repo and AssetName are all required")
|
||||
}
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &Provider{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
// Name identifies the provider in logs and event payloads.
|
||||
func (p *Provider) Name() string { return "gitea" }
|
||||
|
||||
// giteaRelease mirrors the fields we read from the Gitea releases API.
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []giteaAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// giteaAsset mirrors one release attachment.
|
||||
type giteaAsset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// Check finds the newest usable release newer than req.CurrentVersion.
|
||||
// (nil, nil) means up-to-date.
|
||||
func (p *Provider) Check(ctx context.Context, req updater.CheckRequest) (*updater.Release, error) {
|
||||
releases, err := p.listReleases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var best *giteaRelease
|
||||
var bestAsset *giteaAsset
|
||||
for i := range releases {
|
||||
r := &releases[i]
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
if r.Prerelease && !p.cfg.AllowPrerelease {
|
||||
continue
|
||||
}
|
||||
if compareSemver(r.TagName, req.CurrentVersion) <= 0 {
|
||||
continue
|
||||
}
|
||||
asset := findAsset(r.Assets, p.cfg.AssetName)
|
||||
if asset == nil {
|
||||
continue
|
||||
}
|
||||
if best == nil || compareSemver(r.TagName, best.TagName) > 0 {
|
||||
best = r
|
||||
bestAsset = asset
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
verification, err := p.fetchVerification(ctx, *bestAsset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel := &updater.Release{
|
||||
Version: strings.TrimPrefix(strings.TrimPrefix(best.TagName, "v"), "V"),
|
||||
Channel: "stable",
|
||||
Name: best.Name,
|
||||
Notes: best.Body,
|
||||
Artifact: updater.Artifact{
|
||||
Filename: bestAsset.Name,
|
||||
Size: bestAsset.Size,
|
||||
Platform: req.Platform,
|
||||
Arch: req.Arch,
|
||||
},
|
||||
Verification: verification,
|
||||
Metadata: map[string]any{
|
||||
"downloadURL": bestAsset.BrowserDownloadURL,
|
||||
},
|
||||
}
|
||||
if rel.Channel == "stable" && (best.Prerelease) {
|
||||
rel.Channel = "beta"
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
|
||||
rel.PublishedAt = t
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// Download streams the release artifact to dst, reporting progress.
|
||||
func (p *Provider) Download(ctx context.Context, r *updater.Release, dst io.Writer, onProgress func(written, total int64)) error {
|
||||
url, _ := r.Metadata["downloadURL"].(string)
|
||||
if url == "" {
|
||||
return fmt.Errorf("gitea: release %s has no download URL", r.Version)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gitea: download %s: %s", url, resp.Status)
|
||||
}
|
||||
total := r.Artifact.Size
|
||||
if total <= 0 {
|
||||
total = resp.ContentLength
|
||||
}
|
||||
var written int64
|
||||
buf := make([]byte, 128*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
onProgress(written, total)
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
onProgress(written, total)
|
||||
return nil
|
||||
}
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listReleases fetches the releases index (newest first page is enough).
|
||||
func (p *Provider) listReleases(ctx context.Context) ([]giteaRelease, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", p.cfg.BaseURL, p.cfg.Owner, p.cfg.Repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: list releases: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gitea: list releases: %s", resp.Status)
|
||||
}
|
||||
var releases []giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode releases: %w", err)
|
||||
}
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
// findAsset returns the attachment with exactly assetName, or nil.
|
||||
func findAsset(assets []giteaAsset, assetName string) *giteaAsset {
|
||||
for i := range assets {
|
||||
if assets[i].Name == assetName && assets[i].BrowserDownloadURL != "" {
|
||||
return &assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchVerification downloads the .sha512 + .sig sidecars next to the asset.
|
||||
// This mirrors `wails3 updater sign` (SHA-512 digest, ed25519ph signature).
|
||||
// Anything missing or malformed is an error: verification fails closed.
|
||||
func (p *Provider) fetchVerification(ctx context.Context, asset giteaAsset) (*updater.Verification, error) {
|
||||
base := strings.TrimSuffix(asset.BrowserDownloadURL, asset.Name)
|
||||
digestHex, err := p.fetchText(ctx, base+asset.Name+".sha512")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: digest sidecar: %w", err)
|
||||
}
|
||||
sigB64, err := p.fetchText(ctx, base+asset.Name+".sig")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: signature sidecar: %w", err)
|
||||
}
|
||||
|
||||
// "<hex> <filename>" (sha512sum format); tolerate bare hex too.
|
||||
digestHex = strings.Fields(digestHex)[0]
|
||||
digest, err := hex.DecodeString(digestHex)
|
||||
if err != nil || len(digest) != 64 {
|
||||
return nil, fmt.Errorf("gitea: malformed digest sidecar for %s", asset.Name)
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64))
|
||||
if err != nil || len(sig) == 0 {
|
||||
return nil, fmt.Errorf("gitea: malformed signature sidecar for %s", asset.Name)
|
||||
}
|
||||
return &updater.Verification{
|
||||
DigestAlgo: "sha512",
|
||||
Digest: digest,
|
||||
SignatureAlgo: "ed25519ph",
|
||||
Signature: sig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchText GETs url and returns the trimmed body.
|
||||
func (p *Provider) fetchText(ctx context.Context, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "AniTrack-updater")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// compareSemver compares plain versions ("1.99.0", optional v prefix).
|
||||
// Pre-release suffixes (-rc1) sort below the plain release. Returns
|
||||
// -1, 0 or +1. Unparseable input compares as 0.0.0 (never newest).
|
||||
func compareSemver(a, b string) int {
|
||||
an, ap := splitSemver(a)
|
||||
bn, bp := splitSemver(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if an[i] != bn[i] {
|
||||
if an[i] < bn[i] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ap == bp:
|
||||
return 0
|
||||
case ap == "":
|
||||
return 1
|
||||
case bp == "":
|
||||
return -1
|
||||
case ap < bp:
|
||||
return -1
|
||||
case ap > bp:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// splitSemver splits "v1.99.0-rc1" into ([1 99 0], "rc1").
|
||||
func splitSemver(v string) ([3]int, string) {
|
||||
var nums [3]int
|
||||
v = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(v), "v"), "V")
|
||||
core, pre, _ := strings.Cut(v, "-")
|
||||
for i, part := range strings.Split(core, ".") {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return [3]int{}, pre
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums, pre
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user