feat: add basic export

main
imspace 2023-03-09 01:03:57 +08:00
parent 16a4546710
commit 77c621b499
11 changed files with 272 additions and 25 deletions

View File

@ -85,9 +85,9 @@
"resizable": true, "resizable": true,
"title": "s3si.ts", "title": "s3si.ts",
"width": 400, "width": 400,
"height": 300, "height": 400,
"minWidth": 320, "minWidth": 320,
"minHeight": 300, "minHeight": 400,
"visible": false "visible": false
} }
] ]

View File

@ -0,0 +1,16 @@
import React from 'react'
type CheckboxProps = {
children?: React.ReactNode
value?: boolean
onChange?: (value: boolean) => void
}
export const Checkbox: React.FC<CheckboxProps> = ({ 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} onChange={() => onChange?.(!value)} className="checkbox" />
</label>
</div>
}

View File

@ -0,0 +1,54 @@
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config';
import { run } 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 {
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink,file",
monitor: false,
withSummary: false,
skipMode: exportBattle === false ? 'vs' : exportCoop === false ? 'coop' : undefined,
});
await setProfile(0, {
...result,
state: newState,
})
} finally {
setLoading(false);
}
}
return <>
<Checkbox value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button
onClick={onClick}
className={classNames('btn', {
'btn-disabled': !canExport(result) || (!exportBattle && !exportCoop),
'loading': loading,
})}
>{t('导出')}</button>
</>
}

View File

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

View File

@ -1,3 +1,75 @@
export const Guide: React.FC = () => { import classNames from 'classnames';
return <></> 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('登录任天堂帐号'),
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>
} }

View File

@ -1,6 +1,6 @@
import { CheckUpdate } from 'components/CheckUpdate';
import { ErrorContent } from 'components/ErrorContent'; import { ErrorContent } from 'components/ErrorContent';
import { Loading } from 'components/Loading'; import { Loading } from 'components/Loading';
import { RunPanel } from 'components/RunPanel';
import { STAT_INK } from 'constant'; import { STAT_INK } from 'constant';
import { usePromise } from 'hooks/usePromise'; import { usePromise } from 'hooks/usePromise';
import React from 'react' import React from 'react'
@ -32,9 +32,9 @@ export const Home: React.FC = () => {
<div className='full-card'> <div className='full-card'>
<h1 className='mb-4'>{t('欢迎!')}</h1> <h1 className='mb-4'>{t('欢迎!')}</h1>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<Link to='/settings' className='btn btn-primary'>{t('配置')}</Link> <RunPanel />
<Link to='/settings' className='btn'>{t('配置')}</Link>
<a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a> <a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a>
<CheckUpdate className='btn'>{t('检查更新')}</CheckUpdate>
</div> </div>
</div> </div>
</> </>

View File

@ -58,3 +58,7 @@ export async function getProfile(index: number): Promise<Profile> {
export async function setProfile(index: number, profile: Profile) { export async function setProfile(index: number, profile: Profile) {
await fs.writeTextFile(await profileDir.then(c => join(c, `${index}.json`)), JSON.stringify(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
}

View File

@ -1,11 +1,40 @@
import { invoke } from "@tauri-apps/api"; import { invoke } from "@tauri-apps/api";
import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc"; import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc";
import { ExportOpts, State } from "jsonrpc/types";
import { useCallback } from "react"; import { useCallback } from "react";
const client = new JSONRPCClient<S3SIService>({ const client = new JSONRPCClient<S3SIService>({
transport: new StdioTransport() transport: new StdioTransport()
}).getProxy(); }).getProxy();
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;
}
}
}
}
getLogs()
export const useLogin = () => { export const useLogin = () => {
const login = useCallback(async () => { const login = useCallback(async () => {
const result = await client.loginSteps(); const result = await client.loginSteps();
@ -35,3 +64,11 @@ export const useLogin = () => {
login 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;
}

View File

@ -8,20 +8,17 @@ import { DenoIO } from "./jsonrpc/deno.ts";
import { loginSteps } from "./iksm.ts"; import { loginSteps } from "./iksm.ts";
import { DEFAULT_ENV, Env } from "./env.ts"; import { DEFAULT_ENV, Env } from "./env.ts";
import { Queue } from "./jsonrpc/channel.ts"; import { Queue } from "./jsonrpc/channel.ts";
import { ExportOpts, Log } from "./jsonrpc/types.ts";
enum LoggerLevel { import { App } from "./app.ts";
Debug = "debug", import { InMemoryStateBackend, State } from "./state.ts";
Log = "log", import { MemoryCache } from "./cache.ts";
Warn = "warn",
Error = "error",
}
class S3SIServiceImplement implements S3SIService, Service { class S3SIServiceImplement implements S3SIService, Service {
loginMap: Map<string, { loginMap: Map<string, {
step1: (url: string) => void; step1: (url: string) => void;
promise: Promise<string>; promise: Promise<string>;
}> = new Map(); }> = new Map();
loggerQueue: Queue<{ level: LoggerLevel; msg: unknown[] }> = new Queue(); loggerQueue: Queue<Log> = new Queue();
env: Env = { env: Env = {
prompts: { prompts: {
promptLogin: () => { promptLogin: () => {
@ -32,12 +29,10 @@ class S3SIServiceImplement implements S3SIService, Service {
}, },
}, },
logger: { logger: {
debug: (...msg) => debug: (...msg) => this.loggerQueue.push({ level: "debug", msg }),
this.loggerQueue.push({ level: LoggerLevel.Debug, msg }), log: (...msg) => this.loggerQueue.push({ level: "log", msg }),
log: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Log, msg }), warn: (...msg) => this.loggerQueue.push({ level: "warn", msg }),
warn: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Warn, msg }), error: (...msg) => this.loggerQueue.push({ level: "error", msg }),
error: (...msg) =>
this.loggerQueue.push({ level: LoggerLevel.Error, msg }),
}, },
newFetcher: DEFAULT_ENV.newFetcher, newFetcher: DEFAULT_ENV.newFetcher,
}; };
@ -76,6 +71,28 @@ class S3SIServiceImplement implements S3SIService, Service {
result: await loginSteps(this.env, step2), result: await loginSteps(this.env, step2),
}; };
} }
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 // deno-lint-ignore no-explicit-any
[key: string]: any; [key: string]: any;
} }

View File

@ -1,6 +1,6 @@
export class Queue<T> { export class Queue<T> {
queue: T[] = []; private queue: T[] = [];
waiting: ((value: T | undefined) => void)[] = []; private waiting: ((value: T | undefined) => void)[] = [];
pop = (): Promise<T | undefined> => { pop = (): Promise<T | undefined> => {
return new Promise<T | undefined>((resolve) => { return new Promise<T | undefined>((resolve) => {

View File

@ -1,3 +1,34 @@
export type LoginState = {
sessionToken?: string;
gToken?: string;
bulletToken?: string;
};
export type RankState = {
// generated by gameId(battle.id)
gameId: string;
// extract from battle.id
timestamp?: number;
// C-, B, A+, S, S+0, S+12
rank: string;
rankPoint: number;
};
export type State = {
loginState?: LoginState;
fGen: string;
appUserAgent?: string;
userLang?: string;
userCountry?: string;
rankState?: RankState;
cacheDir: string;
// Exporter config
statInkApiKey?: string;
fileExportPath: string;
monitorInterval: number;
};
export type ID = string | number | null; export type ID = string | number | null;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
@ -68,6 +99,20 @@ export const ERROR_INTERNAL_ERROR: ResponseError<-32603> = {
message: "Internal error", message: "Internal error",
}; };
export type LoggerLevel = "debug" | "log" | "warn" | "error";
export type Log = {
level: LoggerLevel;
msg: unknown[];
};
export type ExportOpts = {
exporter: string;
monitor: boolean;
withSummary: boolean;
skipMode?: string;
};
export interface S3SIService { export interface S3SIService {
loginSteps(): Promise< loginSteps(): Promise<
RPCResult< RPCResult<
@ -87,6 +132,8 @@ export interface S3SIService {
} }
> >
>; >;
getLogs(): Promise<RPCResult<Log[]>>;
run(state: State, opts: ExportOpts): Promise<RPCResult<State>>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
[key: string]: any; [key: string]: any;
} }