Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dde5d20537 | ||
|
|
314646e7f5 | ||
|
|
72dfbf4a03 | ||
|
|
d4ad4bc430 | ||
|
|
49681f3ffb | ||
|
|
d98d0e77c1 | ||
|
|
8e57b4b259 | ||
|
|
1e1c891173 | ||
|
|
5ee9c42352 | ||
|
|
23ec111c60 | ||
|
|
f24ee9edfd | ||
|
|
1fd453f399 | ||
|
|
3ab77ea8d3 | ||
|
|
3edfed6272 | ||
|
|
aa81102194 | ||
|
|
0c90c3e29d | ||
|
|
2292ae32c2 | ||
|
|
31cc19ba7a | ||
|
|
5c712454d5 | ||
|
|
10430caddf | ||
|
|
9a6c844691 | ||
|
|
476507a695 | ||
|
|
1fdb859f05 | ||
|
|
064a2c7f7d | ||
|
|
bd39268c0a | ||
|
|
2cffd54c4d | ||
|
|
7e3369d0f0 | ||
|
|
e229311190 | ||
|
|
753ecd968e | ||
|
|
30c48dcf9b | ||
|
|
9b28f2fb0a | ||
|
|
0bf784562a | ||
|
|
ea2c4475de | ||
|
|
572366eb91 | ||
|
|
77dc48fcf2 | ||
|
|
c7694900e3 | ||
|
|
00930f611e | ||
|
|
5cdf86a147 | ||
|
|
74afb317d1 | ||
|
|
a24a187c7f | ||
|
|
9f8014ff00 | ||
|
|
4c329d6b9d | ||
|
|
be6c3439ca | ||
|
|
4866f49d13 | ||
|
|
60a38ff569 | ||
|
|
45b11fa8f4 | ||
|
|
908325628f | ||
|
|
5915bb28b8 | ||
|
|
cbcb07d2f1 | ||
|
|
d611ed8b3a | ||
|
|
77e361b5b2 | ||
|
|
aeec8f79b2 | ||
|
|
5a34c89cd5 | ||
|
|
f5001cff04 | ||
|
|
4fa38b5252 | ||
|
|
8d87100373 | ||
|
|
7b503875ca | ||
|
|
ca6727d2b6 | ||
|
|
58fb1e41c2 | ||
|
|
c962a6ac9b | ||
|
|
8ae3b8dd22 | ||
|
|
e3ed5bb2b9 | ||
|
|
4d9012b43c | ||
|
|
6ebf5ac48e | ||
|
|
55cb0e2bd5 | ||
|
|
f37622010f | ||
|
|
8f884ce1a1 | ||
|
|
5fc3a1792a | ||
|
|
862c78d3b8 | ||
|
|
a433df84db | ||
|
|
76ea4f73ec | ||
|
|
fb2d5fbdea | ||
|
|
3b5518113e | ||
|
|
3c7ca15376 | ||
|
|
22ff290a81 | ||
|
|
f9c6f4b827 | ||
|
|
eb7ad5d1a3 | ||
|
|
90b68b717a | ||
|
|
0ae1e4cb7d | ||
|
|
4a60d45089 | ||
|
|
12706426f4 | ||
|
|
5050cb92d9 | ||
|
|
adad8418a9 | ||
|
|
c2bf45313d | ||
|
|
0f647e85cb | ||
|
|
a0e193e343 | ||
|
|
03413cd11a | ||
|
|
fa3304db92 | ||
|
|
43a054ac92 | ||
|
|
15d61290be | ||
|
|
d573b871ae | ||
|
|
8daf3af5f9 | ||
|
|
d644758253 | ||
|
|
1b08918d8e | ||
|
|
a6b26171d4 | ||
|
|
b11395a617 | ||
|
|
8a6b3bd2b7 |
@@ -0,0 +1,281 @@
|
||||
name: Release
|
||||
|
||||
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||
# runs list shows "Release 1.6.7" instead.
|
||||
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||
|
||||
# Builds the Wails Linux binary via `make build`, packages
|
||||
# AniTrack-<version>.tar.gz from build/, and creates/updates a Gitea Release
|
||||
# named AniTrack-<version> with the archive attached. Triggered by a plain
|
||||
# version tag push (1.6.7), or manually via workflow_dispatch with a tag for
|
||||
# re-runs and secrets tests. Pushing to main does nothing, so
|
||||
# work-in-progress commits never ship.
|
||||
#
|
||||
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
|
||||
# wails.json, commits the bump, creates an annotated tag carrying the
|
||||
# git-cliff notes, and pushes commit + tag. This workflow verifies
|
||||
# wails.json matches the tag before building.
|
||||
#
|
||||
# Secrets test (verifies environment.go wiring without shipping): tag the
|
||||
# current bumped commit with a suffix and push, e.g.
|
||||
# git tag 1.6.7-rc1 && git push origin 1.6.7-rc1
|
||||
# Tags containing '-' are published as pre-releases; delete the test
|
||||
# Release/tag afterwards.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. 1.6.7)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# Resolve the target tag for both triggers: explicit input on manual
|
||||
# dispatch, otherwise the pushed tag ref.
|
||||
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history ensures the tag annotation (the release notes) is present.
|
||||
fetch-depth: 0
|
||||
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||
|
||||
- name: Guard wails.json matches tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
# Normalize "v1.6.7" / "AniTrack-1.6.7" to plain "1.6.7" (make release
|
||||
# only ever creates plain tags; this tolerates manual typos).
|
||||
NORM="${TAG#v}"
|
||||
NORM="${NORM#AniTrack-}"
|
||||
BASE="${NORM%%-*}"
|
||||
WAILS_VER="$(python3 -c "import json; print(json.load(open('wails.json'))['info']['productVersion'])")"
|
||||
if [ "${NORM}" = "${BASE}" ]; then
|
||||
if [ "${WAILS_VER}" != "${NORM}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
|
||||
if [ "${WAILS_VER}" != "${BASE}" ]; then
|
||||
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
|
||||
echo "Version guard passed: wails.json=${WAILS_VER} tag=${NORM}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Cache Go build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
# setup-go only caches modules (GOMODCACHE). This caches compiled
|
||||
# packages (GOCACHE) so the cgo/WebKit compile goes incremental.
|
||||
# restore-keys gives a close cache instead of a cold one when
|
||||
# go.sum changes. First run after adding this still compiles cold
|
||||
# (it saves); the payoff shows from the second run on.
|
||||
path: ~/.cache/go-build
|
||||
key: go-build-1.25-${{ runner.os }}-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-build-1.25-${{ runner.os }}-
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
# Caches npm's download cache keyed on the frontend lockfile, so
|
||||
# the `npm install` inside `wails build` stops fetching cold.
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Wails Linux build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
# 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.20-${{ 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.20
|
||||
|
||||
- name: Add Go bin to PATH
|
||||
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Write environment.go from secrets
|
||||
env:
|
||||
ANILIST_SECRET_TOKEN: ${{ secrets.ANILIST_SECRET_TOKEN }}
|
||||
ANILIST_APP_ID: ${{ secrets.ANILIST_APP_ID }}
|
||||
ANILIST_APP_NAME: ${{ secrets.ANILIST_APP_NAME }}
|
||||
ANILIST_CALLBACK_URI: ${{ secrets.ANILIST_CALLBACK_URI }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
SIMKL_CALLBACK_URI: ${{ secrets.SIMKL_CALLBACK_URI }}
|
||||
MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }}
|
||||
MAL_CLIENT_SECRET: ${{ secrets.MAL_CLIENT_SECRET }}
|
||||
MAL_CALLBACK_URI: ${{ secrets.MAL_CALLBACK_URI }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# All 10 fields come from Gitea Actions secrets (never committed).
|
||||
# Values are masked in logs; do not echo them or run with set -x.
|
||||
for v in ANILIST_SECRET_TOKEN ANILIST_APP_ID ANILIST_APP_NAME ANILIST_CALLBACK_URI SIMKL_CLIENT_ID SIMKL_CLIENT_SECRET SIMKL_CALLBACK_URI MAL_CLIENT_ID MAL_CLIENT_SECRET MAL_CALLBACK_URI; do
|
||||
if [ -z "${!v:-}" ]; then
|
||||
echo "::error::Missing secret ${v}. Add it under Settings → Secrets → Actions." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cat > environment.go <<EOF
|
||||
package main
|
||||
|
||||
var Environment = EnvironmentStruct{
|
||||
ANILIST_SECRET_TOKEN: "${ANILIST_SECRET_TOKEN}",
|
||||
ANILIST_APP_ID: "${ANILIST_APP_ID}",
|
||||
ANILIST_APP_NAME: "${ANILIST_APP_NAME}",
|
||||
ANILIST_CALLBACK_URI: "${ANILIST_CALLBACK_URI}",
|
||||
SIMKL_CLIENT_ID: "${SIMKL_CLIENT_ID}",
|
||||
SIMKL_CLIENT_SECRET: "${SIMKL_CLIENT_SECRET}",
|
||||
SIMKL_CALLBACK_URI: "${SIMKL_CALLBACK_URI}",
|
||||
MAL_CLIENT_ID: "${MAL_CLIENT_ID}",
|
||||
MAL_CLIENT_SECRET: "${MAL_CLIENT_SECRET}",
|
||||
MAL_CALLBACK_URI: "${MAL_CALLBACK_URI}",
|
||||
}
|
||||
EOF
|
||||
echo "Wrote environment.go from secrets."
|
||||
|
||||
- name: Build Linux binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="${HOME}/go/bin:${PATH}"
|
||||
make build
|
||||
test -x build/bin/AniTrack || { echo "::error::build/bin/AniTrack missing after make build" >&2; exit 1; }
|
||||
|
||||
- name: Package release archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${VERSION:?VERSION missing from version-guard step}"
|
||||
STAGE="dist/AniTrack-${VERSION}"
|
||||
rm -rf dist "AniTrack-${VERSION}.tar.gz"
|
||||
mkdir -p "${STAGE}/bin"
|
||||
cp build/bin/AniTrack "${STAGE}/bin/"
|
||||
cp -r build/icon "${STAGE}/"
|
||||
cp build/AniTrack.desktop build/install_linux.sh build/README.md "${STAGE}/"
|
||||
chmod +x "${STAGE}/bin/AniTrack" "${STAGE}/install_linux.sh"
|
||||
tar -czf "AniTrack-${VERSION}.tar.gz" -C dist "AniTrack-${VERSION}"
|
||||
tar tzf "AniTrack-${VERSION}.tar.gz"
|
||||
echo "ARCHIVE=AniTrack-${VERSION}.tar.gz" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||
# token cannot create releases on this instance. The PAT must carry
|
||||
# write:repository scope. Idempotent: re-runs update an existing
|
||||
# release for this tag instead of failing with 409. On any HTTP error
|
||||
# the API response body is printed so a 403 names the missing scope.
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
: "${VERSION:?VERSION missing from version-guard step}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
# Release body = the annotated tag's message (the git-cliff notes).
|
||||
# Manual test tags without an annotation fall back to the tag name.
|
||||
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||
if [ -z "$(printf '%s' "${BODY}" | tr -d '[:space:]')" ]; then
|
||||
BODY="${TAG}"
|
||||
fi
|
||||
|
||||
# Tags containing a '-' (e.g. 1.6.7-rc1) are published as pre-releases.
|
||||
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg t "${TAG}" --arg n "AniTrack-${VERSION}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||
|
||||
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||
# (e.g. "token does not have write scope") instead of failing silently.
|
||||
api_call() {
|
||||
local method="$1" url="$2" resp code rbody
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" "${url}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
api_call PATCH "${API}/${EXISTING_ID}"
|
||||
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||
else
|
||||
api_call POST "${API}"
|
||||
echo "Created new release for ${TAG}"
|
||||
fi
|
||||
|
||||
- name: Upload release archive
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} not found" >&2; exit 1; }
|
||||
|
||||
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
|
||||
if [ -z "${RID}" ]; then
|
||||
echo "::error::No release found for tag ${TAG} after create step" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace a same-named asset so re-runs stay idempotent.
|
||||
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | select(.name==$n) | .id // empty')"
|
||||
if [ -n "${AID}" ]; then
|
||||
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
|
||||
echo "Deleted existing asset id=${AID} (${ARCHIVE})"
|
||||
fi
|
||||
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Asset upload ${code}: ${rbody}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded ${ARCHIVE} to release id=${RID}"
|
||||
@@ -23,9 +23,21 @@ 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
|
||||
.env
|
||||
environment.go
|
||||
|
||||
# REST (http files)
|
||||
http-client.private.env.json
|
||||
|
||||
# Build artifacts
|
||||
*.tar
|
||||
*.tar.gz
|
||||
/AniTrack
|
||||
|
||||
@@ -37,48 +37,120 @@ 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"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type AniListUpdateReturn struct {
|
||||
Data struct {
|
||||
SaveMediaListEntry MediaList `json:"SaveMediaListEntry"`
|
||||
}
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
ID int `json:"id"`
|
||||
IDMal int `json:"idMal"`
|
||||
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"`
|
||||
Description string `json:"description"`
|
||||
Rank int `json:"rank"`
|
||||
IsMediaSpoiler bool `json:"isMediaSpoiler"`
|
||||
IsAdult bool `json:"isAdult"`
|
||||
} `json:"tags"`
|
||||
IsAdult bool `json:"isAdult"`
|
||||
}
|
||||
|
||||
type MediaTitle struct {
|
||||
UserPreferred string `json:"userPreferred"`
|
||||
Romaji string `json:"romaji"`
|
||||
English string `json:"english"`
|
||||
Native string `json:"native"`
|
||||
}
|
||||
|
||||
type MediaRelations struct {
|
||||
Nodes []MediaRelation `json:"nodes"`
|
||||
}
|
||||
|
||||
type MediaRelation struct {
|
||||
Id int `json:"id"`
|
||||
Title MediaTitle `json:"title"`
|
||||
}
|
||||
|
||||
type MediaFuzzyDate struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
Day int `json:"day"`
|
||||
}
|
||||
|
||||
type MediaAiringSchedule struct {
|
||||
Nodes []AiringScheduleNode `json:"nodes"`
|
||||
}
|
||||
|
||||
type AiringScheduleNode struct {
|
||||
Id int `json:"id"`
|
||||
AiringAt int `json:"airingAt"`
|
||||
TimeUntilAiring int `json:"timeUntilAiring"`
|
||||
Episode int `json:"episode"`
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
type MediaList struct {
|
||||
ID int `json:"id"`
|
||||
MediaID int `json:"mediaId"`
|
||||
UserID int `json:"userId"`
|
||||
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"`
|
||||
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"`
|
||||
} `json:"media"`
|
||||
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"`
|
||||
@@ -112,6 +184,35 @@ type MediaList struct {
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type StartedAt struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
Day int `json:"day"`
|
||||
}
|
||||
type CompletedAt struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
Day int `json:"day"`
|
||||
}
|
||||
type AniListUpdateVariables struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Progress int `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Score float64 `json:"score"`
|
||||
Repeat int `json:"repeat"`
|
||||
Notes string `json:"notes"`
|
||||
StartedAt StartedAt `json:"startedAt"`
|
||||
CompletedAt CompletedAt `json:"completedAt"`
|
||||
}
|
||||
|
||||
type DeleteAniListReturn struct {
|
||||
Data struct {
|
||||
DeleteMediaListEntry struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"DeleteMediaListEntry"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
//var MediaListSort = struct {
|
||||
// MediaId string
|
||||
// MediaIdDesc string
|
||||
|
||||
@@ -3,42 +3,53 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"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",
|
||||
})
|
||||
// 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, err := aniRing.Get("anilistTokenType")
|
||||
expiresIn, err := aniRing.Get("anilistTokenExpiresIn")
|
||||
accessToken, err := aniRing.Get("anilistAccessToken")
|
||||
refreshToken, err := aniRing.Get("anilistRefreshToken")
|
||||
if err != nil {
|
||||
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)
|
||||
expiresInString := string(expiresIn.Data)
|
||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(expiresInString)
|
||||
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 {
|
||||
@@ -48,31 +59,38 @@ func (a *App) CheckIfAniListLoggedIn() bool {
|
||||
|
||||
func (a *App) AniListLogin() {
|
||||
if (AniListJWT{} == aniListJwt) {
|
||||
tokenType, err := aniRing.Get("anilistTokenType")
|
||||
expiresIn, err := aniRing.Get("anilistTokenExpiresIn")
|
||||
accessToken, err := aniRing.Get("anilistAccessToken")
|
||||
refreshToken, err := aniRing.Get("anilistRefreshToken")
|
||||
if err != nil {
|
||||
getAniListCodeUrl := "https://anilist.co/api/v2/oauth/authorize?client_id=" + os.Getenv("ANILIST_APP_ID") + "&redirect_uri=" + os.Getenv("ANILIST_CALLBACK_URI") + "&response_type=code"
|
||||
runtime.BrowserOpenURL(a.ctx, getAniListCodeUrl)
|
||||
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"
|
||||
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)
|
||||
handleAniListCallback(serverDone)
|
||||
a.handleAniListCallback(serverDone)
|
||||
serverDone.Wait()
|
||||
} else {
|
||||
aniListJwt.TokenType = string(tokenType.Data)
|
||||
aniListJwt.AccessToken = string(accessToken.Data)
|
||||
aniListJwt.RefreshToken = string(refreshToken.Data)
|
||||
expiresInString := string(expiresIn.Data)
|
||||
aniListJwt.ExpiresIn, _ = strconv.Atoi(expiresInString)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleAniListCallback(wg *sync.WaitGroup) {
|
||||
srv := &http.Server{Addr: ":6734"}
|
||||
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) handleAniListCallback(wg *sync.WaitGroup) {
|
||||
mux := http.NewServeMux()
|
||||
srv := &http.Server{Addr: ":6734", Handler: mux}
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-aniCtxShutdown.Done():
|
||||
fmt.Println("Shutting down...")
|
||||
@@ -80,29 +98,19 @@ func handleAniListCallback(wg *sync.WaitGroup) {
|
||||
default:
|
||||
}
|
||||
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(string(aniListJwt.ExpiresIn)),
|
||||
})
|
||||
_ = aniRing.Set(keyring.Item{
|
||||
Key: "anilistAccessToken",
|
||||
Data: []byte(aniListJwt.AccessToken),
|
||||
})
|
||||
_ = aniRing.Set(keyring.Item{
|
||||
Key: "anilistRefreshToken",
|
||||
Data: []byte(aniListJwt.RefreshToken),
|
||||
})
|
||||
_ = 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 {
|
||||
@@ -115,7 +123,7 @@ func handleAniListCallback(wg *sync.WaitGroup) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
fmt.Println("Shutting down...")
|
||||
@@ -127,9 +135,9 @@ func getAniListAuthorizationToken(content string) AniListJWT {
|
||||
resource := "/api/v2/oauth/token"
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("client_id", os.Getenv("ANILIST_APP_ID"))
|
||||
data.Set("client_secret", os.Getenv("ANILIST_SECRET_TOKEN"))
|
||||
data.Set("redirect_uri", os.Getenv("ANILIST_CALLBACK_URI"))
|
||||
data.Set("client_id", Environment.ANILIST_APP_ID)
|
||||
data.Set("client_secret", Environment.ANILIST_SECRET_TOKEN)
|
||||
data.Set("redirect_uri", Environment.ANILIST_CALLBACK_URI)
|
||||
data.Set("code", content)
|
||||
|
||||
u, _ := url.ParseRequestURI(apiUrl)
|
||||
@@ -140,19 +148,21 @@ func getAniListAuthorizationToken(content string) AniListJWT {
|
||||
if err != nil {
|
||||
log.Printf("Failed at response, %s\n", err)
|
||||
}
|
||||
response.Header.Add("content-type", "application/x-www-form-urlencoded")
|
||||
response.Header.Add("Content-Type", "application/json")
|
||||
response.Header.Add("Content-type", "application/x-www-form-urlencoded")
|
||||
response.Header.Add("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, reserr := client.Do(response)
|
||||
if reserr != nil {
|
||||
res, resErr := client.Do(response)
|
||||
if resErr != nil {
|
||||
log.Printf("Failed at res, %s\n", err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
returnedBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Printf("Could not read returned body, %s\n.", err)
|
||||
}
|
||||
|
||||
var post AniListJWT
|
||||
err = json.Unmarshal(returnedBody, &post)
|
||||
@@ -194,3 +204,18 @@ func (a *App) GetAniListLoggedInUser() AniListUser {
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
func (a *App) LogoutAniList() string {
|
||||
if (AniListJWT{} != aniListJwt) {
|
||||
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 {
|
||||
log.Printf("anilist: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
|
||||
}
|
||||
aniListJwt = AniListJWT{}
|
||||
}
|
||||
|
||||
return "AniList Logged Out Successfully"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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, 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)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
message, _ := json.Marshal(struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "Network error: " + err.Error(),
|
||||
})
|
||||
return message, "", fmt.Errorf("network error: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
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()
|
||||
return MALHelper(method, malUrl, body)
|
||||
}
|
||||
|
||||
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, 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, 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, nil
|
||||
}
|
||||
|
||||
func (a *App) MyAnimeListUpdate(anime MALAnime, update MALUploadStatus) (MalListStatus, error) {
|
||||
if update.NumTimesRewatched >= 1 {
|
||||
update.IsRewatching = true
|
||||
} else {
|
||||
update.IsRewatching = false
|
||||
}
|
||||
body := url.Values{}
|
||||
body.Set("status", update.Status)
|
||||
body.Set("is_rewatching", strconv.FormatBool(update.IsRewatching))
|
||||
body.Set("score", strconv.Itoa(update.Score))
|
||||
body.Set("num_watched_episodes", strconv.Itoa(update.NumWatchedEpisodes))
|
||||
body.Set("num_times_rewatched", strconv.Itoa(update.NumTimesRewatched))
|
||||
body.Set("comments", update.Comments)
|
||||
|
||||
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(anime.Id) + "/my_list_status"
|
||||
var status MalListStatus
|
||||
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, nil
|
||||
}
|
||||
|
||||
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"
|
||||
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, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteMyAnimeListEntry(id int) (bool, error) {
|
||||
malUrl := "https://api.myanimelist.net/v2/anime/" + strconv.Itoa(id) + "/my_list_status"
|
||||
_, 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, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("delete failed with status: %s", respStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type FlexString string
|
||||
|
||||
type MyAnimeListJWT struct {
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type MyAnimeListUser struct {
|
||||
Id int32 `json:"id" ts_type:"id"`
|
||||
Name string `json:"name" ts_type:"name"`
|
||||
Picture string `json:"picture" ts_type:"picture"`
|
||||
Gender string `json:"gender" ts_type:"gender"`
|
||||
Birthday string `json:"birthday" ts_type:"birthday"`
|
||||
Location string `json:"location" ts_type:"location"`
|
||||
JoinedAt string `json:"joined_at" ts_type:"joinedAt"`
|
||||
AnimeStatistics `json:"anime_statistics" ts_type:"AnimeStatistics"`
|
||||
TimeZone string `json:"time_zone" ts_type:"timeZone"`
|
||||
IsSupporter bool `json:"is_supporter" ts_type:"isSupporter"`
|
||||
}
|
||||
|
||||
type AnimeStatistics struct {
|
||||
NumItemsWatching int `json:"num_items_watching" ts_type:"numItemsWatching"`
|
||||
NumItemsCompleted int `json:"num_items_completed" ts_type:"numItemsCompleted"`
|
||||
NumItemsOnHold int `json:"num_items_on_hold" ts_type:"numItemsOnHold"`
|
||||
NumItemsDropped int `json:"num_items_dropped" ts_type:"numItemsDropped"`
|
||||
NumItemsPlanToWatch int `json:"num_items_plan_to_watch" ts_type:"numItemsPlanToWatch"`
|
||||
NumItems int `json:"num_items" ts_type:"numItems"`
|
||||
NumDaysWatched float64 `json:"num_days_watched" ts_type:"numDaysWatched"`
|
||||
NumDaysWatching float64 `json:"num_days_watching" ts_type:"numDaysWatching"`
|
||||
NumDaysCompleted float64 `json:"num_days_completed" ts_type:"numDaysCompleted"`
|
||||
NumDaysOnHold float64 `json:"num_days_on_hold" ts_type:"numDaysOnHold"`
|
||||
NumDaysDropped float64 `json:"num_days_dropped" ts_type:"numDaysDropped"`
|
||||
NumDays float64 `json:"num_days" ts_type:"numDays"`
|
||||
NumEpisodes int `json:"num_episodes" ts_type:"numEpisodes"`
|
||||
NumTimesRewatched int `json:"num_times_rewatched" ts_type:"numTimesRewatched"`
|
||||
MeanScore float64 `json:"mean_score" ts_type:"meanScore"`
|
||||
}
|
||||
|
||||
type MALWatchlist struct {
|
||||
Data []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.Time `json:"updated_at" ts_type:"updatedAt"`
|
||||
StartDate string `json:"start_date" ts_type:"startDate"`
|
||||
FinishDate string `json:"finish_date" ts_type:"finishDate"`
|
||||
} `json:"list_status" ts_type:"listStatus"`
|
||||
} `json:"data" ts_type:"data"`
|
||||
Paging struct {
|
||||
Previous string `json:"previous" ts_type:"previous"`
|
||||
Next string `json:"next" ts_type:"next"`
|
||||
} `json:"paging" ts_type:"paging"`
|
||||
}
|
||||
|
||||
type MALAnime struct {
|
||||
Id int `json:"id" ts_type:"id"`
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
MainPicture struct {
|
||||
Large string `json:"large" ts_type:"large"`
|
||||
Medium string `json:"medium" ts_type:"medium"`
|
||||
} `json:"main_picture" ts_type:"mainPicture"`
|
||||
AlternativeTitles struct {
|
||||
Synonyms []string `json:"synonyms" ts_type:"synonyms"`
|
||||
En string `json:"en" ts_type:"en"`
|
||||
Ja string `json:"ja" ts_type:"ja"`
|
||||
} `json:"alternative_titles" ts_type:"alternativeTitles"`
|
||||
StartDate string `json:"start_date" ts_type:"startDate"`
|
||||
EndDate string `json:"end_date" ts_type:"endDate"`
|
||||
Synopsis string `json:"synopsis" ts_type:"synopsis"`
|
||||
Mean float64 `json:"mean" ts_type:"mean"`
|
||||
Rank int `json:"rank" ts_type:"rank"`
|
||||
Popularity int `json:"popularity" ts_type:"popularity"`
|
||||
NumListUsers int `json:"num_list_users" ts_type:"numListUsers"`
|
||||
NumScoringUsers int `json:"num_scoring_users" ts_type:"numScoringUsers"`
|
||||
NSFW string `json:"nsfw" ts_type:"nsfw"`
|
||||
Genres []struct {
|
||||
Id int `json:"id" ts_type:"id"`
|
||||
Name string `json:"name" ts_type:"name"`
|
||||
} `json:"genres" ts_type:"genres"`
|
||||
CreatedAt string `json:"created_at" ts_type:"createdAt"`
|
||||
UpdatedAt string `json:"updated_at" ts_type:"updatedAt"`
|
||||
MediaType string `json:"media_type" ts_type:"mediaType"`
|
||||
Status string `json:"status" ts_type:"status"`
|
||||
MalListStatus MalListStatus `json:"my_list_status" ts_type:"MalListStatus"`
|
||||
NumEpisodes int `json:"num_episodes" ts_type:"numEpisodes"`
|
||||
StartSeason struct {
|
||||
Year int `json:"year" ts_type:"year"`
|
||||
Season string `json:"season" ts_type:"season"`
|
||||
} `json:"start_season" ts_type:"startSeason"`
|
||||
Broadcast struct {
|
||||
DayOfTheWeek string `json:"day_of_the_week" ts_type:"dayOfTheWeek"`
|
||||
StartTime string `json:"start_time" ts_type:"startTime"`
|
||||
} `json:"broadcast" ts_type:"broadcast"`
|
||||
Source string `json:"source" ts_type:"source"`
|
||||
AverageEpisodeDuration int `json:"average_episode_duration" ts_type:"averageEpisodeDuration"`
|
||||
Rating string `json:"rating" ts_type:"rating"`
|
||||
Studios []struct {
|
||||
Id int `json:"id" ts_type:"id"`
|
||||
Name string `json:"name" ts_type:"name"`
|
||||
} `json:"studios" ts_type:"studios"`
|
||||
Pictures []struct {
|
||||
Large string `json:"large" ts_type:"large"`
|
||||
Medium string `json:"medium" ts_type:"medium"`
|
||||
} `json:"pictures" ts_type:"pictures"`
|
||||
Background string `json:"background" ts_type:"background"`
|
||||
RelatedAnime []struct {
|
||||
Node MALAnime `json:"node" ts_type:"node"`
|
||||
RelationType string `json:"relation_type" ts_type:"relationType"`
|
||||
RelationTypeFormatted string `json:"relation_type_formatted" ts_type:"relationTypeFormatted"`
|
||||
} `json:"related_anime" ts_type:"relatedAnime"`
|
||||
Recommendations []struct {
|
||||
Node MALAnime `json:"node" ts_type:"node"`
|
||||
NumRecommendations int `json:"num_recommendations" ts_type:"numRecommendations"`
|
||||
} `json:"recommendations" ts_type:"recommendations"`
|
||||
Statistics struct {
|
||||
NumListUsers int `json:"num_list_users" ts_type:"numListUsers"`
|
||||
Status struct {
|
||||
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"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MalListStatus 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"`
|
||||
StartDate string `json:"start_date" ts_type:"startDate"`
|
||||
FinishDate string `json:"finish_date" ts_type:"finishDate"`
|
||||
Priority int `json:"priority" ts_type:"priority"`
|
||||
NumTimesRewatched int `json:"num_times_rewatched" ts_type:"numTimesRewatched"`
|
||||
RewatchValue int `json:"rewatch_value" ts_type:"rewatchValue"`
|
||||
Tags []string `json:"tags" ts_type:"tags"`
|
||||
Comments string `json:"comments" ts_type:"comments"`
|
||||
UpdatedAt string `json:"updated_at" ts_type:"updatedAt"`
|
||||
}
|
||||
|
||||
type MALUploadStatus struct {
|
||||
Status string `json:"status"`
|
||||
IsRewatching bool `json:"is_rewatching"`
|
||||
Score int `json:"score"`
|
||||
NumWatchedEpisodes int `json:"num_watched_episodes"`
|
||||
NumTimesRewatched int `json:"num_times_rewatched"`
|
||||
Comments string `json:"comments"`
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
var myAnimeListJwt MyAnimeListJWT
|
||||
|
||||
// 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())
|
||||
|
||||
type CodeVerifier struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
const (
|
||||
length = 32
|
||||
)
|
||||
|
||||
func base64URLEncode(str []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(str)
|
||||
encoded = strings.Replace(encoded, "+", "-", -1)
|
||||
encoded = strings.Replace(encoded, "/", "_", -1)
|
||||
encoded = strings.Replace(encoded, "=", "", -1)
|
||||
return encoded
|
||||
}
|
||||
|
||||
func verifier() (*CodeVerifier, error) {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
b := make([]byte, length)
|
||||
for i := 0; i < length; i++ {
|
||||
b[i] = byte(r.Intn(255))
|
||||
}
|
||||
return CreateCodeVerifierFromBytes(b)
|
||||
}
|
||||
|
||||
func CreateCodeVerifierFromBytes(b []byte) (*CodeVerifier, error) {
|
||||
return &CodeVerifier{
|
||||
Value: base64URLEncode(b),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *CodeVerifier) CodeChallengeS256() string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(v.Value))
|
||||
return base64URLEncode(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (a *App) CheckIfMyAnimeListLoggedIn() bool {
|
||||
if (MyAnimeListJWT{} == myAnimeListJwt) {
|
||||
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 = tokenType
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
return false
|
||||
}
|
||||
myAnimeListJwt.AccessToken = accessToken
|
||||
myAnimeListJwt.RefreshToken = refreshToken
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) MyAnimeListLogin() {
|
||||
if !a.CheckIfMyAnimeListLoggedIn() {
|
||||
fmt.Println("check logged in function failed")
|
||||
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"
|
||||
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 = tokenType
|
||||
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn)
|
||||
if expiresInConvertErr != nil {
|
||||
log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr)
|
||||
}
|
||||
myAnimeListJwt.AccessToken = accessToken
|
||||
myAnimeListJwt.RefreshToken = refreshToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifier) {
|
||||
mux := http.NewServeMux()
|
||||
srv := &http.Server{Addr: ":6734", Handler: mux}
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-myAnimeListCtxShutdown.Done():
|
||||
fmt.Println("Shutting down...")
|
||||
return
|
||||
default:
|
||||
}
|
||||
content := r.FormValue("code")
|
||||
|
||||
if content != "" {
|
||||
myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier)
|
||||
_ = 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()
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
log.Println("server.Shutdown:", err)
|
||||
}
|
||||
} else {
|
||||
_, err := fmt.Fprintf(w, "Getting code failed.")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("Server error: %s\n", err)
|
||||
}
|
||||
fmt.Println("Shutting down...")
|
||||
}()
|
||||
}
|
||||
|
||||
func getMyAnimeListAuthorizationToken(content string, verifier *CodeVerifier) MyAnimeListJWT {
|
||||
dataForURLs := struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Code string `json:"code"`
|
||||
CodeVerifier *CodeVerifier `json:"code_verifier"`
|
||||
}{
|
||||
GrantType: "authorization_code",
|
||||
ClientID: Environment.MAL_CLIENT_ID,
|
||||
ClientSecret: Environment.MAL_CLIENT_SECRET,
|
||||
RedirectURI: Environment.MAL_CALLBACK_URI,
|
||||
Code: content,
|
||||
CodeVerifier: verifier,
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", dataForURLs.GrantType)
|
||||
data.Set("client_id", dataForURLs.ClientID)
|
||||
data.Set("client_secret", dataForURLs.ClientSecret)
|
||||
data.Set("redirect_uri", dataForURLs.RedirectURI)
|
||||
data.Set("code", dataForURLs.Code)
|
||||
data.Set("code_verifier", dataForURLs.CodeVerifier.Value)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
returnedBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Printf("Could not read returned body, %s\n", err)
|
||||
}
|
||||
|
||||
var post MyAnimeListJWT
|
||||
err = json.Unmarshal(returnedBody, &post)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
}
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
func refreshMyAnimeListAuthorizationToken() bool {
|
||||
dataForURLs := struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: myAnimeListJwt.RefreshToken,
|
||||
ClientID: Environment.MAL_CLIENT_ID,
|
||||
ClientSecret: Environment.MAL_CLIENT_SECRET,
|
||||
RedirectURI: Environment.MAL_CALLBACK_URI,
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", dataForURLs.GrantType)
|
||||
data.Set("refresh_token", dataForURLs.RefreshToken)
|
||||
data.Set("client_id", dataForURLs.ClientID)
|
||||
data.Set("client_secret", dataForURLs.ClientSecret)
|
||||
data.Set("redirect_uri", dataForURLs.RedirectURI)
|
||||
|
||||
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", resErr)
|
||||
return false
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
returnedBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Printf("Could not read returned body, %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
var refreshed MyAnimeListJWT
|
||||
err = json.Unmarshal(returnedBody, &refreshed)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+myAnimeListJwt.AccessToken)
|
||||
req.Header.Add("myAnimeList-api-key", Environment.MAL_CLIENT_ID)
|
||||
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Failed at request, %s\n", err)
|
||||
return MyAnimeListUser{}
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
var user MyAnimeListUser
|
||||
|
||||
respBody, _ := io.ReadAll(response.Body)
|
||||
|
||||
err = json.Unmarshal(respBody, &user)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
}
|
||||
|
||||
return user
|
||||
|
||||
}
|
||||
|
||||
func (a *App) LogoutMyAnimeList() string {
|
||||
if (MyAnimeListJWT{} != myAnimeListJwt) {
|
||||
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 {
|
||||
log.Printf("mal: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr)
|
||||
}
|
||||
myAnimeListJwt = MyAnimeListJWT{}
|
||||
}
|
||||
|
||||
return "MAL Logged Out Successfully"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.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 (wails.json bump, committed and pushed) plus an
|
||||
# annotated version tag carrying auto-generated release notes (git-cliff),
|
||||
# and push both. The tag push triggers .gitea/workflows/release.yml, which
|
||||
# verifies wails.json matches the tag, builds via `make build`, packages
|
||||
# AniTrack-<VERSION>.tar.gz from build/, and publishes a Gitea Release
|
||||
# named AniTrack-<VERSION> with the archive attached. Notes come entirely
|
||||
# from Conventional Commits — no hand-written message required.
|
||||
#
|
||||
# git-cliff's --latest needs the tag to exist to scope the notes, so we
|
||||
# create a throwaway lightweight tag, generate the notes, replace it with
|
||||
# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###"
|
||||
# group headers (git's default cleanup would strip lines starting with "#").
|
||||
#
|
||||
# Tags are plain versions (1.6.7) to match wails.json productVersion and the
|
||||
# existing tag history. Pre-releases are created by tagging manually with a
|
||||
# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks
|
||||
# those as prerelease. Use ./release as a shortcut.
|
||||
#
|
||||
# Requires git-cliff: https://git-cliff.org/install
|
||||
# Usage: make release VERSION=1.6.7
|
||||
release:
|
||||
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=1.6.7"; exit 1; }
|
||||
@echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be plain semver like 1.6.7 (no v prefix, no AniTrack- prefix, no suffix)"; exit 1; }
|
||||
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump wails.json"; exit 1; }
|
||||
@git diff --quiet && git diff --cached --quiet || { echo "Working tree dirty — commit or stash first"; exit 1; }
|
||||
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||
@echo "Bumping wails.json to $(VERSION)..."
|
||||
@python3 -c "import json; p='wails.json'; d=json.load(open(p)); d['info']['productVersion']='$(VERSION)'; json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')"
|
||||
# TEMP until wails.json (v2 leftover) is retired: bump build/config.yml too.
|
||||
# The regex targets only the indented info.version line, never the
|
||||
# top-level schema `version: '3'`.
|
||||
@echo "Bumping build/config.yml to $(VERSION)..."
|
||||
@python3 -c "import re; p='build/config.yml'; s=open(p).read(); s2=re.sub(r'^ version: \"[^\"]*\"', ' version: \"$(VERSION)\"', s, count=1, flags=re.M); assert s2 != s, 'info.version not found'; open(p,'w').write(s2)"
|
||||
@git add wails.json build/config.yml
|
||||
@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."
|
||||
@@ -2,17 +2,59 @@
|
||||
|
||||
## About
|
||||
|
||||
Track anime shows by syncing with various services. Anilist, MyAnimeList, Kitsu, Simkl...
|
||||
Track the anime you are watching by syncing with various services. Anilist, MyAnimeList, Simkl...
|
||||
|
||||
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`).
|
||||
> 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
|
||||
|
||||
### Get API Keys
|
||||
First you will need your own API keys for the various services the app connects to.
|
||||
AniList: [AniList Developer App](https://anilist.co/settings/developer)
|
||||
MyAnimeList: [MyAnimeList Developer App](https://myanimelist.net/apiconfig/create)
|
||||
Simkl: [Simkl Developer](https://simkl.com/settings/developer/)
|
||||
|
||||
- Name: AniTrack
|
||||
- Redirect URL: http://localhost:6734/callback
|
||||
- App Type: web
|
||||
- Homepage URL: http://localhost or the Github URL
|
||||
- Company Name: AniTrack
|
||||
- Commercial/Non-Commercial: non-commercial
|
||||
- Purpose of Use: hobbyist
|
||||
|
||||
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://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`.
|
||||
To build a redistributable, production mode package, use `make build`. The binary lands at `build/bin/AniTrack`.
|
||||
|
||||
@@ -1,39 +1,390 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (a *App) SimklGetUserWatchlist() SimklWatchList {
|
||||
var SimklWatchList SimklWatchListType
|
||||
|
||||
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{}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://api.simkl.com/sync/all-items/anime/watching", nil)
|
||||
if body != nil {
|
||||
req, err = http.NewRequest(method, url, bytes.NewBuffer(reader))
|
||||
} else {
|
||||
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")
|
||||
req.Header.Add("Authorization", "Bearer "+simklJwt.AccessToken)
|
||||
req.Header.Add("simkl-api-key", os.Getenv("SIMKL_CLIENT_ID"))
|
||||
req.Header.Add("simkl-api-key", Environment.SIMKL_CLIENT_ID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Errored when sending request to the server")
|
||||
return SimklWatchList{}
|
||||
return nil, fmt.Errorf("network error: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
var watchlist SimklWatchList
|
||||
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, error) {
|
||||
method := "GET"
|
||||
url := "https://api.simkl.com/sync/all-items/anime"
|
||||
|
||||
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)
|
||||
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{}, fmt.Errorf("Simkl API error: %s", errCheck.Message)
|
||||
}
|
||||
|
||||
var watchlist 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)
|
||||
}
|
||||
|
||||
return watchlist
|
||||
SimklWatchList = watchlist
|
||||
|
||||
return watchlist, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncEpisodes(anime SimklAnime, progress int) (SimklAnime, error) {
|
||||
var episodes []Episode
|
||||
var url string
|
||||
var shows []SimklPostShow
|
||||
|
||||
if progress >= anime.WatchedEpisodesCount {
|
||||
for i := 1; i <= progress; i++ {
|
||||
episodes = append(episodes, Episode{Number: i})
|
||||
}
|
||||
url = "https://api.simkl.com/sync/history"
|
||||
} else {
|
||||
for i := anime.WatchedEpisodesCount; i > progress; i-- {
|
||||
episodes = append(episodes, Episode{Number: i})
|
||||
}
|
||||
url = "https://api.simkl.com/sync/history/remove"
|
||||
}
|
||||
|
||||
formattedShow := SimklPostShow{
|
||||
Title: anime.Show.Title,
|
||||
Ids: Ids{
|
||||
Simkl: anime.Show.Ids.Simkl,
|
||||
Mal: anime.Show.Ids.Mal,
|
||||
Anilist: anime.Show.Ids.AniList,
|
||||
},
|
||||
Episodes: episodes,
|
||||
}
|
||||
|
||||
shows = append(shows, formattedShow)
|
||||
|
||||
simklSync := SimklSyncHistoryType{shows}
|
||||
|
||||
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)
|
||||
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 {
|
||||
if anime.Show.Ids.Simkl == simklAnime.Show.Ids.Simkl {
|
||||
SimklWatchList.Anime[i].WatchedEpisodesCount = progress
|
||||
}
|
||||
}
|
||||
|
||||
anime.WatchedEpisodesCount = progress
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncRating(anime SimklAnime, rating int) (SimklAnime, error) {
|
||||
var url string
|
||||
showWithRating := ShowWithRating{
|
||||
Title: anime.Show.Title,
|
||||
Ids: Ids{
|
||||
Simkl: anime.Show.Ids.Simkl,
|
||||
Mal: anime.Show.Ids.Mal,
|
||||
Anilist: anime.Show.Ids.AniList,
|
||||
},
|
||||
Rating: rating,
|
||||
}
|
||||
|
||||
showWithoutRating := ShowWithoutRating{
|
||||
Title: anime.Show.Title,
|
||||
Ids: Ids{
|
||||
Simkl: anime.Show.Ids.Simkl,
|
||||
Mal: anime.Show.Ids.Mal,
|
||||
Anilist: anime.Show.Ids.AniList,
|
||||
},
|
||||
}
|
||||
|
||||
var shows []interface{}
|
||||
|
||||
if rating > 0 {
|
||||
shows = append(shows, showWithRating)
|
||||
url = "https://api.simkl.com/sync/ratings"
|
||||
} else {
|
||||
shows = append(shows, showWithoutRating)
|
||||
url = "https://api.simkl.com/sync/ratings/remove"
|
||||
}
|
||||
|
||||
simklSync := struct {
|
||||
Shows []interface{} `json:"shows" ts_type:"shows"`
|
||||
}{shows}
|
||||
|
||||
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)
|
||||
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 {
|
||||
if anime.Show.Ids.Simkl == simklAnime.Show.Ids.Simkl {
|
||||
SimklWatchList.Anime[i].UserRating = rating
|
||||
}
|
||||
}
|
||||
|
||||
anime.UserRating = rating
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
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,
|
||||
Ids: Ids{
|
||||
Simkl: anime.Show.Ids.Simkl,
|
||||
Mal: anime.Show.Ids.Mal,
|
||||
Anilist: anime.Show.Ids.AniList,
|
||||
},
|
||||
To: status,
|
||||
}
|
||||
|
||||
var shows []SimklShowStatus
|
||||
|
||||
shows = append(shows, show)
|
||||
|
||||
simklSync := struct {
|
||||
Shows []SimklShowStatus `json:"shows" ts_type:"shows"`
|
||||
}{shows}
|
||||
|
||||
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)
|
||||
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 {
|
||||
if anime.Show.Ids.Simkl == simklAnime.Show.Ids.Simkl {
|
||||
SimklWatchList.Anime[i].Status = status
|
||||
}
|
||||
}
|
||||
|
||||
anime.Status = status
|
||||
|
||||
WatchListUpdate(anime)
|
||||
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSearch(aniListAnime MediaList) (SimklAnime, error) {
|
||||
var result SimklAnime
|
||||
|
||||
if reflect.DeepEqual(SimklWatchList, SimklWatchListType{}) {
|
||||
fmt.Println("Watchlist empty. Calling...")
|
||||
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 {
|
||||
id, err := strconv.Atoi(anime.Show.Ids.AniList)
|
||||
if err != nil {
|
||||
fmt.Println("AniList ID does not exist on " + anime.Show.Title)
|
||||
}
|
||||
if id == aniListAnime.Media.ID {
|
||||
result = anime
|
||||
}
|
||||
}
|
||||
|
||||
if reflect.DeepEqual(result, SimklAnime{}) {
|
||||
var anime SimklSearchType
|
||||
url := "https://api.simkl.com/search/id?anilist=" + strconv.Itoa(aniListAnime.Media.ID)
|
||||
|
||||
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, 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, nil
|
||||
}
|
||||
|
||||
for _, watchListAnime := range SimklWatchList.Anime {
|
||||
id := watchListAnime.Show.Ids.Simkl
|
||||
if id == anime[0].Ids.Simkl {
|
||||
result = watchListAnime
|
||||
}
|
||||
}
|
||||
|
||||
if reflect.DeepEqual(result, SimklAnime{}) && len(anime) > 0 {
|
||||
result.Show.Title = anime[0].Title
|
||||
result.Show.Poster = anime[0].Poster
|
||||
result.Show.Ids.Simkl = anime[0].Ids.Simkl
|
||||
result.Show.Ids.Slug = anime[0].Ids.Slug
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) SimklSyncRemove(anime SimklAnime) (bool, error) {
|
||||
url := "https://api.simkl.com/sync/history/remove"
|
||||
var showArray []SimklShowStatus
|
||||
|
||||
singleShow := SimklShowStatus{
|
||||
Title: anime.Show.Title,
|
||||
Ids: Ids{
|
||||
Simkl: anime.Show.Ids.Simkl,
|
||||
Mal: anime.Show.Ids.Mal,
|
||||
Anilist: anime.Show.Ids.AniList,
|
||||
},
|
||||
}
|
||||
|
||||
showArray = append(showArray, singleShow)
|
||||
|
||||
show := struct {
|
||||
Shows []SimklShowStatus `json:"shows"`
|
||||
}{
|
||||
Shows: showArray,
|
||||
}
|
||||
|
||||
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)
|
||||
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, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("no shows were deleted")
|
||||
}
|
||||
|
||||
func WatchListUpdate(anime SimklAnime) {
|
||||
updated := false
|
||||
for i, simklAnime := range SimklWatchList.Anime {
|
||||
if simklAnime.Show.Ids.Simkl == anime.Show.Ids.Simkl {
|
||||
SimklWatchList.Anime[i] = anime
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
SimklWatchList.Anime = append(SimklWatchList.Anime, anime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,43 +26,108 @@ type SimklUser struct {
|
||||
} `json:"connections" ts_type:"connections"`
|
||||
}
|
||||
|
||||
type SimklWatchList struct {
|
||||
Anime []struct {
|
||||
LastWatchedAt string `json:"last_watched_at" ts_type:"last_watched_at"`
|
||||
Status string `json:"status" ts_type:"status"`
|
||||
UserRating float64 `json:"user_rating" ts_type:"user_rating"`
|
||||
LastWatched string `json:"last_watched" ts_type:"last_watched"`
|
||||
NextToWatch string `json:"next_to_watch" ts_type:"next_to_watch"`
|
||||
WatchedEpisodesCount int `json:"watched_episodes_count" ts_type:"watched_episodes_count"`
|
||||
TotalEpisodesCount int `json:"total_episodes_count" ts_type:"total_episodes_count"`
|
||||
NotAiredEpisodesCount int `json:"not_aired_episodes_count" ts_type:"not_aired_episodes_count"`
|
||||
Show struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Poster string `json:"poster" ts_type:"poster"`
|
||||
Ids struct {
|
||||
Simkl int `json:"simkl" ts_type:"simkl"`
|
||||
Slug string `json:"slug" ts_type:"slug"`
|
||||
OffJp string `json:"offjp" ts_type:"offjp"`
|
||||
TW string `json:"tw" ts_type:"tw"`
|
||||
ANN string `json:"ann" ts_type:"ann"`
|
||||
Mal string `json:"mal" ts_type:"mal"`
|
||||
Wikien string `json:"wikien" ts_type:"wikien"`
|
||||
WikiJp string `json:"wikijp" ts_type:"wikijp"`
|
||||
AllCin string `json:"allcin" ts_type:"allcin"`
|
||||
IMDB string `json:"imdb" ts_type:"imdb"`
|
||||
TMDB string `json:"tmdb" ts_type:"tmdb"`
|
||||
Offen string `json:"offen" ts_type:"offen"`
|
||||
Crunchyroll string `json:"crunchyroll" ts_type:"crunchyroll"`
|
||||
TVDBSlug string `json:"tvdbslug" ts_type:"tvdbslug"`
|
||||
AniList string `json:"anilist" ts_type:"anilist"`
|
||||
AnimePlanet string `json:"animeplanet" ts_type:"animeplanet"`
|
||||
AniSearch string `json:"anisearch" ts_type:"anisearch"`
|
||||
Kitsu string `json:"kitsu" ts_type:"kitsu"`
|
||||
LiveChart string `json:"livechart" ts_type:"livechart"`
|
||||
TraktSlug string `json:"traktslug" ts_type:"traktslug"`
|
||||
AniDB string `json:"anidb" ts_type:"anidb"`
|
||||
} `json:"ids" ts_type:"ids"`
|
||||
} `json:"show" ts_type:"show"`
|
||||
AnimeType string `json:"anime_type" ts_type:"anime_type"`
|
||||
} `json:"anime" ts_type:"anime"`
|
||||
type SimklWatchListType struct {
|
||||
Anime []SimklAnime `json:"anime" ts_type:"anime"`
|
||||
}
|
||||
|
||||
type SimklAnime struct {
|
||||
LastWatchedAt string `json:"last_watched_at" ts_type:"last_watched_at"`
|
||||
Status string `json:"status" ts_type:"status"`
|
||||
UserRating int `json:"user_rating" ts_type:"user_rating"`
|
||||
LastWatched string `json:"last_watched" ts_type:"last_watched"`
|
||||
NextToWatch string `json:"next_to_watch" ts_type:"next_to_watch"`
|
||||
WatchedEpisodesCount int `json:"watched_episodes_count" ts_type:"watched_episodes_count"`
|
||||
TotalEpisodesCount int `json:"total_episodes_count" ts_type:"total_episodes_count"`
|
||||
NotAiredEpisodesCount int `json:"not_aired_episodes_count" ts_type:"not_aired_episodes_count"`
|
||||
Show struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Poster string `json:"poster" ts_type:"poster"`
|
||||
Ids struct {
|
||||
Simkl int `json:"simkl" ts_type:"simkl"`
|
||||
Slug string `json:"slug" ts_type:"slug"`
|
||||
OffJp string `json:"offjp" ts_type:"offjp"`
|
||||
TW string `json:"tw" ts_type:"tw"`
|
||||
ANN string `json:"ann" ts_type:"ann"`
|
||||
Mal string `json:"mal" ts_type:"mal"`
|
||||
Wikien string `json:"wikien" ts_type:"wikien"`
|
||||
WikiJp string `json:"wikijp" ts_type:"wikijp"`
|
||||
AllCin string `json:"allcin" ts_type:"allcin"`
|
||||
IMDB string `json:"imdb" ts_type:"imdb"`
|
||||
TMDB string `json:"tmdb" ts_type:"tmdb"`
|
||||
Offen string `json:"offen" ts_type:"offen"`
|
||||
Crunchyroll string `json:"crunchyroll" ts_type:"crunchyroll"`
|
||||
TVDBSlug string `json:"tvdbslug" ts_type:"tvdbslug"`
|
||||
AniList string `json:"anilist" ts_type:"anilist"`
|
||||
AnimePlanet string `json:"animeplanet" ts_type:"animeplanet"`
|
||||
AniSearch string `json:"anisearch" ts_type:"anisearch"`
|
||||
Kitsu string `json:"kitsu" ts_type:"kitsu"`
|
||||
LiveChart string `json:"livechart" ts_type:"livechart"`
|
||||
TraktSlug string `json:"traktslug" ts_type:"traktslug"`
|
||||
AniDB string `json:"anidb" ts_type:"anidb"`
|
||||
} `json:"ids" ts_type:"ids"`
|
||||
} `json:"show" ts_type:"show"`
|
||||
AnimeType string `json:"anime_type" ts_type:"anime_type"`
|
||||
}
|
||||
|
||||
type SimklSyncHistoryType struct {
|
||||
Shows []SimklPostShow `json:"shows" ts_type:"shows"`
|
||||
}
|
||||
|
||||
type SimklPostShow struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Ids `json:"ids" ts_type:"ids"`
|
||||
Episodes []Episode `json:"episodes" ts_type:"episodes"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Number int `json:"number" ts_type:"number"`
|
||||
}
|
||||
|
||||
type Ids struct {
|
||||
Simkl int `json:"simkl" ts_type:"simkl"`
|
||||
Mal string `json:"mal" ts_type:"mal"`
|
||||
Anilist string `json:"anilist" ts_type:"anilist"`
|
||||
}
|
||||
|
||||
type ShowWithRating struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Ids `json:"ids" ts_type:"ids"`
|
||||
Rating int `json:"rating" ts_type:"rating"`
|
||||
}
|
||||
|
||||
type ShowWithoutRating struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Ids `json:"ids" ts_type:"ids"`
|
||||
}
|
||||
|
||||
type SimklShowStatus struct {
|
||||
Title string `json:"title" ts_type:"title"`
|
||||
Ids `json:"ids" ts_type:"ids"`
|
||||
To string `json:"to" ts_type:"to"`
|
||||
}
|
||||
|
||||
type SimklSearchType []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Poster string `json:"poster"`
|
||||
Year int `json:"year"`
|
||||
Status string `json:"status"`
|
||||
Ids struct {
|
||||
Simkl int `json:"simkl"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"ids"`
|
||||
TotalEpisodes int `json:"total_episodes"`
|
||||
AnimeType string `json:"anime_type"`
|
||||
}
|
||||
|
||||
type SimklDeleteType struct {
|
||||
Deleted struct {
|
||||
Movies int `json:"movies"`
|
||||
Shows int `json:"shows"`
|
||||
Episodes int `json:"episodes"`
|
||||
} `json:"deleted"`
|
||||
NotFound struct {
|
||||
Movies []interface{} `json:"movies"`
|
||||
Shows []interface{} `json:"shows"`
|
||||
} `json:"not_found"`
|
||||
}
|
||||
|
||||
@@ -4,36 +4,43 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"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",
|
||||
})
|
||||
// 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, err := simklRing.Get("SimklTokenType")
|
||||
accessToken, err := simklRing.Get("SimklAccessToken")
|
||||
scope, err := simklRing.Get("SimklScope")
|
||||
if err != nil {
|
||||
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 {
|
||||
@@ -42,29 +49,33 @@ func (a *App) CheckIfSimklLoggedIn() bool {
|
||||
}
|
||||
|
||||
func (a *App) SimklLogin() {
|
||||
if a.CheckIfSimklLoggedIn() == false {
|
||||
tokenType, err := simklRing.Get("SimklTokenType")
|
||||
accessToken, err := simklRing.Get("SimklAccessToken")
|
||||
scope, err := simklRing.Get("SimklScope")
|
||||
if err != nil {
|
||||
getSimklCodeUrl := "https://simkl.com/oauth/authorize?response_type=code&client_id=" + os.Getenv("SIMKL_CLIENT_ID") + "&redirect_uri=" + os.Getenv("SIMKL_CALLBACK_URI")
|
||||
runtime.BrowserOpenURL(a.ctx, getSimklCodeUrl)
|
||||
if !a.CheckIfSimklLoggedIn() {
|
||||
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
|
||||
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)
|
||||
handleSimklCallback(serverDone)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSimklCallback(wg *sync.WaitGroup) {
|
||||
srv := &http.Server{Addr: ":6734"}
|
||||
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) handleSimklCallback(wg *sync.WaitGroup) {
|
||||
mux := http.NewServeMux()
|
||||
srv := &http.Server{Addr: ":6734", Handler: mux}
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-simklCtxShutdown.Done():
|
||||
fmt.Println("Shutting down...")
|
||||
@@ -75,22 +86,16 @@ func 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),
|
||||
})
|
||||
_ = 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 {
|
||||
@@ -103,8 +108,8 @@ func handleSimklCallback(wg *sync.WaitGroup) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("Server error: %s\n", err)
|
||||
}
|
||||
fmt.Println("Shutting down...")
|
||||
}()
|
||||
@@ -119,14 +124,15 @@ func getSimklAuthorizationToken(content string) SimklJWT {
|
||||
Code string `json:"code"`
|
||||
}{
|
||||
GrantType: "authorization_code",
|
||||
ClientID: os.Getenv("SIMKL_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("SIMKL_CLIENT_SECRET"),
|
||||
RedirectURI: os.Getenv("SIMKL_CALLBACK_URI"),
|
||||
ClientID: Environment.SIMKL_CLIENT_ID,
|
||||
ClientSecret: Environment.SIMKL_CLIENT_SECRET,
|
||||
RedirectURI: Environment.SIMKL_CALLBACK_URI,
|
||||
Code: content,
|
||||
}
|
||||
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))
|
||||
@@ -136,14 +142,17 @@ func getSimklAuthorizationToken(content string) SimklJWT {
|
||||
response.Header.Add("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, reserr := client.Do(response)
|
||||
if reserr != nil {
|
||||
res, resErr := client.Do(response)
|
||||
if resErr != nil {
|
||||
log.Printf("Failed at res, %s\n", err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
returnedBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Printf("Could not read returned body, %s\n.", err)
|
||||
}
|
||||
|
||||
var post SimklJWT
|
||||
err = json.Unmarshal(returnedBody, &post)
|
||||
@@ -163,10 +172,9 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+simklJwt.AccessToken)
|
||||
req.Header.Add("simkl-api-key", os.Getenv("SIMKL_CLIENT_ID"))
|
||||
req.Header.Add("simkl-api-key", Environment.SIMKL_CLIENT_ID)
|
||||
|
||||
response, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed at request, %s\n", err)
|
||||
return SimklUser{}
|
||||
@@ -174,10 +182,25 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
var user SimklUser
|
||||
|
||||
respBody, _ := io.ReadAll(response.Body)
|
||||
|
||||
var errCheck struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(respBody, &errCheck)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
}
|
||||
|
||||
if errCheck.Error != "" {
|
||||
a.LogoutSimkl()
|
||||
return SimklUser{}
|
||||
}
|
||||
|
||||
var user SimklUser
|
||||
|
||||
err = json.Unmarshal(respBody, &user)
|
||||
if err != nil {
|
||||
log.Printf("Failed at unmarshal, %s\n", err)
|
||||
@@ -185,3 +208,18 @@ func (a *App) GetSimklLoggedInUser() SimklUser {
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (a *App) LogoutSimkl() string {
|
||||
if (SimklJWT{} != simklJwt) {
|
||||
tokenTypeErr := keyring.Delete(simklKeyringService, "SimklTokenType")
|
||||
accessTokenErr := keyring.Delete(simklKeyringService, "SimklAccessToken")
|
||||
scopeErr := keyring.Delete(simklKeyringService, "SimklScope")
|
||||
|
||||
if tokenTypeErr != nil || accessTokenErr != nil || scopeErr != nil {
|
||||
log.Printf("simkl: logout cleanup failed (type=%v access=%v scope=%v)", tokenTypeErr, accessTokenErr, scopeErr)
|
||||
}
|
||||
simklJwt = SimklJWT{}
|
||||
}
|
||||
|
||||
return "Simkl Logged Out Successfully"
|
||||
}
|
||||
|
||||
@@ -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,21 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed wails.json
|
||||
var wailsJSON string
|
||||
|
||||
// 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) {
|
||||
a.ctx = ctx
|
||||
// appVersion reads the product version from wails.json.
|
||||
func appVersion() string {
|
||||
return gjson.Get(wailsJSON, "info.productVersion").String()
|
||||
}
|
||||
|
||||
// appTitle is the window title: "AniTrack <version>".
|
||||
func appTitle() string {
|
||||
return "AniTrack " + appVersion()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
meta {
|
||||
name: AniChart
|
||||
type: graphql
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://graphql.anilist.co
|
||||
body: graphql
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
# Write your query or mutation here
|
||||
query ($page: Int, $perPage: Int, $airingAt_greater:Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
lastPage
|
||||
hasNextPage
|
||||
}
|
||||
airingSchedules(airingAt_greater:$airingAt_greater){
|
||||
id
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
mediaId
|
||||
media{
|
||||
id
|
||||
title{
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
type
|
||||
format
|
||||
status
|
||||
startDate{
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate{
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
season
|
||||
seasonYear
|
||||
episodes
|
||||
duration
|
||||
coverImage{
|
||||
medium
|
||||
large
|
||||
color
|
||||
extraLarge
|
||||
}
|
||||
bannerImage
|
||||
genres
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
trending
|
||||
favourites
|
||||
tags{
|
||||
id
|
||||
name
|
||||
description
|
||||
category
|
||||
rank
|
||||
isGeneralSpoiler
|
||||
isMediaSpoiler
|
||||
isAdult
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"page": 50,
|
||||
"perPage": 20,
|
||||
"airingAt_greater": 1730260800
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ post {
|
||||
headers {
|
||||
Content-Type: "application/json"
|
||||
Accept: "application/json"
|
||||
Authorization: Bearer {{ANILIST_ACCESS_TOKEN}}
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
@@ -24,7 +25,8 @@ body:graphql {
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
tags{
|
||||
genres
|
||||
tags {
|
||||
id
|
||||
name
|
||||
description
|
||||
@@ -92,7 +94,7 @@ body:graphql {
|
||||
body:graphql:vars {
|
||||
{
|
||||
"userId": 413504,
|
||||
"mediaId": 157371,
|
||||
"mediaId": 170998,
|
||||
"listType": "ANIME"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ body:graphql {
|
||||
english
|
||||
native
|
||||
}
|
||||
genre
|
||||
description
|
||||
coverImage {
|
||||
large
|
||||
@@ -53,7 +54,7 @@ body:graphql {
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"search": "frieren",
|
||||
"search": "dan-da-dan",
|
||||
"listType": "ANIME"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,12 +19,33 @@ headers {
|
||||
body:graphql {
|
||||
mutation($mediaId:Int, $progress:Int, $status:MediaListStatus){
|
||||
SaveMediaListEntry(mediaId:$mediaId, progress:$progress, status:$status){
|
||||
id
|
||||
mediaId
|
||||
progress
|
||||
userId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
season
|
||||
seasonYear
|
||||
status
|
||||
episodes
|
||||
nextAiringEpisode {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
isAdult
|
||||
}
|
||||
status
|
||||
score
|
||||
repeat
|
||||
notes
|
||||
startedAt{
|
||||
year
|
||||
month
|
||||
@@ -35,14 +56,35 @@ body:graphql {
|
||||
month
|
||||
day
|
||||
}
|
||||
notes
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatar{
|
||||
large
|
||||
medium
|
||||
}
|
||||
statistics{
|
||||
anime{
|
||||
count
|
||||
statuses{
|
||||
status
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"mediaId": 1,
|
||||
"progress": 26,
|
||||
"mediaId": 169417,
|
||||
"progress": 12,
|
||||
"status":"COMPLETED"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
meta {
|
||||
name: Change Episode Count
|
||||
type: graphql
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://graphql.anilist.co
|
||||
body: graphql
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Authorization: Bearer {{ANILIST_ACCESS_TOKEN}}
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
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
|
||||
) {
|
||||
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
|
||||
}
|
||||
status
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
notes
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
statuses {
|
||||
status
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"mediaId": 170998,
|
||||
"progress": 5,
|
||||
"status": "CURRENT",
|
||||
"score": 9,
|
||||
"repeat": 0,
|
||||
"notes": ",malSync::eyJ1IjoiaHR0cHM6Ly93d3cuY3J1bmNoeXJvbGwuY29tL3Nlcmllcy9HVkRIWDg1Wk4vI3NlYXNvbj1HNjNWQzJHUUsiLCJwIjoiIn0=::",
|
||||
"startedAt": {
|
||||
"year": 2024,
|
||||
"month": 7,
|
||||
"day": 10
|
||||
},
|
||||
"completedAt": {
|
||||
"year": 0,
|
||||
"month": 0,
|
||||
"day": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: Delete Media
|
||||
type: graphql
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://graphql.anilist.co
|
||||
body: graphql
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Authorization: Bearer {{ANILIST_ACCESS_TOKEN}}
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
body:graphql {
|
||||
mutation($id:Int){
|
||||
DeleteMediaListEntry(id:$id){
|
||||
deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:graphql:vars {
|
||||
{
|
||||
"id":430978266
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
meta {
|
||||
name: Get AnimeList
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.myanimelist.net/v2/users/{{MAL_USER}}/animelist?fields=list_status&status=watching&limit=1000
|
||||
body: formUrlEncoded
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:query {
|
||||
fields: list_status
|
||||
status: watching
|
||||
limit: 1000
|
||||
}
|
||||
|
||||
headers {
|
||||
:
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{MAL_ACCESS_TOKEN}}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Get Authorization
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://myanimelist.net/v1/oauth2/token
|
||||
body: formUrlEncoded
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
grant_type: authorization_code
|
||||
client_id: {{MAL_CLIENT_ID}}
|
||||
client_secret: {{MAL_CLIENT_SECRET}}
|
||||
redirect_uri: {{MAL_CALLBACK_URI}}
|
||||
code: {{MAL_CODE}}
|
||||
code_verifier: {{MAL_VERIFIER}}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
meta {
|
||||
name: Get Single Anime
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.myanimelist.net/v2/anime/57380?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
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:query {
|
||||
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
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{MAL_ACCESS_TOKEN}}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
meta {
|
||||
name: MAL Oauth Page
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id={{MAL_CLIENT_ID}}&redirect_uri={{MAL_CALLBACK_URI}}
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:query {
|
||||
response_type: code
|
||||
client_id: {{MAL_CLIENT_ID}}
|
||||
redirect_uri: {{MAL_CALLBACK_URI}}
|
||||
:
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
meta {
|
||||
name: Update Anime Status
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
patch {
|
||||
url: https://api.myanimelist.net/v2/anime/50205/my_list_status
|
||||
body: formUrlEncoded
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{MAL_ACCESS_TOKEN}}
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
num_watched_episodes: 3
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
meta {
|
||||
name: Get Anime Full Info
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.simkl.com/anime/40084?extended=full
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:query {
|
||||
extended: full
|
||||
}
|
||||
|
||||
headers {
|
||||
simkl-api-key: {{SIMKL_CLIENT_ID}}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.simkl.com/sync/all-items/anime/watching
|
||||
url: https://api.simkl.com/sync/all-items/anime/
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
meta {
|
||||
name: Search By MalID to Get Simkl ID
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.simkl.com/search/id?anilist=174576
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:query {
|
||||
anilist: 174576
|
||||
}
|
||||
|
||||
headers {
|
||||
simkl-api-key: {{SIMKL_CLIENT_ID}}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
meta {
|
||||
name: Search By MalID
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api.simkl.com/search/id?client_id={{SIMKL_CLIENT_ID}}&mal=52991
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:query {
|
||||
client_id: {{SIMKL_CLIENT_ID}}
|
||||
mal: 52991
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
meta {
|
||||
name: Delete Entry
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://api.simkl.com/sync/history/remove
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Authorization: Bearer {{SIMKL_AUTH_TOKEN}}
|
||||
Content-Type: application/json
|
||||
simkl-api-key: {{SIMKL_CLIENT_ID}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"shows": [
|
||||
{
|
||||
"ids": {
|
||||
"simkl": 909121
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
meta {
|
||||
name: Update Episode
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://api.simkl.com/sync/history
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Authorization: Bearer {{SIMKL_AUTH_TOKEN}}
|
||||
Content-Type: application/json
|
||||
simkl-api-key: {{SIMKL_CLIENT_ID}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"shows": [
|
||||
{
|
||||
"title": "Ramen Aka Neko",
|
||||
"ids": {
|
||||
"simkl": 2307708,
|
||||
"mal": "57325",
|
||||
"anilist": "170998"
|
||||
},
|
||||
"episodes": [
|
||||
{
|
||||
"number": 1
|
||||
},
|
||||
{
|
||||
"number": 2
|
||||
},
|
||||
{
|
||||
"number": 3
|
||||
},
|
||||
{
|
||||
"number": 4
|
||||
},
|
||||
{
|
||||
"number": 5
|
||||
},
|
||||
{
|
||||
"number": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,16 @@ vars {
|
||||
ANILIST_SECRET_TOKEN: {{process.env.ANILIST_SECRET_TOKEN}}
|
||||
SIMKL_CLIENT_ID: {{process.env.SIMKL_CLIENT_ID}}
|
||||
SIMKL_CLIENT_SECRET: {{process.env.SIMKL_CLIENT_SECRET}}
|
||||
MAL_CLIENT_ID: {{process.env.MAL_CLIENT_ID}}
|
||||
MAL_CLIENT_SECRET: {{process.env.MAL_CLIENT_SECRET}}
|
||||
MAL_CALLBACK_URI: {{process.env.MAL_CALLBACK_URI}}
|
||||
}
|
||||
vars:secret [
|
||||
code,
|
||||
ANILIST_ACCESS_TOKEN,
|
||||
ANILIST_CODE,
|
||||
SIMKL_AUTH_TOKEN,
|
||||
ANILIST_ACCESS_TOKEN
|
||||
MAL_CODE,
|
||||
MAL_VERIFIER,
|
||||
MAL_USER,
|
||||
~MAL_ACCESS_TOKEN
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Name=AniTrack
|
||||
Comment=A manual synchronizer for various Anime trackers.
|
||||
Exec=/home/nymusicman/Applications/AniTrack
|
||||
Icon=AniTrack
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
Categories=Internet
|
||||
Keywords=anitrack;anilist;simkl;mal;myanimelist;anime;sync
|
||||
Path=
|
||||
@@ -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}}"
|
||||
|
Before Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,42 @@
|
||||
# Wails v3 project configuration.
|
||||
# NOTE: `make release` bumps `info.version` below alongside wails.json.
|
||||
# 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: "1.6.8"
|
||||
|
||||
# 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,63 +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}}
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 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."
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 20 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>
|
||||
@@ -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,49 @@
|
||||
# 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`. `make release` bumps both `wails.json` and `build/config.yml:info.version` (TEMP until `wails.json` is retired).
|
||||
- `.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.
|
||||
|
||||
## 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` (on `wailsv3` — this branch's whole purpose):** real-world dogfooding (logins, watchlist sync, error modals), then merge to `main` when Wails `v3` hits stable.
|
||||
2. **Frontend toolchain refresh (on `wailsv3`, before the merge):** Svelte 4 → 5, Vite 4 → 8, `vite-plugin-svelte 2 → 7`, Tailwind 3 → 4 — majors deferred intentionally; they pair naturally with `v3`’s Svelte 5 templates and touch the same desktop files, so no separate branch.
|
||||
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` — still `v2`, stable, ships `1.6.8` releases.
|
||||
- `wailsv3` — `v3` desktop, unpushed until dogfooding passes, ahead of `main` by the commits above plus `2bf8d38` (`frontend/package-lock.json`) and `efe45f3`/`a19080e`/`336a1f3` from the CI/cache work. Merge `main` forward before each `v3` push.
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
type EnvironmentStruct struct {
|
||||
ANILIST_SECRET_TOKEN string
|
||||
ANILIST_APP_ID string
|
||||
ANILIST_APP_NAME string
|
||||
ANILIST_CALLBACK_URI string
|
||||
SIMKL_CLIENT_ID string
|
||||
SIMKL_CLIENT_SECRET string
|
||||
SIMKL_CALLBACK_URI string
|
||||
MAL_CLIENT_ID string
|
||||
MAL_CLIENT_SECRET string
|
||||
MAL_CALLBACK_URI string
|
||||
}
|
||||
|
||||
// created a separate environment.go file and add var Environment EnvironmentStruct = {}
|
||||
@@ -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
|
||||
@@ -1,13 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
<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"
|
||||
type="module"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,29 +5,32 @@
|
||||
"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": "^1.0.1",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"svelte": "^3.49.0",
|
||||
"svelte-check": "^2.8.0",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7"
|
||||
"@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.3",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ernane/svelte-star-rating": "^1.1.7",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"flowbite": "^2.4.1",
|
||||
"flowbite-svelte": "^0.46.15",
|
||||
"@wailsio/runtime": "3.0.0-beta.20",
|
||||
"flowbite": "^2.5.1",
|
||||
"flowbite-svelte": "^0.46.16",
|
||||
"moment": "^2.30.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +1,71 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
anilistModal,
|
||||
GetAniListSingleItemAndOpenModal,
|
||||
title,
|
||||
simklWatchList,
|
||||
aniListLoggedIn,
|
||||
simklLoggedIn
|
||||
} from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {
|
||||
CheckIfAniListLoggedIn,
|
||||
CheckIfSimklLoggedIn,
|
||||
GetAniListLoggedInUser,
|
||||
GetAniListUserWatchingList,
|
||||
GetSimklLoggedInUser,
|
||||
SimklGetUserWatchlist,
|
||||
} from "../wailsjs/go/main/App";
|
||||
import {type AniListUser, MediaListSort} from "./anilist/types/AniListTypes";
|
||||
import type {AniListCurrentUserWatchList} from "./anilist/types/AniListCurrentUserWatchListType"
|
||||
import Header from "./Header.svelte";
|
||||
import {Button, Rating} from "flowbite-svelte";
|
||||
import {default as Modal} from "./modal/Modal.svelte"
|
||||
import ChangeDataDialogue from "./ChangeDataDialogue.svelte";
|
||||
import {onMount} from "svelte";
|
||||
import type {SimklUser} from "./simkl/types/simklTypes";
|
||||
import {
|
||||
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 Home from "./routes/Home.svelte";
|
||||
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 { CheckIfMALLoggedInAndSetUser } from "./helperModules/CheckIfMyAnimeListLoggedIn.svelte";
|
||||
import { CheckIfSimklLoggedInAndSetUser } from "./helperModules/CheckIsSimklLoggedIn.svelte";
|
||||
import {App} from "../bindings/AniTrack";
|
||||
import { loc } from "svelte-spa-router";
|
||||
import ErrorModal from "./helperComponents/ErrorModal.svelte";
|
||||
|
||||
let aniListPrimary = true
|
||||
let simklUser: SimklUser
|
||||
let aniListUser: AniListUser
|
||||
let aniListWatchlist: AniListCurrentUserWatchList
|
||||
let isAniListLoggedIn: boolean
|
||||
let isSimklLoggedIn: boolean
|
||||
onMount(async () => {
|
||||
let isAniListLoggedIn: boolean;
|
||||
let isMALLoggedIn: boolean;
|
||||
let isSimklLoggedIn: boolean;
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMALLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
|
||||
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value)
|
||||
simklLoggedIn.subscribe((value) => isSimklLoggedIn = value)
|
||||
serviceLoggingIn.set(["anilist", "mal", "simkl"]);
|
||||
|
||||
let page = 1
|
||||
let perPage = 20
|
||||
const size = "xl"
|
||||
|
||||
onMount(async () => {
|
||||
await CheckIfAniListLoggedIn().then(result => {
|
||||
if (result) {
|
||||
GetAniListLoggedInUser().then(result => {
|
||||
aniListUser = result
|
||||
if (aniListPrimary) {
|
||||
GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then((result) => {
|
||||
aniListWatchlist = result
|
||||
aniListLoggedIn.set(true)
|
||||
})
|
||||
} else {
|
||||
aniListLoggedIn.set(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await CheckIfSimklLoggedIn().then(result => {
|
||||
if (result) {
|
||||
GetSimklLoggedInUser().then(result => {
|
||||
simklUser = result
|
||||
SimklGetUserWatchlist().then(result => {
|
||||
simklWatchList.set(result)
|
||||
simklLoggedIn.set(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function loginToSimkl(): void {
|
||||
GetSimklLoggedInUser().then(result => {
|
||||
simklUser = result
|
||||
simklLoggedIn.set(true)
|
||||
})
|
||||
}
|
||||
|
||||
function loginToAniList(): void {
|
||||
GetAniListLoggedInUser().then(result => {
|
||||
aniListUser = result
|
||||
aniListLoggedIn.set(true)
|
||||
if (aniListPrimary) {
|
||||
GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then(result => aniListWatchlist = result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// function anilistGetUserWatchlist(): void {
|
||||
// GetAniListUserWatchingList(page, perPage, MediaListSort.UpdatedTimeDesc).then((result) => {
|
||||
// aniListWatchlist = result
|
||||
// aniListLoggedIn.set(true)
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// let count = 1;
|
||||
//
|
||||
// const decrement = () => {
|
||||
// if (count > 0) {
|
||||
// count--
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const increment = () => {
|
||||
// count++
|
||||
// }
|
||||
!isAniListLoggedIn && (await CheckIfAniListLoggedInAndLoadWatchList());
|
||||
!isMALLoggedIn && (await CheckIfMALLoggedInAndSetUser());
|
||||
!isSimklLoggedIn && (await CheckIfSimklLoggedInAndSetUser());
|
||||
});
|
||||
|
||||
$: if ($loc?.location === "/" && $watchlistNeedsRefresh) {
|
||||
(async () => {
|
||||
if ($aniListLoggedIn && $aniListPrimary) {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
}
|
||||
if ($malLoggedIn && $malPrimary) {
|
||||
await App.GetMyAnimeList(1000).then((w) => malWatchList.set(w));
|
||||
}
|
||||
if ($simklLoggedIn && $simklPrimary) {
|
||||
await App.SimklGetUserWatchlist().then((w) => simklWatchList.set(w));
|
||||
}
|
||||
|
||||
watchlistNeedsRefresh.set(false);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header/>
|
||||
|
||||
<main>
|
||||
{#if isAniListLoggedIn}
|
||||
<div>You are logged into AniList, {aniListUser.data.Viewer.name}!</div>
|
||||
{:else}
|
||||
<button class="btn" on:click={loginToAniList}>Login to AniList</button>
|
||||
{/if}
|
||||
|
||||
{#if isSimklLoggedIn}
|
||||
<div>You are logged into Simkl, {simklUser.user.name}</div>
|
||||
{:else}
|
||||
<Button class="btn" on:click={loginToSimkl}>Login to Simkl</Button>
|
||||
{/if}
|
||||
|
||||
{#if isAniListLoggedIn}
|
||||
<div class="mx-auto max-w-2xl p-4 sm:p-6 lg:max-w-7xl lg:px-8">
|
||||
<h1 class="text-left text-xl font-bold mb-4">Your Watching List</h1>
|
||||
|
||||
<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 aniListWatchlist.data.Page.mediaList as media}
|
||||
<div 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={() => GetAniListSingleItemAndOpenModal(media.media.id, true)}>
|
||||
<img class="rounded-lg" 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={() => GetAniListSingleItemAndOpenModal(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>
|
||||
{#if media.media.episodes > 0}
|
||||
<p class="mt-1 text-lg font-medium text-white-900">Total
|
||||
Episodes: {media.media.episodes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal title={$title} bind:open={$anilistModal} {size} autoclose={false}>
|
||||
<ChangeDataDialogue/>
|
||||
</Modal>
|
||||
</main>
|
||||
<Header />
|
||||
<ErrorModal />
|
||||
<Router
|
||||
routes={{
|
||||
"/": Home,
|
||||
"/anime/:id": wrap({
|
||||
asyncComponent: () => import("./routes/AnimeRoutePage.svelte"),
|
||||
conditions: [async () => await App.CheckIfAniListLoggedIn()],
|
||||
loadingComponent: Spinner,
|
||||
}),
|
||||
// '*': "Not Found"
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
anilistModal,
|
||||
simklWatchList,
|
||||
aniListLoggedIn,
|
||||
simklLoggedIn
|
||||
} from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {aniListAnime} from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {Button} from "flowbite-svelte";
|
||||
// @ts-ignore
|
||||
import StarRatting from "@ernane/svelte-star-rating"
|
||||
import moment from 'moment'
|
||||
import { Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell } from 'flowbite-svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import type {SimklAnime} from "./simkl/types/simklTypes";
|
||||
import { get } from 'svelte/store';
|
||||
import {AniListUpdateEntry} from "../wailsjs/go/main/App";
|
||||
|
||||
const simklWatch = get(simklWatchList);
|
||||
let isAniListLoggedIn: boolean
|
||||
let isSimklLoggedIn: boolean
|
||||
|
||||
aniListLoggedIn.subscribe((value) => isAniListLoggedIn = value)
|
||||
simklLoggedIn.subscribe((value) => isSimklLoggedIn = value)
|
||||
|
||||
const simklAnime: SimklAnime | undefined = simklWatch.anime.filter((x) => Number(x.show.ids.mal) === aniListAnime.data.MediaList.media.idMal)[0]
|
||||
|
||||
let items = [];
|
||||
|
||||
if(isAniListLoggedIn) {
|
||||
items.push({
|
||||
id: aniListAnime.data.MediaList.id,
|
||||
service: "AniList",
|
||||
progress: aniListAnime.data.MediaList.progress,
|
||||
status: aniListAnime.data.MediaList.status,
|
||||
startedAt: `${aniListAnime.data.MediaList.startedAt.month}-${aniListAnime.data.MediaList.startedAt.day}-${aniListAnime.data.MediaList.startedAt.year}`,
|
||||
completedAt: `${aniListAnime.data.MediaList.completedAt.month}-${aniListAnime.data.MediaList.completedAt.day}-${aniListAnime.data.MediaList.completedAt.year}`,
|
||||
score: aniListAnime.data.MediaList.score,
|
||||
repeat: aniListAnime.data.MediaList.repeat,
|
||||
notes: aniListAnime.data.MediaList.notes
|
||||
})
|
||||
}
|
||||
|
||||
if(isSimklLoggedIn && simklAnime !== undefined) {
|
||||
items.push({
|
||||
id: simklAnime.show.ids.simkl,
|
||||
service: "Simkl",
|
||||
progress: simklAnime.watched_episodes_count,
|
||||
status: simklAnime.status,
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: simklAnime.user_rating,
|
||||
repeat: 0,
|
||||
notes: ""
|
||||
})
|
||||
}
|
||||
|
||||
const sortKey = writable('service'); // default sort key
|
||||
const sortDirection = writable(1); // default sort direction (ascending)
|
||||
const sortItems = writable(items.slice()); // make a copy of the items array
|
||||
|
||||
// Define a function to sort the items
|
||||
const sortTable = (key: any) => {
|
||||
// If the same key is clicked, reverse the sort direction
|
||||
if ($sortKey === key) {
|
||||
sortDirection.update((val) => -val);
|
||||
} else {
|
||||
sortKey.set(key);
|
||||
sortDirection.set(1);
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
const key = $sortKey;
|
||||
const direction = $sortDirection;
|
||||
const sorted = [...$sortItems].sort((a, b) => {
|
||||
const aVal = a[key];
|
||||
const bVal = b[key];
|
||||
if (aVal < bVal) {
|
||||
return -direction;
|
||||
} else if (aVal > bVal) {
|
||||
return direction;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
sortItems.set(sorted);
|
||||
}
|
||||
|
||||
const ratingInWords = {
|
||||
0: "Not Reviewed",
|
||||
1: "Apalling",
|
||||
2: "Horrible",
|
||||
3: "Very Bad",
|
||||
4: "Bad",
|
||||
5: "Average",
|
||||
6: "Fine",
|
||||
7: "Good",
|
||||
8: "Very Good",
|
||||
9: "Great",
|
||||
10: "Masterpiece",
|
||||
}
|
||||
|
||||
const hide = (e) => {
|
||||
e.preventDefault();
|
||||
anilistModal.set(false)
|
||||
};
|
||||
|
||||
const title = aniListAnime.data.MediaList.media.title.english !== "" ?
|
||||
aniListAnime.data.MediaList.media.title.english :
|
||||
aniListAnime.data.MediaList.media.title.romaji
|
||||
|
||||
let config = {
|
||||
readOnly: false,
|
||||
countStars: 5,
|
||||
range: {
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5
|
||||
},
|
||||
score: aniListAnime.data.MediaList.score / 2,
|
||||
showScore: false,
|
||||
scoreFormat: function () {
|
||||
return `(${this.score.toFixed(1)}/${this.countStars})`
|
||||
},
|
||||
name: "",
|
||||
starConfig: {
|
||||
size: 32,
|
||||
fillColor: '#F9ED4F',
|
||||
strokeColor: "#e2c714",
|
||||
unfilledColor: '#FFF',
|
||||
strokeUnfilledColor: '#000'
|
||||
}
|
||||
}
|
||||
|
||||
let values = {
|
||||
progress: aniListAnime.data.MediaList.progress,
|
||||
status: aniListAnime.data.MediaList.status,
|
||||
startedAt: {
|
||||
year: aniListAnime.data.MediaList.startedAt.year,
|
||||
month: aniListAnime.data.MediaList.startedAt.month,
|
||||
day: aniListAnime.data.MediaList.startedAt.day
|
||||
},
|
||||
completedAt: {
|
||||
year: aniListAnime.data.MediaList.completedAt.year,
|
||||
month: aniListAnime.data.MediaList.completedAt.month,
|
||||
day: aniListAnime.data.MediaList.completedAt.day
|
||||
},
|
||||
repeat: aniListAnime.data.MediaList.repeat,
|
||||
score: aniListAnime.data.MediaList.score,
|
||||
notes: aniListAnime.data.MediaList.notes
|
||||
}
|
||||
let startedAtDate: string
|
||||
let completedAtDate: string
|
||||
|
||||
const changeRating = (e) => {
|
||||
config.score = e.target.valueAsNumber
|
||||
values.score = e.target.valueAsNumber * 2
|
||||
}
|
||||
|
||||
|
||||
if (values.startedAt.year > 0) {
|
||||
let startedAtISODate = new Date(values.startedAt.year, values.startedAt.month - 1, values.startedAt.day)
|
||||
let startedAtMoment = moment(startedAtISODate)
|
||||
startedAtDate = startedAtMoment.format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const transformStartedAtDate = (e) => {
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/
|
||||
const date = re.exec(e.target.value)
|
||||
values.startedAt.year = Number(date[1])
|
||||
values.startedAt.month = Number(date[2])
|
||||
values.startedAt.day = Number(date[3])
|
||||
}
|
||||
|
||||
if (values.completedAt.year > 0) {
|
||||
let completedAtISODate = new Date(values.completedAt.year, values.completedAt.month - 1, values.completedAt.day)
|
||||
let completedAtMoment = moment(completedAtISODate)
|
||||
completedAtDate = completedAtMoment.format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const transformCompletedAtDate = (e) => {
|
||||
const re = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/
|
||||
const date = re.exec(e.target.value)
|
||||
values.completedAt.year = Number(date[1])
|
||||
values.completedAt.month = Number(date[2])
|
||||
values.completedAt.day = Number(date[3])
|
||||
}
|
||||
|
||||
const submitData = async () => {
|
||||
await AniListUpdateEntry(
|
||||
aniListAnime.data.MediaList.mediaId,
|
||||
values.progress,
|
||||
values.status,
|
||||
values.score,
|
||||
values.repeat,
|
||||
values.notes,
|
||||
values.startedAt.year,
|
||||
values.startedAt.month,
|
||||
values.startedAt.day,
|
||||
values.completedAt.year,
|
||||
values.completedAt.month,
|
||||
values.completedAt.day
|
||||
).then((value) => {
|
||||
console.log(value)
|
||||
})
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="inapp-data">
|
||||
<div class="grid grid-cols-1 md:grid-cols-10 grid-flow-col gap-4">
|
||||
<div class="md:col-span-2 space-y-3">
|
||||
<img class="rounded-lg" src={aniListAnime.data.MediaList.media.coverImage.large} alt="{title} Cover Image">
|
||||
<StarRatting bind:config on:change={changeRating}/>
|
||||
<p>Rating: {config.score * 2}</p>
|
||||
<p>{ratingInWords[config.score * 2]}</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-8 ">
|
||||
<div class="flex flex-col md:flex-row md:pl-10 md:pr-10 pt-5 pb-5 justify-center md:gap-x-24 lg:gap-x-56">
|
||||
<div>
|
||||
<label for="episodes" class="text-left block mb-2 text-sm font-medium text-gray-900 dark:text-white">Episode
|
||||
Progress</label>
|
||||
<input
|
||||
type="number"
|
||||
name="episodes"
|
||||
min="0"
|
||||
id="episodes"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600
|
||||
focus:border-primary-600 block w-24 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400
|
||||
dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
bind:value={values.progress}
|
||||
required>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="status"
|
||||
class="text-left block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500
|
||||
focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400
|
||||
dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
bind:value={values.status}
|
||||
>
|
||||
|
||||
<option>CURRENT</option>
|
||||
<option>PLANNING</option>
|
||||
<option>COMPLETED</option>
|
||||
<option>DROPPED</option>
|
||||
<option>PAUSED</option>
|
||||
<option>REPEATING</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div 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">
|
||||
|
||||
<div>
|
||||
|
||||
<label for="startedAt" class="text-left block mb-2 text-sm font-medium text-gray-900 dark: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-500 dark: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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="startedAt"
|
||||
type="date"
|
||||
name="startedAt"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500
|
||||
focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
value={startedAtDate}
|
||||
placeholder="Date Started"
|
||||
on:change={transformStartedAtDate}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<label for="completedAt" class="text-left block mb-2 text-sm font-medium text-gray-900 dark: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-500 dark: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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="completedAt"
|
||||
type="date"
|
||||
name="completedAt"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500
|
||||
focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
value={completedAtDate}
|
||||
placeholder="Date Completed"
|
||||
on:change={transformCompletedAtDate}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="repeat"
|
||||
class="text-left block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rewatched</label>
|
||||
<input
|
||||
type="number"
|
||||
name="repeat"
|
||||
min="0"
|
||||
id="repeat"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600
|
||||
focus:border-primary-600 block w-24 p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400
|
||||
dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
bind:value={values.repeat}
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:pl-10 md:pr-10 pt-5 pb-5 justify-center">
|
||||
<div class="w-full">
|
||||
<label for="notes" class="text-left block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your
|
||||
notes</label>
|
||||
<textarea id="notes" rows="3"
|
||||
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Write your thoughts here..."
|
||||
bind:value={values.notes}></textarea>
|
||||
</div>
|
||||
</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-gray-900 dark:text-white">AniList</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table hoverable={true}>
|
||||
<TableHead>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('id')}>ID</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('service')}>Service</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('progress')}>Episode Progress</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('status')}>Status</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('startedAt')}>Date Started</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('completedAt')}>Date Completed</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('score')}>Rating</TableHeadCell>
|
||||
<TableHeadCell class="cursor-pointer" on:click={() => sortTable('repeat')}>Rewatches</TableHeadCell>
|
||||
<TableHeadCell>Notes</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each $sortItems as item}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell class="overflow-x-auto">{item.id}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.service}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.progress}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.status}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.startedAt}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.completedAt}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.score}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.repeat}</TableBodyCell>
|
||||
<TableBodyCell class="overflow-x-auto">{item.notes}</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
||||
<div class="bg-white rounded-lg shadow max-w-4-4 dark:bg-gray-800">
|
||||
<div class="w-full mx-auto max-w-screen-xl p-4 md:flex md:items-center md:justify-end">
|
||||
<Button
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium
|
||||
rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none
|
||||
dark:focus:ring-blue-800"
|
||||
on:click={submitData}>Sync Changes
|
||||
</Button>
|
||||
<Button
|
||||
class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4
|
||||
focus:ring-gray-100 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"
|
||||
on:click={hide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-2xl">
|
||||
Summary
|
||||
</h3>
|
||||
<p>{@html aniListAnime.data.MediaList.media.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts" context="module">
|
||||
import {GetAniListItem} from "../wailsjs/go/main/App";
|
||||
import type {AniListGetSingleAnime} from "./anilist/types/AniListCurrentUserWatchListType.js";
|
||||
import {writable} from 'svelte/store'
|
||||
import type {SimklWatchList} from "./simkl/types/simklTypes";
|
||||
|
||||
export let aniListAnime: AniListGetSingleAnime
|
||||
export let title = writable("")
|
||||
export let anilistModal = writable(false);
|
||||
export let aniListLoggedIn = writable(false)
|
||||
export let simklLoggedIn = writable(false)
|
||||
export let simklWatchList = writable({} as SimklWatchList)
|
||||
|
||||
export function GetAniListSingleItemAndOpenModal(aniId: number, login: boolean): void {
|
||||
GetAniListItem(aniId, login).then(result => {
|
||||
aniListAnime = result
|
||||
title.set(aniListAnime.data.MediaList.media.title.english === "" ?
|
||||
aniListAnime.data.MediaList.media.title.romaji :
|
||||
aniListAnime.data.MediaList.media.title.english)
|
||||
anilistModal.set(true)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import logo from "./assets/images/AniTrackLogo.svg"
|
||||
import Search from "./Search.svelte"
|
||||
import UserDialogue from "./UserHeaderDialogue.svelte";
|
||||
|
||||
</script>
|
||||
|
||||
<header class="mb-2">
|
||||
<nav class="bg-white border-gray-200 px-4 lg:px-6 py-2.5 dark:bg-gray-800">
|
||||
<div class="flex flex-wrap justify-between items-center mx-auto max-w-screen-xl">
|
||||
<a href="#" class="flex items-center">
|
||||
<img src={logo} class="mr-3 h-6 sm:h-9" alt="AniTrack Logo" />
|
||||
</a>
|
||||
<div class="flex space-x-4 items-center lg:order-2">
|
||||
|
||||
<Search />
|
||||
|
||||
<UserDialogue />
|
||||
<button data-collapse-toggle="mobile-menu-2" type="button" class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="mobile-menu-2" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
|
||||
<svg class="hidden w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1" id="mobile-menu-2">
|
||||
<ul class="flex flex-col mt-4 font-medium lg:flex-row lg:space-x-8 lg:mt-0">
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-white rounded bg-blue-700 lg:bg-transparent lg:text-blue-700 lg:p-0 dark:text-white" aria-current="page">Home</a>-->
|
||||
<!-- </li>-->
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-blue-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">Company</a>-->
|
||||
<!-- </li>-->
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-blue-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">Marketplace</a>-->
|
||||
<!-- </li>-->
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-blue-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">Features</a>-->
|
||||
<!-- </li>-->
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-blue-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">Team</a>-->
|
||||
<!-- </li>-->
|
||||
<!-- <li>-->
|
||||
<!-- <a href="#" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-blue-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">Contact</a>-->
|
||||
<!-- </li>-->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
|
||||
import {AniListSearch} from "../wailsjs/go/main/App";
|
||||
import type {AniSearchList} from "./anilist/types/AniListTypes";
|
||||
import {GetAniListSingleItemAndOpenModal} from "./GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
let aniSearch = ""
|
||||
let aniListSearch: AniSearchList
|
||||
let aniListSearchActive = false
|
||||
|
||||
function runAniListSearch(): void {
|
||||
AniListSearch(aniSearch).then(result => {
|
||||
console.log(result.data.Page.media[5])
|
||||
aniListSearch = result
|
||||
aniListSearchActive = true
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<div>
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="flex">
|
||||
<label for="anime-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Find
|
||||
Anime</label>
|
||||
<div class="relative w-full">
|
||||
<input type="search" id="anime-search" bind:value={aniSearch}
|
||||
class="rounded-s-lg block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-e-lg border-s-gray-50 border-s-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-s-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500"
|
||||
placeholder="Search for Anime" required/>
|
||||
<button id="aniListSearchButton" data-dropdown-toggle="aniListSearch"
|
||||
data-dropdown-offset-distance="0"
|
||||
data-dropdown-offset-skidding="-50"
|
||||
data-dropdown-placement="bottom"
|
||||
class="absolute top-0 end-0 h-full p-2.5 text-sm font-medium text-white bg-blue-700 rounded-e-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
on:click={() => {if(aniSearch.length > 0) runAniListSearch()}}>
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="aniListSearch" class="z-10 hidden bg-white rounded-lg shadow w-80 dark:bg-gray-700">
|
||||
{#if aniListSearchActive}
|
||||
<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}
|
||||
<li class="w-full">
|
||||
<div class="flex w-full items-start p-1 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white rounded-lg">
|
||||
<button on:click={() => {
|
||||
GetAniListSingleItemAndOpenModal(media.id, false)
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
</button>
|
||||
<button class="rounded-bl-lg rounded-tl-lg w-full h-24" on:click={() => {
|
||||
GetAniListSingleItemAndOpenModal(media.id, false)
|
||||
}} >{media.title.english === '' || media.title.english === null ? media.title.romaji : media.title.english }</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if aniSearch.length === 0}
|
||||
<div class="m-4">Please enter a search term...</div>
|
||||
{/if}
|
||||
<a href="#"
|
||||
class="flex items-center p-3 text-sm font-medium text-blue-600 border-t border-gray-200 rounded-b-lg bg-gray-50 dark:border-gray-600 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-blue-500 hover:underline">
|
||||
<svg class="w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
||||
viewBox="0 0 20 18">
|
||||
<path d="M6.5 9a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM8 10H5a5.006 5.006 0 0 0-5 5v2a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-2a5.006 5.006 0 0 0-5-5Zm11-3h-2V5a1 1 0 0 0-2 0v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 0 0 2 0V9h2a1 1 0 1 0 0-2Z"/>
|
||||
</svg>
|
||||
Add new user
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
let loggedIn = false
|
||||
</script>
|
||||
|
||||
|
||||
<div id="avatarButton" type="button" data-dropdown-toggle="userDropdown" data-dropdown-placement="bottom-start" class="relative w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600">
|
||||
{#if loggedIn}
|
||||
<img class="w-10 h-10 rounded-full cursor-pointer" src="/docs/images/people/profile-picture-5.jpg" alt="User dropdown">
|
||||
{:else}
|
||||
<svg class="absolute w-12 h-12 text-gray-400 -left-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path></svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div id="userDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
<div>Bonnie Green</div>
|
||||
<div class="font-medium truncate">name@flowbite.com</div>
|
||||
</div>
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="avatarButton">
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Dashboard</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Earnings</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="py-1">
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,80 +1,94 @@
|
||||
export interface AniListCurrentUserWatchList {
|
||||
data: {
|
||||
Page: {
|
||||
pageInfo: {
|
||||
total: number
|
||||
perPage: number
|
||||
currentPage: number
|
||||
lastPage: number
|
||||
hasNextPage: boolean
|
||||
},
|
||||
mediaList: MediaList[]
|
||||
}
|
||||
}
|
||||
data: {
|
||||
Page: {
|
||||
pageInfo: {
|
||||
total: number;
|
||||
perPage: number;
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
mediaList: MediaList[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AniListGetSingleAnime {
|
||||
data: {
|
||||
MediaList: MediaList
|
||||
}
|
||||
data: {
|
||||
MediaList: MediaList;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MediaList {
|
||||
id: number
|
||||
mediaId: number
|
||||
userId: number
|
||||
media: {
|
||||
id: number
|
||||
idMal: number
|
||||
title: {
|
||||
romaji: string
|
||||
english?: string
|
||||
native: string
|
||||
}
|
||||
description: string
|
||||
coverImage: {
|
||||
large: string
|
||||
}
|
||||
season: string
|
||||
seasonYear: number
|
||||
status: string
|
||||
episodes?: number
|
||||
nextAiringEpisode?: {
|
||||
airingAt: number
|
||||
timeUntilAiring: number
|
||||
episode: number
|
||||
}
|
||||
}
|
||||
status: string
|
||||
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
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id: number;
|
||||
mediaId: number;
|
||||
userId: number;
|
||||
media: {
|
||||
id: number;
|
||||
idMal: number;
|
||||
title: {
|
||||
romaji: string;
|
||||
english?: string;
|
||||
native: string;
|
||||
};
|
||||
description: string;
|
||||
coverImage: {
|
||||
large: string;
|
||||
};
|
||||
season: string;
|
||||
seasonYear: number;
|
||||
status: string;
|
||||
episodes?: number;
|
||||
nextAiringEpisode?: {
|
||||
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;
|
||||
};
|
||||
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;
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,4 +80,27 @@ export enum MediaListSort {
|
||||
MediaTitleNativeDesc = "MEDIA_TITLE_NATIVE_DESC",
|
||||
MediaPopularity = "MEDIA_POPULARITY",
|
||||
MediaPopularityDesc = "MEDIA_POPULARITY_DESC"
|
||||
}
|
||||
|
||||
export interface StartedAt {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
export interface CompletedAt {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
export interface AniListUpdateVariables {
|
||||
mediaId: number
|
||||
progress: number
|
||||
status: string
|
||||
score: number
|
||||
repeat: number
|
||||
notes: string
|
||||
startedAt: StartedAt
|
||||
completedAt: CompletedAt
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,925 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListAnime,
|
||||
aniListLoggedIn,
|
||||
malAnime,
|
||||
malLoggedIn,
|
||||
setApiError,
|
||||
simklAnime,
|
||||
simklLoggedIn,
|
||||
watchlistNeedsRefresh,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import WebsiteLink from "./WebsiteLink.svelte";
|
||||
import type { AniListGetSingleAnime } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import Rating from "./Rating.svelte";
|
||||
import {
|
||||
convertAniListDateToString,
|
||||
convertAniListDateToDate,
|
||||
} from "../helperFunctions/convertAniListDateIn";
|
||||
import AnimeTable from "./AnimeTable.svelte";
|
||||
import type {
|
||||
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 { 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 submitting = writable(false);
|
||||
let isSubmitting: boolean;
|
||||
let submitSuccess = writable(false);
|
||||
|
||||
aniListLoggedIn.subscribe((value) => (isAniListLoggedIn = value));
|
||||
malLoggedIn.subscribe((value) => (isMalLoggedIn = value));
|
||||
simklLoggedIn.subscribe((value) => (isSimklLoggedIn = value));
|
||||
aniListAnime.subscribe((value) => (currentAniListAnime = value));
|
||||
malAnime.subscribe((value) => (currentMalAnime = value));
|
||||
simklAnime.subscribe((value) => (currentSimklAnime = value));
|
||||
submitting.subscribe((value) => (isSubmitting = value));
|
||||
|
||||
const title =
|
||||
currentAniListAnime.data.MediaList.media.title.english !== ""
|
||||
? currentAniListAnime.data.MediaList.media.title.english
|
||||
: currentAniListAnime.data.MediaList.media.title.romaji;
|
||||
const statusOptions: StatusOptions = [
|
||||
{ id: 0, aniList: "CURRENT", mal: "watching", simkl: "watching" },
|
||||
{
|
||||
id: 1,
|
||||
aniList: "PLANNING",
|
||||
mal: "plan_to_watch",
|
||||
simkl: "plantowatch",
|
||||
},
|
||||
{ id: 2, aniList: "COMPLETED", mal: "completed", simkl: "completed" },
|
||||
{ id: 3, aniList: "DROPPED", mal: "dropped", simkl: "dropped" },
|
||||
{ id: 4, aniList: "PAUSED", mal: "on_hold", simkl: "hold" },
|
||||
{ id: 5, aniList: "REPEATING", mal: "rewatching", simkl: "watching" },
|
||||
];
|
||||
let startingAnilistStatusOption: StatusOption = statusOptions.filter(
|
||||
(option) => currentAniListAnime.data.MediaList.status === option.aniList,
|
||||
)[0];
|
||||
let startedAtDate: Date | null = convertAniListDateToDate(
|
||||
currentAniListAnime.data.MediaList.startedAt,
|
||||
);
|
||||
let completedAtDate: Date | null = convertAniListDateToDate(
|
||||
currentAniListAnime.data.MediaList.completedAt,
|
||||
);
|
||||
|
||||
if (isAniListLoggedIn)
|
||||
AddAnimeServiceToTable({
|
||||
id: `a-${currentAniListAnime.data.MediaList.mediaId}`,
|
||||
title,
|
||||
service: "AniList",
|
||||
progress: currentAniListAnime.data.MediaList.progress,
|
||||
status: currentAniListAnime.data.MediaList.status,
|
||||
startedAt: convertAniListDateToString(
|
||||
currentAniListAnime.data.MediaList.startedAt,
|
||||
),
|
||||
completedAt: convertAniListDateToString(
|
||||
currentAniListAnime.data.MediaList.completedAt,
|
||||
),
|
||||
score: currentAniListAnime.data.MediaList.score,
|
||||
repeat: currentAniListAnime.data.MediaList.repeat,
|
||||
notes: currentAniListAnime.data.MediaList.notes,
|
||||
});
|
||||
|
||||
if (isMalLoggedIn) {
|
||||
let startDate = "";
|
||||
let finishDate = "";
|
||||
if (currentMalAnime.my_list_status.start_date !== "") {
|
||||
const startArray = re.exec(currentMalAnime.my_list_status.start_date);
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(currentMalAnime.my_list_status.finish_date);
|
||||
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: 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({
|
||||
id: `s-${currentSimklAnime.show.ids.simkl}`,
|
||||
title: currentSimklAnime.show.title,
|
||||
service: "Simkl",
|
||||
progress: currentSimklAnime.watched_episodes_count,
|
||||
status: currentSimklAnime.status,
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: currentSimklAnime.user_rating,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
submitting.set(true);
|
||||
let submitData: {
|
||||
rating: number;
|
||||
episodes: number;
|
||||
status: StatusOption;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
repeat: number;
|
||||
notes: string;
|
||||
} = {
|
||||
rating: 0,
|
||||
episodes: 0,
|
||||
status: {
|
||||
id: 0,
|
||||
aniList: "",
|
||||
mal: "",
|
||||
simkl: "",
|
||||
},
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
};
|
||||
const formData = new FormData(e.target);
|
||||
for (let field of formData) {
|
||||
const [key, value] = field;
|
||||
if (key === "rating") {
|
||||
submitData.rating = Number(value) * 2;
|
||||
continue;
|
||||
}
|
||||
if (key === "episodes") {
|
||||
submitData.episodes = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (key === "repeat") {
|
||||
submitData.repeat = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (key === "status") {
|
||||
submitData.status = startingAnilistStatusOption;
|
||||
continue;
|
||||
}
|
||||
submitData[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
isAniListLoggedIn &&
|
||||
currentAniListAnime.data.MediaList.mediaId !== 0
|
||||
) {
|
||||
let body: AniListUpdateVariables = {
|
||||
mediaId: currentAniListAnime.data.MediaList.mediaId,
|
||||
progress: submitData.episodes,
|
||||
status: submitData.status.aniList,
|
||||
score: submitData.rating,
|
||||
repeat: submitData.repeat,
|
||||
notes: submitData.notes,
|
||||
startedAt: convertDateToAniList(startedAtDate),
|
||||
completedAt: convertDateToAniList(completedAtDate),
|
||||
};
|
||||
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;
|
||||
});
|
||||
AddAnimeServiceToTable({
|
||||
id: `a-${currentAniListAnime.data.MediaList.mediaId}`,
|
||||
title,
|
||||
service: "AniList",
|
||||
progress: currentAniListAnime.data.MediaList.progress,
|
||||
status: currentAniListAnime.data.MediaList.status,
|
||||
startedAt: convertAniListDateToString(
|
||||
currentAniListAnime.data.MediaList.startedAt,
|
||||
),
|
||||
completedAt: convertAniListDateToString(
|
||||
currentAniListAnime.data.MediaList.completedAt,
|
||||
),
|
||||
score: currentAniListAnime.data.MediaList.score,
|
||||
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,
|
||||
is_rewatching: submitData.repeat > 0,
|
||||
score: submitData.rating,
|
||||
num_watched_episodes: submitData.episodes,
|
||||
num_times_rewatched: submitData.repeat,
|
||||
comments: submitData.notes,
|
||||
};
|
||||
|
||||
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.score = malAnimeReturn.score;
|
||||
value.my_list_status.num_episodes_watched =
|
||||
malAnimeReturn.num_episodes_watched;
|
||||
value.my_list_status.num_times_rewatched =
|
||||
malAnimeReturn.num_times_rewatched;
|
||||
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,
|
||||
);
|
||||
startDate = `${startArray[2]}-${startArray[3]}-${startArray[1]}`;
|
||||
}
|
||||
if (currentMalAnime.my_list_status.finish_date !== "") {
|
||||
const finishArray = re.exec(
|
||||
currentMalAnime.my_list_status.finish_date,
|
||||
);
|
||||
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: startDate,
|
||||
completedAt: finishDate,
|
||||
score: currentMalAnime.my_list_status.score,
|
||||
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 App.SimklSyncEpisodes(currentSimklAnime, submitData.episodes).then(
|
||||
(value: SimklAnime) => {
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${value.show.ids.simkl}`,
|
||||
title: value.show.title,
|
||||
service: "Simkl",
|
||||
progress: value.watched_episodes_count,
|
||||
status: value.status,
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: value.user_rating,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
});
|
||||
simklAnime.update((newValue) => {
|
||||
newValue = value;
|
||||
return newValue;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (currentSimklAnime.user_rating !== submitData.rating) {
|
||||
await App.SimklSyncRating(currentSimklAnime, submitData.rating).then(
|
||||
(value) => {
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${value.show.ids.simkl}`,
|
||||
title: value.show.title,
|
||||
service: "Simkl",
|
||||
progress: value.watched_episodes_count,
|
||||
status: value.status,
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: value.user_rating,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
});
|
||||
simklAnime.update((newValue) => {
|
||||
newValue = value;
|
||||
return newValue;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (currentSimklAnime.status !== submitData.status.simkl) {
|
||||
await App.SimklSyncStatus(
|
||||
currentSimklAnime,
|
||||
submitData.status.simkl,
|
||||
).then((value) => {
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${value.show.ids.simkl}`,
|
||||
title: value.show.title,
|
||||
service: "Simkl",
|
||||
progress: value.watched_episodes_count,
|
||||
status: value.status,
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: value.user_rating,
|
||||
repeat: 0,
|
||||
notes: "",
|
||||
});
|
||||
simklAnime.update((newValue) => {
|
||||
newValue = value;
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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 App.AniListDeleteEntry(currentAniListAnime.data.MediaList.id);
|
||||
AddAnimeServiceToTable({
|
||||
id: `a-${currentAniListAnime.data.MediaList.mediaId}`,
|
||||
title,
|
||||
service: "AniList",
|
||||
progress: 0,
|
||||
status: "",
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: 0,
|
||||
repeat: 0,
|
||||
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 App.DeleteMyAnimeListEntry(currentMalAnime.id);
|
||||
AddAnimeServiceToTable({
|
||||
id: `m-${currentMalAnime.id}`,
|
||||
title: currentMalAnime.title,
|
||||
service: "MyAnimeList",
|
||||
progress: 0,
|
||||
status: "",
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: 0,
|
||||
repeat: 0,
|
||||
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 App.SimklSyncRemove(currentSimklAnime);
|
||||
AddAnimeServiceToTable({
|
||||
id: `s-${currentSimklAnime.show.ids.simkl}`,
|
||||
title: currentSimklAnime.show.title,
|
||||
service: "Simkl",
|
||||
progress: 0,
|
||||
status: "",
|
||||
startedAt: "",
|
||||
completedAt: "",
|
||||
score: 0,
|
||||
repeat: 0,
|
||||
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);
|
||||
};
|
||||
|
||||
let max = 999;
|
||||
|
||||
if (currentAniListAnime.data.MediaList.media.episodes !== 0) {
|
||||
max = currentAniListAnime.data.MediaList.media.episodes;
|
||||
}
|
||||
if (
|
||||
currentAniListAnime.data.MediaList.media.episodes === 0 &&
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode !== 0
|
||||
) {
|
||||
max =
|
||||
currentAniListAnime.data.MediaList.media.nextAiringEpisode.episode - 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="container pt-3 pb-10">
|
||||
<h1 class="text-white font-bold text-left text-xl pb-3">
|
||||
{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">
|
||||
<img
|
||||
class="rounded-lg"
|
||||
src={currentAniListAnime.data.MediaList.media.coverImage.large}
|
||||
alt="{title} Cover Image"
|
||||
/>
|
||||
<Rating bind:score={currentAniListAnime.data.MediaList.score} />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-8">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:pl-10 md:pr-10 pt-5 pb-5 justify-center md:gap-x-24 lg:gap-x-56"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="episodes"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Episode Progress</label
|
||||
>
|
||||
<div class="relative flex items-center max-w-[8rem]">
|
||||
<button
|
||||
type="button"
|
||||
id="decrement-button"
|
||||
data-input-counter-decrement="quantity-input"
|
||||
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-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"
|
||||
name="episodes"
|
||||
min="0"
|
||||
{max}
|
||||
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.progress >
|
||||
currentAniListAnime.data.MediaList.media.episodes) ||
|
||||
(currentAniListAnime.data.MediaList.media.nextAiringEpisode
|
||||
.episode > 0 &&
|
||||
currentAniListAnime.data.MediaList.progress >
|
||||
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}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="increment-button"
|
||||
data-input-counter-increment="quantity-input"
|
||||
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-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>
|
||||
<div>
|
||||
/ {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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="status"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Status</label
|
||||
>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="border text-sm rounded-lg
|
||||
block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400
|
||||
text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
bind:value={startingAnilistStatusOption}
|
||||
>
|
||||
{#each statusOptions as option}
|
||||
<option value={option}>{option.aniList}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="startedAt"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Date Started</label
|
||||
>
|
||||
<Datepicker
|
||||
bind:value={startedAtDate}
|
||||
color="slate"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
showActionButtons
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="completedAt"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Date Completed</label
|
||||
>
|
||||
<Datepicker
|
||||
bind:value={completedAtDate}
|
||||
color="slate"
|
||||
dateFormat={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
showActionButtons
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="repeat"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Rewatched</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="repeat"
|
||||
min="0"
|
||||
id="repeat"
|
||||
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}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:pl-10 md:pr-10 pt-5 pb-5 justify-center"
|
||||
>
|
||||
<div class="w-full">
|
||||
<label
|
||||
for="notes"
|
||||
class="text-left block mb-2 text-sm font-medium text-white"
|
||||
>Your notes</label
|
||||
>
|
||||
<textarea
|
||||
id="notes"
|
||||
rows="3"
|
||||
name="notes"
|
||||
class="block p-2.5 w-full text-sm rounded-lg border bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Write your thoughts here..."
|
||||
bind:value={currentAniListAnime.data.MediaList.notes}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
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
|
||||
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
|
||||
hover:border-gray-600"
|
||||
on:click={async () => {
|
||||
await CheckIfAniListLoggedInAndLoadWatchList();
|
||||
return push("/");
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</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 class="rounded border border-gray-700 p-2 mt-2">
|
||||
{@html currentAniListAnime.data.MediaList.media.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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";
|
||||
|
||||
//when adding sort here is code { sort: addSortBy() }
|
||||
const table = createTable(tableItems, { sort: addSortBy() });
|
||||
|
||||
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",
|
||||
}),
|
||||
]);
|
||||
|
||||
//add pluginStates when add sort back
|
||||
const { headerRows, rows, tableAttrs, tableBodyAttrs } =
|
||||
table.createViewModel(columns);
|
||||
</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}
|
||||
>
|
||||
<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
|
||||
>
|
||||
<th
|
||||
{...attrs}
|
||||
on:click={props.sort.toggle}
|
||||
class:sorted={props.sort.order !==
|
||||
undefined}
|
||||
class="px-6 py-3"
|
||||
>
|
||||
<div>
|
||||
<Render of={cell.render()} />
|
||||
{#if props.sort.order === "asc"}
|
||||
⬇️
|
||||
{:else if props.sort.order === "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()} />
|
||||
</td>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tr>
|
||||
</Subscribe>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "flowbite-svelte";
|
||||
import type { AniListUser } from "../anilist/types/AniListTypes";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListUser,
|
||||
malUser,
|
||||
simklUser,
|
||||
malLoggedIn,
|
||||
simklLoggedIn,
|
||||
loginToAniList,
|
||||
loginToMAL,
|
||||
loginToSimkl,
|
||||
logoutOfAniList,
|
||||
logoutOfMAL,
|
||||
logoutOfSimkl,
|
||||
serviceLoggingIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import {Application} from "@wailsio/runtime";
|
||||
import type { MyAnimeListUser } from "../mal/types/MALTypes";
|
||||
import type { SimklUser } from "../simkl/types/simklTypes";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
let currentAniListUser: AniListUser;
|
||||
let currentMALUser: MyAnimeListUser;
|
||||
let currentSimklUser: SimklUser;
|
||||
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));
|
||||
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");
|
||||
dropdown.classList.toggle("hidden");
|
||||
|
||||
if (!dropdown.classList.contains("hidden")) {
|
||||
document.addEventListener("click", clickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(event: Event): void {
|
||||
let dropdown = document.querySelector("#userDropdown");
|
||||
let toggleBtn = document.querySelector("#userDropdownButton");
|
||||
|
||||
if (
|
||||
!dropdown.contains(event.target as Node) &&
|
||||
!toggleBtn.contains(event.target as Node)
|
||||
) {
|
||||
dropdown.classList.add("hidden");
|
||||
document.removeEventListener("click", clickOutside);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button id="userDropdownButton" on:click={dropdownUser}>
|
||||
{#if isAniListLoggedIn}
|
||||
<Avatar
|
||||
src={currentAniListUser.data.Viewer.avatar.medium}
|
||||
class="cursor-pointer"
|
||||
dot={isAniListLoggedIn && isMALLoggedIn && isSimklLoggedIn
|
||||
? { color: "green" }
|
||||
: { color: "yellow" }}
|
||||
/>
|
||||
{:else}
|
||||
<Avatar class="cursor-pointer" dot={{ color: "red" }} />
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
id="userDropdown"
|
||||
class="absolute hidden right-0 2xl:left-1/2 2xl:-translate-x-1/2 z-10 divide-y rounded-lg shadow w-44 bg-gray-700 divide-gray-600"
|
||||
>
|
||||
<div class="px-4 py-3 text-sm text-white">
|
||||
{#if isAniListLoggedIn}
|
||||
<div>{currentAniListUser.data.Viewer.name}</div>
|
||||
{:else}
|
||||
<div>You are not logged into AniList</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ul
|
||||
class="py-2 text-sm text-gray-200"
|
||||
aria-labelledby="dropdownUserAvatarButton"
|
||||
>
|
||||
{#if isAniListLoggedIn}
|
||||
<li>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
{#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}
|
||||
<li>
|
||||
<button
|
||||
on:click={logoutOfMAL}
|
||||
class="block px-4 py-2 w-full hover:bg-gray-600 truncate bg-blue-800 hover:text-white"
|
||||
>
|
||||
<span class="maple-font text-lg text-blue-200 mr-4">M</span>Logout {currentMALUser.name}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
{#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}
|
||||
<li>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
{#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={() => {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,481 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Button } 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: Button["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: Button["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 "slate":
|
||||
return "focus:ring-2 focus:ring-slate-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getRangeBackgroundClass(color: Button["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 "slate":
|
||||
return "bg-slate-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
|
||||
on:click={() => 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
|
||||
on:click={() => 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)
|
||||
: ''}"
|
||||
on:click={() => handleDaySelect(day)}
|
||||
on:keydown={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 on:click={handleToday} {color} size="sm"
|
||||
>Today</Button
|
||||
>
|
||||
<Button on:click={handleClear} color="red" size="sm"
|
||||
>Clear</Button
|
||||
>
|
||||
<Button on:click={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,73 @@
|
||||
<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>
|
||||
<div slot="footer" class="flex gap-3 justify-end">
|
||||
{#if $apiError.canRetry}
|
||||
<Button on:click={handleRetry} class="bg-blue-600 hover:bg-blue-700">
|
||||
Retry Connection
|
||||
</Button>
|
||||
{/if}
|
||||
<Button on:click={handleDismiss} color="alternative">Dismiss</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import Search from "./Search.svelte";
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
loginToAniList,
|
||||
loginToMAL,
|
||||
loginToSimkl,
|
||||
malLoggedIn,
|
||||
simklLoggedIn,
|
||||
serviceLoggingIn,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
import AvatarMenu from "./AvatarMenu.svelte";
|
||||
import logo from "../assets/images/AniTrackLogo.svg";
|
||||
import { link } from "svelte-spa-router";
|
||||
|
||||
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));
|
||||
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="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<li>
|
||||
{#if !isAniListLoggedIn}
|
||||
<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
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListSort,
|
||||
aniListWatchlist,
|
||||
animePerPage,
|
||||
watchListPage,
|
||||
} from "../helperModules/GlobalVariablesAndHelperFunctions.svelte";
|
||||
|
||||
import type { AniListCurrentUserWatchList } from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
|
||||
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));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
const perPageOptions = [10, 20, 50];
|
||||
|
||||
function ChangeWatchListPage(newPage: number) {
|
||||
App.GetAniListUserWatchingList(newPage, perPage, sort).then((result) => {
|
||||
watchListPage.set(newPage);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
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: Event & { currentTarget: HTMLSelectElement },
|
||||
): void {
|
||||
App.GetAniListUserWatchingList(1, Number(e.currentTarget.value), sort).then(
|
||||
(result) => {
|
||||
animePerPage.set(Number(e.currentTarget.value));
|
||||
watchListPage.set(1);
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-8">
|
||||
{#if aniListWatchListLoaded.data.Page.pageInfo.lastPage <= 12}
|
||||
<nav aria-label="Page navigation" class="hidden md:block">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#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 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
|
||||
>
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
<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"
|
||||
>
|
||||
{#each perPageOptions as option}
|
||||
<option value={option}>
|
||||
{option}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{:else}
|
||||
<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={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"
|
||||
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"
|
||||
>
|
||||
<span>Page #</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import StarRatting from "../star-rating/Stars.svelte";
|
||||
|
||||
export let score
|
||||
|
||||
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 = {
|
||||
0: "Not Reviewed",
|
||||
1: "Appalling",
|
||||
2: "Horrible",
|
||||
3: "Very Bad",
|
||||
4: "Bad",
|
||||
5: "Average",
|
||||
6: "Fine",
|
||||
7: "Good",
|
||||
8: "Very Good",
|
||||
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>
|
||||
</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>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import type {AniSearchList} from "../anilist/types/AniListTypes";
|
||||
import {push} from "svelte-spa-router";
|
||||
|
||||
let aniSearch = ""
|
||||
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
|
||||
|
||||
const SLOW_NOTICE_MS = 8000
|
||||
const SEARCH_TIMEOUT_MS = 30000
|
||||
|
||||
function openDropdown(): void {
|
||||
dropdownOpen = true
|
||||
}
|
||||
|
||||
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">
|
||||
<label for="anime-search" class="mb-2 text-sm font-medium sr-only text-white">Find
|
||||
Anime</label>
|
||||
<div class="relative w-full">
|
||||
<input type="search" id="anime-search" bind:value={aniSearch}
|
||||
class="rounded-s-lg block p-2.5 w-full z-20 text-sm rounded-e-lg bg-gray-700 border-s-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-blue-500"
|
||||
placeholder="Search for Anime"
|
||||
on:keypress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
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 disabled:opacity-50"
|
||||
disabled={isSearching}
|
||||
on:click={() => {
|
||||
runAniListSearch()
|
||||
}}>
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 (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={() => goToAnime(media.id)}
|
||||
>
|
||||
<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={() => goToAnime(media.id)} >{displayTitle(media)}</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{: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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div id="spinner" role="status" class="fixed -translate-x-1/2 -translate-y-1/2 top-2/4 left-1/2">
|
||||
<svg aria-hidden="true"
|
||||
class="inline w-16 h-16 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
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="currentColor"/>
|
||||
<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="currentFill"/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
aniListLoggedIn,
|
||||
aniListWatchlist,
|
||||
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 { CheckIfAniListLoggedInAndLoadWatchList } from "../helperModules/CheckIfAniListLoggedInAndLoadWatchList.svelte";
|
||||
import Sort from "../helperComponents/Sort.svelte";
|
||||
import RefreshWatchListButton from "./RefreshWatchListButton.svelte";
|
||||
|
||||
let isAniListLoggedIn: boolean;
|
||||
let aniListWatchListLoaded: AniListCurrentUserWatchList;
|
||||
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{#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 class="flex flex-col items-center group">
|
||||
<button
|
||||
on:click={() => {
|
||||
push(`#/anime/${media.media.id}`);
|
||||
// loading.set(true)
|
||||
// GetAniListSingleItem(media.media.id, true).then(() => {
|
||||
// loading.set(false)
|
||||
//
|
||||
// })
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
</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>
|
||||
{#if media.media.episodes > 0}
|
||||
<p class="mt-1 text-lg font-medium text-white-900">
|
||||
Total Episodes: {media.media.episodes}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {Browser} from "@wailsio/runtime";
|
||||
|
||||
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-");
|
||||
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}`;
|
||||
</script>
|
||||
|
||||
{#if url.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="underline underline-offset-2 px-4 py-1"
|
||||
on:click={() => Browser.OpenURL(url)}>{newId}</button
|
||||
>
|
||||
{:else}
|
||||
{id}
|
||||
{/if}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {AniListGetSingleAnime} from "../anilist/types/AniListCurrentUserWatchListType";
|
||||
|
||||
export const AniListGetSingleAnimeDefaultData: AniListGetSingleAnime = {
|
||||
data: {
|
||||
MediaList: {
|
||||
id: 0,
|
||||
mediaId: 0,
|
||||
userId: 0,
|
||||
media: {
|
||||
id: 0,
|
||||
idMal: 0,
|
||||
title: {
|
||||
romaji: "",
|
||||
english: "",
|
||||
native: "",
|
||||
},
|
||||
description: "",
|
||||
coverImage: {
|
||||
large: "",
|
||||
},
|
||||
season: "",
|
||||
seasonYear: 0,
|
||||
status: "",
|
||||
episodes: 0,
|
||||
nextAiringEpisode: {
|
||||
airingAt: 0,
|
||||
timeUntilAiring: 0,
|
||||
episode: 0,
|
||||
}
|
||||
},
|
||||
status: "",
|
||||
startedAt: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
completedAt: {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day: 0,
|
||||
},
|
||||
notes: "",
|
||||
progress: 0,
|
||||
score: 0,
|
||||
repeat: 0,
|
||||
user: {
|
||||
id: 0,
|
||||
name: "",
|
||||
avatar: {
|
||||
large: "",
|
||||
medium: "",
|
||||
},
|
||||
statistics: {
|
||||
anime: {
|
||||
count: 0,
|
||||
statuses: [{
|
||||
status: "",
|
||||
count: 0,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
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 };
|
||||
@@ -0,0 +1,18 @@
|
||||
import Spinner from '../helperComponents/Spinner.svelte';
|
||||
|
||||
export default (node: any, loading: any) => {
|
||||
let Spin: any
|
||||
loading.subscribe((loading: any) => {
|
||||
if(loading){
|
||||
Spin = new Spinner({
|
||||
target: node,
|
||||
intro: true
|
||||
})
|
||||
} else {
|
||||
if(Spin){
|
||||
Spin?.$destroy?.()
|
||||
Spin = undefined;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {TableItem} from "../helperTypes/TableTypes";
|
||||
import { tableItems } from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
|
||||
export function AddAnimeServiceToTable(animeItem: TableItem) {
|
||||
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
|
||||
}
|
||||
}
|
||||
table.push(animeItem)
|
||||
}
|
||||
return table
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" context="module">
|
||||
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 sort: string;
|
||||
|
||||
aniListPrimary.subscribe((value) => (isAniListPrimary = value));
|
||||
watchListPage.subscribe((value) => (page = value));
|
||||
animePerPage.subscribe((value) => (perPage = value));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
export const LoadAniListUser = async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
serviceLoggingIn.update((s) => [...s, "anilist"]);
|
||||
try {
|
||||
const loggedIn = await App.CheckIfAniListLoggedIn();
|
||||
if (loggedIn) {
|
||||
await LoadAniListUser();
|
||||
if (isAniListPrimary) await LoadAniListWatchList();
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" context="module">
|
||||
import {App} from "../../bindings/AniTrack";
|
||||
import {malUser, malPrimary, malWatchList, malLoggedIn, serviceLoggingIn} from "./GlobalVariablesAndHelperFunctions.svelte"
|
||||
import type { MyAnimeListUser } from "../mal/types/MALTypes";
|
||||
|
||||
let isMalPrimary: boolean
|
||||
malPrimary.subscribe(value => isMalPrimary = value)
|
||||
|
||||
export const CheckIfMALLoggedInAndSetUser = async () => {
|
||||
serviceLoggingIn.update((s) => [...s, "mal"])
|
||||
await App.CheckIfMyAnimeListLoggedIn().then(loggedIn => {
|
||||
if (loggedIn) {
|
||||
App.GetMyAnimeListLoggedInUser().then(user => {
|
||||
if (!user.name) {
|
||||
malUser.set({} as MyAnimeListUser)
|
||||
malLoggedIn.set(false)
|
||||
return
|
||||
}
|
||||
malUser.set(user)
|
||||
if (isMalPrimary) {
|
||||
App.GetMyAnimeList(1000).then(watchList => {
|
||||
malWatchList.set(watchList)
|
||||
malLoggedIn.set(loggedIn)
|
||||
})
|
||||
} else {
|
||||
malLoggedIn.set(loggedIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
serviceLoggingIn.update((s) => s.filter(item => item !== "mal"))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" context="module">
|
||||
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 () => {
|
||||
serviceLoggingIn.update((s) => [...s, "simkl"])
|
||||
await App.CheckIfSimklLoggedIn().then(loggedIn => {
|
||||
if (loggedIn) {
|
||||
App.GetSimklLoggedInUser().then(user => {
|
||||
if (Object.keys(user).length === 0) {
|
||||
simklLoggedIn.set(false)
|
||||
} else {
|
||||
simklUser.set(user)
|
||||
if (isSimklPrimary) {
|
||||
App.SimklGetUserWatchlist().then(result => {
|
||||
simklWatchList.set(result)
|
||||
simklLoggedIn.set(loggedIn)
|
||||
})
|
||||
} else {
|
||||
simklLoggedIn.set(loggedIn)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
serviceLoggingIn.update((s) => s.filter(item => item !== "simkl"))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts" context="module">
|
||||
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";
|
||||
|
||||
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 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 watchlistNeedsRefresh = writable(false);
|
||||
export let aniListSort = writable(MediaListSort.UpdatedTimeDesc);
|
||||
|
||||
export let watchListPage = writable(1);
|
||||
export let animePerPage = writable(20);
|
||||
|
||||
let isAniListPrimary: boolean;
|
||||
let page: number;
|
||||
let perPage: number;
|
||||
let sort: string;
|
||||
let aniWatchlist: AniListCurrentUserWatchList;
|
||||
let currentAniListAnime: AniListGetSingleAnime;
|
||||
|
||||
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));
|
||||
aniListSort.subscribe((value) => (sort = value));
|
||||
|
||||
export interface ApiError {
|
||||
service: string;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
canRetry: boolean;
|
||||
}
|
||||
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 App.GetMyAnimeListAnime(
|
||||
currentAniListAnime.data.MediaList.media.idMal,
|
||||
).then((malResult) => {
|
||||
malAnime.set(malResult);
|
||||
});
|
||||
}
|
||||
if (isSimklLoggedIn) {
|
||||
await App.SimklSearch(currentAniListAnime.data.MediaList).then(
|
||||
(value: SimklAnime) => {
|
||||
simklAnime.set(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
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 {
|
||||
setServiceLoggingIn("simkl", true);
|
||||
App.GetSimklLoggedInUser()
|
||||
.then((user) => {
|
||||
if (Object.keys(user).length === 0) {
|
||||
simklLoggedIn.set(false);
|
||||
} else {
|
||||
simklUser.set(user);
|
||||
App.SimklGetUserWatchlist().then((result) => {
|
||||
simklWatchList.set(result);
|
||||
simklLoggedIn.set(true);
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => setServiceLoggingIn("simkl", false));
|
||||
}
|
||||
|
||||
export function loginToAniList(): void {
|
||||
setServiceLoggingIn("anilist", true);
|
||||
App.GetAniListLoggedInUser()
|
||||
.then((result) => {
|
||||
aniListUser.set(result);
|
||||
if (isAniListPrimary) {
|
||||
App.GetAniListUserWatchingList(page, perPage, sort).then((result) => {
|
||||
aniListWatchlist.set(result);
|
||||
aniListLoggedIn.set(true);
|
||||
});
|
||||
} else {
|
||||
aniListLoggedIn.set(true);
|
||||
}
|
||||
})
|
||||
.finally(() => setServiceLoggingIn("anilist", false));
|
||||
}
|
||||
|
||||
export function loginToMAL(): void {
|
||||
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 {
|
||||
App.LogoutAniList().then((result) => {
|
||||
console.log(result);
|
||||
if (Object.keys(aniWatchlist).length !== 0) {
|
||||
aniListWatchlist.set({} as AniListCurrentUserWatchList);
|
||||
}
|
||||
aniListUser.set({} as AniListUser);
|
||||
aniListLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutOfMAL(): void {
|
||||
App.LogoutMyAnimeList().then((result) => {
|
||||
console.log(result);
|
||||
malUser.set({} as MyAnimeListUser);
|
||||
malLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutOfSimkl(): void {
|
||||
App.LogoutSimkl().then((result) => {
|
||||
console.log(result);
|
||||
simklUser.set({} as SimklUser);
|
||||
simklLoggedIn.set(false);
|
||||
});
|
||||
}
|
||||
</script>
|
||||