Compare commits

...

80 Commits
1.0.1 ... main

Author SHA1 Message Date
Rosalina d5a31fdf85
add stage record query 2023-06-03 15:01:14 -04:00
Rosalina fcfa346969
Merge remote-tracking branch 'upstream/main' 2023-06-03 14:57:37 -04:00
imspace cdaf7f3f1f feat: update `WEB_VIEW_VERSION` 2023-06-03 23:33:06 +08:00
imspace 116243eac8 feat: add support for Challenges (#72) 2023-06-03 23:31:43 +08:00
spacemeowx2 3a599717de chore: bump version (0.4.0) 2023-06-02 17:16:41 +08:00
spacemeowx2 e1b3703401 feat: update `callImink`
https://github.com/samuelthomas2774/nxapi/discussions/10#discussioncomment-5995498
https://github.com/frozenpandaman/s3s/issues/125
2023-06-02 17:13:31 +08:00
spacemeowx2 812e7ab611 feat: update VersionData 2023-06-02 16:55:04 +08:00
spacemeowx2 858f9a3bcc chore: update gui deps 2023-06-01 01:47:27 +08:00
spacemeowx2 f026571844 chore: bump version(v0.3.6) 2023-06-01 01:47:27 +08:00
spacemeowx2 f81232ce86 feat: update WEB_VIEW_VERSION and Queries 2023-06-01 01:47:27 +08:00
spacemeowx2 c2f42863b8 feat: add Queries fetch in update-constant 2023-06-01 01:47:27 +08:00
hasefumi23 feb486e775
fix(ci): use Deno.Command instead of Deno.run to fix Deno fmt error (#70) 2023-05-29 21:44:58 +08:00
Rosalina 5fd0dbbd14
Merge remote-tracking branch 'upstream/main' 2023-05-24 23:41:56 -04:00
Rosalina f04f88a02a
commit splashcat stuff 2023-05-24 23:41:09 -04:00
spacemeowx2 e78d3cd1cf chore: update NSOAPP_VERSION 2023-05-23 21:01:55 +08:00
imspace c524887fc6 style: fix fmt 2023-04-27 00:21:56 +08:00
imspace 234356d39c fix: wrong ability keys in some languages (0.3.5) 2023-04-27 00:20:08 +08:00
spacemeowx2 2af6b0628c build: bump version to 0.3.4 2023-04-24 18:53:57 +08:00
Kenta Kase f42892c7b1
fix: GUI ja translation about VS and Salmon Run (#68) 2023-04-22 01:09:47 +08:00
spacemeowx2 e1737f7c30 ci: enable updater only when tag 2023-04-21 22:09:00 +08:00
Rosalina 32d4586cce
fmt 2023-04-16 23:46:01 -04:00
Rosalina 261b19c40b
Merge remote-tracking branch 'upstream/main' 2023-04-16 15:06:48 -04:00
imspace c4242e9f66 feat: add eggstra work mode 2023-04-17 00:34:22 +08:00
Rosalina f3085af9aa
Merge remote-tracking branch 'upstream/main' 2023-04-12 20:05:15 -04:00
imspace 0acc59b918 build: gui 0.2.1 2023-04-10 01:27:56 +08:00
Rosalina 61728e6838
bump version 2023-03-24 19:30:04 -04:00
Rosalina f66bba53d4
Merge from upstream and bump version. 2023-03-24 19:29:35 -04:00
spacemeowx2 147524e44e style: deno fmt 2023-03-25 00:49:50 +08:00
spacemeowx2 1c83368257 feat: update `WEB_VIEW_VERSION` and query hashes 2023-03-24 23:27:16 +08:00
imspace ccd2439420 feat: update wvv 2023-03-22 16:39:28 +08:00
imspace 8f62e777ec build: bump version 2023-03-13 12:19:40 +08:00
imspace afa239df64 feat: add languages 2023-03-13 12:10:06 +08:00
imspace 772af8da0d style: fmt scripts 2023-03-13 12:10:06 +08:00
imspace 63635cda07
ci: add gui check when push (#63)
* build: regenerate deno.lock

* build: add gui check when push

also disabled fail-fast

* build: fix pnpm

* style: fmt json

* env

* ci: change exp

* ci: change key

* build: compile current platform

* ci: build

* ci: add privkey

* feat: compile with sha256

* ci: cache on failure
2023-03-10 05:04:28 +08:00
imspace 3fdc66c384 feat: refetch token before open splatnet 2023-03-09 22:08:42 +08:00
imspace 4c458861c6 feat: css patch for splatnet3 2023-03-09 20:49:37 +08:00
spacemeowx2 fb14cf5d71 fix(gui): color and layout, disable file export 2023-03-09 19:33:32 +08:00
imspace 85a3241b66 feat: add make-update script 2023-03-09 18:53:42 +08:00
imspace 6d5b0f6bb2 feat: open Splatnet3 in app 2023-03-09 16:34:26 +08:00
imspace f8af485c90 fix: productName 2023-03-09 16:34:26 +08:00
imspace 989fd4b30b feat: add useSubField hook 2023-03-09 16:34:26 +08:00
imspace e3fea3f815 build: gui 0.1.0 2023-03-09 16:34:26 +08:00
imspace 043bcb3ae4 feat: add log display 2023-03-09 16:34:26 +08:00
imspace 77c621b499 feat: add basic export 2023-03-09 16:34:26 +08:00
imspace 16a4546710 fix: i18n-backend 2023-03-09 16:34:26 +08:00
imspace 7532fde754 refactor: extract components 2023-03-09 16:34:26 +08:00
imspace f5c565396d feat: add i18next http backend 2023-03-09 16:34:26 +08:00
imspace 3c06216f34 feat: add i18n-backend 2023-03-09 16:34:26 +08:00
spacemeowx2 8a913e35aa feat: add CheckUpdate 2023-03-09 16:34:26 +08:00
spacemeowx2 3b36a6b7d8 build: add release actions 2023-03-09 16:34:26 +08:00
imspace cffc2a98a5 feat: add stat.ink link, extract constant 2023-03-09 16:34:26 +08:00
imspace 90aeaefae7 feat: better window size 2023-03-09 16:34:26 +08:00
imspace 27729dacc6 feat: add sesionToken field in settings 2023-03-09 16:34:26 +08:00
imspace 4a081e1237 feat: implement settings page 2023-03-09 16:34:26 +08:00
spacemeowx2 a98e02f15e feat: use react-router-dom's Link 2023-03-09 16:34:26 +08:00
spacemeowx2 830456ea62 feat: add config 2023-03-09 16:34:26 +08:00
spacemeowx2 01c0478a15 build: add eslint, i18n, daisyui 2023-03-09 16:34:26 +08:00
imspace 5f9ee2e3fd build: update ci deno version 2023-03-09 16:34:26 +08:00
imspace 0cb33a7527 fix: fmt 2023-03-09 16:34:26 +08:00
imspace d9992d1644 fix: sessionToken not get 2023-03-09 16:34:26 +08:00
imspace 7da5de6c1e feat: hacky tauri login 2023-03-09 16:34:26 +08:00
imspace 80c0e26b3e feat: add jsonrpc interface 2023-03-09 16:34:26 +08:00
imspace 8df1224ea9 feat: add jsonrpc 2023-03-09 16:34:26 +08:00
imspace 1890e3a2f4 build: add favicon 2023-03-09 16:34:26 +08:00
imspace 64e11aa389 feat: run daemon using deno when dev 2023-03-09 16:34:26 +08:00
imspace 259aa852d8 feat: add ipc 2023-03-09 16:34:26 +08:00
imspace abb46979da refactor: show after render 2023-03-09 16:34:26 +08:00
imspace 586df102a9 refactor: rewrite compile script in ts 2023-03-09 16:34:26 +08:00
spacemeowx2 a39e7df069 feat: add Loading component 2023-03-09 16:34:26 +08:00
spacemeowx2 beee24e367 build: add -A to `deno compile` 2023-03-09 16:34:26 +08:00
spacemeowx2 b76faea133 build: add baseUrl 2023-03-09 16:34:26 +08:00
spacemeowx2 401fd35bd0 refactor: move css 2023-03-09 16:34:26 +08:00
spacemeowx2 bafec72d37 build: bundle deno executable 2023-03-09 16:34:26 +08:00
spacemeowx2 73eeb73680 build: add tailwindcss 2023-03-09 16:34:26 +08:00
spacemeowx2 e171609bc7 build: change name and version 2023-03-09 16:34:26 +08:00
spacemeowx2 1e5aeac212 feat: add tauri project 2023-03-09 16:34:26 +08:00
Rosalina 009e87d4ab
add stage exporter 2023-03-07 13:33:59 -05:00
Rosalina 3f31bc7ea9
add stage exporting 2023-03-07 10:09:32 -05:00
Rosalina 93b360d5b2
write fixer script 2023-03-04 19:19:22 -05:00
Rosalina a7fa1541b2
fix types 2023-03-04 19:17:19 -05:00
110 changed files with 13018 additions and 199 deletions

View File

@ -4,9 +4,10 @@ jobs:
build: build:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
deno: [1.x, "1.22.x", canary] deno: [1.x, "1.31.x", canary]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: denoland/setup-deno@v1 - uses: denoland/setup-deno@v1

83
.github/workflows/gui.yaml vendored Normal file
View File

@ -0,0 +1,83 @@
name: GUI
on: [push, pull_request, workflow_dispatch]
jobs:
build:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- uses: denoland/setup-deno@v1
with:
deno-version: 1.x
- uses: pnpm/action-setup@v2
with:
version: 7.29.1
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: "lts/*"
cache: "pnpm"
cache-dependency-path: gui/pnpm-lock.yaml
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./gui/src-tauri -> target"
cache-on-failure: true
- name: Compile s3si.ts
run: deno run -A ./scripts/compile.ts
- name: Install app dependencies
working-directory: ./gui
run: pnpm i
- name: Build the app
uses: tauri-apps/tauri-action@v0
if: ${{ !startsWith(github.ref, 'refs/tags/gui-v') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
projectPath: ./gui
- name: Prepare to release the app
if: ${{ startsWith(github.ref, 'refs/tags/gui-v') }}
run: deno run -A ./gui/scripts/enable-updater.ts
- name: Release the app
uses: tauri-apps/tauri-action@v0
if: ${{ startsWith(github.ref, 'refs/tags/gui-v') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: "s3si.ts v__VERSION__"
releaseBody: "See the assets to download and install this version."
releaseDraft: true
prerelease: false
projectPath: ./gui

2
.gitignore vendored
View File

@ -3,4 +3,4 @@ profile.json
export/ export/
cache/ cache/
.DS_Store .DS_Store
*.json /*.json

View File

@ -1,3 +1,35 @@
## 0.4.1
feat: add support for Challenges
([#72](https://github.com/spacemeowx2/s3si.ts/issues/72))
## 0.4.0
feat: update `callImink`
feat: update VersionData
## 0.3.6
feat: update `WEB_VIEW_VERSION` and query hashes for 4.0.0
## 0.3.5
fix: wrong ability keys in some languages
## 0.3.4
fix(gui): GUI ja translation about VS and Salmon Run
([#68](https://github.com/spacemeowx2/s3si.ts/issues/68))
## 0.3.3
feat: add eggstra work mode
## 0.3.2
feat: update `WEB_VIEW_VERSION` and query hashes
## 0.3.1 ## 0.3.1
feat: use ID for uniform(https://stat.ink/api-info/salmon-uniform3) feat: use ID for uniform(https://stat.ink/api-info/salmon-uniform3)

View File

@ -14,7 +14,20 @@
"cache/", "cache/",
".vscode/", ".vscode/",
".github/", ".github/",
"profile.json" "profile.json",
"gui/"
]
}
},
"lint": {
"files": {
"exclude": [
"export/",
"cache/",
".vscode/",
".github/",
"profile.json",
"gui/"
] ]
} }
} }

View File

@ -31,6 +31,7 @@
"https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24", "https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
"https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", "https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
"https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d", "https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
"https://deno.land/std@0.160.0/streams/conversion.ts": "328afbedee0a7e0c330ac4c7b4c1af569ee53974f970230f6a78f545b93abb9b",
"https://deno.land/std@0.160.0/uuid/_common.ts": "76e1fdfb03aecf733f7b3a5edc900f5734f2433b359fdb1535f8de72873bdb3f", "https://deno.land/std@0.160.0/uuid/_common.ts": "76e1fdfb03aecf733f7b3a5edc900f5734f2433b359fdb1535f8de72873bdb3f",
"https://deno.land/std@0.160.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5", "https://deno.land/std@0.160.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5",
"https://deno.land/std@0.160.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295", "https://deno.land/std@0.160.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295",
@ -69,7 +70,10 @@
"https://deno.land/x/ts_essentials@v9.1.2/mod.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345" "https://deno.land/x/ts_essentials@v9.1.2/mod.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345"
}, },
"npm": { "npm": {
"specifiers": { "mongodb": "mongodb@5.1.0" }, "specifiers": {
"mongodb": "mongodb@5.5.0",
"splatnet3-types": "splatnet3-types@0.2.20230227204004"
},
"packages": { "packages": {
"@types/node@18.14.2": { "@types/node@18.14.2": {
"integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==", "integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==",
@ -90,6 +94,10 @@
"integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==", "integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==",
"dependencies": {} "dependencies": {}
}, },
"bson@5.3.0": {
"integrity": "sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==",
"dependencies": {}
},
"ip@2.0.0": { "ip@2.0.0": {
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dependencies": {} "dependencies": {}
@ -114,13 +122,24 @@
"socks": "socks@2.7.1" "socks": "socks@2.7.1"
} }
}, },
"mongodb@5.5.0": {
"integrity": "sha512-XgrkUgAAdfnZKQfk5AsYL8j7O99WHd4YXPxYxnh8dZxD+ekYWFRA3JktUsBnfg+455Smf75/+asoU/YLwNGoQQ==",
"dependencies": {
"bson": "bson@5.3.0",
"mongodb-connection-string-url": "mongodb-connection-string-url@2.6.0",
"saslprep": "saslprep@1.0.3",
"socks": "socks@2.7.1"
}
},
"punycode@2.3.0": { "punycode@2.3.0": {
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
"dependencies": {} "dependencies": {}
}, },
"saslprep@1.0.3": { "saslprep@1.0.3": {
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"dependencies": { "sparse-bitfield": "sparse-bitfield@3.0.3" } "dependencies": {
"sparse-bitfield": "sparse-bitfield@3.0.3"
}
}, },
"smart-buffer@4.2.0": { "smart-buffer@4.2.0": {
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
@ -135,11 +154,19 @@
}, },
"sparse-bitfield@3.0.3": { "sparse-bitfield@3.0.3": {
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": { "memory-pager": "memory-pager@1.5.0" } "dependencies": {
"memory-pager": "memory-pager@1.5.0"
}
},
"splatnet3-types@0.2.20230227204004": {
"integrity": "sha512-FAY6pbUcrp5O8c49BNXSKxoyM3UlCrRx2AtA9Y3qlvqOLdHqwxtzcdzbk1b1hRam8ZcrxRzE/ii6ESRiPIAnZw==",
"dependencies": {}
}, },
"tr46@3.0.0": { "tr46@3.0.0": {
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dependencies": { "punycode": "punycode@2.3.0" } "dependencies": {
"punycode": "punycode@2.3.0"
}
}, },
"webidl-conversions@7.0.0": { "webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",

View File

@ -14,3 +14,5 @@ export { MultiProgressBar } from "https://deno.land/x/progress@v1.2.8/mod.ts";
export { Mutex } from "https://deno.land/x/semaphore@v1.1.1/mod.ts"; export { Mutex } from "https://deno.land/x/semaphore@v1.1.1/mod.ts";
export type { DeepReadonly } from "https://deno.land/x/ts_essentials@v9.1.2/mod.ts"; export type { DeepReadonly } from "https://deno.land/x/ts_essentials@v9.1.2/mod.ts";
export * as MongoDB from "npm:mongodb"; export * as MongoDB from "npm:mongodb";
export * as splatNet3Types from "npm:splatnet3-types/splatnet3";
export { writeAll } from "https://deno.land/std@0.160.0/streams/conversion.ts";

15
gui/.editorconfig Normal file
View File

@ -0,0 +1,15 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.rs]
indent_size = 4

25
gui/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
binaries/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
gui/README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

23
gui/index.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/src/main.css" rel="stylesheet" />
<title>s3si.ts</title>
</head>
<body>
<div id="root">
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024"
class="animate-spin my-2 mx-auto" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
<path
d="M512 1024c-69.1 0-136.2-13.5-199.3-40.2C251.7 958 197 921 150 874c-47-47-84-101.7-109.8-162.7C13.5 648.2 0 581.1 0 512c0-19.9 16.1-36 36-36s36 16.1 36 36c0 59.4 11.6 117 34.6 171.3 22.2 52.4 53.9 99.5 94.3 139.9 40.4 40.4 87.5 72.2 139.9 94.3C395 940.4 452.6 952 512 952c59.4 0 117-11.6 171.3-34.6 52.4-22.2 99.5-53.9 139.9-94.3 40.4-40.4 72.2-87.5 94.3-139.9C940.4 629 952 571.4 952 512c0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 0 0-94.3-139.9 437.71 437.71 0 0 0-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.2C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3s-13.5 136.2-40.2 199.3C958 772.3 921 827 874 874c-47 47-101.8 83.9-162.7 109.7-63.1 26.8-130.2 40.3-199.3 40.3z">
</path>
</svg>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
gui/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

60
gui/package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "s3si-ts",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint --max-warnings=0 src"
},
"dependencies": {
"@tauri-apps/api": "^1.3.0",
"classnames": "^2.3.2",
"daisyui": "^2.52.0",
"i18next": "^22.5.0",
"i18next-browser-languagedetector": "^7.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.3.1",
"react-icons": "^4.9.0",
"react-router-dom": "^6.11.2",
"react-use": "^17.4.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@types/node": "^20.2.5",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.41.0",
"eslint-config-react-app": "^7.0.1",
"i18next-http-backend": "^2.2.1",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"vite": "^4.3.9",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.2.0"
},
"eslintConfig": {
"extends": "react-app"
},
"pnpm": {
"packageExtensions": {
"eslint-plugin-flowtype": {
"peerDependenciesMeta": {
"@babel/plugin-syntax-flow": {
"optional": true
},
"@babel/plugin-transform-react-jsx": {
"optional": true
}
}
}
}
}
}

5114
gui/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
gui/postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
gui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

5
gui/scripts/deno.json Normal file
View File

@ -0,0 +1,5 @@
{
"tasks": {
"i18n-backend": "deno run -A ./i18n-backend.ts"
}
}

269
gui/scripts/deno.lock Normal file
View File

@ -0,0 +1,269 @@
{
"version": "2",
"remote": {
"https://deno.land/std@0.178.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
"https://deno.land/std@0.178.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3",
"https://deno.land/std@0.178.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
"https://deno.land/std@0.178.0/bytes/bytes_list.ts": "b4cbdfd2c263a13e8a904b12d082f6177ea97d9297274a4be134e989450dfa6a",
"https://deno.land/std@0.178.0/bytes/concat.ts": "d26d6f3d7922e6d663dacfcd357563b7bf4a380ce5b9c2bbe0c8586662f25ce2",
"https://deno.land/std@0.178.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219",
"https://deno.land/std@0.178.0/bytes/ends_with.ts": "4228811ebc71615d27f065c54b5e815ec1972538772b0f413c0efe05245b472e",
"https://deno.land/std@0.178.0/bytes/equals.ts": "b87494ce5442dc786db46f91378100028c402f83a14a2f7bbff6bda7810aefe3",
"https://deno.land/std@0.178.0/bytes/includes_needle.ts": "76a8163126fb2f8bf86fd7f22192c3bb04bf6a20b987a095127c2ca08adf3ba6",
"https://deno.land/std@0.178.0/bytes/index_of_needle.ts": "65c939607df609374c4415598fa4dad04a2f14c4d98cd15775216f0aaf597f24",
"https://deno.land/std@0.178.0/bytes/last_index_of_needle.ts": "7181072883cb4908c6ce8f7a5bb1d96787eef2c2ab3aa94fe4268ab326a53cbf",
"https://deno.land/std@0.178.0/bytes/mod.ts": "e869bba1e7a2e3a9cc6c2d55471888429a544e70a840c087672e656e7ba21815",
"https://deno.land/std@0.178.0/bytes/repeat.ts": "6f5e490d8d72bcbf8d84a6bb04690b9b3eb5822c5a11687bca73a2318a842294",
"https://deno.land/std@0.178.0/bytes/starts_with.ts": "3e607a70c9c09f5140b7a7f17a695221abcc7244d20af3eb47ccbb63f5885135",
"https://deno.land/std@0.178.0/crypto/keystack.ts": "877ab0f19eb7d37ad6495190d3c3e39f58e9c52e0b6a966f82fd6df67ca55f90",
"https://deno.land/std@0.178.0/crypto/timing_safe_equal.ts": "29a3e05afa48277ab4d9588c0b61f4afe542529302af180c866a4f2a09524169",
"https://deno.land/std@0.178.0/encoding/base64.ts": "7de04c2f8aeeb41453b09b186480be90f2ff357613b988e99fabb91d2eeceba1",
"https://deno.land/std@0.178.0/encoding/base64url.ts": "3f1178f6446834457b16bfde8b559c1cd3481727fe384d3385e4a9995dc2d851",
"https://deno.land/std@0.178.0/http/_negotiation/common.ts": "14d1a52427ab258a4b7161cd80e1d8a207b7cc64b46e911780f57ead5f4323c6",
"https://deno.land/std@0.178.0/http/_negotiation/encoding.ts": "ff747d107277c88cb7a6a62a08eeb8d56dad91564cbcccb30694d5dc126dcc53",
"https://deno.land/std@0.178.0/http/_negotiation/language.ts": "7bcddd8db3330bdb7ce4fc00a213c5547c1968139864201efd67ef2d0d51887d",
"https://deno.land/std@0.178.0/http/_negotiation/media_type.ts": "58847517cd549384ad677c0fe89e0a4815be36fe7a303ea63cee5f6a1d7e1692",
"https://deno.land/std@0.178.0/http/cookie_map.ts": "8cf428c03ef17c197196a5c382495cd4adecbfb597f6f8f3cd7e1775c3e79ffd",
"https://deno.land/std@0.178.0/http/http_errors.ts": "57169d9bdf4cda1982a3742693c146ab1bf2cbc88df003b40ac905a30013d4cb",
"https://deno.land/std@0.178.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932",
"https://deno.land/std@0.178.0/http/negotiation.ts": "32761c921afa7847cf767fe81c81785721abccc0db0fc51c7ec2a45868b4ee4a",
"https://deno.land/std@0.178.0/io/buf_reader.ts": "90a7adcb3638d8e1361695cdf844d58bcd97c41711dc6f9f8acc0626ebe097f5",
"https://deno.land/std@0.178.0/io/buf_writer.ts": "2fcaadd9f157970fede6e79c8ea9a58556d8cf3c8a686c3fcaaf3875460092cc",
"https://deno.land/std@0.178.0/io/buffer.ts": "e2b7564f684dad625cab08f5106f33572d325705d19a36822b3272fbdfb8f726",
"https://deno.land/std@0.178.0/io/copy_n.ts": "c498021ce291576a68b5bae9f9d3a27f97644f4af6c1047fb1cff054af19e436",
"https://deno.land/std@0.178.0/io/limited_reader.ts": "d709b5b3113d4cbf934415ba242596e0ecb130e8868fb47197217e09dbb59558",
"https://deno.land/std@0.178.0/io/mod.ts": "2665bcccc1fd6e8627cca167c3e92aaecbd9897556b6f69e6d258070ef63fd9b",
"https://deno.land/std@0.178.0/io/multi_reader.ts": "5f7ef6e987486322b38c72e206b8fbc8916d55a87fbcdc97a8b2596386c28d44",
"https://deno.land/std@0.178.0/io/read_delim.ts": "7e102c66f00a118fa1e1ccd4abb080496f43766686907fd8b9522fdf85443586",
"https://deno.land/std@0.178.0/io/read_int.ts": "7cb8bcdfaf1107586c3bacc583d11c64c060196cb070bb13ae8c2061404f911f",
"https://deno.land/std@0.178.0/io/read_lines.ts": "baee9e35034f2fdfccf63bc24b7e3cb45aa1c1c5de26d178f7bcbc572e87772f",
"https://deno.land/std@0.178.0/io/read_long.ts": "f0aaa420e3da1261c5d33c5e729f09922f3d9fa49f046258d4ff7a00d800c71e",
"https://deno.land/std@0.178.0/io/read_range.ts": "28152daf32e43dd9f7d41d8466852b0d18ad766cd5c4334c91fef6e1b3a74eb5",
"https://deno.land/std@0.178.0/io/read_short.ts": "805cb329574b850b84bf14a92c052c59b5977a492cd780c41df8ad40826c1a20",
"https://deno.land/std@0.178.0/io/read_string_delim.ts": "46eb0c9db3547caf8c759631effa200bbe48924f9b34f41edc627bde36cee52d",
"https://deno.land/std@0.178.0/io/slice_long_to_bytes.ts": "b096472afa3a0dd90fa84584dde7706ed29fc16d48009a581c49368f09fe70f4",
"https://deno.land/std@0.178.0/io/string_reader.ts": "ad9cbecb8509732afcf3d73bb72fa551ec0ccc34f9b8127826247f0190753a65",
"https://deno.land/std@0.178.0/io/string_writer.ts": "8a03c5858c24965a54c6538bed15f32a7c72f5704a12bda56f83a40e28e5433e",
"https://deno.land/std@0.178.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570",
"https://deno.land/std@0.178.0/media_types/_util.ts": "916efbd30b6148a716f110e67a4db29d6949bf4048997b754415dd7e42c52378",
"https://deno.land/std@0.178.0/media_types/content_type.ts": "c682589a0aeb016bfed355cc1ed6fbb3ead2ea48fc0000ac5de6a5730613ad1c",
"https://deno.land/std@0.178.0/media_types/extension.ts": "7a4ef2813d7182f724a941f38161525996e4a67abc3cf6a0f9bc2168d73a0f0e",
"https://deno.land/std@0.178.0/media_types/extensions_by_type.ts": "4358023feac696e6e9d49c0f1e76a859f03ca254df57812f31f8536890c3a443",
"https://deno.land/std@0.178.0/media_types/format_media_type.ts": "1e35e16562e5c417401ffc388a9f8f421f97f0ee06259cbe990c51bae4e6c7a8",
"https://deno.land/std@0.178.0/media_types/get_charset.ts": "8be15a1fd31a545736b91ace56d0e4c66ea0d7b3fdc5c90760e8202e7b4b1fad",
"https://deno.land/std@0.178.0/media_types/mod.ts": "d3f0b99f85053bc0b98ecc24eaa3546dfa09b856dc0bbaf60d8956d2cdd710c8",
"https://deno.land/std@0.178.0/media_types/parse_media_type.ts": "bed260d868ea271445ae41d748e7afed9b5a7f407d2777ead08cecf73e9278de",
"https://deno.land/std@0.178.0/media_types/type_by_extension.ts": "6076a7fc63181d70f92ec582fdea2c927eb2cfc7f9c9bee9d6add2aca86f2355",
"https://deno.land/std@0.178.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586",
"https://deno.land/std@0.178.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
"https://deno.land/std@0.178.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
"https://deno.land/std@0.178.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0",
"https://deno.land/std@0.178.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000",
"https://deno.land/std@0.178.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1",
"https://deno.land/std@0.178.0/path/mod.ts": "4b83694ac500d7d31b0cdafc927080a53dc0c3027eb2895790fb155082b0d232",
"https://deno.land/std@0.178.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d",
"https://deno.land/std@0.178.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1",
"https://deno.land/std@0.178.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba",
"https://deno.land/std@0.178.0/streams/_common.ts": "f45cba84f0d813de3326466095539602364a9ba521f804cc758f7a475cda692d",
"https://deno.land/std@0.178.0/streams/buffer.ts": "7e7676c29e0e72f6821c3b5fede2540886a216bb91c849bb5db20bb82a01d8a1",
"https://deno.land/std@0.178.0/streams/byte_slice_stream.ts": "cf5785b0d9223ebb51fcf6679d881dfaf614c3b288fb4577b511b6f7801a01aa",
"https://deno.land/std@0.178.0/streams/copy.ts": "de0de21701d8cceba84ca01d9731c77f4b3597bb9de6a1b08f32250353feeae8",
"https://deno.land/std@0.178.0/streams/delimiter_stream.ts": "de8f822a1c394cdb805a97e364400955cd1757cc282d932b4040a0f78fba3b5f",
"https://deno.land/std@0.178.0/streams/early_zip_readable_streams.ts": "64828085be5812ec5f4896c10b59f14e5a322b2c0439be9331dde332ae9c91de",
"https://deno.land/std@0.178.0/streams/iterate_reader.ts": "06491ed8f1bb1c619abbfa04c10b173ff95a93e51fe5037b7c1ad0b5cc01fc7d",
"https://deno.land/std@0.178.0/streams/limited_bytes_transform_stream.ts": "3bc04143b8b91a923f5ee81a3c618b6606ac7da66ccbcde62a67aaa0375cbc71",
"https://deno.land/std@0.178.0/streams/limited_transform_stream.ts": "b336f5d649a06e35e2692849e3682a673bb32531738443eb2ce9f57538722f75",
"https://deno.land/std@0.178.0/streams/merge_readable_streams.ts": "5d6302888f4bb0616dafb5768771be0aec9bedc05fbae6b3d726d05ffbec5b15",
"https://deno.land/std@0.178.0/streams/mod.ts": "c07ec010e700b9ea887dc36ca08729828bc7912f711e4054e24d33fd46282252",
"https://deno.land/std@0.178.0/streams/read_all.ts": "bfa220b0e1d06fa4d0cb5178baba8f8b466019a7411511982bfa2320ca292815",
"https://deno.land/std@0.178.0/streams/readable_stream_from_iterable.ts": "cae337ddafd2abc5e3df699ef2af888ac04091f12732ae658448fba2c7b187e8",
"https://deno.land/std@0.178.0/streams/readable_stream_from_reader.ts": "9aceaeefa9e04b08f56b2d07272baedc3b6432840b66198d72fa2ada3e6608ea",
"https://deno.land/std@0.178.0/streams/reader_from_iterable.ts": "05f7759b9336fd4c233d9daadf92aec9a7d2c07a05986da466a935cec2dd79d9",
"https://deno.land/std@0.178.0/streams/reader_from_stream_reader.ts": "3fda9390ec8520c8a9ea2aba2579208b18880a7663d7a9feec8f193b7af14e41",
"https://deno.land/std@0.178.0/streams/text_delimiter_stream.ts": "ee216316360366c3744197f5665a066a25e6baa8cfe836fbe9a0033e079e089e",
"https://deno.land/std@0.178.0/streams/text_line_stream.ts": "a9dd2636c6b90e626e19df26c97034c5f638bdd957cbd5c531d6278fe1d08e90",
"https://deno.land/std@0.178.0/streams/to_transform_stream.ts": "31c8cce967a2f602be5f164973a5c6cedd4c76e1d2fbc22ae0081b084f73fb0e",
"https://deno.land/std@0.178.0/streams/writable_stream_from_writer.ts": "0320b759aa343f9f4f58b014fe301d9a7abcbfb8413d260502a885995e6a0776",
"https://deno.land/std@0.178.0/streams/write_all.ts": "3b2e1ce44913f966348ce353d02fa5369e94115181037cd8b602510853ec3033",
"https://deno.land/std@0.178.0/streams/writer_from_stream_writer.ts": "31126a6bf2e678c5a718011d4831dbe75dbdbd885965d3dbd5dd105e6f20f976",
"https://deno.land/std@0.178.0/streams/zip_readable_streams.ts": "9eb82070d83055fe6f077192fb204dc7612695a4b330148e9aa376df1a65e708",
"https://deno.land/std@0.178.0/types.d.ts": "220ed56662a0bd393ba5d124aa6ae2ad36a00d2fcbc0e8666a65f4606aaa9784",
"https://deno.land/x/oak@v12.1.0/application.ts": "641c071bf14b476d603be18a984573e8a7c0da9c1bfc141ae44dad8012f2c5d6",
"https://deno.land/x/oak@v12.1.0/body.ts": "c7392f1dae04a360838f43b9cdd2f83d29c1eff4e6071d5f0cf1f3999b1602bc",
"https://deno.land/x/oak@v12.1.0/buf_reader.ts": "7cf96aa0ac670b75098113cf88a291a68332cc45efa8a9698f064ac5b8098a0f",
"https://deno.land/x/oak@v12.1.0/content_disposition.ts": "8b8c3cb2fba7138cd5b7f82fc3b5ea39b33db924a824b28261659db7e164621e",
"https://deno.land/x/oak@v12.1.0/context.ts": "81b97dab6b35ab872d5b2ff5221b4bc71b425f7069ce1a1dcc1d20c50a09865f",
"https://deno.land/x/oak@v12.1.0/deps.ts": "29134b39303d85527915650d6f11909c1e29c4cd208dd2ad9887ce2537167b42",
"https://deno.land/x/oak@v12.1.0/etag.ts": "19918f5e1964e3fe6c9fe524a88ffbf9900ce1dfe4146b187b2a86256bb6b663",
"https://deno.land/x/oak@v12.1.0/headers.ts": "f50fb05614432bda971021633129aa2e8737e0844e0f01c27a937997b4d8dd4f",
"https://deno.land/x/oak@v12.1.0/helpers.ts": "42212afa07a560b2958359cc19577417e89d9574d6579551a0af36ff7f00cc6e",
"https://deno.land/x/oak@v12.1.0/http_request.ts": "9f2cc5f4b9e72c5156dd4710875d9f54f992d69ca1a8ad5e0915a773f0e26e8b",
"https://deno.land/x/oak@v12.1.0/http_server_flash.ts": "6afeb6bfe77a08d99b205ebf09d18c98b4050ce9cbc66e24be1fba6e46bc7a4f",
"https://deno.land/x/oak@v12.1.0/http_server_native.ts": "0141e1339ed9a33bc26ce537ddab5adbb3542b35916d92de286aed4937e4a6d6",
"https://deno.land/x/oak@v12.1.0/http_server_native_request.ts": "be315d476550e149c58d7ccd2812be30f373ceedc9c323c300eef03b7c071aa9",
"https://deno.land/x/oak@v12.1.0/isMediaType.ts": "62d638abcf837ece3a8f07a4b7ca59794135cb0d4b73194c7d5837efd4161005",
"https://deno.land/x/oak@v12.1.0/mediaTyper.ts": "042b853fc8e9c3f6c628dd389e03ef481552bf07242efc3f8a1af042102a6105",
"https://deno.land/x/oak@v12.1.0/middleware.ts": "de14f045a2ddfe845d89b5d3140ff52cbcc6f3b3965391106ce04480f9786737",
"https://deno.land/x/oak@v12.1.0/middleware/proxy.ts": "b927232f97ec18af4185d7912e45b1191e3ffe24a9c875262ad524211b1274c9",
"https://deno.land/x/oak@v12.1.0/mod.ts": "210619d431e41c763486467b0498ef50de79119d750abc57331f76e36f642fc1",
"https://deno.land/x/oak@v12.1.0/multipart.ts": "98fe9f226de8c26a16d067027b69fb1e34ad8c4055767dd157907d06cea36f9a",
"https://deno.land/x/oak@v12.1.0/range.ts": "68a6df7ab3b868843e33f52deb94c3d4cab25cb9ef369691990c2ac15b04fafb",
"https://deno.land/x/oak@v12.1.0/request.ts": "5852ad36389b48e0428a6f3c90854d01f10d1b15949b56001e1e75c2a00ef0f9",
"https://deno.land/x/oak@v12.1.0/response.ts": "867d81f7eb0c65c7b8e0e0e9e145ededd5b6daa9ad922e6adc6a36a525f439a6",
"https://deno.land/x/oak@v12.1.0/router.ts": "5b266091e55f634c9130e6de5dd331ddfc4c190ee7916a25e0a0f75502edbc32",
"https://deno.land/x/oak@v12.1.0/send.ts": "c592a6782c82442d011923297a724c2cba8de14c4a9384c6eb9cebb766817e92",
"https://deno.land/x/oak@v12.1.0/server_sent_event.ts": "948b0fe4cb3fe38c7db15e476eb3b7671ef20e566d130e9f701d7c0146aa47dd",
"https://deno.land/x/oak@v12.1.0/structured_clone.ts": "9c2d21c62f616400305a60cbd29eb06764ee97edc423223424b6cf55df0e8be2",
"https://deno.land/x/oak@v12.1.0/testing.ts": "a0be5c84981afde666de29630f34b09d944ca1a2fe6a5185644b60ad95e16d18",
"https://deno.land/x/oak@v12.1.0/types.d.ts": "41951a18c3bfdb11e40707cab75da078ba8a4907cd7d4e11d8536bc2db0dde05",
"https://deno.land/x/oak@v12.1.0/util.ts": "3af8c4ed04c6cc2bedbe66e562a77fc59c72df31c55a902a63885861ca1639d6",
"https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "894060567837bae8fc9c5cbd4d0a05e9024672083d5883b525c031eea940e556"
},
"npm": {
"specifiers": {
"@octokit/rest@19.0.7": "@octokit/rest@19.0.7_@octokit+core@4.2.0"
},
"packages": {
"@octokit/auth-token@3.0.3": {
"integrity": "sha512-/aFM2M4HVDBT/jjDBa84sJniv1t9Gm/rLkalaz9htOm+L+8JMj1k9w0CkUdcxNyNxZPlTxKPVko+m1VlM58ZVA==",
"dependencies": {
"@octokit/types": "@octokit/types@9.0.0"
}
},
"@octokit/core@4.2.0": {
"integrity": "sha512-AgvDRUg3COpR82P7PBdGZF/NNqGmtMq2NiPqeSsDIeCfYFOZ9gddqWNQHnFdEUf+YwOj4aZYmJnlPp7OXmDIDg==",
"dependencies": {
"@octokit/auth-token": "@octokit/auth-token@3.0.3",
"@octokit/graphql": "@octokit/graphql@5.0.5",
"@octokit/request": "@octokit/request@6.2.3",
"@octokit/request-error": "@octokit/request-error@3.0.3",
"@octokit/types": "@octokit/types@9.0.0",
"before-after-hook": "before-after-hook@2.2.3",
"universal-user-agent": "universal-user-agent@6.0.0"
}
},
"@octokit/endpoint@7.0.5": {
"integrity": "sha512-LG4o4HMY1Xoaec87IqQ41TQ+glvIeTKqfjkCEmt5AIwDZJwQeVZFIEYXrYY6yLwK+pAScb9Gj4q+Nz2qSw1roA==",
"dependencies": {
"@octokit/types": "@octokit/types@9.0.0",
"is-plain-object": "is-plain-object@5.0.0",
"universal-user-agent": "universal-user-agent@6.0.0"
}
},
"@octokit/graphql@5.0.5": {
"integrity": "sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ==",
"dependencies": {
"@octokit/request": "@octokit/request@6.2.3",
"@octokit/types": "@octokit/types@9.0.0",
"universal-user-agent": "universal-user-agent@6.0.0"
}
},
"@octokit/openapi-types@16.0.0": {
"integrity": "sha512-JbFWOqTJVLHZSUUoF4FzAZKYtqdxWu9Z5m2QQnOyEa04fOFljvyh7D3GYKbfuaSWisqehImiVIMG4eyJeP5VEA==",
"dependencies": {}
},
"@octokit/plugin-paginate-rest@6.0.0_@octokit+core@4.2.0": {
"integrity": "sha512-Sq5VU1PfT6/JyuXPyt04KZNVsFOSBaYOAq2QRZUwzVlI10KFvcbUo8lR258AAQL1Et60b0WuVik+zOWKLuDZxw==",
"dependencies": {
"@octokit/core": "@octokit/core@4.2.0",
"@octokit/types": "@octokit/types@9.0.0"
}
},
"@octokit/plugin-request-log@1.0.4_@octokit+core@4.2.0": {
"integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==",
"dependencies": {
"@octokit/core": "@octokit/core@4.2.0"
}
},
"@octokit/plugin-rest-endpoint-methods@7.0.1_@octokit+core@4.2.0": {
"integrity": "sha512-pnCaLwZBudK5xCdrR823xHGNgqOzRnJ/mpC/76YPpNP7DybdsJtP7mdOwh+wYZxK5jqeQuhu59ogMI4NRlBUvA==",
"dependencies": {
"@octokit/core": "@octokit/core@4.2.0",
"@octokit/types": "@octokit/types@9.0.0",
"deprecation": "deprecation@2.3.1"
}
},
"@octokit/request-error@3.0.3": {
"integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==",
"dependencies": {
"@octokit/types": "@octokit/types@9.0.0",
"deprecation": "deprecation@2.3.1",
"once": "once@1.4.0"
}
},
"@octokit/request@6.2.3": {
"integrity": "sha512-TNAodj5yNzrrZ/VxP+H5HiYaZep0H3GU0O7PaF+fhDrt8FPrnkei9Aal/txsN/1P7V3CPiThG0tIvpPDYUsyAA==",
"dependencies": {
"@octokit/endpoint": "@octokit/endpoint@7.0.5",
"@octokit/request-error": "@octokit/request-error@3.0.3",
"@octokit/types": "@octokit/types@9.0.0",
"is-plain-object": "is-plain-object@5.0.0",
"node-fetch": "node-fetch@2.6.9",
"universal-user-agent": "universal-user-agent@6.0.0"
}
},
"@octokit/rest@19.0.7_@octokit+core@4.2.0": {
"integrity": "sha512-HRtSfjrWmWVNp2uAkEpQnuGMJsu/+dBr47dRc5QVgsCbnIc1+GFEaoKBWkYG+zjrsHpSqcAElMio+n10c0b5JA==",
"dependencies": {
"@octokit/core": "@octokit/core@4.2.0",
"@octokit/plugin-paginate-rest": "@octokit/plugin-paginate-rest@6.0.0_@octokit+core@4.2.0",
"@octokit/plugin-request-log": "@octokit/plugin-request-log@1.0.4_@octokit+core@4.2.0",
"@octokit/plugin-rest-endpoint-methods": "@octokit/plugin-rest-endpoint-methods@7.0.1_@octokit+core@4.2.0"
}
},
"@octokit/types@9.0.0": {
"integrity": "sha512-LUewfj94xCMH2rbD5YJ+6AQ4AVjFYTgpp6rboWM5T7N3IsIF65SBEOVcYMGAEzO/kKNiNaW4LoWtoThOhH06gw==",
"dependencies": {
"@octokit/openapi-types": "@octokit/openapi-types@16.0.0"
}
},
"before-after-hook@2.2.3": {
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==",
"dependencies": {}
},
"deprecation@2.3.1": {
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==",
"dependencies": {}
},
"is-plain-object@5.0.0": {
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dependencies": {}
},
"node-fetch@2.6.9": {
"integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==",
"dependencies": {
"whatwg-url": "whatwg-url@5.0.0"
}
},
"once@1.4.0": {
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "wrappy@1.0.2"
}
},
"tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dependencies": {}
},
"universal-user-agent@6.0.0": {
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==",
"dependencies": {}
},
"webidl-conversions@3.0.1": {
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dependencies": {}
},
"whatwg-url@5.0.0": {
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "tr46@0.0.3",
"webidl-conversions": "webidl-conversions@3.0.1"
}
},
"wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dependencies": {}
}
}
}
}

View File

@ -0,0 +1,10 @@
import * as path from "https://deno.land/std@0.178.0/path/mod.ts";
if (import.meta.main) {
const __dirname = path.dirname(path.fromFileUrl(import.meta.url));
const tauriConf = path.join(__dirname, '../src-tauri/tauri.conf.json');
const tauriConfContent = await Deno.readTextFile(tauriConf);
const tauriConfJson = JSON.parse(tauriConfContent);
tauriConfJson.tauri.updater.active = true;
await Deno.writeTextFile(tauriConf, JSON.stringify(tauriConfJson, null, 2));
}

View File

@ -0,0 +1,73 @@
import { Application, Router } from "https://deno.land/x/oak@v12.1.0/mod.ts";
import * as path from "https://deno.land/std@0.178.0/path/mod.ts";
const PORT = 1421;
const __dirname = path.dirname(path.fromFileUrl(import.meta.url));
const app = new Application();
const router = new Router();
const keys: Set<string> = new Set();
async function updateFile() {
delayId = null;
for (const lng of ["en", 'ja', "zh-CN"]) {
const translationPath = path.join(
__dirname,
`../src/i18n/translation/${lng}.json`,
);
let translations: Record<string, string> = {};
try {
translations = JSON.parse(await Deno.readTextFile(translationPath));
} catch (error) {}
const toAdd = [...keys].filter((k) =>
!Object.keys(translations).includes(k)
);
translations = Object.fromEntries(
[
...Object.entries(translations),
...toAdd
.map((i) => [i, i] as const),
]
.sort(([a], [b]) => a.localeCompare(b)),
);
console.log("Add keys:", toAdd, "for", lng);
await Deno.writeTextFile(
translationPath,
JSON.stringify(translations, null, 2),
);
}
keys.clear();
}
let delayId: number | null = null;
router.post("/locales/add/:lng/:ns", async (context) => {
try {
// ns, lng is ignored
const body: Record<string, string> = await context.request.body({
type: "json",
}).value;
for (const key of Object.keys(body)) {
keys.add(key);
}
if (delayId !== null) {
clearTimeout(delayId);
}
delayId = setTimeout(updateFile, 1000);
context.response.status = 200;
context.response.body = { message: "Translation added." };
} catch (error) {
context.response.status = 500;
context.response.body = { message: error.message };
}
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`Listening on port ${PORT}...`);
await app.listen({ port: PORT });

105
gui/scripts/make-update.ts Normal file
View File

@ -0,0 +1,105 @@
import { Octokit } from "npm:@octokit/rest@19.0.7";
const TAG_PREFIX = "gui-";
type Platform =
| "darwin-x86_64"
| "darwin-aarch64"
| "linux-x86_64"
| "windows-x86_64";
const PLATFORMS: Platform[] = [
"darwin-x86_64",
"darwin-aarch64",
"linux-x86_64",
"windows-x86_64",
];
const PlatformSuffix: Record<Platform, string> = {
"darwin-x86_64": ".app.tar.gz",
"darwin-aarch64": ".app.tar.gz",
"linux-x86_64": ".AppImage.tar.gz",
"windows-x86_64": ".msi.zip",
};
type File = {
signature: string;
url: string;
};
type UpdateJson = {
version: string;
notes: string;
pub_date: string;
platforms: Record<Platform, File>;
};
const REPO = {
owner: "spacemeowx2",
repo: "s3si.ts",
};
const octokit = new Octokit({
auth: Deno.env.get("GITHUB_TOKEN"),
});
async function findFirstGuiRelease() {
let page = 1;
while (true) {
const { data: list } = await octokit.repos.listReleases({
...REPO,
page,
});
if (list.length === 0) {
return undefined;
}
for (const release of list) {
if (release.tag_name.startsWith(TAG_PREFIX)) {
return release;
}
}
page += 1;
}
}
const release = await findFirstGuiRelease();
const version = release?.tag_name.slice(TAG_PREFIX.length) ?? "unknown";
const notes = release?.body ?? "unknown";
const pub_date = release?.published_at ?? "unknown";
async function makePlatforms(r: typeof release) {
const assets = r?.assets ?? [];
const platforms = Object.fromEntries(PLATFORMS.map((p) => {
const asset = assets.find((i) => i.name.endsWith(PlatformSuffix[p]));
if (!asset) {
throw new Error(`Asset not found for ${p}`);
}
return [p, {
signature: asset.browser_download_url + ".sig",
url: asset.browser_download_url,
}];
})) as Record<Platform, File>;
return platforms;
}
const updateJson: UpdateJson = {
version,
notes,
pub_date,
platforms: await makePlatforms(release),
};
// fetch signatures
for (const platform of PLATFORMS) {
const file = updateJson.platforms[platform];
const res = await fetch(file.signature);
file.signature = await res.text();
}
console.log(JSON.stringify(updateJson, null, 2));

4
gui/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/

3279
gui/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
gui/src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "s3si-ts"
version = "0.1.0"
description = "Export your battles from SplatNet to https://stat.ink"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["fs-all", "path-all", "process-relaunch", "shell-execute", "shell-open", "shell-sidecar", "window-all"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["time"] }
urlencoding = "2.1.2"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

3
gui/src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

121
gui/src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,121 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tauri::{window::WindowBuilder, WindowEvent};
use tokio::time::sleep;
const INIT_SCRIPT: &str = r#"
function onSelectUserClick(e) {
const element = document.getElementById('authorize-switch-approval-link');
if (!element) {
return;
}
e.preventDefault();
// very hacky way...
window.ipc.postMessage(JSON.stringify({
"cmd":"tauri",
"callback":0,
"error":0,
"__tauriModule":"Event",
"message":{"cmd":"emit","event":"login","payload":{"url":element.href}}
}))
}
function detectAndInject() {
const element = document.getElementById('authorize-switch-approval-link');
if (!element) {
window.setTimeout(detectAndInject, 100);
return;
}
element.addEventListener('click', onSelectUserClick);
}
detectAndInject();
"#;
#[tauri::command]
async fn open_splatnet(app: tauri::AppHandle, gtoken: String, lang: Option<String>) -> Option<()> {
let ui_lang = lang.unwrap_or_else(|| "en-US".to_string());
let _window = WindowBuilder::new(
&app,
"splatnet3",
tauri::WindowUrl::App(format!("https://api.lp1.av5ja.srv.nintendo.net/?lang={ui_lang}").into()),
)
.title("Splatnet3")
.center()
.inner_size(400.0, 700.0)
.initialization_script(&format!(
r##"
const gtoken = "_gtoken={gtoken}";
if (!document.cookie.includes(gtoken)) {{
document.cookie = gtoken;
window.location.reload();
}}
document.addEventListener("DOMContentLoaded", () => {{
// insert css
const style = document.createElement('style');
style.innerHTML = `
[class^="App_App_"] , [class^="InAppContent_children_"] , [class^="SwipableView_swipableViewItem_"] ,
[class^="MainWrapper_wrapper_"] , [class^="FriendList_wrapper_"] {{
overflow: auto;
}}
`;
document.head.appendChild(style);
}});
"##
))
.build()
.ok()?;
None
}
#[tauri::command]
async fn open_login_window(app: tauri::AppHandle, url: String) -> Option<String> {
let encoded = urlencoding::encode(&url);
let window = WindowBuilder::new(
&app,
"login",
tauri::WindowUrl::App(format!("/redirect?url={encoded}").into()),
)
.title("Login")
.center()
.inner_size(1040.0, 960.0)
.initialization_script(INIT_SCRIPT)
.build()
.ok()?;
let result: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let r2 = result.clone();
let r3 = result.clone();
window.listen("login", move |e| {
let mut result = r2.lock().unwrap();
*result = e.payload().map(ToString::to_string);
});
window.on_window_event(move |e| {
if let WindowEvent::Destroyed = e {
let mut result = r3.lock().unwrap();
if result.is_none() {
*result = Some("".to_string());
}
}
});
loop {
sleep(Duration::from_millis(100)).await;
let result = result.lock().unwrap();
if result.is_some() {
window.close().ok();
return result.clone();
}
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_login_window, open_splatnet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,96 @@
{
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "s3si-ts",
"version": "0.4.1"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"sidecar": true,
"execute": true,
"scope": [
{
"name": "../binaries/s3si",
"sidecar": true
},
{
"name": "deno",
"cmd": "deno",
"args": [
"run",
"-A",
"../../src/daemon.ts"
]
}
],
"all": false,
"open": true
},
"window": {
"all": true
},
"fs": {
"scope": [
"$APPCONFIG/**/*",
"$APPDATA/**/*",
"$APPCACHE/**/*"
],
"all": true
},
"path": {
"all": true
},
"process": {
"all": false,
"relaunch": true
}
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "cn.imspace.s3si.ts",
"targets": "all",
"externalBin": [
"../binaries/s3si"
]
},
"security": {
"csp": null
},
"updater": {
"active": false,
"endpoints": [
"https://s3si-update.imspace.cn/v1/{{target}}/{{arch}}/{{current_version}}",
"https://gist.githubusercontent.com/spacemeowx2/a67078487d3450b75927953f6edc14e2/raw/update-request.json"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERBMzkzNjFEQ0ZCMjc5QjAKUldTd2ViTFBIVFk1MmdubFh6UTc0NndsZnBQV2hnR3NNSThTYlNudENWYm5yZ0JBRDM0MlloRjAK"
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "s3si.ts",
"width": 500,
"height": 600,
"minWidth": 400,
"minHeight": 500,
"visible": false
}
]
}
}

24
gui/src/App.tsx Normal file
View File

@ -0,0 +1,24 @@
import 'i18n/config';
import { Routes, Route } from "react-router-dom";
import { Layout } from "components/Layout";
import { Home } from "pages/Home";
import { Settings } from "pages/Settings";
import { Guide } from 'pages/Guide';
import { RedirectLogin } from 'pages/RedirectLogin';
import { useShowWindow } from 'hooks/useShowWindow';
function App() {
useShowWindow();
return (
<Routes>
<Route path='/' element={<Layout />}>
<Route index element={<Home />} />
<Route path='/settings' element={<Settings />} />
<Route path='/guide' element={<Guide />} />
<Route path='/redirect' element={<RedirectLogin />} />
</Route>
</Routes>
);
}
export default App;

View File

@ -0,0 +1,16 @@
import { ReactNode } from "react";
import { checkUpdate } from '@tauri-apps/api/updater'
export const CheckUpdate: React.FC<{ className?: string, children?: ReactNode }> = ({ className, children }) => {
const onClick = async () => {
try {
await checkUpdate()
} catch (error) {
console.log(error)
}
}
return <>
<button className={className} onClick={onClick}>{children}</button>
</>;
}

View File

@ -0,0 +1,17 @@
import React from 'react'
type CheckboxProps = {
disabled?: boolean
children?: React.ReactNode
value?: boolean
onChange?: (value: boolean) => void
}
export const Checkbox: React.FC<CheckboxProps> = ({ disabled, value, onChange, children }) => {
return <div className="form-control">
<label className="label cursor-pointer">
<span className="label-text">{children}</span>
<input type="checkbox" checked={value ?? false} disabled={disabled} onChange={() => onChange?.(!value)} className="checkbox" />
</label>
</div>
}

View File

@ -0,0 +1,26 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { AiOutlineWarning } from 'react-icons/ai'
type ErrorContentProps = {
error: any
retry?: () => void
}
export const ErrorContent: React.FC<ErrorContentProps> = ({ error, retry }) => {
const { t } = useTranslation();
if (!error) {
return <></>;
}
return <div className='w-full h-full flex justify-center items-center flex-col'>
<span className='inline-flex items-center'>
<AiOutlineWarning className='inline-block scale-[2] mr-4 justify-end flex-none' />
<div className='max-w-full break-all'>
<div>{t('发生了错误')}{retry && <button className='link link-info ml-1'>{t('重试')}</button>}</div>
{String(error)}
</div>
</span>
</div>
}

View File

@ -0,0 +1,14 @@
import React from 'react'
import { AiOutlineLeft } from 'react-icons/ai';
import { useNavigate } from 'react-router-dom';
type HeaderProps = {
title?: React.ReactNode
}
export const Header: React.FC<HeaderProps> = ({ title }) => {
const navigate = useNavigate();
return <>
<h2 className="card-title" data-tauri-drag-region><button onClick={() => navigate(-1)}><AiOutlineLeft /></button>{title}</h2>
</>
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Outlet } from "react-router-dom";
export const Layout: React.FC = () => {
return (
<div className='w-full h-full flex flex-col'>
<Outlet />
</div>
);
};

View File

@ -0,0 +1,10 @@
import React from 'react'
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
type LoadingProps = {
className?: string
}
export const Loading: React.FC<LoadingProps> = ({ className }) => {
return <AiOutlineLoading3Quarters className={`animate-spin my-2 mx-auto ${className}`} />
}

View File

@ -0,0 +1,60 @@
import { invoke } from '@tauri-apps/api';
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import React, { useState } from 'react'
import { getConfig, getProfile, setProfile } from 'services/config';
import { ensureTokenValid } from 'services/s3si';
import { composeLoadable } from 'utils/composeLoadable';
import { ErrorContent } from './ErrorContent';
type OpenSplatnetProps = {
children?: React.ReactNode
}
export const OpenSplatnet: React.FC<OpenSplatnetProps> = ({ children }) => {
let { loading, error, retry, result } = composeLoadable({
config: usePromise(getConfig),
profile: usePromise(() => getProfile(0)),
});
const [doing, setDoing] = useState(false);
const [err, setError] = useState<any>();
const onClick = async () => {
setDoing(true);
try {
if (!result) {
return;
}
const state = result.profile.state;
const newState = await ensureTokenValid(state);
await setProfile(0, {
...result.profile,
state: newState,
});
retry?.();
const gtoken = newState.loginState?.gToken;
await invoke('open_splatnet', {
gtoken,
lang: result.profile.state.userLang,
});
} catch (e) {
setError(e);
} finally {
setDoing(false);
}
};
if (error || err) {
return <>
<ErrorContent error={error || err} retry={retry} />
</>
}
return <>
<button className={classNames('btn', {
'btn-disabled': !result?.profile.state.loginState?.sessionToken,
'loading': loading || doing,
})} onClick={onClick}>{children}</button>
</>
}

View File

@ -0,0 +1,92 @@
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config';
import { addLog, run, useLog } from 'services/s3si';
import { Checkbox } from './Checkbox';
import { Loading } from './Loading';
type RunPanelProps = {
}
export const RunPanel: React.FC<RunPanelProps> = () => {
const { t } = useTranslation();
const { result } = usePromise(() => getProfile(0));
const [exportBattle, setExportBattle] = useState(true);
const [exportCoop, setExportCoop] = useState(true);
const [loading, setLoading] = useState(false);
if (!result) {
return <Loading />
}
const onClick = async () => {
setLoading(true);
try {
addLog({
level: 'log',
msg: ['Export started at', new Date().toLocaleString()],
})
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink",
monitor: false,
withSummary: false,
skipMode: exportBattle === false ? 'vs' : exportCoop === false ? 'coop' : undefined,
});
await setProfile(0, {
...result,
state: newState,
})
} catch (e) {
console.error(e)
addLog({
level: 'error',
msg: [e],
})
} finally {
addLog({
level: 'log',
msg: ['Export ended at', new Date().toLocaleString()],
})
setLoading(false);
}
}
const disabled = !canExport(result);
return <>
<div className="tooltip" data-tip={disabled ? t('请先在设置中完成Nintendo Account登录和stat.ink的API密钥') : undefined}>
<Checkbox disabled={disabled || loading} value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button
onClick={onClick}
className={classNames('btn w-full', {
'btn-disabled': disabled || (!exportBattle && !exportCoop),
'loading': loading,
})}
>{t('导出')}</button>
</div>
</>
}
export type LogPanelProps = {
className?: string
}
export const LogPanel: React.FC<LogPanelProps> = ({ className }) => {
const { renderedLogs } = useLog();
const div = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
if (div.current) {
div.current.scrollTop = div.current.scrollHeight;
}
}, [renderedLogs])
return <div ref={div} className={`bg-neutral text-neutral-content overflow-auto rounded p-4 ${className}`}>
{renderedLogs.length === 0 && <pre><code>{t('欢迎! 请点击"导出"按钮开始使用.')}</code></pre>}
{renderedLogs.map((line, i) => <pre key={i}><code>{line}</code></pre>)}
</div>
}

View File

@ -0,0 +1,4 @@
/**
* https://stat.ink
*/
export const STAT_INK = 'https://stat.ink'

View File

@ -0,0 +1,84 @@
import { useState } from "react";
/**
* A hook that returns a promise and its state.
*
* @param factory A function that returns a promise.
* @returns An object containing the promise's state and result.
* @example
* const { loading, result, error } = usePromise(() => fetch('https://example.com')
* .then(response => response.text())
* );
* if (loading) {
* return <p>Loading...</p>;
* }
* if (error) {
* return <p>Error: {error.message}</p>;
* }
* return <p>Result: {result}</p>;
*/
export function usePromise<T>(factory: () => Promise<T>) {
const init = () => {
const promise = factory();
if (!promise || typeof promise.then !== "function") {
throw new Error("The factory function must return a promise.");
}
return promise
.then(r => {
setResult(r);
setLoading(false);
return r;
})
.catch(e => {
setError(e);
setLoading(false);
throw e;
});
}
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<any | undefined>(undefined);
const [promise, setPromise] = useState(init);
const retry = () => {
setLoading(true);
setResult(undefined);
setError(undefined);
setPromise(init);
}
return { loading, result, error, promise, retry };
}
/**
* A hook that returns a promise and its state.
*/
export function usePromiseLazy<T, Args extends any[]>(factory: (...args: Args) => Promise<T>) {
const init = (promise: Promise<T>) => {
if (!promise || typeof promise.then !== "function") {
throw new Error("The factory function must return a promise.");
}
return promise
.then(r => {
setResult(r);
setLoading(false);
return r;
})
.catch(e => {
setError(e);
setLoading(false);
throw e;
});
}
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<any | undefined>(undefined);
const [promise, setPromise] = useState<Promise<T> | undefined>(undefined);
const execute = (...args: Args) => {
setLoading(true);
setResult(undefined);
setError(undefined);
setPromise(init(factory(...args)));
}
return [execute, { loading, result, error, promise }] as const;
}

View File

@ -0,0 +1,20 @@
import { useCallback, useEffect } from "react";
import { getCurrent } from "@tauri-apps/api/window";
export const useShowWindow = () => {
const show = useCallback(() => {
if (window.location.pathname === '/hide') {
return;
}
try {
getCurrent().show().catch(e => console.error(e))
} catch (e) {
console.error(e)
}
}, [])
useEffect(() => {
show();
}, [show])
return show;
}

View File

@ -0,0 +1,86 @@
type Maybe<T> = T | null | undefined;
type KeyOf<T extends Record<string, any>, K = keyof T> = K extends string ? (T[K] extends Function ? never : K) : never;
type DotField<T extends Maybe<Record<string, any>>, K = KeyOf<NonNullable<T>>> = K extends string
? K | `${K}.${DotField<NonNullable<T>[K]>}`
: never;
type ValueOf<T extends Record<string, any>, K> = K extends `${infer I}.${infer R}`
? ValueOf<NonNullable<T>[I], R>
: K extends string
? NonNullable<T>[K]
: never;
export type FormProps<T> = {
value: T;
onChange: (value: T) => void;
};
const pick = <T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => {
const ret = {} as Pick<T, K>;
keys.forEach((key) => {
ret[key] = obj[key];
});
return ret;
};
export const mapFormProps = <T, U>(
formProps: FormProps<T>,
{ mapValue, mapOnChange }: {
mapValue: (v: T) => U;
mapOnChange: (v: U) => T;
},
): FormProps<U> => {
const { value, onChange } = formProps;
return {
value: mapValue(value),
onChange: (value: U) => onChange(mapOnChange(value)),
};
};
export const useSubField = <T extends Record<string, any>>({
value,
onChange,
}: {
value: T;
onChange?: (cb: (value: T) => T) => void;
}) => {
const subField = <K extends DotField<T>>(key: K): FormProps<ValueOf<T, K>> => {
const v = key.split('.').reduce((o, x) => (o ?? {})[x], value) as ValueOf<T, K>;
return {
value: v,
onChange: (v: ValueOf<T, K>) => {
const setInner = <O extends Record<string, any>>(o: O, k: string[], v: any): O => {
const [head, ...tail] = k;
let out;
if (tail.length === 0) {
out = {
...o,
[head]: v,
};
} else {
out = {
...o,
[head]: setInner(o[head], tail, v),
};
}
return out;
};
onChange?.((old) => setInner(old, key.split('.'), v));
},
};
};
const subKeys = <K extends keyof T>(keys: K[]) => {
return {
value: pick(value, keys),
onChange: (v: Pick<T, K>) => {
onChange?.((old) => ({
...old,
v,
}));
},
};
};
return {
subField,
subKeys,
};
};

View File

@ -0,0 +1,31 @@
import { getCurrent, LogicalSize, appWindow } from '@tauri-apps/api/window'
import { useEffect, useRef } from 'react';
/**
* Sets the window size, and disable resizable, and restores it on unmount.
*/
export const useWindowSize = ({ w, h }: { w: number, h: number }) => {
const oldSize = useRef<{ w: number, h: number }>();
useEffect(() => {
const run = async () => {
const factor = await appWindow.scaleFactor();
const outerSize = (await getCurrent().outerSize()).toLogical(factor);
oldSize.current = {
w: outerSize.width,
h: outerSize.height
};
await getCurrent().setResizable(false);
await getCurrent().setSize(new LogicalSize(w, h));
}
run();
return () => {
const size = oldSize.current;
if (size) {
getCurrent().setSize(new LogicalSize(size.w, size.h));
getCurrent().setResizable(true);
}
}
}, [oldSize, w, h]);
};

34
gui/src/i18n/config.ts Normal file
View File

@ -0,0 +1,34 @@
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './translation/en.json';
import zhCN from './translation/zh-CN.json';
import ja from './translation/ja.json';
import HttpBackend from 'i18next-http-backend'
export const resources = {
en: {
translation: en,
},
'zh-CN': {
translation: zhCN,
},
ja: {
translation: ja,
}
};
const instance = i18next
.use(initReactI18next)
.use(LanguageDetector);
if (import.meta.env.DEV) {
instance.use(HttpBackend);
}
instance.init({
debug: import.meta.env.DEV,
resources,
fallbackLng: 'en',
// saveMissing: true,
});

View File

@ -0,0 +1,23 @@
{
"保存": "Save",
"查看API密钥": "View API Key",
"打开 stat.ink": "Open stat.ink",
"打开鱿鱼圈3": "Open Splatnet3",
"导出": "Export",
"导出打工数据": "Export Salmon Run",
"导出对战数据": "Export VS",
"欢迎! 请点击\"导出\"按钮开始使用.": "Welcome! Please click \"Export\" button to start using.",
"界面语言": "Interface Language",
"没有更改": "No changes made",
"密钥的长度应该为{{length}}, 请检查": "The length of the key should be {{length}}, please check",
"前往 stat.ink": "Go to stat.ink",
"请从stat.ink中获取API密钥": "Please obtain the API key from stat.ink",
"请点击右上角的登录填入": "Please click the top right corner to log in",
"请先在设置中完成Nintendo Account登录和stat.ink的API密钥": "Please complete Nintendo Account login and stat.ink API key setup in settings first",
"设置": "Settings",
"网页登录": "Web Login",
"鱿鱼圈3语言偏好": "Splatnet3 Language",
"重置": "Reset",
"Nintendo Account 会话令牌": "Nintendo Account Session Token",
"stat.ink API密钥": "stat.ink API Key"
}

View File

@ -0,0 +1,23 @@
{
"保存": "保存",
"查看API密钥": "APIキーを表示する",
"打开 stat.ink": "stat.inkを開く",
"打开鱿鱼圈3": "イカリング3を開く",
"导出": "エクスポートする",
"导出打工数据": "サーモンランデータをエクスポートする",
"导出对战数据": "バトルデータをエクスポートする",
"欢迎! 请点击\"导出\"按钮开始使用.": "ようこそ!「エクスポート」ボタンをクリックして使い始めてください。",
"界面语言": "インターフェース言語",
"没有更改": "変更はありません",
"密钥的长度应该为{{length}}, 请检查": "キーの長さは{{length}}でなければなりません。確認してください。",
"前往 stat.ink": "stat.inkに移動する",
"请从stat.ink中获取API密钥": "stat.inkからAPIキーを取得してください",
"请点击右上角的登录填入": "右上隅のログインをクリックして入力してください",
"请先在设置中完成Nintendo Account登录和stat.ink的API密钥": "Nintendoアカウントのログインとstat.inkのAPIキー設定を先に設定してください",
"设置": "設定",
"网页登录": "ウェブサイトにログインする",
"鱿鱼圈3语言偏好": "イカリング3言語設定",
"重置": "リセット",
"Nintendo Account 会话令牌": "Nintendo Accountセッショントークン",
"stat.ink API密钥": "stat.ink APIキー"
}

View File

@ -0,0 +1,23 @@
{
"保存": "保存",
"查看API密钥": "查看API密钥",
"打开 stat.ink": "打开 stat.ink",
"打开鱿鱼圈3": "打开鱿鱼圈3",
"导出": "导出",
"导出打工数据": "导出打工数据",
"导出对战数据": "导出对战数据",
"欢迎! 请点击\"导出\"按钮开始使用.": "欢迎! 请点击\"导出\"按钮开始使用.",
"界面语言": "界面语言",
"没有更改": "没有更改",
"密钥的长度应该为{{length}}, 请检查": "密钥的长度应该为{{length}}, 请检查",
"前往 stat.ink": "前往 stat.ink",
"请从stat.ink中获取API密钥": "请从stat.ink中获取API密钥",
"请点击右上角的登录填入": "请点击右上角的登录填入",
"请先在设置中完成Nintendo Account登录和stat.ink的API密钥": "请先在设置中完成Nintendo Account登录和stat.ink的API密钥",
"设置": "设置",
"网页登录": "网页登录",
"鱿鱼圈3语言偏好": "鱿鱼圈3语言偏好",
"重置": "重置",
"Nintendo Account 会话令牌": "Nintendo Account 会话令牌",
"stat.ink API密钥": "stat.ink API密钥"
}

133
gui/src/jsonrpc/client.ts Normal file
View File

@ -0,0 +1,133 @@
// A copy of `../../../src/jsonrpc/client.ts`
// deno-lint-ignore-file no-explicit-any
import {
ID,
Request,
Response,
ResponseError,
RPCResult,
Service,
Transport,
} from "./types";
export class JSONRPCError extends Error {
constructor(public rpcError: ResponseError) {
super(rpcError.message);
}
}
export class JSONRPCClient<S extends Service> {
protected nextId = 1;
protected transport: Transport;
protected requestMap: Map<
ID,
(result: RPCResult<any, ResponseError>) => void
> = new Map();
protected fatal: unknown = undefined;
protected task: Promise<void>;
constructor(
{ transport }: { transport: Transport },
) {
this.transport = transport;
this.task = this.run();
}
protected setFatal(e: unknown) {
if (!this.fatal) {
this.fatal = e;
}
}
protected handleResponse(
resp: Response<unknown, ResponseError>,
) {
const { id } = resp;
const callback = this.requestMap.get(id);
if (callback) {
this.requestMap.delete(id);
callback(resp);
} else {
this.setFatal(new Error("invalid response id: " + String(id)));
}
}
// receive response from server
protected async run() {
try {
while (true) {
const data = await this.transport.recv();
if (data === undefined) {
this.setFatal(new Error("transport closed"));
break;
}
const result = JSON.parse(data);
if (Array.isArray(result)) {
for (const resp of result) {
this.handleResponse(resp);
}
} else {
this.handleResponse(result);
}
}
} catch (e) {
this.setFatal(e);
}
}
makeRequest<
K extends keyof S & string,
P extends Parameters<S[K]>,
>(
method: K,
params: P,
): Request<K, P> {
const req = {
jsonrpc: "2.0",
id: this.nextId,
method,
params,
} as const;
this.nextId += 1;
return req;
}
async call<
K extends keyof S & string,
P extends Parameters<S[K]>,
R extends ReturnType<S[K]>,
>(
method: K,
...params: P
): Promise<R> {
if (this.fatal) {
throw this.fatal;
}
const req = this.makeRequest(method, params);
await this.transport.send(JSON.stringify(req));
return new Promise<R>((res, rej) => {
this.requestMap.set(req.id, (result) => {
if (result.error) {
rej(new JSONRPCError(result.error));
} else {
res(result.result);
}
});
});
}
getProxy(): S {
const proxy = new Proxy({}, {
get: (_, method: string) => {
return (...params: unknown[]) => this.call(method, ...params as any);
},
});
return proxy as S;
}
async close() {
await this.transport.close();
await this.task;
}
}

3
gui/src/jsonrpc/index.ts Normal file
View File

@ -0,0 +1,3 @@
export type { S3SIService } from './types'
export { JSONRPCClient } from './client'
export { StdioTransport } from './stdio'

47
gui/src/jsonrpc/stdio.ts Normal file
View File

@ -0,0 +1,47 @@
import { Command, Child } from '@tauri-apps/api/shell'
export class StdioTransport {
queue: string[] = [];
waiting: ((value: string | undefined) => void)[] = [];
callback = (data: string) => {
const waiting = this.waiting.shift();
if (waiting) {
waiting(data);
} else {
this.queue.push(data);
}
};
child: Promise<Child>;
constructor() {
const command = import.meta.env.DEV
? new Command("deno", ["run", "-A", "../../src/daemon.ts"])
: Command.sidecar('../binaries/s3si');
command.stdout.on('data', line => {
this.callback(line)
})
command.stderr.on('data', line => {
console.error('daemon stderr', line)
})
this.child = command.spawn()
}
async recv(): Promise<string | undefined> {
return new Promise((resolve) => {
const data = this.queue.shift();
if (data) {
resolve(data);
} else {
this.waiting.push(resolve);
}
});
}
async send(data: string) {
const child = await this.child;
await child.write(data + "\n")
}
async close() {
const child = await this.child;
await child.kill()
}
}

1
gui/src/jsonrpc/types.ts Normal file
View File

@ -0,0 +1 @@
export type * from '../../../src/jsonrpc/types';

47
gui/src/main.css Normal file
View File

@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
body {
width: 100vw;
height: 100vh;
}
#root {
height: 100vh;
overflow: hidden
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
}
/* custom classes */
.flex-auto-all > * {
@apply flex-auto;
}
.full-card {
@apply card p-2 h-full;
}

15
gui/src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { LogProvider } from "services/s3si";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<LogProvider limit={100}>
<BrowserRouter>
<App />
</BrowserRouter>
</LogProvider>
</React.StrictMode >
);

75
gui/src/pages/Guide.tsx Normal file
View File

@ -0,0 +1,75 @@
import classNames from 'classnames';
import { Header } from 'components/Header';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type StepState = {
next: boolean,
prev: boolean,
}
type Step = {
title: string,
element: React.FC<{ onChange: (v: StepState) => void }>,
}
const Steps: React.FC<{ steps: Step[], className?: string }> = ({ className, steps }) => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [state, setState] = useState({ next: true, prev: true });
const hasPrev = step > 0;
const hasNext = step < steps.length - 1;
const Content = steps[step].element;
return <div className={`flex flex-col items-center ${className}`}>
{/* <ul className="steps w-full mb-4">
{steps.map(({ title }, i) => <li key={i} className={classNames("step", {
'step-primary': i <= step,
})}>{title}</li>)}
</ul> */}
{Content && <Content onChange={setState} />}
<div className='mt-4 flex gap-2'>
<button
onClick={() => setStep(s => s - 1)}
className={classNames('btn', {
'btn-disabled': !hasPrev || !state.prev,
})}
>{t('上一步')}</button>
<button
onClick={() => setStep(s => s + 1)}
className={classNames('btn', {
'btn-disabled': !hasNext || !state.next,
})}
>{t('下一步')}</button>
</div>
</div>
}
const LoginNintendoAccount: React.FC<{ onChange: (v: StepState) => void }> = ({ onChange }) => {
const { t } = useTranslation();
return <div className='my-3'>
<button className='btn' onClick={() => onChange({ next: true, prev: true })}>{t('点击登录')}</button>
</div>
}
export const Guide: React.FC = () => {
const { t } = useTranslation();
const steps: Step[] = [{
title: t('登录Nintendo Account'),
element: LoginNintendoAccount,
}, {
title: t('填写stat.ink API密钥'),
element: () => <></>,
}, {
title: t('完成'),
element: () => <></>,
}]
return <div className="full-card">
<Header title={t('设置向导')} />
<Steps className='mt-4' steps={steps} />
</div>
}

25
gui/src/pages/Home.tsx Normal file
View File

@ -0,0 +1,25 @@
import { OpenSplatnet } from 'components/OpenSplatnet';
import { LogPanel, RunPanel } from 'components/RunPanel';
import { STAT_INK } from 'constant';
import React from 'react'
import { useTranslation } from 'react-i18next';
import { Link } from "react-router-dom";
export const Home: React.FC = () => {
const { t } = useTranslation();
return <div className='flex p-2 w-full h-full gap-2'>
<div className='max-w-full h-full md:max-w-sm flex-auto'>
<div className='flex flex-col gap-2 h-full'>
<LogPanel className='sm:hidden flex-auto' />
<RunPanel />
<Link to='/settings' className='btn'>{t('设置')}</Link>
<div className='flex gap-2 flex-auto-all'>
<OpenSplatnet>{t('打开鱿鱼圈3')}</OpenSplatnet>
<a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a>
</div>
</div>
</div>
<LogPanel className='hidden sm:block flex-1' />
</div>
}

View File

@ -0,0 +1,25 @@
import { Loading } from 'components/Loading';
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
export const RedirectLogin: React.FC = () => {
const { t } = useTranslation();
const state = useLocation();
useEffect(() => {
const search = state.search ?? '';
const index = search.indexOf('url=');
if (index === -1) {
return;
}
const url = decodeURIComponent(search.substring(index + 4));
window.location.href = url;
}, [state])
return <div className='h-full flex justify-center items-center'>
<span className='flex justify-center items-center gap-1'><Loading className='align-middle' />{t('正在跳转到登录页面...')}</span>
</div>
}

185
gui/src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,185 @@
import { ErrorContent } from 'components/ErrorContent';
import { Loading } from 'components/Loading';
import { usePromise, usePromiseLazy } from 'hooks/usePromise';
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next';
import { Config, getConfig, getProfile, Profile, setConfig, setProfile } from 'services/config';
import { composeLoadable } from 'utils/composeLoadable';
import classNames from 'classnames';
import { useLogin } from 'services/s3si';
import { STAT_INK } from 'constant';
import { Header } from 'components/Header';
import { useSubField } from 'hooks/useSubField';
import { useNavigate } from 'react-router-dom';
const STAT_INK_KEY_LENGTH = 43;
const Page: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
return <div className='full-card'>
<Header title={t('设置')} />
{children}
</div>
}
type FormData = {
config: Config,
profile: Profile,
}
const SPLATNET3_LANGS = {
"de-DE": "German",
"en-GB": "English (UK/Australia)",
"en-US": "English (US)",
"es-ES": "Spanish (Spain)",
"es-MX": "Spanish (Latin America)",
"fr-CA": "French (Canada)",
"fr-FR": "French (France)",
"it-IT": "Italian",
"ja-JP": "Japanese",
"ko-KR": "Korean",
"nl-NL": "Dutch",
"ru-RU": "Russian",
"zh-CN": "Chinese (China)",
"zh-TW": "Chinese (Taiwan)"
}
const UI_LANGS = {
"en": "English",
"zh-CN": "简体中文",
"ja": "日本語",
};
const Form: React.FC<{
oldValue: FormData,
onSaved?: () => void,
}> = ({ oldValue, onSaved }) => {
const { login } = useLogin();
const { t, i18n } = useTranslation();
const [value, setValue] = useState(oldValue);
const { subField } = useSubField({ value, onChange: setValue });
const changed = JSON.stringify(value) !== JSON.stringify(oldValue);
const sessionToken = subField('profile.state.loginState.sessionToken')
const statInkApiKey = subField('profile.state.statInkApiKey')
const splatnet3Lang = subField('profile.state.userLang')
const [onSave, { loading, error }] = usePromiseLazy(async () => {
await setProfile(0, value.profile);
await setConfig(value.config);
onSaved?.();
})
const [onLogin, loginState] = usePromiseLazy(async () => {
const result = await login();
if (!result) {
return;
}
sessionToken.onChange(result.sessionToken);
})
const statInkKeyError = (statInkApiKey.value?.length ?? STAT_INK_KEY_LENGTH) !== STAT_INK_KEY_LENGTH;
return <>
<div className='card'>
<div className="form-control w-full max-w-md mb-4">
<label className="label">
<span className="label-text">{t('Nintendo Account 会话令牌')}</span>
<span className="label-text-alt"><button
className={classNames('link', {
loading: loginState.loading,
})}
onClick={onLogin}
disabled={loginState.loading}
>{t('网页登录')}</button></span>
</label>
<input
className="input input-bordered w-full"
type="text"
placeholder={t('请点击右上角的登录填入') ?? undefined}
value={sessionToken.value ?? ''}
onChange={e => sessionToken.onChange(e.target.value)}
/>
</div>
<div className="form-control w-full max-w-md mb-4">
<label className="label">
<span className="label-text">{t('stat.ink API密钥')}</span>
<span className="label-text-alt"><a
className='underline'
target='_blank'
rel='noopener noreferrer'
href={`${STAT_INK}/profile`}
title={t('打开 stat.ink') ?? undefined}
>{t('查看API密钥')}</a></span>
</label>
<div className='tooltip' data-tip={statInkKeyError ? t('密钥的长度应该为{{length}}, 请检查', { length: STAT_INK_KEY_LENGTH }) : null}>
<input
className={classNames("input input-bordered w-full", {
'input-error': statInkKeyError,
})}
type="text"
placeholder={t('请从stat.ink中获取API密钥') ?? undefined}
value={statInkApiKey.value ?? ''}
onChange={e => statInkApiKey.onChange(e.target.value)}
/>
</div>
</div>
<div className="form-control w-full max-w-md mb-4">
<label className="label">
<span className="label-text">{t('鱿鱼圈3语言偏好')}</span>
</label>
<select className="select w-full" value={splatnet3Lang.value} onChange={
e => splatnet3Lang.onChange(e.target.value)
}>
{Object.entries(SPLATNET3_LANGS).map(([key, value]) => <option key={key} value={key}>{value} ({key})</option>)}
</select>
</div>
<div className="form-control w-full max-w-md mb-4">
<label className="label">
<span className="label-text">{t('界面语言')}</span>
</label>
<select className="select w-full" value={i18n.language} onChange={
e => {
i18n.changeLanguage(e.target.value);
}
}>
{Object.entries(UI_LANGS).map(([key, value]) => <option key={key} value={key}>{value} ({key})</option>)}
</select>
</div>
</div>
<ErrorContent error={error} />
<div className='flex gap-4 max-w-md justify-between flex-auto-all'>
<div className="tooltip" data-tip={changed ? undefined : t('没有更改')}>
<button className={classNames('btn btn-primary w-full', {
loading,
})} onClick={onSave} disabled={!changed || statInkKeyError}>{t('保存')}</button>
</div>
<button className={classNames('btn', {
loading,
})} onClick={() => setValue(oldValue)}>{t('重置')}</button>
</div>
</>
}
export const Settings: React.FC = () => {
const navigate = useNavigate();
let { loading, error, retry, result } = composeLoadable({
config: usePromise(getConfig),
profile: usePromise(() => getProfile(0)),
});
if (loading) {
return <Page>
<div className='h-full flex items-center justify-center'><Loading /></div>
</Page>
}
if (error) {
return <Page>
<ErrorContent error={error} retry={retry} />
</Page>
}
return <Page>
{result && <Form oldValue={result} onSaved={() => navigate(-1)} />}
</Page>
}

View File

@ -0,0 +1,64 @@
import { fs } from "@tauri-apps/api"
import { appConfigDir, join } from '@tauri-apps/api/path'
import { State } from '../../../src/state';
const configFile = appConfigDir().then(c => join(c, 'config.json'));
const profileDir = appConfigDir().then(c => join(c, 'profile'));
export type Profile = {
state: State,
}
export type Config = {
}
// TODO: import from state.ts.
const DEFAULT_STATE: State = {
cacheDir: "./cache",
fGen: "https://api.imink.app/f",
fileExportPath: "./export",
monitorInterval: 500,
};
const defaultProfile: Profile = {
state: DEFAULT_STATE,
}
const defaultConfig: Config = {
}
export async function initFiles() {
await fs.createDir(await profileDir, { recursive: true });
await configFile;
}
initFiles().catch(console.error);
export async function getConfig(): Promise<Config> {
try {
const config = await fs.readTextFile(await configFile);
return JSON.parse(config);
} catch (e) {
return defaultConfig;
}
}
export async function setConfig(config: Config) {
await fs.writeTextFile(await configFile, JSON.stringify(config));
}
export async function getProfile(index: number): Promise<Profile> {
try {
const profile = await fs.readTextFile(await profileDir.then(c => join(c, `${index}.json`)));
return JSON.parse(profile);
} catch (e) {
return defaultProfile;
}
}
export async function setProfile(index: number, profile: Profile) {
await fs.writeTextFile(await profileDir.then(c => join(c, `${index}.json`)), JSON.stringify(profile));
}
export function canExport(profile: Profile): boolean {
return !!(profile.state.loginState?.sessionToken && profile.state.statInkApiKey)
}

150
gui/src/services/s3si.tsx Normal file
View File

@ -0,0 +1,150 @@
import { invoke } from "@tauri-apps/api";
import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc";
import { ExportOpts, Log, LoggerLevel, State } from "jsonrpc/types";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
const client = new JSONRPCClient<S3SIService>({
transport: new StdioTransport()
}).getProxy();
const LOG_SUB = new Set<(logs: Log[]) => void>();
async function getLogs() {
while (true) {
const r = await client.getLogs()
if (r.error) {
throw new Error(r.error.message);
}
for (const { level, msg } of r.result) {
switch (level) {
case 'debug':
console.debug(...msg);
break;
case 'log':
console.log(...msg);
break;
case 'warn':
console.warn(...msg);
break;
case 'error':
console.error(...msg);
break;
}
}
for (const cb of LOG_SUB) {
cb(r.result);
}
}
}
getLogs()
export function addLog(...log: Log[]) {
for (const cb of LOG_SUB) {
cb(log);
}
}
const LOG_CONTEXT = createContext<{
logs: Log[],
renderedLogs: React.ReactNode[]
}>({
logs: [],
renderedLogs: [],
});
export const useLog = () => {
return useContext(LOG_CONTEXT);
}
function renderMsg(i: any) {
if (i instanceof Error) {
return i.message
}
return String(i)
}
const DISPLAY_MAP: Record<LoggerLevel, string> = {
debug: 'DEBUG',
log: 'INFO',
warn: 'WARN',
error: 'ERROR',
}
function renderLevel(log: Log) {
return `[${DISPLAY_MAP[log.level]}]`.padEnd(7)
}
function renderLog(log: Log) {
return `${renderLevel(log)} ${log.msg.map(renderMsg).join(' ')}`
}
export const LogProvider: React.FC<{ limit?: number, children?: React.ReactNode }> = ({ children, limit = 10 }) => {
const [logs, setLogs] = useState<Log[]>([]);
useEffect(() => {
const cb = (logs: Log[]) => {
setLogs(old => [...old, ...logs].slice(-limit));
}
LOG_SUB.add(cb);
return () => {
LOG_SUB.delete(cb);
}
}, [limit])
const renderedLogs = useMemo(() => logs.map(renderLog), [logs])
return <LOG_CONTEXT.Provider value={{
logs,
renderedLogs,
}}>
{children}
</LOG_CONTEXT.Provider>
}
export const useLogin = () => {
const login = useCallback(async () => {
const result = await client.loginSteps();
if (result.error) {
throw new Error(result.error.message);
}
const login: string | null = await invoke('open_login_window', {
url: result.result.url
})
if (login === null || login === '') {
console.log('user cancel login');
return;
}
const loginResult: { url: string } = JSON.parse(login);
const sessionToken = await client.loginSteps({
authCodeVerifier: result.result.authCodeVerifier,
login: loginResult.url,
})
if (sessionToken.error) {
throw new Error(sessionToken.error.message);
}
return sessionToken.result;
}, [])
return {
login
}
}
export async function run(state: State, opts: ExportOpts) {
const r = await client.run(state, opts);
if (r.error) {
throw new Error(r.error.message);
}
return r.result;
}
export async function ensureTokenValid(state: State) {
const r = await client.ensureTokenValid(state);
if (r.error) {
throw new Error(r.error.message);
}
return r.result;
}

View File

@ -0,0 +1,19 @@
export type Loadable<T> = {
loading: boolean;
result?: T;
error?: any;
retry?: () => void;
}
export function composeLoadable<T extends Record<string, Loadable<any>>>(map: T): Loadable<{
[P in keyof T]: T[P] extends Loadable<infer R> ? R : never
}> {
const values = Object.values(map)
const loading = values.some(v => v.loading);
const error = values.find(v => v.error)?.error;
const result = loading || error ? undefined : Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v.result])) as any;
const retry = values.some(i => !!i.retry) ? () => Object.values(map).forEach(v => v.retry?.()) : undefined;
return { loading, result, error, retry };
}

3
gui/src/utils/sleep.ts Normal file
View File

@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}

1
gui/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

10
gui/tailwind.config.cjs Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require("daisyui")],
}

22
gui/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"baseUrl": "src",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
gui/tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

36
gui/vite.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from 'vite-tsconfig-paths';
import eslint from 'vite-plugin-eslint';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
eslint(),
],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
proxy: {
'/locales/': 'http://127.0.0.1:1421'
}
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13",
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
});

View File

@ -14,6 +14,7 @@ const parseArgs = (args: string[]) => {
"monitor": ["m"], "monitor": ["m"],
"skipMode": ["s", "skip-mode"], "skipMode": ["s", "skip-mode"],
"withSummary": "with-summary", "withSummary": "with-summary",
"withStages": "with-stages",
}, },
}); });
return parsed; return parsed;
@ -34,6 +35,7 @@ Options:
--skip-mode <mode>, -s Skip mode (default: null) --skip-mode <mode>, -s Skip mode (default: null)
("vs", "coop") ("vs", "coop")
--with-summary Include summary in the output --with-summary Include summary in the output
--with-stages Include stage records in the output
--help Show this help message and exit`, --help Show this help message and exit`,
); );
Deno.exit(0); Deno.exit(0);

84
scripts/compile.ts Normal file
View File

@ -0,0 +1,84 @@
import * as path from "https://deno.land/std@0.178.0/path/mod.ts";
import { assertEquals } from "../dev_deps.ts";
if (import.meta.main) {
const __dirname = path.dirname(path.fromFileUrl(import.meta.url));
const TARGETS = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
];
const rustInfo = await (new Deno.Command("rustc", {
args: ["-Vv"],
})).output();
const target =
/host: (\S+)/g.exec(new TextDecoder().decode(rustInfo.stdout))?.[1] ?? "?";
if (!TARGETS.includes(target)) {
console.error(`Unsupported target: ${target}`);
Deno.exit(1);
}
const p = new Deno.Command("deno", {
args: [
"compile",
"--target",
target,
"-o",
`../gui/binaries/s3si-${target}`,
"-A",
"../src/daemon.ts",
],
cwd: __dirname,
});
const status = await p.output();
if (!status.success) {
console.error(
"Failed to run deno compile for target",
target,
"code:",
status.code,
);
Deno.exit(status.code);
}
const binPath = `${__dirname}/../gui/binaries/s3si-${target}${
Deno.build.os === "windows" ? ".exe" : ""
}`;
console.log("Test the binary");
const s3si = new Deno.Command(binPath, {
stdin: "piped",
stdout: "piped",
}).spawn();
const s3siWriter = s3si.stdin.getWriter();
await s3siWriter.write(
new TextEncoder().encode(
'{"jsonrpc":"2.0","method":"hello","params":[],"id":1}\n',
),
);
const output = new TextDecoder().decode(
(await s3si.stdout.getReader().read()).value,
);
await s3siWriter.close();
assertEquals(
output,
'{"jsonrpc":"2.0","id":1,"result":{"result":"world"}}\n',
);
console.log("Test passed");
const hashBuffer = await crypto.subtle.digest(
"SHA-256",
await Deno.readFile(binPath),
);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// 将 Uint8Array 转换为十六进制字符串形式的散列值
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
"",
);
console.log("Hash:", hashHex);
}

View File

@ -3,7 +3,7 @@ import { DEFAULT_ENV } from "../src/env.ts";
import { MongoDBExporter } from "../src/exporters/mongodb.ts"; import { MongoDBExporter } from "../src/exporters/mongodb.ts";
import { FileStateBackend, Profile } from "../src/state.ts"; import { FileStateBackend, Profile } from "../src/state.ts";
const OLD_BATTLES_END_DATE = new Date('2023-02-28T03:42:47.000+00:00'); const OLD_BATTLES_END_DATE = new Date("2023-02-28T03:42:47.000+00:00");
const env = DEFAULT_ENV; const env = DEFAULT_ENV;
const stateBackend = new FileStateBackend("./profile.json"); const stateBackend = new FileStateBackend("./profile.json");
@ -37,11 +37,13 @@ for await (const doc of cursor) {
const splatNetId = splatNetData.id; const splatNetId = splatNetData.id;
const uniqueId = MongoDBExporter.getGameId(splatNetId); const uniqueId = MongoDBExporter.getGameId(splatNetId);
await battlesCollection.updateOne({ _id }, { "$set": { await battlesCollection.updateOne({ _id }, {
"$set": {
gameId: uniqueId, gameId: uniqueId,
}}); },
});
console.log(`Updated ${splatNetId} to ${uniqueId}`); console.log(`Updated ${splatNetId} to ${uniqueId}`);
} }
console.log("Done!") console.log("Done!");

1
scripts/deno.json Normal file
View File

@ -0,0 +1 @@
{}

187
scripts/deno.lock Normal file
View File

@ -0,0 +1,187 @@
{
"version": "2",
"remote": {
"https://deno.land/std@0.141.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.141.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d",
"https://deno.land/std@0.141.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9",
"https://deno.land/std@0.141.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.141.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37",
"https://deno.land/std@0.141.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
"https://deno.land/std@0.141.0/io/types.d.ts": "01f60ae7ec02675b5dbed150d258fc184a78dfe5c209ef53ba4422b46b58822c",
"https://deno.land/std@0.141.0/streams/conversion.ts": "8268f3f1a43324953dd8e9e4e31adb42e3caddb4502433bde03c279e43d70a3b",
"https://deno.land/std@0.160.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.160.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
"https://deno.land/std@0.160.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.160.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.160.0/bytes/mod.ts": "b2e342fd3669176a27a4e15061e9d588b89c1aaf5008ab71766e23669565d179",
"https://deno.land/std@0.160.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2",
"https://deno.land/std@0.160.0/flags/mod.ts": "686b6b36e14b00f11c9e26cecf439021158436a6e34f60eeb0d927f0b169ae20",
"https://deno.land/std@0.160.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4",
"https://deno.land/std@0.160.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
"https://deno.land/std@0.160.0/io/mod.ts": "6e781ebafd5cdccf9ab4afa1f499b08c513602d023cb08ceebc58758501f78bd",
"https://deno.land/std@0.160.0/io/readers.ts": "45847ad404afd2f605eae1cff193f223462bc55eeb9ae313c2f3db28aada0fd6",
"https://deno.land/std@0.160.0/io/types.d.ts": "107e1e64834c5ba917c783f446b407d33432c5d612c4b3430df64fc2b4ecf091",
"https://deno.land/std@0.160.0/io/util.ts": "23e706b4b6a3ebb34af00ad74d7549d906f3211fc98c1fba1185a36e017fb727",
"https://deno.land/std@0.160.0/io/writers.ts": "2e1c63ffd0cfba411b1fd8374609abff9ea86187c9d4d885d42e6fc20325ef0e",
"https://deno.land/std@0.160.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
"https://deno.land/std@0.160.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
"https://deno.land/std@0.160.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
"https://deno.land/std@0.160.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
"https://deno.land/std@0.160.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
"https://deno.land/std@0.160.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
"https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
"https://deno.land/std@0.160.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
"https://deno.land/std@0.160.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
"https://deno.land/std@0.160.0/streams/conversion.ts": "328afbedee0a7e0c330ac4c7b4c1af569ee53974f970230f6a78f545b93abb9b",
"https://deno.land/std@0.160.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c",
"https://deno.land/std@0.160.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832",
"https://deno.land/std@0.160.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8",
"https://deno.land/std@0.160.0/uuid/_common.ts": "76e1fdfb03aecf733f7b3a5edc900f5734f2433b359fdb1535f8de72873bdb3f",
"https://deno.land/std@0.160.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5",
"https://deno.land/std@0.160.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295",
"https://deno.land/std@0.160.0/uuid/v4.ts": "3e983c6ac895ea2a7ba03da927a2438fe1c26ac43fb38dc44f2f8aa50c23cb53",
"https://deno.land/std@0.160.0/uuid/v5.ts": "43973aeda44ad212f2ec9b8d6c042b74d5cef4ce583d6aa6fc4cdb339344c74c",
"https://deno.land/std@0.178.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
"https://deno.land/std@0.178.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3",
"https://deno.land/std@0.178.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
"https://deno.land/std@0.178.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
"https://deno.land/std@0.178.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0",
"https://deno.land/std@0.178.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000",
"https://deno.land/std@0.178.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1",
"https://deno.land/std@0.178.0/path/mod.ts": "4b83694ac500d7d31b0cdafc927080a53dc0c3027eb2895790fb155082b0d232",
"https://deno.land/std@0.178.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d",
"https://deno.land/std@0.178.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1",
"https://deno.land/std@0.178.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie.ts": "72d6a6633ea13dd2f13b53d9726735b194996353a958024072c4d6b077c97baf",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie_jar.ts": "9accd36e76929f2f06fa710d2165fb544703617245fa36ac63560b9fa2a22a25",
"https://deno.land/x/another_cookiejar@v4.1.4/fetch_wrapper.ts": "d8918c0776413b2d4a675415727973390b4401a026f6dfdcffedce3296b5e0dc",
"https://deno.land/x/another_cookiejar@v4.1.4/mod.ts": "eff949014965771f2cd447fe78625a1ad28b59333afa40640f02c0922534d89a",
"https://deno.land/x/msgpack@v1.4/CachedKeyDecoder.ts": "c39b6f1572902ae08c0e4971f639e81031ac59403957fc43c6fb3c7fe69d99a1",
"https://deno.land/x/msgpack@v1.4/Decoder.ts": "bdb68309cd51da2b9a897f269784c6d636796258838a97f25b0e1b399c6f369b",
"https://deno.land/x/msgpack@v1.4/Encoder.ts": "4852bbacb30cd66eb2bd61a9e20476802458b991e13aacb5eb984d0348247ffe",
"https://deno.land/x/msgpack@v1.4/ExtData.ts": "8d97fe43568e119a1eeb93e1ef1c431e0a24e392fb0c6ffed775aac1e579f244",
"https://deno.land/x/msgpack@v1.4/ExtensionCodec.ts": "e8a24eb1786156239f589cc3058c8ff3d79ed393f420c40fdf7a93df943c91f2",
"https://deno.land/x/msgpack@v1.4/context.ts": "6228de10854dbadf6aef096960af0115214078ec3784eca4565587769fde3d1c",
"https://deno.land/x/msgpack@v1.4/decode.ts": "c808aeec46f6d0e5b28d0bbacd40e78d0a3614b229368c70db2e53c03f7555ca",
"https://deno.land/x/msgpack@v1.4/decodeAsync.ts": "19e4f33ba0cc8d200b857deb9721bace863c0e89f7bff73e2b04379e4ee85bad",
"https://deno.land/x/msgpack@v1.4/encode.ts": "c5598f8eec9efcbd0ef07f246ade049a8f4906703cdb601baf03b2774b293916",
"https://deno.land/x/msgpack@v1.4/mod.ts": "c28290db26b1ea027e1798085fd6c8055685ea086f1418d54a33542b285633c9",
"https://deno.land/x/msgpack@v1.4/timestamp.ts": "5169949efe39bc24f58cd5dcaae682cdf5353c762a54abf9ae6e18c8d9feb648",
"https://deno.land/x/msgpack@v1.4/utils/int.ts": "b08743982f954d2dd7f4f11d868019576b63cb8147d8acc1bce3843f39398188",
"https://deno.land/x/msgpack@v1.4/utils/prettyByte.ts": "35c8104d57ba2a727056beaf1063bbe941d512cdd23ce6b04d7c5b44dafcd46e",
"https://deno.land/x/msgpack@v1.4/utils/stream.ts": "1315e29af5c1a40d97bfa6f1c4f7f73d26067b912236f56851981f2f049500b8",
"https://deno.land/x/msgpack@v1.4/utils/typedArrays.ts": "bb819c2f28cf7f85ed50b2e57f108462715555cc61ce315e8134cf1eef2ae662",
"https://deno.land/x/msgpack@v1.4/utils/utf8.ts": "93183055a7a41986080eeb711e83d553e7c8b121642da4379a5adf253b7beefd",
"https://deno.land/x/murmurhash@v1.0.0/mod.ts": "13fd2c5534dfd22ffbfcd4255ea13e47a2f2b99e9c90a83dc43e814a0e278829",
"https://deno.land/x/progress@v1.2.8/deps.ts": "e0abdc972a0c152508b28ced5ae9c4be26a5773f0aa4a3caa72371c84d2e28a2",
"https://deno.land/x/progress@v1.2.8/mod.ts": "5ef7c7ff079d71effed5055666af81cc58a566bc98e2df8473526bd6457976c5",
"https://deno.land/x/progress@v1.2.8/multi.ts": "392553552243204539d83ee53cadda990db20b1b421520411318ff9bd0320646",
"https://deno.land/x/semaphore@v1.1.1/mod.ts": "431abb51927a16c537cec1cfb05bf2de6a8f3916331f1ec3f9f13ad7ad6a4ea5",
"https://deno.land/x/semaphore@v1.1.1/mutex.ts": "2cc6490481f0fdfe97c6b326a2073819d76b76eac3877864a8ada6a2127492f2",
"https://deno.land/x/semaphore@v1.1.1/semaphore.ts": "0acf1159d635fa3b9198a4ad4acac9e877d79196601aa80544ac0db5a71c646d",
"https://deno.land/x/ts_essentials@v9.1.2/lib/functions.ts": "20681c98ce82d503dba56f5ef9313c196f18a2317ce7c0c331cc3fdea0d56688",
"https://deno.land/x/ts_essentials@v9.1.2/lib/literal-types/mod.ts": "c1b9e16a7e49814e9509bed8a5dec25b717761a37d0ef1589d411bd6130dd2e5",
"https://deno.land/x/ts_essentials@v9.1.2/lib/mod.ts": "d7e44a25aa621425ffd118a0210a492c5c354411018e2db648a68614d5901f5b",
"https://deno.land/x/ts_essentials@v9.1.2/lib/types.ts": "7ee99797a880948c07020e90d569ca3c5d465c378949262110283aa7856f5603",
"https://deno.land/x/ts_essentials@v9.1.2/mod.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345"
},
"npm": {
"specifiers": {
"mongodb": "mongodb@5.1.0",
"splatnet3-types": "splatnet3-types@0.2.20230227204004"
},
"packages": {
"@types/node@18.14.2": {
"integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==",
"dependencies": {}
},
"@types/webidl-conversions@7.0.0": {
"integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==",
"dependencies": {}
},
"@types/whatwg-url@8.2.2": {
"integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
"dependencies": {
"@types/node": "@types/node@18.14.2",
"@types/webidl-conversions": "@types/webidl-conversions@7.0.0"
}
},
"bson@5.0.1": {
"integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==",
"dependencies": {}
},
"ip@2.0.0": {
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dependencies": {}
},
"memory-pager@1.5.0": {
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"dependencies": {}
},
"mongodb-connection-string-url@2.6.0": {
"integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
"dependencies": {
"@types/whatwg-url": "@types/whatwg-url@8.2.2",
"whatwg-url": "whatwg-url@11.0.0"
}
},
"mongodb@5.1.0": {
"integrity": "sha512-qgKb7y+EI90y4weY3z5+lIgm8wmexbonz0GalHkSElQXVKtRuwqXuhXKccyvIjXCJVy9qPV82zsinY0W1FBnJw==",
"dependencies": {
"bson": "bson@5.0.1",
"mongodb-connection-string-url": "mongodb-connection-string-url@2.6.0",
"saslprep": "saslprep@1.0.3",
"socks": "socks@2.7.1"
}
},
"punycode@2.3.0": {
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
"dependencies": {}
},
"saslprep@1.0.3": {
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"dependencies": {
"sparse-bitfield": "sparse-bitfield@3.0.3"
}
},
"smart-buffer@4.2.0": {
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dependencies": {}
},
"socks@2.7.1": {
"integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
"dependencies": {
"ip": "ip@2.0.0",
"smart-buffer": "smart-buffer@4.2.0"
}
},
"sparse-bitfield@3.0.3": {
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": {
"memory-pager": "memory-pager@1.5.0"
}
},
"splatnet3-types@0.2.20230227204004": {
"integrity": "sha512-FAY6pbUcrp5O8c49BNXSKxoyM3UlCrRx2AtA9Y3qlvqOLdHqwxtzcdzbk1b1hRam8ZcrxRzE/ii6ESRiPIAnZw==",
"dependencies": {}
},
"tr46@3.0.0": {
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dependencies": {
"punycode": "punycode@2.3.0"
}
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dependencies": {}
},
"whatwg-url@11.0.0": {
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"dependencies": {
"tr46": "tr46@3.0.0",
"webidl-conversions": "webidl-conversions@7.0.0"
}
}
}
}
}

View File

@ -0,0 +1,47 @@
import { MongoDB } from "../deps.ts";
import { DEFAULT_ENV } from "../src/env.ts";
import { MongoDBExporter } from "../src/exporters/mongodb.ts";
import { FileStateBackend, Profile } from "../src/state.ts";
const env = DEFAULT_ENV;
const stateBackend = new FileStateBackend("./profile.json");
const profile = new Profile({ stateBackend, env });
await profile.readState();
if (!profile.state.mongoDbUri) {
console.error("MongoDB URI not set");
Deno.exit(1);
}
const mongoDbClient = new MongoDB.MongoClient(profile.state.mongoDbUri);
const battlesCollection = mongoDbClient.db("splashcat").collection("battles");
const filter = {
"splatNetData.playedTime": {
$type: "string",
},
};
const cursor = battlesCollection.find(filter);
const oldDocuments = await battlesCollection.countDocuments(filter);
console.log(`Found ${oldDocuments} old battles to update...`);
for await (const doc of cursor) {
const { splatNetData, _id } = doc;
await battlesCollection.updateOne({ _id }, {
"$set": {
"splatNetData.playedTime": new Date(splatNetData.playedTime),
},
});
console.log(
`Updated ${splatNetData.playedTime} to ${new Date(
splatNetData.playedTime,
)}`,
);
}
console.log("Done!");

View File

@ -0,0 +1,77 @@
import { MongoDB } from "../deps.ts";
import { DEFAULT_ENV } from "../src/env.ts";
import { MongoDBExporter } from "../src/exporters/mongodb.ts";
import { FileStateBackend, Profile } from "../src/state.ts";
const env = DEFAULT_ENV;
const stateBackend = new FileStateBackend("./profile.json");
const profile = new Profile({ stateBackend, env });
await profile.readState();
if (!profile.state.mongoDbUri) {
console.error("MongoDB URI not set");
Deno.exit(1);
}
const mongoDbClient = new MongoDB.MongoClient(profile.state.mongoDbUri);
const battlesCollection = mongoDbClient.db("splashcat").collection("battles");
const cursor = battlesCollection.find();
const oldDocuments = await battlesCollection.countDocuments();
console.log(`Found ${oldDocuments} old battles to upload...`);
let count = 0;
const erroredBattles = [];
for await (const doc of cursor) {
const { splatNetData, _id } = doc;
// start time for performance tracking, needs to be very accurate
const startTime = new Date();
splatNetData.playedTime = splatNetData.playedTime.toISOString();
const response = await fetch("http://127.0.0.1:8000/battles/api/upload/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${profile.state.splashcatApiKey}`,
},
body: JSON.stringify({
"data_type": "splatnet3",
"battle": splatNetData,
})
})
if (!response.ok) {
console.error(`Failed to upload ${splatNetData.id}`);
erroredBattles.push({
id: doc.gameId,
error: await response.text(),
});
}
// end time for performance tracking, needs to be very accurate
const endTime = new Date();
const timeTaken = endTime.getTime() - startTime.getTime();
console.log(`Uploaded ${splatNetData.id} (${timeTaken}ms)`);
count++;
console.log(`Uploaded ${count}/${oldDocuments} battles`)
if (count % 100 === 0) {
console.log("Updating error logs...");
if (erroredBattles.length > 0) {
await Deno.writeFile("./errored-battles.json", new TextEncoder().encode(JSON.stringify(erroredBattles, null, "\t")));
}
}
}
console.log("Done!");
if (erroredBattles.length > 0) {
await Deno.writeFile("./errored-battles.json", new TextEncoder().encode(JSON.stringify(erroredBattles, null, 2)));
}

View File

@ -30,6 +30,62 @@ function getConst(content: string, name: string): string {
return JSON.parse(match[1]); return JSON.parse(match[1]);
} }
function replaceEnum(
content: string,
name: string,
pairs: Record<string, string>,
): string {
const regex = new RegExp(`export enum ${name} {([\\s\\S^}]+?)}`);
const body = Object.entries(pairs).map(([key, value]) =>
` ${key} = "${value}"`
).join(",\n");
return content.replace(regex, `export enum ${name} {\n${body}\n}`);
}
function getEnumKeys(content: string, name: string): string[] {
const regex = new RegExp(`export enum ${name} {([\\s\\S^}]+?)}`);
const match = regex.exec(content);
if (!match) {
throw new Error(`Cannot find ${name}`);
}
const body = match[1];
// extract keys from `key = "value"`
const keys: string[] = [];
const keyRE = /\s*(\w+)\s*=/g;
while (true) {
const match = keyRE.exec(body);
if (!match) {
break;
}
keys.push(match[1]);
}
return keys;
}
function getQueryHash(js: string, query: string): string {
const regex = new RegExp(
`params:\\{id:"([^"]*?)",metadata:{},name:"${query}"`,
);
const match = regex.exec(js);
if (!match) {
throw new Error(`Cannot find ${query}`);
}
if (match[0].length > 500) {
throw new Error(`Match too large ${match[0].length}`);
}
return match[1];
}
async function printError<T>(p: Promise<T>): Promise<T | undefined> { async function printError<T>(p: Promise<T>): Promise<T | undefined> {
try { try {
return await p; return await p;
@ -39,7 +95,7 @@ async function printError<T>(p: Promise<T>): Promise<T | undefined> {
} }
} }
async function getWebViewVer(): Promise<string> { async function getMainJSBody(): Promise<string> {
const splatnet3Home = await (await fetch(SPLATNET3_URL)).text(); const splatnet3Home = await (await fetch(SPLATNET3_URL)).text();
const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1]; const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1];
@ -50,9 +106,16 @@ async function getWebViewVer(): Promise<string> {
const mainJSBody = await (await fetch(SPLATNET3_URL + mainJS)).text(); const mainJSBody = await (await fetch(SPLATNET3_URL + mainJS)).text();
const revision = /"([0-9a-f]{40})"/.exec(mainJSBody)?.[1]; return mainJSBody;
}
const mainJSBody = await getMainJSBody();
// deno-lint-ignore require-await
async function getWebViewVer(js: string): Promise<string> {
const revision = /"([0-9a-f]{40})"/.exec(js)?.[1];
const version = /revision_info_not_set.*?=("|`)(\d+\.\d+\.\d+)-/.exec( const version = /revision_info_not_set.*?=("|`)(\d+\.\d+\.\d+)-/.exec(
mainJSBody, js,
) )
?.[2]; ?.[2];
@ -83,7 +146,7 @@ const oldValues = {
}; };
const newValues: Record<string, string | undefined> = {}; const newValues: Record<string, string | undefined> = {};
newValues.WEB_VIEW_VERSION = await printError(getWebViewVer()); newValues.WEB_VIEW_VERSION = await printError(getWebViewVer(mainJSBody));
newValues.NSOAPP_VERSION = await printError(getNSOVer()); newValues.NSOAPP_VERSION = await printError(getNSOVer());
for (const [key, value] of Object.entries(newValues)) { for (const [key, value] of Object.entries(newValues)) {
@ -91,8 +154,27 @@ for (const [key, value] of Object.entries(newValues)) {
content = replaceConst(content, key, value); content = replaceConst(content, key, value);
} }
} }
await Deno.writeTextFile(CONSTANT_PATH, content);
console.log("Done"); console.log("const updated");
console.log("Old:", oldValues); console.log("Old:", oldValues);
console.log("New:", newValues); console.log("New:", newValues);
const keys = getEnumKeys(content, "Queries");
const pairs = Object.fromEntries(
keys.map((key) => [key, getQueryHash(mainJSBody, key)]),
);
content = replaceEnum(content, "Queries", pairs);
console.log("query updated");
await Deno.writeTextFile(CONSTANT_PATH, content);
const command = new Deno.Command(Deno.execPath(), {
args: ["fmt", "./src/constant.ts"],
cwd: ROOT_DIR,
stdin: "inherit",
stdout: "inherit",
});
const { code } = command.outputSync();
if (code !== 0) {
Deno.exit(code);
}

View File

@ -26,6 +26,10 @@ Deno.test("getSeason", () => {
assertEquals(season3?.id, "season202303"); assertEquals(season3?.id, "season202303");
const season4 = getSeason(new Date("2023-06-01T00:00:00+00:00"));
assertEquals(season4?.id, "season202306");
const nonExist = getSeason(new Date("2022-06-09T00:00:00+00:00")); const nonExist = getSeason(new Date("2022-06-09T00:00:00+00:00"));
assertEquals(nonExist, undefined); assertEquals(nonExist, undefined);

View File

@ -24,6 +24,12 @@ export const SEASONS: Season[] = [
start: new Date("2023-03-01T00:00:00+00:00"), start: new Date("2023-03-01T00:00:00+00:00"),
end: new Date("2023-06-01T00:00:00+00:00"), end: new Date("2023-06-01T00:00:00+00:00"),
}, },
{
id: "season202306",
name: "Sizzle Season 2023",
start: new Date("2023-06-01T00:00:00+00:00"),
end: new Date("2023-09-01T00:00:00+00:00"),
},
]; ];
export const getSeason = (date: Date): Season | undefined => { export const getSeason = (date: Date): Season | undefined => {

View File

@ -17,6 +17,7 @@ export type Opts = {
noProgress: boolean; noProgress: boolean;
monitor: boolean; monitor: boolean;
withSummary: boolean; withSummary: boolean;
withStages: boolean;
skipMode?: string; skipMode?: string;
cache?: Cache; cache?: Cache;
stateBackend?: StateBackend; stateBackend?: StateBackend;
@ -29,6 +30,7 @@ export const DEFAULT_OPTS: Opts = {
noProgress: false, noProgress: false,
monitor: false, monitor: false,
withSummary: false, withSummary: false,
withStages: true,
env: DEFAULT_ENV, env: DEFAULT_ENV,
}; };
@ -310,6 +312,34 @@ export class App {
throw errors[0]; throw errors[0];
} }
} }
const stageExporters = exporters.filter((e) => e.exportStages);
if (!this.opts.withStages || stageExporters.length === 0) {
this.env.logger.log("Skip exporting stages.");
} else {
const stageRecords = await splatnet.getStageRecords();
await Promise.all(
stageExporters.map((e) =>
showError(
this.env,
e.exportStages!(stageRecords.stageRecords.nodes),
).then((result) => {
if (result.status === "success") {
this.env.logger.log(`Exported stages to ${result.url}`);
} else if (result.status === "skip") {
this.env.logger.log(`Skipped exporting stages to ${e.name}`);
} else {
const _never: never = result;
}
})
.catch((err) => {
errors.push(err);
this.env.logger.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
}
} }
async monitor() { async monitor() {
while (true) { while (true) {

522
src/assets/gear-map.json Normal file
View File

@ -0,0 +1,522 @@
[
{
"key": "ink_saver_main",
"name": {
"de-DE": "Hauptverbrauch",
"en-GB": "Ink Saver (Main)",
"en-US": "Ink Saver (Main)",
"es-ES": "Tintahorro (ppal.)",
"es-MX": "Ahorro tinta (ppal.)",
"fr-CA": "Encrémenteur (pr.)",
"fr-FR": "Encrémenteur (pr.)",
"it-IT": "Eco-colore princ.",
"ja-JP": "インク効率アップ(メイン)",
"ko-KR": "잉크 효율 업(메인)",
"nl-NL": "Hoofdspaarder",
"ru-RU": "Основной баллон X",
"zh-CN": "提升墨汁效率(主要武器)",
"zh-TW": "提升墨汁效率(主要武器)"
},
"primary_only": false
},
{
"key": "ink_saver_sub",
"name": {
"de-DE": "Sekundärverbrauch",
"en-GB": "Ink Saver (Sub)",
"en-US": "Ink Saver (Sub)",
"es-ES": "Tintahorro (sec.)",
"es-MX": "Ahorro tinta (sec.)",
"fr-CA": "Encrémenteur (sec.)",
"fr-FR": "Encrémenteur (sec.)",
"it-IT": "Eco-colore second.",
"ja-JP": "インク効率アップ(サブ)",
"ko-KR": "잉크 효율 업(서브)",
"nl-NL": "Subspaarder",
"ru-RU": "Запасной баллон X",
"zh-CN": "提升墨汁效率(次要武器)",
"zh-TW": "提升墨汁效率(次要武器)"
},
"primary_only": false
},
{
"key": "ink_recovery_up",
"name": {
"de-DE": "Regeneration +",
"en-GB": "Ink Recovery Up",
"en-US": "Ink Recovery Up",
"es-ES": "Recarga rápida",
"es-MX": "Mejor recarga tinta",
"fr-CA": "Levée d'encre",
"fr-FR": "Levée d'encre",
"it-IT": "Recupero colore +",
"ja-JP": "インク回復力アップ",
"ko-KR": "잉크 회복력 업",
"nl-NL": "Inktvulling",
"ru-RU": "Быстрый баллон",
"zh-CN": "提升墨汁回复力",
"zh-TW": "提升墨汁回復力"
},
"primary_only": false
},
{
"key": "run_speed_up",
"name": {
"de-DE": "Lauftempo +",
"en-GB": "Run Speed Up",
"en-US": "Run Speed Up",
"es-ES": "Supercarrera",
"es-MX": "Carrera acelerada",
"fr-CA": "Course à pied",
"fr-FR": "Course à pied",
"it-IT": "Velocità +",
"ja-JP": "ヒト移動速度アップ",
"ko-KR": "인간 이동 속도 업",
"nl-NL": "Hardloper",
"ru-RU": "Спринтер",
"zh-CN": "提升人类移动速度",
"zh-TW": "提升人類移動速度"
},
"primary_only": false
},
{
"key": "swim_speed_up",
"name": {
"de-DE": "Schwimmtempo +",
"en-GB": "Swim Speed Up",
"en-US": "Swim Speed Up",
"es-ES": "Superbuceo",
"es-MX": "Nado acelerado",
"fr-CA": "Turbo-calmar",
"fr-FR": "Turbo-calamar",
"it-IT": "Velocità nuoto +",
"ja-JP": "イカダッシュ速度アップ",
"ko-KR": "징어대시 속도 업",
"nl-NL": "Zwemdiploma",
"ru-RU": "Плавунец",
"zh-CN": "提升鱿鱼冲刺速度",
"zh-TW": "提升魷魚衝刺速度"
},
"primary_only": false
},
{
"key": "special_charge_up",
"name": {
"de-DE": "Spezialladezeit +",
"en-GB": "Special Charge Up",
"en-US": "Special Charge Up",
"es-ES": "Recarga especial",
"es-MX": "Recarga especial",
"fr-CA": "Jauge spéciale +",
"fr-FR": "Jauge spéciale +",
"it-IT": "Ricarica speciale +",
"ja-JP": "スペシャル増加量アップ",
"ko-KR": "스페셜 증가량 업",
"nl-NL": "Speciaallader",
"ru-RU": "Особый насос",
"zh-CN": "提升特殊武器增加量",
"zh-TW": "提升特殊武器增加量"
},
"primary_only": false
},
{
"key": "special_saver",
"name": {
"de-DE": "Spezialabzug -",
"en-GB": "Special Saver",
"en-US": "Special Saver",
"es-ES": "Reducción especial",
"es-MX": "Ahorro especial",
"fr-CA": "Baisse spéciale -",
"fr-FR": "Baisse spéciale -",
"it-IT": "Riduzione speciale -",
"ja-JP": "スペシャル減少量ダウン",
"ko-KR": "스페셜 감소량 다운",
"nl-NL": "Speciaalspaarder",
"ru-RU": "Особый резерв",
"zh-CN": "降低特殊武器减少量",
"zh-TW": "降低特殊武器減少量"
},
"primary_only": false
},
{
"key": "special_power_up",
"name": {
"de-DE": "Spezialstärke +",
"en-GB": "Special Power Up",
"en-US": "Special Power Up",
"es-ES": "Superarma especial",
"es-MX": "Mejora especial",
"fr-CA": "Arme spéciale +",
"fr-FR": "Arme spéciale +",
"it-IT": "Arma speciale +",
"ja-JP": "スペシャル性能アップ",
"ko-KR": "스페셜 성능 업",
"nl-NL": "Specialist",
"ru-RU": "Особый подход",
"zh-CN": "提升特殊武器性能",
"zh-TW": "提升特殊武器性能"
},
"primary_only": false
},
{
"key": "quick_respawn",
"name": {
"de-DE": "Schnelle Rückkehr",
"en-GB": "Quick Respawn",
"en-US": "Quick Respawn",
"es-ES": "Retorno exprés",
"es-MX": "Regeneración rápida",
"fr-CA": "Sans temps mort",
"fr-FR": "Sans temps morts",
"it-IT": "Il tempo è colore",
"ja-JP": "復活時間短縮",
"ko-KR": "부활 시간 단축",
"nl-NL": "Comeback",
"ru-RU": "Феникс",
"zh-CN": "缩短复活时间",
"zh-TW": "縮短復活時間"
},
"primary_only": false
},
{
"key": "quick_super_jump",
"name": {
"de-DE": "Supersprung +",
"en-GB": "Quick Super Jump",
"en-US": "Quick Super Jump",
"es-ES": "Supersalto rápido",
"es-MX": "Supersalto rápido",
"fr-CA": "Aérodynamisme",
"fr-FR": "Aérodynamisme",
"it-IT": "Salti super e veloci",
"ja-JP": "スーパージャンプ時間短縮",
"ko-KR": "슈퍼 점프 시간 단축",
"nl-NL": "Turbosprong",
"ru-RU": "Суперпрыгун",
"zh-CN": "缩短超级跳跃时间",
"zh-TW": "縮短超級跳躍時間"
},
"primary_only": false
},
{
"key": "sub_power_up",
"name": {
"de-DE": "Sekundärstärke +",
"en-GB": "Sub Power Up",
"en-US": "Sub Power Up",
"es-ES": "Superarma secundaria",
"es-MX": "Mejora secundaria",
"fr-CA": "Arme secondaire +",
"fr-FR": "Arme secondaire +",
"it-IT": "Arma secondaria +",
"ja-JP": "サブ性能アップ",
"ko-KR": "서브 성능 업",
"nl-NL": "Subtopper",
"ru-RU": "Про-Запас",
"zh-CN": "提升次要武器性能",
"zh-TW": "提升次要武器性能"
},
"primary_only": false
},
{
"key": "ink_resistance_up",
"name": {
"de-DE": "Tintentoleranz +",
"en-GB": "Ink Resistance Up",
"en-US": "Ink Resistance Up",
"es-ES": "Impermeabilidad",
"es-MX": "Impermeabilidad",
"fr-CA": "Imperméabilité",
"fr-FR": "Pieds au sec",
"it-IT": "Scarpe impermeabili",
"ja-JP": "相手インク影響軽減",
"ko-KR": "상대 잉크 영향 감소",
"nl-NL": "Inkttolerantie",
"ru-RU": "Краскостойкость",
"zh-CN": "减轻对手墨汁影响",
"zh-TW": "減輕對手墨汁影響"
},
"primary_only": false
},
{
"key": "sub_resistance_up",
"name": {
"de-DE": "Sekundärschutz +",
"en-GB": "Sub Resistance Up",
"en-US": "Sub Resistance Up",
"es-ES": "Resistencia secundaria",
"es-MX": "Resistencia secundaria",
"fr-CA": "Filtre à secondaires",
"fr-FR": "Filtre à secondaires",
"it-IT": "Arma sec. impermeabile",
"ja-JP": "サブ影響軽減",
"ko-KR": "서브 영향 감소",
"nl-NL": "Subdemper",
"ru-RU": "Стойкость запаса",
"zh-CN": "减轻次要武器影响",
"zh-TW": "減輕次要武器影響"
},
"primary_only": false
},
{
"key": "intensify_action",
"name": {
"de-DE": "Action +",
"en-GB": "Intensify Action",
"en-US": "Intensify Action",
"es-ES": "Agilidad extra",
"es-MX": "Agilidad extra",
"fr-CA": "Feu de l'action",
"fr-FR": "Feu de l'action",
"it-IT": "Intensificazione",
"ja-JP": "アクション強化",
"ko-KR": "액션 강화",
"nl-NL": "Actie-assistentie",
"ru-RU": "Ультраудар",
"zh-CN": "行动强化",
"zh-TW": "行動強化"
},
"primary_only": false
},
{
"key": "opening_gambit",
"name": {
"de-DE": "Startvorteil",
"en-GB": "Opening Gambit",
"en-US": "Opening Gambit",
"es-ES": "Acelerón de salida",
"es-MX": "Acelerón de salida",
"fr-CA": "Départ toute allure",
"fr-FR": "Chapeaux de roue",
"it-IT": "Partenza a razzo",
"ja-JP": "スタートダッシュ",
"ko-KR": "스타트 대시",
"nl-NL": "Vliegende start",
"ru-RU": "Стартовый спурт",
"zh-CN": "最初冲刺",
"zh-TW": "最初衝刺"
},
"primary_only": true
},
{
"key": "last_ditch_effort",
"name": {
"de-DE": "Endspurt",
"en-GB": "Last-Ditch Effort",
"en-US": "Last-Ditch Effort",
"es-ES": "Sprint final",
"es-MX": "Último recurso",
"fr-CA": "Ultime sursaut",
"fr-FR": "Ultime sursaut",
"it-IT": "Splash finale",
"ja-JP": "ラストスパート",
"ko-KR": "라스트 스퍼트",
"nl-NL": "Eindsprint",
"ru-RU": "Финишный спурт",
"zh-CN": "最后冲刺",
"zh-TW": "最後衝刺"
},
"primary_only": true
},
{
"key": "tenacity",
"name": {
"de-DE": "Zähigkeit",
"en-GB": "Tenacity",
"en-US": "Tenacity",
"es-ES": "Ventaja",
"es-MX": "Tenacidad",
"fr-CA": "Ténacité",
"fr-FR": "Justice",
"it-IT": "Tenacia",
"ja-JP": "逆境強化",
"ko-KR": "역경 강화",
"nl-NL": "Volharding",
"ru-RU": "Компенсатор",
"zh-CN": "逆境强化",
"zh-TW": "逆境強化"
},
"primary_only": true
},
{
"key": "comeback",
"name": {
"de-DE": "Rückkehr",
"en-GB": "Comeback",
"en-US": "Comeback",
"es-ES": "Remontada",
"es-MX": "Remonte",
"fr-CA": "Retour",
"fr-FR": "Come-back",
"it-IT": "Gran ritorno",
"ja-JP": "カムバック",
"ko-KR": "컴백",
"nl-NL": "Opfrisser",
"ru-RU": "Ответный удар",
"zh-CN": "回归",
"zh-TW": "回歸"
},
"primary_only": true
},
{
"key": "ninja_squid",
"name": {
"de-DE": "Tintenfisch-Ninja",
"en-GB": "Ninja Squid",
"en-US": "Ninja Squid",
"es-ES": "Ninjalamar",
"es-MX": "Ninjalamar",
"fr-CA": "Ninjalmar",
"fr-FR": "Ninjalamar",
"it-IT": "Calamaro ninja",
"ja-JP": "イカニンジャ",
"ko-KR": "징어닌자",
"nl-NL": "Ninja-inktvis",
"ru-RU": "Мимикрия",
"zh-CN": "鱿鱼忍者",
"zh-TW": "魷魚忍者"
},
"primary_only": true
},
{
"key": "haunt",
"name": {
"de-DE": "Vergeltung",
"en-GB": "Haunt",
"en-US": "Haunt",
"es-ES": "Represalia",
"es-MX": "Resentimiento",
"fr-CA": "Vengeance",
"fr-FR": "Revanche",
"it-IT": "Rappresaglia",
"ja-JP": "リベンジ",
"ko-KR": "리벤지",
"nl-NL": "Revanche",
"ru-RU": "Вендетта",
"zh-CN": "复仇",
"zh-TW": "復仇"
},
"primary_only": true
},
{
"key": "thermal_ink",
"name": {
"de-DE": "Markierfarbe",
"en-GB": "Thermal Ink",
"en-US": "Thermal Ink",
"es-ES": "Señuelo",
"es-MX": "Tinta rastreadora",
"fr-CA": "Encre thermique",
"fr-FR": "Encre thermique",
"it-IT": "Inchiostro termico",
"ja-JP": "サーマルインク",
"ko-KR": "서멀 잉크",
"nl-NL": "Markeerstift",
"ru-RU": "Клеймо",
"zh-CN": "热力墨汁",
"zh-TW": "熱力墨汁"
},
"primary_only": true
},
{
"key": "respawn_punisher",
"name": {
"de-DE": "Heimsuchung",
"en-GB": "Respawn Punisher",
"en-US": "Respawn Punisher",
"es-ES": "Castigo póstumo",
"es-MX": "Castigo póstumo",
"fr-CA": "Retour perdant",
"fr-FR": "Retour perdant",
"it-IT": "Castigo",
"ja-JP": "復活ペナルティアップ",
"ko-KR": "부활 페널티 업",
"nl-NL": "Repercussie",
"ru-RU": "Кара",
"zh-CN": "提升复活惩罚",
"zh-TW": "提升復活懲罰"
},
"primary_only": true
},
{
"key": "ability_doubler",
"name": {
"de-DE": "Effektdoppelung",
"en-GB": "Ability Doubler",
"en-US": "Ability Doubler",
"es-ES": "Duplicador",
"es-MX": "Duplicador",
"fr-CA": "Bonus ×2",
"fr-FR": "Bonus ×2",
"it-IT": "Raddoppiatore",
"ja-JP": "追加ギアパワー倍化",
"ko-KR": "추가 기어 파워 2배",
"nl-NL": "Verdubbelaar",
"ru-RU": "Дупликатор",
"zh-CN": "追加装备能力增倍",
"zh-TW": "追加裝備能力增倍"
},
"primary_only": true
},
{
"key": "stealth_jump",
"name": {
"de-DE": "Sprunginfiltration",
"en-GB": "Stealth Jump",
"en-US": "Stealth Jump",
"es-ES": "Supersalto invisible",
"es-MX": "Supersalto invisible",
"fr-CA": "Super saut invisible",
"fr-FR": "Réception réussie",
"it-IT": "Salto al buio",
"ja-JP": "ステルスジャンプ",
"ko-KR": "스텔스 점프",
"nl-NL": "Sluipsprong",
"ru-RU": "Десант",
"zh-CN": "隐身跳跃",
"zh-TW": "隱身跳躍"
},
"primary_only": true
},
{
"key": "object_shredder",
"name": {
"de-DE": "Zerstörer",
"en-GB": "Object Shredder",
"en-US": "Object Shredder",
"es-ES": "Demolición",
"es-MX": "Demolición",
"fr-CA": "Démolition",
"fr-FR": "Démolition",
"it-IT": "Demolitore",
"ja-JP": "対物攻撃力アップ",
"ko-KR": "오브젝트 공격력 업",
"nl-NL": "Sloper",
"ru-RU": "Демонтажник",
"zh-CN": "提升对物体攻击力",
"zh-TW": "提升對物體攻擊力"
},
"primary_only": true
},
{
"key": "drop_roller",
"name": {
"de-DE": "Tricklandung",
"en-GB": "Drop Roller",
"en-US": "Drop Roller",
"es-ES": "Amortiguador",
"es-MX": "Aterrizaje rodante",
"fr-CA": "Super roulade",
"fr-FR": "Super roulade",
"it-IT": "Atterraggio stiloso",
"ja-JP": "受け身術",
"ko-KR": "낙법",
"nl-NL": "Rolmodel",
"ru-RU": "Акробат",
"zh-CN": "受身术",
"zh-TW": "受身術"
},
"primary_only": true
}
]

View File

@ -1,11 +1,28 @@
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "splashcat / s3si.ts"; export const AGENT_NAME = "splashcat / s3si.ts";
export const AGENT_VERSION = "1.0.1"; export const AGENT_VERSION = "1.1.1";
export const S3SI_VERSION = "0.3.1"; export const S3SI_VERSION = "0.4.1";
export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`; export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`;
export const NSOAPP_VERSION = "2.5.0"; export const NSOAPP_VERSION = "2.5.1";
export const WEB_VIEW_VERSION = "3.0.0-2857bc50"; export const WEB_VIEW_VERSION = "4.0.0-d5178440";
export enum Queries {
HomeQuery = "7dcc64ea27a08e70919893a0d3f70871",
LatestBattleHistoriesQuery = "0d90c7576f1916469b2ae69f64292c02",
RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de",
BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803",
XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5",
PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28",
VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd",
CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15",
CoopHistoryDetailQuery = "379f0d9b78b531be53044bcac031b34b",
myOutfitCommonDataFilteringConditionQuery =
"d02ab22c9dccc440076055c8baa0fa7a",
myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",
HistoryRecordQuery = "d9246baf077b2a29b5f7aac321810a77",
ConfigureAnalyticsQuery = "f8ae00773cc412a50dd41a6d9a159ddd",
StageRecordQuery = "f08a932d533845dde86e674e03bbb7d3",
}
export const S3SI_LINK = "https://forgejo.catgirlin.space/catgirl/s3si.ts"; export const S3SI_LINK = "https://forgejo.catgirlin.space/catgirl/s3si.ts";
export const USERAGENT = `${AGENT_NAME}/(${COMBINED_VERSION}) (${S3SI_LINK})`; export const USERAGENT = `${AGENT_NAME}/(${COMBINED_VERSION}) (${S3SI_LINK})`;

136
src/daemon.ts Normal file
View File

@ -0,0 +1,136 @@
import {
JSONRPCServer,
RPCResult,
S3SIService,
Service,
} from "./jsonrpc/mod.ts";
import { DenoIO } from "./jsonrpc/deno.ts";
import { loginSteps } from "./iksm.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { Queue } from "./jsonrpc/channel.ts";
import { ExportOpts, Log } from "./jsonrpc/types.ts";
import { App } from "./app.ts";
import { InMemoryStateBackend, Profile, State } from "./state.ts";
import { MemoryCache } from "./cache.ts";
import { Splatnet3 } from "./splatnet3.ts";
class S3SIServiceImplement implements S3SIService, Service {
loginMap: Map<string, {
step1: (url: string) => void;
promise: Promise<string>;
}> = new Map();
loggerQueue: Queue<Log> = new Queue();
env: Env = {
prompts: {
promptLogin: () => {
return Promise.reject("Not implemented");
},
prompt: () => {
return Promise.reject("Not implemented");
},
},
logger: {
debug: (...msg) => this.loggerQueue.push({ level: "debug", msg }),
log: (...msg) => this.loggerQueue.push({ level: "log", msg }),
warn: (...msg) => this.loggerQueue.push({ level: "warn", msg }),
error: (...msg) => this.loggerQueue.push({ level: "error", msg }),
},
newFetcher: DEFAULT_ENV.newFetcher,
};
hello(): Promise<RPCResult<string>> {
return Promise.resolve({
result: "world",
});
}
loginSteps(): Promise<
RPCResult<{
authCodeVerifier: string;
url: string;
}>
>;
loginSteps(step2: {
authCodeVerifier: string;
login: string;
}): Promise<
RPCResult<{ sessionToken: string }>
>;
async loginSteps(step2?: {
authCodeVerifier: string;
login: string;
}): Promise<
RPCResult<
{
authCodeVerifier: string;
url: string;
} | {
sessionToken: string;
}
>
> {
if (!step2) {
return {
result: await loginSteps(this.env),
};
}
return {
result: await loginSteps(this.env, step2),
};
}
async ensureTokenValid(state: State): Promise<
RPCResult<State>
> {
const stateBackend = new InMemoryStateBackend(state);
const profile = new Profile({ stateBackend, env: this.env });
await profile.readState();
const splatnet3 = new Splatnet3({ profile, env: this.env });
if (!await splatnet3.checkToken()) {
return {
error: {
code: 101,
message: "SessionToken is invalid",
},
};
}
return {
result: stateBackend.state,
};
}
async getLogs(): Promise<RPCResult<Log[]>> {
const log = await this.loggerQueue.pop();
return {
result: log ? [log] : [],
};
}
async run(state: State, opts: ExportOpts): Promise<RPCResult<State>> {
const stateBackend = new InMemoryStateBackend(state);
const app = new App({
...opts,
noProgress: true,
env: this.env,
profilePath: "",
stateBackend,
cache: new MemoryCache(),
});
await app.run();
return {
result: stateBackend.state,
};
}
// deno-lint-ignore no-explicit-any
[key: string]: any;
}
if (import.meta.main) {
const service = new S3SIServiceImplement();
const server = new JSONRPCServer({
transport: new DenoIO({
reader: Deno.stdin,
writer: Deno.stdout,
}),
service,
});
await server.serve();
}

View File

@ -1,6 +1,15 @@
import { MongoDB } from "../../deps.ts"; import { MongoDB } from "../../deps.ts";
import { AGENT_VERSION, NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts"; import { AGENT_VERSION, NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
import { CoopHistoryDetail, ExportResult, Game, GameExporter, Summary, VsHistoryDetail } from "../types.ts"; import {
CoopHistoryDetail,
ExportResult,
Game,
GameExporter,
Queries,
RespMap,
Summary,
VsHistoryDetail,
} from "../types.ts";
import { parseHistoryDetailId } from "../utils.ts"; import { parseHistoryDetailId } from "../utils.ts";
export class MongoDBExporter implements GameExporter { export class MongoDBExporter implements GameExporter {
@ -24,10 +33,14 @@ export class MongoDBExporter implements GameExporter {
return `${uid}_${timestamp}Z`; return `${uid}_${timestamp}Z`;
} }
async notExported({ type, list }: { type: Game["type"], list: string[] }): Promise<string[]> { async notExported(
{ type, list }: { type: Game["type"]; list: string[] },
): Promise<string[]> {
const out: string[] = []; const out: string[] = [];
const collection = type === "CoopInfo" ? this.jobsCollection : this.battlesCollection; const collection = type === "CoopInfo"
? this.jobsCollection
: this.battlesCollection;
for (const id of list) { for (const id of list) {
const uniqueId = MongoDBExporter.getGameId(id); const uniqueId = MongoDBExporter.getGameId(id);
@ -60,11 +73,12 @@ export class MongoDBExporter implements GameExporter {
playedTime: new Date(game.detail.playedTime), playedTime: new Date(game.detail.playedTime),
}; };
const body: const body: {
{ data: Game;
data: Game, splatNetData:
splatNetData: VsHistoryDetail | CoopHistoryDetail, & Omit<(VsHistoryDetail | CoopHistoryDetail), "playedTime">
gameId: string, & { playedTime: Date };
gameId: string;
} & typeof common = { } & typeof common = {
...common, ...common,
data: game, data: game,
@ -98,4 +112,22 @@ export class MongoDBExporter implements GameExporter {
status: "success", status: "success",
}; };
} }
async exportStages(
stages: RespMap[Queries.StageRecordQuery]["stageRecords"]["nodes"],
): Promise<ExportResult> {
for (const stage of stages) {
await this.mongoDb.collection("stages").updateOne({
"stage.id": stage.id,
}, {
$set: stage,
}, {
upsert: true,
});
}
return {
status: "success",
};
}
} }

View File

@ -15,7 +15,6 @@ import {
GameExporter, GameExporter,
Image, Image,
PlayerGear, PlayerGear,
StatInkAbility,
StatInkCoopPlayer, StatInkCoopPlayer,
StatInkCoopPostBody, StatInkCoopPostBody,
StatInkCoopWave, StatInkCoopWave,
@ -42,6 +41,7 @@ import {
urlSimplify, urlSimplify,
} from "../utils.ts"; } from "../utils.ts";
import { Env } from "../env.ts"; import { Env } from "../env.ts";
import GEAR_MAP from "../assets/gear-map.json" assert { type: "json" };
const COOP_POINT_MAP: Record<number, number | undefined> = { const COOP_POINT_MAP: Record<number, number | undefined> = {
0: -20, 0: -20,
@ -229,8 +229,7 @@ class StatInkAPI {
); );
getWeapon = () => getWeapon = () =>
this._getCached<StatInkWeapon>(`${this.statInk}/api/v3/weapon?full=1`); this._getCached<StatInkWeapon>(`${this.statInk}/api/v3/weapon?full=1`);
getAbility = () => getAbility = () => GEAR_MAP;
this._getCached<StatInkAbility>(`${this.statInk}/api/v3/ability?full=1`);
getStage = () => getStage = () =>
this._getCached<StatInkStage>(`${this.statInk}/api/v3/stage`); this._getCached<StatInkStage>(`${this.statInk}/api/v3/stage`);
} }
@ -331,6 +330,8 @@ export class StatInkExporter implements GameExporter {
} }
} else if (vsMode === "X_MATCH") { } else if (vsMode === "X_MATCH") {
return "xmatch"; return "xmatch";
} else if (vsMode === "LEAGUE") {
return "event";
} }
throw new TypeError(`Unknown vsMode ${vsMode}`); throw new TypeError(`Unknown vsMode ${vsMode}`);
@ -422,6 +423,7 @@ export class StatInkExporter implements GameExporter {
myTeam, myTeam,
otherTeams, otherTeams,
bankaraMatch, bankaraMatch,
leagueMatch,
festMatch, festMatch,
playedTime, playedTime,
} = vsDetail; } = vsDetail;
@ -564,6 +566,10 @@ export class StatInkExporter implements GameExporter {
result.rank_after_s_plus = result.rank_before_s_plus; result.rank_after_s_plus = result.rank_before_s_plus;
} }
} }
if (leagueMatch) {
result.event = leagueMatch.leagueMatchEvent?.id;
result.event_power = leagueMatch.myLeaguePower;
}
if (challengeProgress) { if (challengeProgress) {
result.challenge_win = challengeProgress.winCount; result.challenge_win = challengeProgress.winCount;
@ -724,6 +730,8 @@ export class StatInkExporter implements GameExporter {
golden_appearances: wave.goldenPopCount, golden_appearances: wave.goldenPopCount,
golden_delivered: wave.teamDeliverCount, golden_delivered: wave.teamDeliverCount,
special_uses, special_uses,
// fill it later
danger_rate: null,
}; };
} }
async mapCoop( async mapCoop(
@ -767,10 +775,13 @@ export class StatInkExporter implements GameExporter {
: undefined; : undefined;
const title_exp_after = detail.afterGradePoint; const title_exp_after = detail.afterGradePoint;
const maxWaves = detail.rule === "TEAM_CONTEST" ? 5 : 3;
let clear_waves: number; let clear_waves: number;
if (waveResults.length > 0) { if (waveResults.length > 0) {
// when cleared, resultWave === 0, so we need to add 1. // when cleared, resultWave === 0, so we need to add 1.
clear_waves = waveResults.filter((i) => i.waveNumber < 4).length - clear_waves = waveResults.filter((i) =>
i.waveNumber < maxWaves + 1
).length -
1 + (resultWave === 0 ? 1 : 0); 1 + (resultWave === 0 ? 1 : 0);
} else { } else {
clear_waves = 0; clear_waves = 0;
@ -811,7 +822,7 @@ export class StatInkExporter implements GameExporter {
let fail_reason: StatInkCoopPostBody["fail_reason"] = null; let fail_reason: StatInkCoopPostBody["fail_reason"] = null;
// failed // failed
if (clear_waves !== 3 && waveResults.length > 0) { if (clear_waves !== maxWaves && waveResults.length > 0) {
const lastWave = waveResults[waveResults.length - 1]; const lastWave = waveResults[waveResults.length - 1];
if (lastWave.teamDeliverCount >= lastWave.deliverNorm) { if (lastWave.teamDeliverCount >= lastWave.deliverNorm) {
fail_reason = "wipe_out"; fail_reason = "wipe_out";
@ -822,8 +833,9 @@ export class StatInkExporter implements GameExporter {
uuid: await gameId(detail.id), uuid: await gameId(detail.id),
private: groupInfo?.mode === "PRIVATE_CUSTOM" ? "yes" : "no", private: groupInfo?.mode === "PRIVATE_CUSTOM" ? "yes" : "no",
big_run: detail.rule === "BIG_RUN" ? "yes" : "no", big_run: detail.rule === "BIG_RUN" ? "yes" : "no",
eggstra_work: detail.rule === "TEAM_CONTEST" ? "yes" : "no",
stage: b64Number(detail.coopStage.id).toString(), stage: b64Number(detail.coopStage.id).toString(),
danger_rate: dangerRate * 100, danger_rate: detail.rule === "TEAM_CONTEST" ? null : dangerRate * 100,
clear_waves, clear_waves,
fail_reason, fail_reason,
king_smell: smellMeter, king_smell: smellMeter,
@ -856,6 +868,57 @@ export class StatInkExporter implements GameExporter {
automated: "yes", automated: "yes",
start_at: startedAt, start_at: startedAt,
}; };
// caculate wave danger_rate.
// translated from here: https://github.com/frozenpandaman/s3s/commit/d46ece00e5a7706688eaf025f18c5a8ea1c54c0f#diff-819571ec7b067d2398cd1f9dbc737160312efc4128ba4a2f0e165c70225dea0eR1050
if (detail.rule === "TEAM_CONTEST") {
let lastWave: StatInkCoopWave | undefined;
for (
const [wave] of result.waves
.map((p, i) => [p, i] as const)
) {
let haz_level: number;
if (!lastWave) {
haz_level = 60;
} else {
const num_players = result.players.length;
const quota = lastWave.golden_quota; // last wave, most recent one added to the list
const delivered = lastWave.golden_delivered;
let added_percent = 0; // default, no increase if less than 1.5x quota delivered
if (num_players == 4) {
if (delivered >= quota * 2) {
added_percent = 60;
} else if (delivered >= quota * 1.5) {
added_percent = 30;
}
} else if (num_players == 3) {
if (delivered >= quota * 2) {
added_percent = 40;
} else if (delivered >= quota * 1.5) {
added_percent = 20;
}
} else if (num_players == 2) {
if (delivered >= quota * 2) {
added_percent = 20;
} else if (delivered >= quota * 1.5) {
added_percent = 10;
added_percent = 5;
}
} else if (num_players == 1) {
if (delivered >= quota * 2) {
added_percent = 10;
} else if (delivered >= quota * 1.5) {
added_percent = 5;
}
}
const prev_percent = lastWave.danger_rate!;
haz_level = prev_percent + added_percent;
}
wave.danger_rate = haz_level;
lastWave = wave;
}
}
return result; return result;
} }
} }

View File

@ -8,11 +8,42 @@ import {
import { APIError } from "./APIError.ts"; import { APIError } from "./APIError.ts";
import { Env, Fetcher } from "./env.ts"; import { Env, Fetcher } from "./env.ts";
export async function loginManually( export async function loginSteps(
{ newFetcher, prompts: { promptLogin } }: Env, env: Env,
): Promise<string> { ): Promise<
{
authCodeVerifier: string;
url: string;
}
>;
export async function loginSteps(
env: Env,
step2: {
authCodeVerifier: string;
login: string;
},
): Promise<
{
sessionToken: string;
}
>;
export async function loginSteps(
{ newFetcher }: Env,
step2?: {
authCodeVerifier: string;
login: string;
},
): Promise<
{
authCodeVerifier: string;
url: string;
} | {
sessionToken: string;
}
> {
const fetch = newFetcher(); const fetch = newFetcher();
if (!step2) {
const state = urlBase64Encode(random(36)); const state = urlBase64Encode(random(36));
const authCodeVerifier = urlBase64Encode(random(32)); const authCodeVerifier = urlBase64Encode(random(32));
const authCvHash = await crypto.subtle.digest( const authCvHash = await crypto.subtle.digest(
@ -51,10 +82,12 @@ export async function loginManually(
}, },
); );
const login = (await promptLogin(res.url)).trim(); return {
if (!login) { authCodeVerifier,
throw new Error("No login URL provided"); url: res.url,
} };
} else {
const { login, authCodeVerifier } = step2;
const loginURL = new URL(login); const loginURL = new URL(login);
const params = new URLSearchParams(loginURL.hash.substring(1)); const params = new URLSearchParams(loginURL.hash.substring(1));
const sessionTokenCode = params.get("session_token_code"); const sessionTokenCode = params.get("session_token_code");
@ -71,7 +104,27 @@ export async function loginManually(
throw new Error("No session token found"); throw new Error("No session token found");
} }
return sessionToken; return { sessionToken };
}
}
export async function loginManually(
env: Env,
): Promise<string> {
const { prompts: { promptLogin } } = env;
const step1 = await loginSteps(env);
const { url, authCodeVerifier } = step1;
const login = (await promptLogin(url)).trim();
if (!login) {
throw new Error("No login URL provided");
}
const step2 = await loginSteps(env, { authCodeVerifier, login });
return step2.sessionToken;
} }
export async function getGToken( export async function getGToken(
@ -122,13 +175,14 @@ export async function getGToken(
}, },
); );
const uiRespJson = await uiResp.json(); const uiRespJson = await uiResp.json();
const { nickname, birthday, language, country } = uiRespJson; const { nickname, birthday, language, country, id: userId } = uiRespJson;
const getIdToken2 = async (idToken: string) => { const getIdToken2 = async (idToken: string) => {
const { f, request_id: requestId, timestamp } = await callImink({ const { f, request_id: requestId, timestamp } = await callImink({
fApi, fApi,
step: 1, step: 1,
idToken, idToken,
userId,
env, env,
}); });
const resp = await fetch.post( const resp = await fetch.post(
@ -157,23 +211,28 @@ export async function getGToken(
); );
const respJson = await resp.json(); const respJson = await resp.json();
const idToken2 = respJson?.result?.webApiServerCredential?.accessToken; const idToken2: string = respJson?.result?.webApiServerCredential
?.accessToken;
const coralUserId: number = respJson?.result?.user?.id;
if (!idToken2) { if (!idToken2 || !coralUserId) {
throw new APIError({ throw new APIError({
response: resp, response: resp,
json: respJson, json: respJson,
message: "No idToken2 found", message:
`No idToken2 or coralUserId found. Please try again later. ('${idToken2}', '${coralUserId}')`,
}); });
} }
return idToken2 as string; return [idToken2, coralUserId] as const;
}; };
const getGToken = async (idToken: string) => { const getGToken = async (idToken: string, coralUserId: number) => {
const { f, request_id: requestId, timestamp } = await callImink({ const { f, request_id: requestId, timestamp } = await callImink({
step: 2, step: 2,
idToken, idToken,
fApi, fApi,
userId,
coralUserId,
env, env,
}); });
const resp = await fetch.post( const resp = await fetch.post(
@ -213,8 +272,8 @@ export async function getGToken(
return webServiceToken as string; return webServiceToken as string;
}; };
const idToken2 = await retry(() => getIdToken2(idToken)); const [idToken2, coralUserId] = await retry(() => getIdToken2(idToken));
const webServiceToken = await retry(() => getGToken(idToken2)); const webServiceToken = await retry(() => getGToken(idToken2, coralUserId));
return { return {
webServiceToken, webServiceToken,
@ -350,13 +409,16 @@ type IminkResponse = {
timestamp: number; timestamp: number;
}; };
async function callImink( async function callImink(
{ fApi, step, idToken, env }: { params: {
fApi: string; fApi: string;
step: number; step: number;
idToken: string; idToken: string;
userId: string;
coralUserId?: number;
env: Env; env: Env;
}, },
): Promise<IminkResponse> { ): Promise<IminkResponse> {
const { fApi, step, idToken, userId, coralUserId, env } = params;
const { post } = env.newFetcher(); const { post } = env.newFetcher();
const resp = await post({ const resp = await post({
url: fApi, url: fApi,
@ -367,6 +429,8 @@ async function callImink(
body: JSON.stringify({ body: JSON.stringify({
"token": idToken, "token": idToken,
"hash_method": step, "hash_method": step,
"na_id": userId,
"coral_user_id": coralUserId,
}), }),
}); });

1
src/ipc/mod.ts Normal file
View File

@ -0,0 +1 @@
export { IPC } from "./stdio.ts";

40
src/ipc/stdio.ts Normal file
View File

@ -0,0 +1,40 @@
/// <reference lib="deno.ns" />
import { io, writeAll } from "../../deps.ts";
import type { ExtractType } from "./types.ts";
export class IPC<T extends { type: string }> {
lines: AsyncIterableIterator<string>;
writer: Deno.Writer;
constructor({ reader, writer }: {
reader: Deno.Reader;
writer: Deno.Writer;
}) {
this.lines = io.readLines(reader);
this.writer = writer;
}
async recvType<K extends T["type"]>(
type: K,
): Promise<ExtractType<T, K>> {
const data = await this.recv();
if (data.type !== type) {
throw new Error(`Unexpected type: ${data.type}`);
}
return data as ExtractType<T, K>;
}
async recv(): Promise<T> {
const result = await this.lines.next();
if (!result.done) {
return JSON.parse(result.value);
}
throw new Error("EOF");
}
async send(data: T) {
await writeAll(
this.writer,
new TextEncoder().encode(JSON.stringify(data) + "\n"),
);
}
}

10
src/ipc/types.ts Normal file
View File

@ -0,0 +1,10 @@
export type Command = {
type: "hello";
data: string;
};
export type ExtractType<T extends { type: string }, K extends T["type"]> =
Extract<
T,
{ type: K }
>;

Some files were not shown because too many files have changed in this diff Show More