├── .nvmrc ├── .npmrc ├── src ├── store │ ├── index.ts │ ├── search.ts │ ├── quest.ts │ ├── filterTags.ts │ ├── store.tsx │ └── gameQuest.tsx ├── stories │ ├── App.stories.tsx │ ├── Settings.stories.tsx │ ├── QuestTag.stories.tsx │ └── QuestCard.stories.tsx ├── utils.ts ├── poi │ ├── env.ts │ ├── utils.ts │ ├── store.ts │ ├── types.ts │ └── hooks.ts ├── __tests__ │ ├── kcanotifyData.spec.ts │ ├── kcwikiData.spec.ts │ ├── questCategory.spec.ts │ ├── questHelper.spec.ts │ ├── fixtures │ │ ├── firstLoginWithOneComplete.json │ │ └── questList.json │ └── __snapshots__ │ │ ├── questHelper.spec.ts.snap │ │ └── questCategory.spec.ts.snap ├── reducer.ts ├── index.ts ├── App.tsx ├── components │ ├── QuestCard │ │ ├── MinimalQuestCard.tsx │ │ ├── styles.ts │ │ ├── utils.tsx │ │ └── index.tsx │ ├── QuestList.tsx │ └── QuestTag.tsx ├── patch.ts ├── Settings.tsx ├── Toolbar.tsx ├── tags.tsx └── questHelper.ts ├── assets ├── IconSortie.png ├── IconArsenal.png ├── IconCompleted.png ├── IconExercise.png ├── IconComposition.png ├── IconExpedition.png ├── IconInProgress.png ├── IconModernization.png └── IconSupplyDocking.png ├── .changeset ├── bot-data-update.md ├── config.json └── README.md ├── prettier.config.js ├── .vscode ├── extensions.json └── settings.json ├── .storybook ├── addons │ └── poi │ │ ├── preset.js │ │ ├── i18n.js │ │ ├── preview.js │ │ └── themes │ │ ├── index.jsx │ │ └── poi-global.css ├── manager.js ├── preview.js └── main.js ├── .npmignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ ├── build.yml │ └── update.yml ├── .babelrc.json ├── scripts ├── proxyFetch.ts ├── utils.ts ├── convertAssets.ts ├── downloadSprites.ts ├── downloadKcQuestsData.ts ├── downloadKcanotifyGamedata.ts └── genQuestData.ts ├── jest.config.ts ├── shims ├── globals.d.ts └── poi.d.ts ├── i18n ├── index.ts ├── ko-KR.json ├── zh-TW.json ├── zh-CN.json ├── ja-JP.json └── en-US.json ├── .gitignore ├── .eslintrc.js ├── README.md ├── package.json ├── tsconfig.json └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './quest' 2 | export * from './store' 3 | -------------------------------------------------------------------------------- /assets/IconSortie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconSortie.png -------------------------------------------------------------------------------- /assets/IconArsenal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconArsenal.png -------------------------------------------------------------------------------- /assets/IconCompleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconCompleted.png -------------------------------------------------------------------------------- /assets/IconExercise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconExercise.png -------------------------------------------------------------------------------- /assets/IconComposition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconComposition.png -------------------------------------------------------------------------------- /assets/IconExpedition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconExpedition.png -------------------------------------------------------------------------------- /assets/IconInProgress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconInProgress.png -------------------------------------------------------------------------------- /.changeset/bot-data-update.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'poi-plugin-quest-info-2': patch 3 | --- 4 | 5 | Update quest data 6 | 7 | -------------------------------------------------------------------------------- /assets/IconModernization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconModernization.png -------------------------------------------------------------------------------- /assets/IconSupplyDocking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/poi-plugin-quest-2/HEAD/assets/IconSupplyDocking.png -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/configuration.html 2 | module.exports = { 3 | semi: false, 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "streetsidesoftware.code-spell-checker" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/addons/poi/preset.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | module.exports.config = (entry = []) => { 4 | return [...entry, require.resolve('./preview')] 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Config 2 | .github 3 | .vscode 4 | .storybook/ 5 | scripts/ 6 | 7 | # Build output 8 | assets/ 9 | storybook-static/ 10 | coverage/ 11 | build/dutySprites/ 12 | *.tgz 13 | 14 | # Other 15 | __tests__/ 16 | src/stories/ 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 功能建议 3 | about: Suggest an idea 帮助我们改进插件的功能 4 | --- 5 | 6 | **poi 版本 / poi version:** 7 | 8 | **操作系统 / OS:** 9 | 10 | **插件版本 / Plugin version:** 11 | 12 | **功能的详细描述 / Details on the feature:** 13 | -------------------------------------------------------------------------------- /src/stories/App.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, Story } from '@storybook/react' 2 | import React from 'react' 3 | import { App } from '../App' 4 | 5 | export default { 6 | title: 'App', 7 | component: App, 8 | argTypes: {}, 9 | } as Meta 10 | 11 | const Template: Story = () => 12 | 13 | export const Primary = Template.bind({}) 14 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming' 2 | import { addons } from '@storybook/addons' 3 | 4 | addons.setConfig({ 5 | theme: create({ 6 | base: 'light', 7 | brandUrl: 'https://github.com/poooi/poi', 8 | brandImage: 9 | 'https://raw.githubusercontent.com/poooi/poi/master/assets/img/logo.png', 10 | }), 11 | }) 12 | -------------------------------------------------------------------------------- /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100, 9 | "safari": 15, 10 | "firefox": 91 11 | } 12 | } 13 | ], 14 | "@babel/preset-typescript", 15 | "@babel/preset-react" 16 | ], 17 | "plugins": [] 18 | } -------------------------------------------------------------------------------- /src/stories/Settings.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, Story } from '@storybook/react' 2 | import React from 'react' 3 | import { Settings } from '../Settings' 4 | 5 | export default { 6 | title: 'Settings', 7 | component: Settings, 8 | argTypes: {}, 9 | } as Meta 10 | 11 | const Template: Story = () => 12 | 13 | export const Main = Template.bind({}) 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 代码缺陷汇报 3 | about: Create a report to help us improve 汇报程序代码错误 4 | --- 5 | 6 | **poi 版本 / poi version:** 7 | 8 | **操作系统 / OS:** 9 | 10 | **插件版本 / Plugin version:** 11 | 12 | **你遇到了什么样的问题 / The problem you've met:** 13 | 14 | **有没有重现的方法,或者与问题相关的任何信息 / How to reproduce, or any information that might be related:** 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit", 5 | "source.fixAll": "explicit" 6 | }, 7 | "npm.packageManager": "npm", 8 | "cSpell.words": [ 9 | "Kcanotify", 10 | "Kcwiki", 11 | "moize" 12 | ], 13 | "i18n-ally.localesPaths": [ 14 | "i18n" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /scripts/proxyFetch.ts: -------------------------------------------------------------------------------- 1 | import { fetch, ProxyAgent, setGlobalDispatcher } from 'undici' 2 | 3 | // HTTP/HTTPS proxy to connect to 4 | const proxy = process.env.https_proxy || process.env.http_proxy 5 | 6 | if (proxy) { 7 | // eslint-disable-next-line no-console 8 | console.log('using proxy server %j', proxy) 9 | setGlobalDispatcher(new ProxyAgent(proxy)) 10 | } 11 | 12 | export { fetch } 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "lawvs/poi-plugin-quest-2" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [] 16 | } 17 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createI18nDecorator } from './addons/poi/i18n' 3 | import { i18nResources } from '../i18n' 4 | 5 | export const parameters = { 6 | actions: { argTypesRegex: '^on[A-Z].*' }, 7 | options: { 8 | showPanel: false, 9 | }, 10 | } 11 | 12 | export const decorators = [ 13 | createI18nDecorator({ 14 | options: { 15 | debug: true, 16 | resources: i18nResources, 17 | }, 18 | }), 19 | ] 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => {} 2 | 3 | export const id = (x: T) => x 4 | 5 | export const yes = () => true as const 6 | 7 | export const And = 8 | boolean>(...fnArray: T[]) => 9 | (...args: Parameters) => 10 | fnArray.every((fn) => fn(...args)) 11 | 12 | export const Or = 13 | boolean>(...fnArray: T[]) => 14 | (...args: Parameters) => 15 | fnArray.some((fn) => fn(...args)) 16 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | clearMocks: true, 6 | coverageDirectory: 'coverage', 7 | transform: { 8 | '^.+\\.(ts)$': 'ts-jest', 9 | }, 10 | moduleFileExtensions: ['js', 'json', 'ts', 'tsx'], 11 | testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], 12 | testPathIgnorePatterns: ['/cypress'], 13 | collectCoverageFrom: ['/src/*.ts'], 14 | } 15 | 16 | export default config 17 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/poi/env.ts: -------------------------------------------------------------------------------- 1 | import { name } from '../../package.json' 2 | 3 | export const PACKAGE_NAME = name as 'poi-plugin-quest-info-2' 4 | 5 | export const IN_POI = 'POI_VERSION' in globalThis 6 | /** 7 | * Prevent webpack early error 8 | * Module not found: Error: Can't resolve 'views/create-store' 9 | * TODO fix webpack warn 10 | * Critical dependency: the request of a dependency is an expression 11 | */ 12 | 13 | export const importFromPoi = (path: string): Promise => { 14 | if (!IN_POI) { 15 | return new Promise(() => { 16 | // Never resolve 17 | }) 18 | } 19 | return import(path) 20 | } 21 | -------------------------------------------------------------------------------- /src/__tests__/kcanotifyData.spec.ts: -------------------------------------------------------------------------------- 1 | import { kcanotifyGameData, version } from '../../build/kcanotifyGamedata' 2 | 3 | test('should Kcanotify Game data version correct', () => { 4 | expect(version).toMatchInlineSnapshot(`"2025122101"`) 5 | }) 6 | 7 | describe('should format correct', () => { 8 | kcanotifyGameData.forEach((data) => { 9 | test(`${data.key} key format`, () => { 10 | Object.keys(data.res).forEach((key) => { 11 | // gameId should not extra space 12 | expect(key.trim()).toEqual(key) 13 | // gameId should be number 14 | expect(String(+key)).toEqual(key) 15 | }) 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { GameQuest, PoiAction, QuestTab } from './poi/types' 2 | 3 | const initState = { 4 | questList: null as null | GameQuest[], 5 | tabId: QuestTab.ALL, 6 | } 7 | 8 | export type PluginState = { _: typeof initState } 9 | 10 | export const reducer = ( 11 | state = initState, 12 | action: PoiAction, 13 | ): typeof initState => { 14 | switch (action.type) { 15 | case '@@Response/kcsapi/api_get_member/questlist': { 16 | const { body, postBody } = action 17 | 18 | return { 19 | ...state, 20 | questList: body.api_list, 21 | tabId: postBody.api_tab_id, 22 | } 23 | } 24 | default: 25 | return state 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | 6 | addons: [ 7 | { 8 | name: '@storybook/addon-essentials', 9 | options: 10 | process.env.NODE_ENV !== 'development' 11 | ? { 12 | measure: false, 13 | outline: false, 14 | docs: false, 15 | } 16 | : undefined, 17 | }, 18 | '@storybook/addon-links', 19 | /** 20 | * See https://github.com/momocow/poi-plugin-tabex/blob/master/.storybook/addons/story-addon-poooi/preset.js 21 | * Credit to @momocow 22 | */ 23 | './addons/poi/preset', 24 | ], 25 | 26 | framework: { 27 | name: '@storybook/react-webpack5', 28 | options: {} 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | patchLegacyQuestPluginReducer, 3 | clearPatchLegacyQuestPluginReducer, 4 | } from './patch' 5 | // See https://dev.poooi.app/docs/plugin-exports.html 6 | 7 | /** 8 | * The plugin will be started as a new-window or not for default. 9 | */ 10 | export const windowMode = false 11 | /** 12 | * @env poi 13 | */ 14 | export const pluginDidLoad = () => { 15 | patchLegacyQuestPluginReducer() 16 | } 17 | /** 18 | * @env poi 19 | */ 20 | export const pluginWillUnload = () => { 21 | clearPatchLegacyQuestPluginReducer() 22 | } 23 | export { App as reactClass } from './App' 24 | export { Settings as settingsClass } from './Settings' 25 | export { reducer } from './reducer' 26 | /** 27 | * Game response URI list for poi to switch to the plugin. 28 | */ 29 | export const switchPluginPath = ['/kcsapi/api_get_member/questlist'] 30 | -------------------------------------------------------------------------------- /shims/globals.d.ts: -------------------------------------------------------------------------------- 1 | // @see https://github.com/poooi/plugin-ship-info/blob/cb251d3858ee793e39bffd2f336b94762e62b87c/shims/globals.d.ts 2 | // @see https://github.com/poooi/poi/blob/master/views/env.es# 3 | 4 | interface IConfig { 5 | get: (path: string, defaultValue: T) => T 6 | set: (path: string, value?: any) => void 7 | } 8 | 9 | declare namespace NodeJS { 10 | interface Global { 11 | config: IConfig 12 | } 13 | } 14 | 15 | interface Window { 16 | POI_VERSION: string 17 | ROOT: string 18 | APPDATA_PATH: string 19 | PLUGIN_PATH: string 20 | config: IConfig 21 | language: string 22 | getStore: (path?: string) => any 23 | isMain: boolean 24 | 25 | toast: (message: string) => void 26 | // log: (message: string) => void 27 | // warn: (message: string) => void 28 | // error: (message: string) => void 29 | // success: (message: string) => void 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/QuestTag.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, Story } from '@storybook/react' 2 | import React from 'react' 3 | import styled from 'styled-components' 4 | import { QuestTag } from '../components/QuestTag' 5 | 6 | export default { 7 | title: 'QuestTag', 8 | component: QuestTag, 9 | argTypes: {}, 10 | } as Meta 11 | 12 | const Spacing = styled.div` 13 | * + * { 14 | margin-top: 8px; 15 | } 16 | ` 17 | 18 | const Template: Story<{ list: Parameters[0][] }> = (args) => ( 19 | 20 | {args.list.map((tag) => ( 21 | 22 | ))} 23 | 24 | ) 25 | 26 | export const Main = Template 27 | Main.args = { 28 | list: [ 29 | { 30 | code: 'A1', 31 | }, 32 | { 33 | code: 'A2', 34 | }, 35 | { 36 | code: 'B1', 37 | }, 38 | { 39 | code: 'B2', 40 | }, 41 | { 42 | code: 'A0', // Unknown quest 43 | }, 44 | ], 45 | } 46 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PathLike } from 'fs' 2 | import { existsSync, mkdirSync } from 'fs' 3 | 4 | export const prepareDir = (dir: PathLike) => { 5 | if (!existsSync(dir)) { 6 | mkdirSync(dir, { recursive: true }) 7 | } 8 | } 9 | 10 | /** 11 | * @example 12 | * ```ts 13 | * parseQuestCode('A1') // { type: 'A', number: 1 } 14 | * parseQuestCode('Bq11') // { type: 'Bq', number: 11 } 15 | * parseQuestCode('Cy10') // { type: 'Cy', number: 10 } 16 | * 17 | * // Special case 18 | * // バレンタイン2024限定任務【特別演習】 19 | * parseQuestCode('2402C1') // { type: '2402C', number: 1 } 20 | * // 【节分任务:枡】节分演习!二〇二四 21 | * parseQuestCode('L2401C1') // { type: 'L2401C', number: 1 } 22 | * ``` 23 | */ 24 | export const parseQuestCode = (str: string) => { 25 | if (str.length === 0) return 26 | const number = +(str.match(/\d+$/)?.[0] ?? '') 27 | if (!number || isNaN(number)) return 28 | const type = str.slice(0, -String(number).length) 29 | if (type.length === 0) return 30 | 31 | return { type, number } 32 | } 33 | -------------------------------------------------------------------------------- /src/__tests__/kcwikiData.spec.ts: -------------------------------------------------------------------------------- 1 | import { kcwikiGameData, version } from '../../build/kcQuestsData' 2 | import newQuestData from '../../build/kcQuestsData/quests-scn-new.json' 3 | 4 | describe('should version correct', () => { 5 | test('should KcwikiQuestData Game data version correct', () => { 6 | expect(version).toMatchInlineSnapshot( 7 | `"16be25f145be656c8787c844f013b71f7208aece"`, 8 | ) 9 | }) 10 | }) 11 | 12 | describe('should format correct', () => { 13 | test('key format', () => { 14 | Object.keys(kcwikiGameData.res).forEach((key) => { 15 | // gameId should not extra space 16 | expect(key).toEqual(key.trim()) 17 | // gameId should be number 18 | expect(key).toEqual(String(+key)) 19 | }) 20 | }) 21 | 22 | test('new quest key format', () => { 23 | Object.keys(newQuestData).forEach((gameId) => { 24 | // gameId should not extra space 25 | expect(gameId).toEqual(gameId.trim()) 26 | // gameId should be number 27 | expect(gameId).toEqual(String(gameId)) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /.storybook/addons/poi/i18n.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import i18next from 'i18next' 3 | import { useEffect } from 'react' 4 | import { initReactI18next } from 'react-i18next' 5 | 6 | // See react-i18next 7 | // https://react.i18next.com/getting-started 8 | 9 | /** 10 | * @param {import('i18next').InitOptions} options Initial options. 11 | */ 12 | const createI18n = (options = {}) => { 13 | i18next.use(initReactI18next).init({ 14 | lng: 'en-US', 15 | fallbackLng: 'en-US', 16 | keySeparator: false, 17 | interpolation: { 18 | escapeValue: false, 19 | }, 20 | ...options, 21 | }) 22 | return i18next 23 | } 24 | 25 | /** 26 | * @param {{options: import('i18next').InitOptions}} options Initial options. 27 | */ 28 | export const createI18nDecorator = ({ options } = { options: {} }) => { 29 | const i18n = createI18n(options) 30 | const withI18n = (Story, context) => { 31 | const locale = context.globals.locale 32 | useEffect(() => { 33 | i18n.changeLanguage(locale) 34 | }, [locale]) 35 | return 36 | } 37 | return withI18n 38 | } 39 | -------------------------------------------------------------------------------- /i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { PACKAGE_NAME } from '../src/poi/env' 2 | 3 | import en_US from './en-US.json' 4 | import ja_JP from './ja-JP.json' 5 | import ko_KR from './ko-KR.json' 6 | import zh_CN from './zh-CN.json' 7 | import zh_TW from './zh-TW.json' 8 | 9 | export const i18nResources = { 10 | 'zh-CN': { [PACKAGE_NAME]: zh_CN }, 11 | 'zh-TW': { [PACKAGE_NAME]: zh_TW }, 12 | 'ja-JP': { [PACKAGE_NAME]: ja_JP }, 13 | 'en-US': { [PACKAGE_NAME]: en_US }, 14 | 'ko-KR': { [PACKAGE_NAME]: ko_KR }, 15 | } 16 | 17 | // react-i18next versions higher than 11.11.0 18 | declare module 'react-i18next' { 19 | // and extend them! 20 | interface CustomTypeOptions { 21 | // custom namespace type if you changed it 22 | defaultNS: 'translation' 23 | // custom resources type 24 | resources: { 25 | // poi handles i18n resources by itself, 26 | // so we have to declare a incorrect type 27 | // XXX this is NOT correct 28 | 'en-US': typeof en_US 29 | // the real types should be like this 30 | // 'en-US': { [PACKAGE_NAME]: typeof en_US } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /i18n/ko-KR.json: -------------------------------------------------------------------------------- 1 | { 2 | "Quest Information": "임무 정보 2", 3 | "Show task information & enhance task panel": "임무 공략 데이터와 임무 패널 강화. 데이터는 [kcanotify](https://github.com/antest1/kcanotify-gamedata) & [KC3Kai](https://github.com/KC3Kai/kc3-translations) 에서 제공되고 있습니다.", 4 | "Search": "검색", 5 | "All": "전체", 6 | "Locked": "잠김 {{number}}", 7 | "Unlocked": "잠김 풀림 {{number}}", 8 | "In Progress": "진행 중 {{number}}", 9 | "Completed": "달성", 10 | "Already Completed": "완료 {{number}}", 11 | "Unknown": "알 수 없는 상태", 12 | "Version": "버전: {{version}}", 13 | "Data Version": "데이터 버전: {{version}}", 14 | "View source code on GitHub": "GitHub에서 소스코드를 알아보세요", 15 | "Restore defaults": "기본값 복원", 16 | "Use Kcwiki data": "Kcwiki의 데이터 사용(중국어 간체만 해당)", 17 | "Requires": "필요", 18 | "Unlocks": "잠금 해제", 19 | "Search in wikiwiki": "艦これ 攻略 Wiki에서 이 작업 검색", 20 | "Search in Kcwiki": "艦娘百科에서 이 작업 검색", 21 | "Search in KanColle Wiki": "KanColle Wiki에서 이 작업 검색", 22 | "Search in Richelieu Manager": "リシュリューの任務マネージャ에서 이 작업 검색", 23 | "Star project, support the author": "스타 프로젝트, 작가 지원", 24 | "Data Source": "데이터 소스", 25 | "Auto detect": "자동 감지", 26 | "Report issue": "문제 보고", 27 | "": "" 28 | } 29 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@blueprintjs/core' 2 | import React, { StrictMode } from 'react' 3 | import styled from 'styled-components' 4 | 5 | import { Toolbar, useFilterQuest } from './Toolbar' 6 | import { QuestList } from './components/QuestList' 7 | import { usePluginTranslation } from './poi/hooks' 8 | import { StoreProvider } from './store' 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | flex: 1; 13 | flex-direction: column; 14 | height: 100%; 15 | overflow: hidden; 16 | user-select: text; 17 | 18 | & > * + * { 19 | margin-top: 8px; 20 | } 21 | ` 22 | 23 | const CountText = styled(Text)` 24 | margin: 0 8px; 25 | ` 26 | 27 | const Main: React.FC = () => { 28 | const { t } = usePluginTranslation() 29 | const quests = useFilterQuest() 30 | 31 | return ( 32 | <> 33 | 34 | {t('TotalQuests', { number: quests.length })} 35 | 36 | 37 | ) 38 | } 39 | 40 | export const App = () => ( 41 | 42 | 43 | 44 |
45 |
46 |
47 |
48 | ) 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | # Provenance generation in GitHub Actions requires "write" access to the "id-token" permission 12 | id-token: write 13 | # Create pull requests needs "pull-requests: write" and "contents: write" permissions 14 | pull-requests: write 15 | contents: write 16 | 17 | jobs: 18 | release: 19 | name: Release 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Repo 23 | uses: actions/checkout@v6 24 | 25 | - name: Use Node.js 26 | uses: actions/setup-node@v6 27 | with: 28 | node-version-file: '.nvmrc' 29 | 30 | - name: Install Dependencies 31 | uses: bahmutov/npm-install@v1 32 | 33 | # Note: Trusted publishing requires npm CLI version 11.5.1 or later. 34 | - name: Update npm 35 | run: | 36 | npm install -g npm@latest 37 | 38 | # We use trusted publishing 39 | # Learn more: https://docs.npmjs.com/trusted-publishers 40 | - name: Create Release Pull Request or Publish to npm 41 | id: changesets 42 | uses: changesets/action@v1 43 | with: 44 | publish: npm run release 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /src/components/QuestCard/MinimalQuestCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Elevation, Text, Tooltip } from '@blueprintjs/core' 2 | import React, { ComponentPropsWithoutRef, forwardRef } from 'react' 3 | import { guessQuestCategory, QUEST_STATUS } from '../../questHelper' 4 | import type { QuestCardProps } from './index' 5 | import { CardBody, CardTail, CatIndicator, FlexCard } from './styles' 6 | import { questStatusMap } from './utils' 7 | 8 | /** 9 | * @deprecated 10 | */ 11 | export const MinimalQuestCard = forwardRef< 12 | Card, 13 | QuestCardProps & ComponentPropsWithoutRef 14 | >(({ code, name, desc, tip, status = QUEST_STATUS.DEFAULT, ...props }, ref) => { 15 | const indicatorColor = guessQuestCategory(code).color 16 | const TailIcon = questStatusMap[status] 17 | 18 | return ( 19 | 23 | {desc} 24 |
25 | {tip} 26 | 27 | } 28 | > 29 | 35 | 36 | 37 | {[code, name].filter((i) => i != undefined).join(' - ')} 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | ) 46 | }) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | .DS_Store 76 | 77 | build/ 78 | storybook-static/ 79 | assets/index.ts 80 | -------------------------------------------------------------------------------- /.storybook/addons/poi/preview.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { withPoiTheme, POI_THEMES } from './themes' 3 | 4 | let MINIMAL_VIEWPORTS 5 | try { 6 | MINIMAL_VIEWPORTS = require('@storybook/addon-viewport').MINIMAL_VIEWPORTS 7 | } catch (e) { 8 | MINIMAL_VIEWPORTS = {} 9 | } 10 | 11 | export const parameters = { 12 | viewport: { 13 | defaultViewport: 'poiFullHDCanvas100%', 14 | viewports: { 15 | ...MINIMAL_VIEWPORTS, 16 | 'poiFullHDCanvas100%': { 17 | name: 'Poi Full HD, Canvas 100% ', 18 | styles: { 19 | width: '700px', // 1920 - 1200 20 | height: '100%', 21 | }, 22 | }, 23 | }, 24 | }, 25 | backgrounds: { 26 | default: 'Poi dark', 27 | values: Object.entries(POI_THEMES).map(([name, theme]) => ({ 28 | name, 29 | value: theme.background, 30 | })), 31 | }, 32 | } 33 | 34 | export const globalTypes = { 35 | locale: { 36 | name: 'Locale', 37 | description: 'Internationalization locale', 38 | defaultValue: 'en-US', 39 | toolbar: { 40 | icon: 'globe', 41 | items: [ 42 | { value: 'zh-CN', right: '🇨🇳', title: '简体中文' }, 43 | { value: 'zh-TW', right: '🇹🇼', title: '正體中文' }, 44 | { value: 'ja-JP', right: '🇯🇵', title: '日本語' }, 45 | { value: 'en-US', right: '🇺🇸', title: 'English' }, 46 | { value: 'ko-KR', right: '🇰🇷', title: '한국어' }, 47 | ], 48 | }, 49 | }, 50 | } 51 | 52 | export const decorators = [withPoiTheme()] 53 | -------------------------------------------------------------------------------- /src/poi/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://dev.poooi.app/docs/api-poi-utils.html#notifications 3 | */ 4 | import { IN_POI } from './env' 5 | 6 | export const toast = (message: string) => { 7 | if (!IN_POI) { 8 | // eslint-disable-next-line no-console 9 | console.log('[Toast]', message) 10 | return 11 | } 12 | window.toast(message) 13 | } 14 | 15 | export const tips = { 16 | log(message: string) { 17 | if (!IN_POI) { 18 | // eslint-disable-next-line no-console 19 | console.log('[log]', message) 20 | return 21 | } 22 | // @ts-expect-error poi env 23 | window.log(message) // display on the information bar below game window 24 | }, 25 | warn(message: string) { 26 | if (!IN_POI) { 27 | console.warn('[warn]', message) 28 | return 29 | } 30 | // @ts-expect-error poi env 31 | window.warn(message) // display on the information bar below game window 32 | }, 33 | error(message: string) { 34 | if (!IN_POI) { 35 | console.error('[error]', message) 36 | return 37 | } 38 | // @ts-expect-error poi env 39 | window.error(message) // display on the information bar below game window 40 | }, 41 | success(message: string) { 42 | if (!IN_POI) { 43 | // eslint-disable-next-line no-console 44 | console.log('[success]', message) 45 | return 46 | } 47 | // @ts-expect-error poi env 48 | window.success(message) // display on the information bar below game window 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /src/__tests__/questCategory.spec.ts: -------------------------------------------------------------------------------- 1 | import { kcwikiGameData } from '../../build/kcQuestsData' 2 | import { kcanotifyGameData } from '../../build/kcanotifyGamedata' 3 | import questCategory from '../../build/questCategory.json' 4 | 5 | describe('should questCategory correct', () => { 6 | test('length', () => { 7 | expect(questCategory.dailyQuest.length).toMatchInlineSnapshot(`23`) 8 | expect(questCategory.weeklyQuest.length).toMatchInlineSnapshot(`19`) 9 | expect(questCategory.monthlyQuest.length).toMatchInlineSnapshot(`14`) 10 | expect(questCategory.quarterlyQuest.length).toMatchInlineSnapshot(`28`) 11 | expect(questCategory.yearlyQuest.length).toMatchInlineSnapshot(`54`) 12 | expect(questCategory.singleQuest.length).toMatchInlineSnapshot(`496`) 13 | }) 14 | 15 | test('snapshot', () => { 16 | const mergeData = { 17 | ...kcanotifyGameData[0].res, 18 | ...kcwikiGameData.res, 19 | } 20 | const humanReadableData = Object.fromEntries( 21 | Object.entries(questCategory).map(([key, val]) => [ 22 | key, 23 | val 24 | .sort((a, b) => a - b) 25 | .map((gameId) => ({ 26 | gameId, 27 | code: mergeData[String(gameId) as keyof typeof mergeData].code, 28 | name: mergeData[String(gameId) as keyof typeof mergeData].name, 29 | })), 30 | ]), 31 | ) 32 | 33 | expect(humanReadableData).toMatchSnapshot({ 34 | singleQuest: expect.any(Array), 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /i18n/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "Quest Information": "任务信息 2", 3 | "Show task information & enhance task panel": "任務信息查詢 & 任務面板強化,感謝 [kcanotify](https://github.com/antest1/kcanotify-gamedata) & [KC3改](https://github.com/KC3Kai/kc3-translations) 維護數據。", 4 | "Search": "搜尋", 5 | "All": "全部", 6 | "Composition": "編成", 7 | "Sortie": "出擊", 8 | "Exercise": "演習", 9 | "Expedition": "遠征", 10 | "Supply / Docking": "補給 / 入渠", 11 | "Arsenal": "工廠", 12 | "Modernization": "改装", 13 | "Others": "其他", 14 | "New": "新任務 {{number}}", 15 | "Daily": "每日", 16 | "Weekly": "每週", 17 | "Monthly": "每月", 18 | "Quarterly": "每季", 19 | "Yearly": "每年", 20 | "One-time": "單次", 21 | "Locked": "未解鎖 {{number}}", 22 | "Unlocked": "已解鎖 {{number}}", 23 | "In Progress": "進行中 {{number}}", 24 | "Completed": "達成", 25 | "Already Completed": "已完成 {{number}}", 26 | "Unknown": "未知狀態", 27 | "TotalQuests": "一共 {{number}} 個任務", 28 | "Version": "版本: {{version}}", 29 | "Data Version": "資料版本: {{version}}", 30 | "View source code on GitHub": "去 GitHub 查看源碼", 31 | "Restore defaults": "還原預設值", 32 | "Use Kcwiki data": "使用 Kcwiki 的數據(僅限簡體中文)", 33 | "Requires": "需要", 34 | "Unlocks": "解鎖", 35 | "Search in wikiwiki": "在 艦これ 攻略 Wiki 搜索該任務", 36 | "Search in Kcwiki": "在 艦娘百科 搜索該任務", 37 | "Search in KanColle Wiki": "在 KanColle Wiki 搜索該任務", 38 | "Search in Richelieu Manager": "在 黎塞留任務管理器 搜索該任務", 39 | "Star project, support the author": "Star項目,支持作者", 40 | "Data Source": "資料來源", 41 | "Auto detect": "自動偵測", 42 | "Report issue": "報告問題", 43 | "": "" 44 | } 45 | -------------------------------------------------------------------------------- /.storybook/addons/poi/themes/index.jsx: -------------------------------------------------------------------------------- 1 | const { Card } = require('@blueprintjs/core') 2 | const { makeDecorator } = require('@storybook/addons') 3 | const styled = require('styled-components').default 4 | const { useAddonState } = require('@storybook/client-api') 5 | 6 | // See https://github.com/poooi/poi/blob/da75b507e8f67615a39dc4fdb466e34ff5b5bdcf/views/env-parts/theme.es 7 | // See https://github.com/poooi/poi-asset-themes 8 | 9 | require('poi-asset-themes/dist/blueprint/blueprint-normal.css') 10 | require('./poi-global.css') 11 | 12 | const PoiPluginContainer = styled.div` 13 | position: absolute; 14 | top: 0; 15 | bottom: 0; 16 | left: 0; 17 | right: 0; 18 | ` 19 | 20 | const PoiPluginCard = styled(Card)` 21 | padding: 4px; 22 | height: 100%; 23 | display: flex; 24 | flex-direction: column; 25 | overflow: auto; 26 | ` 27 | 28 | const POI_THEMES = { 29 | 'Poi dark': { 30 | background: 'rgb(47, 52, 60)', 31 | container: ({ children }) => ( 32 | 33 | {children} 34 | 35 | ), 36 | }, 37 | } 38 | 39 | const withPoiTheme = makeDecorator({ 40 | name: 'withPoiTheme', 41 | parameterName: 'poooi', 42 | wrapper: (Story, context) => { 43 | const [state] = useAddonState('backgrounds', { name: 'Poi dark' }) 44 | const PoiContainer = POI_THEMES[state.name].container 45 | return {Story(context)} 46 | }, 47 | }) 48 | 49 | module.exports = { 50 | POI_THEMES, 51 | withPoiTheme, 52 | } 53 | -------------------------------------------------------------------------------- /src/__tests__/questHelper.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calcQuestMap, 3 | getCompletedQuest, 4 | getLockedQuest, 5 | getPostQuestIds, 6 | getPreQuestIds, 7 | getQuestCodeByGameId, 8 | } from '../questHelper' 9 | 10 | describe('questHelper', () => { 11 | test('should getQuestCodeByGameId correct', () => { 12 | expect(getQuestCodeByGameId(0)).toEqual(null) 13 | expect(getQuestCodeByGameId(101)).toEqual('A1') 14 | }) 15 | 16 | test('should getPreQuestIds correct', () => { 17 | expect(getPreQuestIds(101)).toEqual([]) 18 | expect(getPreQuestIds(102)).toEqual([101]) 19 | expect(getPreQuestIds(236)).toEqual([235, 273]) 20 | }) 21 | 22 | test('should getPostQuestIds correct', () => { 23 | expect(getPostQuestIds(101)).toEqual([102]) 24 | expect(getPostQuestIds(105)).toEqual([106, 108, 174, 254, 401, 612]) 25 | expect(getPostQuestIds(140)).toEqual([]) 26 | }) 27 | 28 | test('should 101 no completed quest', () => { 29 | expect(getCompletedQuest([101])).toEqual({}) 30 | }) 31 | 32 | test('should getCompletedQuest quest match snapshot', () => { 33 | expect(calcQuestMap([817], getPreQuestIds)).toMatchSnapshot() 34 | }) 35 | 36 | test('should 236 getCompletedQuest correct', () => { 37 | expect(calcQuestMap([236], getPreQuestIds)).toMatchSnapshot() 38 | }) 39 | 40 | test('should 101 locked quests match snapshot', () => { 41 | expect(getLockedQuest([101])).toMatchSnapshot() 42 | }) 43 | 44 | test('should 196 getLockedQuest correct', () => { 45 | expect(getLockedQuest([196])).toMatchSnapshot() 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/store/search.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | import { useStore } from './store' 3 | 4 | // Fix https://github.com/streamich/react-use/issues/2488 5 | // Ported from https://hooks-guide.netlify.app/community/useThrottle 6 | const useThrottle = (value: T, limit = 200) => { 7 | const [throttledValue, setThrottledValue] = useState(value) 8 | const lastRan = useRef(Date.now()) 9 | useEffect(() => { 10 | const handler = setTimeout( 11 | function () { 12 | if (Date.now() - lastRan.current >= limit) { 13 | setThrottledValue(value) 14 | lastRan.current = Date.now() 15 | } 16 | }, 17 | limit - (Date.now() - lastRan.current), 18 | ) 19 | return () => { 20 | clearTimeout(handler) 21 | } 22 | }, [value, limit]) 23 | return throttledValue 24 | } 25 | 26 | export const useSearchInput = () => { 27 | const { 28 | store: { searchInput }, 29 | updateStore, 30 | } = useStore() 31 | const setSearchInput = useCallback( 32 | (value: string) => updateStore({ searchInput: value }), 33 | [updateStore], 34 | ) 35 | return { 36 | searchInput, 37 | setSearchInput, 38 | } 39 | } 40 | 41 | export const useStableSearchWords = () => { 42 | const { searchInput } = useSearchInput() 43 | const throttledSearchInput = useThrottle(searchInput) 44 | const searchKeywords = throttledSearchInput 45 | .split(' ') 46 | // Remove empty string 47 | .filter((i) => !!i) 48 | .map((i) => i.toUpperCase()) 49 | 50 | return searchKeywords 51 | } 52 | -------------------------------------------------------------------------------- /src/components/QuestCard/styles.ts: -------------------------------------------------------------------------------- 1 | import { Button, Card } from '@blueprintjs/core' 2 | import styled from 'styled-components' 3 | 4 | export const FlexCard = styled(Card)` 5 | display: flex; 6 | align-items: center; 7 | 8 | & > * + * { 9 | margin-left: 8px; 10 | } 11 | ` 12 | 13 | export const CardMedia = styled.img` 14 | width: 64px; 15 | height: 64px; 16 | ` 17 | 18 | export const CatIndicator = styled.span<{ color: string }>` 19 | height: 1em; 20 | width: 4px; 21 | background-color: ${({ color }) => color}; 22 | ` 23 | 24 | export const CardBody = styled.div` 25 | display: flex; 26 | flex: 1; 27 | flex-direction: column; 28 | 29 | & > * + * { 30 | margin-top: 8px; 31 | } 32 | ` 33 | 34 | export const CardTail = styled.div` 35 | align-self: stretch; 36 | display: flex; 37 | flex-direction: column; 38 | align-items: center; 39 | 40 | img { 41 | height: 20px; 42 | } 43 | ` 44 | 45 | export const MoreButton = styled(Button)` 46 | opacity: 0; 47 | 48 | ${FlexCard}:hover & { 49 | opacity: 1; 50 | } 51 | ` 52 | 53 | export const TailIconWrapper = styled.div` 54 | flex: 1; 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | ` 59 | 60 | export const CardActionWrapper = styled.div` 61 | display: flex; 62 | flex-direction: column; 63 | align-items: baseline; 64 | ` 65 | 66 | export const TagsWrapper = styled.div` 67 | display: flex; 68 | flex: 1; 69 | flex-wrap: wrap; 70 | align-items: center; 71 | ` 72 | 73 | export const SpanText = styled.span` 74 | white-space: nowrap; 75 | ` 76 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | 15 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 16 | runs-on: ${{ matrix.os }} 17 | 18 | permissions: 19 | pages: write # to deploy to Pages 20 | id-token: write # to verify the deployment originates from an appropriate source 21 | 22 | steps: 23 | - name: Checkout Repo 24 | uses: actions/checkout@v3 25 | 26 | - name: Use Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version-file: '.nvmrc' 30 | 31 | - name: Install node modules 32 | uses: bahmutov/npm-install@v1 33 | 34 | - name: Build 35 | run: npm run build 36 | 37 | - name: Lint 38 | run: npm run lint -- --max-warnings=0 39 | 40 | - name: Type check 41 | run: npm run typeCheck 42 | 43 | - name: Test 44 | run: npm run test -- --coverage 45 | 46 | - name: Build storybook 47 | run: npm run build-storybook -- --quiet 48 | 49 | - name: Upload pages artifacts 50 | # https://github.com/actions/upload-pages-artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: 'storybook-static' 54 | 55 | - name: Deploy GitHub Pages 56 | if: github.ref == 'refs/heads/main' 57 | # https://github.com/actions/deploy-pages 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Quest Information": "任务信息 2", 3 | "Show task information & enhance task panel": "任务信息查询 & 任务面板强化,感谢 [舰娘百科](https://zh.kcwiki.cn/wiki/%E8%88%B0%E5%A8%98%E7%99%BE%E7%A7%91) & [kcanotify](https://github.com/antest1/kcanotify-gamedata) & [KC3改](https://github.com/KC3Kai/kc3-translations) 维护数据。", 4 | "Search": "搜索", 5 | "All": "全部", 6 | "Composition": "编成", 7 | "Sortie": "出击", 8 | "Exercise": "演习", 9 | "Expedition": "远征", 10 | "Supply / Docking": "补给 / 入渠", 11 | "Arsenal": "工厂", 12 | "Modernization": "改装", 13 | "Others": "其他", 14 | "New": "新任务 {{number}}", 15 | "Daily": "日常", 16 | "Weekly": "周常", 17 | "Monthly": "月常", 18 | "Quarterly": "季常", 19 | "Yearly": "年常", 20 | "One-time": "单次", 21 | "Locked": "未解锁 {{number}}", 22 | "Unlocked": "已解锁 {{number}}", 23 | "In Progress": "进行中 {{number}}", 24 | "Completed": "达成", 25 | "Already Completed": "已完成 {{number}}", 26 | "Unknown": "状态未知", 27 | "TotalQuests": "一共 {{number}} 个任务", 28 | "Version": "版本: {{version}}", 29 | "Data Version": "数据版本: {{version}}", 30 | "View source code on GitHub": "去 GitHub 查看源码", 31 | "Restore defaults": "恢复默认设置", 32 | "Use Kcwiki data": "使用 Kcwiki 的数据", 33 | "Requires": "前置", 34 | "Unlocks": "后置", 35 | "Search in wikiwiki": "在 艦これ攻略Wiki 搜索该任务", 36 | "Search in Kcwiki": "在 舰娘百科 搜索该任务", 37 | "Search in KanColle Wiki": "在 英wiki 搜索该任务", 38 | "Search in Richelieu Manager": "在 黎塞留任务管理器 搜索该任务", 39 | "Star project, support the author": "Star 项目,支持作者", 40 | "Data Source": "数据源", 41 | "Auto detect": "自动检测", 42 | "Report issue": "报告问题", 43 | "": "" 44 | } 45 | -------------------------------------------------------------------------------- /i18n/ja-JP.json: -------------------------------------------------------------------------------- 1 | { 2 | "Quest Information": "任務信息 2", 3 | "Show task information & enhance task panel": "任務攻略データや任務パネルの強化、データは [kcanotify](https://github.com/antest1/kcanotify-gamedata) & [KC3Kai](https://github.com/KC3Kai/kc3-translations) によって提供されている。", 4 | "Search": "検索", 5 | "All": "すべて", 6 | "Composition": "編成", 7 | "Sortie": "出撃", 8 | "Exercise": "演習", 9 | "Expedition": "遠征", 10 | "Supply / Docking": "補給 / 入渠", 11 | "Arsenal": "工廠", 12 | "Modernization": "改装", 13 | "Others": "その他", 14 | "New": "最新任務 {{number}}", 15 | "Daily": "デイリー", 16 | "Weekly": "ウィークリー", 17 | "Monthly": "マンスリー", 18 | "Quarterly": "クォータリー", 19 | "Yearly": "イヤーリー", 20 | "One-time": "単発", 21 | "Locked": "ロック中 {{number}}", 22 | "Unlocked": "ロック解除 {{number}}", 23 | "In Progress": "進行中 {{number}}", 24 | "Completed": "達成", 25 | "Already Completed": "完了 {{number}}", 26 | "Unknown": "状態不明", 27 | "TotalQuests": "全て {{number}} 個の任務", 28 | "Version": "バージョン: {{version}}", 29 | "Data Version": "データ バージョン: {{version}}", 30 | "View source code on GitHub": "GitHubでソースコードを表示", 31 | "Restore defaults": "デフォルトに戻す", 32 | "Use Kcwiki data": "Kcwikiのデータを利用する(簡体字中国語のみ)", 33 | "Requires": "開放条件", 34 | "Unlocks": "ロック解除", 35 | "Search in wikiwiki": "艦これ 攻略 Wikiでこのタスクを検索", 36 | "Search in Kcwiki": "艦娘百科でこのタスクを検索", 37 | "Search in KanColle Wiki": "KanColle Wikiでこのタスクを検索", 38 | "Search in Richelieu Manager": "リシュリューの任務マネージャを検索", 39 | "Star project, support the author": "Starプロジェクト、著者をサポート", 40 | "Data Source": "データソース", 41 | "Auto detect": "自動検出", 42 | "Report issue": "問題を報告する", 43 | "": "" 44 | } 45 | -------------------------------------------------------------------------------- /scripts/convertAssets.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { prepareDir } from './utils' 4 | 5 | const ASSETS_PATH = path.resolve('assets') 6 | const OUTPUT_PATH = path.resolve('build') 7 | const OUTPUT_FILE = path.resolve(OUTPUT_PATH, 'assets.ts') 8 | const CONVERT_EXTS = ['jpg', 'png'] as const 9 | 10 | const HEADER = `/* eslint-disable prettier/prettier */ 11 | /** 12 | * This file was automatically generated by \`${path.relative( 13 | // project root 14 | process.cwd(), 15 | __filename, 16 | )}\` 17 | * Do not edit this file directly. 18 | */` as const 19 | 20 | function base64Encode(file: string) { 21 | const bitmap = fs.readFileSync(file) 22 | return bitmap.toString('base64') 23 | } 24 | 25 | function main() { 26 | prepareDir(OUTPUT_PATH) 27 | const imageData = fs 28 | .readdirSync(ASSETS_PATH) 29 | // exclusive ignored ext 30 | .filter((f) => CONVERT_EXTS.some((ext) => f.endsWith('.' + ext))) 31 | .map((fileName) => { 32 | const filePath = path.resolve(ASSETS_PATH, fileName) 33 | const parsedFile = path.parse(fileName) 34 | return { 35 | name: parsedFile.name, 36 | ext: parsedFile.ext.slice(1), 37 | base64: base64Encode(filePath), 38 | } 39 | }) 40 | 41 | const data = `${HEADER} 42 | 43 | ${imageData 44 | .map( 45 | ({ name, ext, base64 }) => 46 | `export const ${name} = 'data:image/${ext};base64, ${base64}'`, 47 | ) 48 | .join('\n')} 49 | ` 50 | 51 | fs.writeFileSync(OUTPUT_FILE, data) 52 | 53 | // eslint-disable-next-line no-console 54 | console.log('Converted', imageData.length, 'images.') 55 | } 56 | 57 | main() 58 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | // "off" or 0 - turn the rule off 3 | // "warn" or 1 - turn the rule on as a warning (doesn’t affect exit code) 4 | // "error" or 2 - turn the rule on as an error (exit code will be 1) 5 | 6 | /** @type { import('eslint').Linter.Config } */ 7 | module.exports = { 8 | env: { 9 | es6: true, 10 | node: true, 11 | browser: true, 12 | jest: true, 13 | }, 14 | root: true, 15 | extends: [ 16 | 'eslint:recommended', 17 | 'plugin:@typescript-eslint/eslint-recommended', 18 | 'plugin:@typescript-eslint/recommended', 19 | 'plugin:react/recommended', 20 | 'plugin:react-hooks/recommended', 21 | 'plugin:storybook/recommended', 22 | 'prettier', 23 | ], 24 | parser: '@typescript-eslint/parser', 25 | parserOptions: { 26 | ecmaVersion: 2020, 27 | }, 28 | plugins: ['@typescript-eslint', 'prettier'], 29 | rules: { 30 | '@typescript-eslint/explicit-function-return-type': 'off', 31 | '@typescript-eslint/explicit-module-boundary-types': 'off', 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | '@typescript-eslint/ban-ts-comment': 'off', 34 | '@typescript-eslint/no-non-null-assertion': 'off', 35 | '@typescript-eslint/no-empty-function': 'off', 36 | 'react/prop-types': 'off', 37 | 'react/display-name': 'off', 38 | 'linebreak-style': ['error', 'unix'], 39 | 'no-var': 'error', 40 | 'prefer-const': 'error', 41 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 42 | 'prettier/prettier': 'warn', 43 | }, 44 | ignorePatterns: ['*.js', 'packages/*/build/**/*.ts'], 45 | settings: { 46 | react: { 47 | version: require('react').version, 48 | }, 49 | }, 50 | reportUnusedDisableDirectives: true, 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # plugin-quest-2 2 | 3 | ![build](https://github.com/lawvs/poi-plugin-quest-2/workflows/Build/badge.svg) 4 | [![npm](https://img.shields.io/npm/v/poi-plugin-quest-info-2)](https://www.npmjs.com/package/poi-plugin-quest-info-2) 5 | 6 | A [poi](https://github.com/poooi/poi) plugin that helps you view quest info. Data maintained by [kcanotify-gamedata](https://github.com/antest1/kcanotify-gamedata) & [kc3-translations](https://github.com/KC3Kai/kc3-translations) & [kcQuests](https://github.com/kcwikizh/kcQuests). 7 | 8 | demo 9 | 10 | ## Installation 11 | 12 | Paste `poi-plugin-quest-info-2` in the plugins tab and click the install button. 13 | 14 | ![image](https://user-images.githubusercontent.com/18554747/161830757-0a4e500c-f246-4dbd-820d-0b9a9c5a34a4.png) 15 | 16 | ## Features 17 | 18 | - Translated quest info (English/Simplified Chinese/Traditional Chinese/Korean). 19 | - Task panel translation. 20 | - Quest search and filter. 21 | - Sync with game quest data. 22 | - Auto switch to quest tab when enter quest views. 23 | - Export quest data to json file. 24 | 25 | ## Development 26 | 27 | ```sh 28 | # Install dependencies 29 | npm install 30 | 31 | # Download game data from github and convert assets to base64 32 | # try set `http_proxy` or `https_proxy` as environment when download fail 33 | npm run build 34 | 35 | # Run the plugin in web environment 36 | npm run storybook 37 | ``` 38 | 39 | ## Thanks 40 | 41 | - [poi](https://github.com/poooi/poi) 42 | - [plugin-quest](https://github.com/poooi/plugin-quest) 43 | - [kcanotify-gamedata](https://github.com/antest1/kcanotify-gamedata) 44 | - [kcQuests](https://github.com/kcwikizh/kcQuests) 45 | - [舰娘百科](https://zh.kcwiki.cn/wiki/%E8%88%B0%E5%A8%98%E7%99%BE%E7%A7%91) 46 | - [poi-plugin-tabex](https://github.com/momocow/poi-plugin-tabex) 47 | 48 | ## License 49 | 50 | MIT 51 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/firstLoginWithOneComplete.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "api_no": 101, 4 | "api_category": 1, 5 | "api_type": 4, 6 | "api_label_type": 1, 7 | "api_state": 2, 8 | "api_title": "はじめての「編成」!", 9 | "api_detail": "2隻以上の艦で構成される「艦隊」を編成せよ!", 10 | "api_voice_id": 0, 11 | "api_get_material": [20, 20, 0, 0], 12 | "api_bonus_flag": 1, 13 | "api_progress_flag": 0, 14 | "api_invalid_flag": 0 15 | }, 16 | { 17 | "api_no": 202, 18 | "api_category": 2, 19 | "api_type": 4, 20 | "api_label_type": 1, 21 | "api_state": 2, 22 | "api_title": "はじめての「出撃」!", 23 | "api_detail": "艦隊を出撃させ、敵艦隊と交戦せよ!", 24 | "api_voice_id": 0, 25 | "api_get_material": [20, 20, 0, 0], 26 | "api_bonus_flag": 1, 27 | "api_progress_flag": 0, 28 | "api_invalid_flag": 0 29 | }, 30 | { 31 | "api_no": 301, 32 | "api_category": 3, 33 | "api_type": 4, 34 | "api_label_type": 1, 35 | "api_state": 3, 36 | "api_title": "はじめての「演習」!", 37 | "api_detail": "他の提督(プレイヤー)の艦隊と「演習」を行おう!", 38 | "api_voice_id": 0, 39 | "api_get_material": [10, 10, 0, 0], 40 | "api_bonus_flag": 1, 41 | "api_progress_flag": 0, 42 | "api_invalid_flag": 0 43 | }, 44 | { 45 | "api_no": 601, 46 | "api_category": 6, 47 | "api_type": 4, 48 | "api_label_type": 1, 49 | "api_state": 2, 50 | "api_title": "はじめての「建造」!", 51 | "api_detail": "「工廠」で鋼材などの資材を使って新しい艦を「建造」しよう!", 52 | "api_voice_id": 0, 53 | "api_get_material": [50, 50, 50, 50], 54 | "api_bonus_flag": 1, 55 | "api_progress_flag": 0, 56 | "api_invalid_flag": 0 57 | }, 58 | { 59 | "api_no": 701, 60 | "api_category": 7, 61 | "api_type": 4, 62 | "api_label_type": 1, 63 | "api_state": 1, 64 | "api_title": "はじめての「近代化改修」!", 65 | "api_detail": "任意の艦を近代化改修(合成)して、強化せよ!", 66 | "api_voice_id": 0, 67 | "api_get_material": [0, 0, 50, 30], 68 | "api_bonus_flag": 1, 69 | "api_progress_flag": 0, 70 | "api_invalid_flag": 0 71 | } 72 | ] 73 | -------------------------------------------------------------------------------- /i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "Quest Information": "Quest Information 2", 3 | "Show task information & enhance task panel": "Show task information & enhance task panel, Data maintained by [kcanotify](https://github.com/antest1/kcanotify-gamedata) & [KC3Kai](https://github.com/KC3Kai/kc3-translations).", 4 | "Search": "Search", 5 | "All": "All", 6 | "Composition": "Composition", 7 | "Sortie": "Sortie", 8 | "Exercise": "Exercise", 9 | "Expedition": "Expedition", 10 | "Supply / Docking": "Supply / Docking", 11 | "Arsenal": "Arsenal", 12 | "Modernization": "Modernization", 13 | "Others": "Others", 14 | "New": "New {{number}}", 15 | "Daily": "Daily", 16 | "Weekly": "Weekly", 17 | "Monthly": "Monthly", 18 | "Quarterly": "Quarterly", 19 | "Yearly": "Yearly", 20 | "One-time": "One-time", 21 | "Locked": "Locked {{number}}", 22 | "Unlocked": "Unlocked {{number}}", 23 | "In Progress": "In Progress {{number}}", 24 | "Completed": "Completed", 25 | "Already Completed": "Already Completed {{number}}", 26 | "Unknown": "Unknown", 27 | "TotalQuests": "Total {{number}} quests", 28 | "Version": "Version: {{version}}", 29 | "Data Version": "Data Version: {{version}}", 30 | "View source code on GitHub": "View source code on GitHub", 31 | "Restore defaults": "Restore defaults", 32 | "Use Kcwiki data": "Use Kcwiki's data (Simplified Chinese only)", 33 | "Requires": "Requires", 34 | "Unlocks": "Unlocks", 35 | "Import quest data": "Import quest data", 36 | "Export quest data": "Export quest data", 37 | "Import data success": "Import data success", 38 | "Copied data to clipboard": "Copied data to clipboard", 39 | "Failed to export quest data! Please sync quest data first": "Failed to export quest data! Please sync quest data first", 40 | "Search in wikiwiki": "Search in 艦これ 攻略 Wiki", 41 | "Search in Kcwiki": "Search in Kcwiki", 42 | "Search in KanColle Wiki": "Search in KanColle Wiki", 43 | "Search in Richelieu Manager": "Search in Richelieu Manager", 44 | "Star project, support the author": "Star project, support the author", 45 | "Data Source": "Data Source", 46 | "Auto detect": "Auto detect", 47 | "Report issue": "Report issue", 48 | "": "" 49 | } 50 | -------------------------------------------------------------------------------- /src/stories/QuestCard.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, Story } from '@storybook/react' 2 | import React from 'react' 3 | import styled from 'styled-components' 4 | import { QuestCard } from '../components/QuestCard' 5 | import { MinimalQuestCard } from '../components/QuestCard/MinimalQuestCard' 6 | import { QUEST_STATUS } from '../questHelper' 7 | 8 | export default { 9 | title: 'QuestCard', 10 | component: QuestCard, 11 | argTypes: {}, 12 | } as Meta 13 | 14 | const Spacing = styled.div` 15 | * + * { 16 | margin-top: 8px; 17 | } 18 | ` 19 | 20 | const Template: Story[0]> = (args) => ( 21 | 22 | 23 | 24 | 25 | ) 26 | 27 | export const Composition = Template 28 | Composition.args = { 29 | gameId: 101, 30 | code: 'A1', 31 | name: 'はじめての「編成」!', 32 | desc: '2隻以上の艦で編成される「艦隊」を編成せよ!', 33 | } 34 | 35 | export const Sortie = Template.bind({}) 36 | Sortie.args = { 37 | code: 'B1', 38 | name: 'はじめての「出撃」!', 39 | desc: '艦隊を出撃させ、敵艦隊と交戦せよ!', 40 | status: QUEST_STATUS.LOCKED, 41 | } 42 | 43 | export const Other = Template.bind({}) 44 | Other.args = { 45 | code: 'WF01', 46 | name: '式の準備!(その壱)', 47 | desc: '式の準備をします!「工廠」で装備アイテムを2回「廃棄」して身の回りの整理を!', 48 | status: QUEST_STATUS.IN_PROGRESS, 49 | } 50 | 51 | export const OverflowTest = Template.bind({}) 52 | OverflowTest.args = { 53 | code: 'A0', 54 | name: 'はじめての「編成」!はじめての「編成」!はじめての「編成」!はじめての「編成」!', 55 | desc: 'TBF を秘書艦一番スロットに搭載、「13 号対空電探」x2「22 号対水上電探」x2 廃棄、開発資材 x40、改修資材 x10、弾薬 5,000、ボーキサイト 8,000、「新型航空兵装資材」x1、「熟練搭乗員」を用意せよ!', 56 | status: QUEST_STATUS.COMPLETED, 57 | } 58 | 59 | export const ComplexCard = Template.bind({}) 60 | ComplexCard.args = { 61 | gameId: 290, 62 | code: 'B128', 63 | desc: '「比叡」在南方海域的出击任务:使用旗舰为高速战舰「比叡」的强有力舰队,出击南方海域萨部岛近海海域与沙门海域。与该作战海域的敌方舰队交战,消灭她们!', 64 | tip: '奖励:“比叡” 挂轴以下奖励三选一:战斗详报 ×196 式 150cm 探照灯 ×1 勋章 ×1', 65 | tip2: '非限时任务使用以比叡作为旗舰的舰队取得以下海域 S 胜:5-3、5-4 背景相关:比叡于 1942 年 11 月 13 日沉没于所罗门海域,2019 年 2 月 6 日舰体被发现。', 66 | name: '「比叡」的出击', 67 | } 68 | 69 | export const PreQuestCard = Template.bind({}) 70 | PreQuestCard.args = { 71 | gameId: 202, 72 | code: 'B1', 73 | name: 'はじめての「出撃」!', 74 | desc: '艦隊を出撃させ、敵艦隊と交戦せよ!', 75 | } 76 | -------------------------------------------------------------------------------- /src/components/QuestCard/utils.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconSize, Tooltip } from '@blueprintjs/core' 2 | import { IconNames } from '@blueprintjs/icons' 3 | import React from 'react' 4 | import { 5 | IconArsenal, 6 | IconCompleted, 7 | IconComposition, 8 | IconExercise, 9 | IconExpedition, 10 | IconInProgress, 11 | IconModernization, 12 | IconSortie, 13 | IconSupplyDocking, 14 | } from '../../../build/assets' 15 | import { usePluginTranslation } from '../../poi/hooks' 16 | import { QUEST_CATEGORY, QUEST_STATUS } from '../../questHelper' 17 | 18 | export const questStatusMap: Record = { 19 | [QUEST_STATUS.LOCKED]: function Locked() { 20 | const { t } = usePluginTranslation() 21 | return ( 22 | 23 | 24 | 25 | ) 26 | }, 27 | // Display nothing 28 | [QUEST_STATUS.DEFAULT]: () => null, 29 | [QUEST_STATUS.IN_PROGRESS]: function InProgress() { 30 | const { t } = usePluginTranslation() 31 | return ( 32 | 33 | 34 | 35 | ) 36 | }, 37 | [QUEST_STATUS.COMPLETED]: function Completed() { 38 | const { t } = usePluginTranslation() 39 | return ( 40 | 41 | 42 | 43 | ) 44 | }, 45 | [QUEST_STATUS.ALREADY_COMPLETED]: function AlreadyCompleted() { 46 | const { t } = usePluginTranslation() 47 | return ( 48 | 49 | 50 | 51 | ) 52 | }, 53 | [QUEST_STATUS.UNKNOWN]: function AlreadyCompleted() { 54 | const { t } = usePluginTranslation() 55 | return ( 56 | 57 | 58 | 59 | ) 60 | }, 61 | } 62 | 63 | // transparent GIF pixel 64 | const PLACEHOLDER = 65 | 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' 66 | 67 | export const questIconMap = { 68 | [QUEST_CATEGORY.Composition]: IconComposition, 69 | [QUEST_CATEGORY.Sortie]: IconSortie, 70 | [QUEST_CATEGORY.Exercise]: IconExercise, 71 | [QUEST_CATEGORY.Expedition]: IconExpedition, 72 | [QUEST_CATEGORY.SupplyOrDocking]: IconSupplyDocking, 73 | [QUEST_CATEGORY.Arsenal]: IconArsenal, 74 | [QUEST_CATEGORY.Modernization]: IconModernization, 75 | [QUEST_CATEGORY.Unknown]: PLACEHOLDER, 76 | } as const 77 | -------------------------------------------------------------------------------- /src/poi/store.ts: -------------------------------------------------------------------------------- 1 | import type { PluginState } from '../reducer' 2 | import { id, noop } from '../utils' 3 | import { importFromPoi, IN_POI, PACKAGE_NAME } from './env' 4 | import type { PoiState, Store } from './types' 5 | 6 | /** 7 | * See https://redux.js.org/api/store#subscribelistener 8 | */ 9 | const observeStore = ( 10 | store: Store, 11 | onChange: (state: SelectedState) => void, 12 | selector: (s: State) => SelectedState = id as any, 13 | ) => { 14 | let currentState: SelectedState 15 | 16 | const handleChange = () => { 17 | const nextState = selector(store.getState()) 18 | if (nextState !== currentState) { 19 | currentState = nextState 20 | onChange(currentState) 21 | } 22 | } 23 | 24 | const unsubscribe = store.subscribe(handleChange) 25 | handleChange() 26 | return unsubscribe 27 | } 28 | 29 | export const observePoiStore = ( 30 | onChange: (state: SelectedState) => void, 31 | selector: (state: PoiState) => SelectedState = id as any, 32 | ) => { 33 | let valid = true 34 | let unsubscribe = noop 35 | getPoiStore().then((store) => { 36 | if (!valid) { 37 | return 38 | } 39 | unsubscribe = observeStore(store, onChange, selector) 40 | }) 41 | 42 | return () => { 43 | valid = false 44 | unsubscribe() 45 | } 46 | } 47 | 48 | export const observePluginStore = ( 49 | onChange: (state: SelectedState) => void, 50 | selector: (state: PluginState) => SelectedState = id as any, 51 | ) => observePoiStore(onChange, (s) => selector(s?.ext[PACKAGE_NAME])) 52 | 53 | const genFallbackStore = (state?: PoiState) => 54 | ({ 55 | getState: () => state, 56 | subscribe: () => (() => {}) as () => () => void, 57 | }) as Store 58 | 59 | let globalStore: Store | null = null 60 | /** 61 | * Get poi global Store if in poi env 62 | */ 63 | export const getPoiStore: () => Promise> = async () => { 64 | if (globalStore !== null) { 65 | return globalStore 66 | } 67 | if (IN_POI) { 68 | try { 69 | const { store } = await importFromPoi('views/create-store') 70 | globalStore = store 71 | return store 72 | } catch (error) { 73 | console.warn('Load global store error', error) 74 | } 75 | } 76 | globalStore = genFallbackStore() 77 | return globalStore 78 | } 79 | 80 | export const exportPoiState = async () => { 81 | if (!IN_POI) { 82 | throw new Error( 83 | 'Failed export state from poi! You are not currently in the poi environment!', 84 | ) 85 | } 86 | const { getState } = await getPoiStore() 87 | return getState() 88 | } 89 | 90 | /** 91 | * TODO fix state update 92 | */ 93 | export const importPoiState = (state: PoiState) => { 94 | globalStore = genFallbackStore(state) 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update quest data 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 2 * * *' # At every day 02:00(UTC). 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout Repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: '.nvmrc' 28 | 29 | - name: Install node modules 30 | uses: bahmutov/npm-install@v1 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Lint 36 | run: npm run lint -- --max-warnings=0 37 | 38 | - name: Update 39 | run: npm test -- --updateSnapshot 40 | 41 | - name: Push Branch 42 | if: github.ref == 'refs/heads/main' 43 | id: push 44 | run: | 45 | git add . 46 | # Do not proceed if there are no file differences 47 | COMMIT=$(git rev-parse --verify origin/$TARGET_BRANCH || echo HEAD) 48 | FILES_CHANGED=$(git diff-index --name-only --cached $COMMIT | wc -l) 49 | if [[ "$FILES_CHANGED" = "0" ]]; then 50 | echo "No file changes detected." 51 | exit 0 52 | fi 53 | echo -e "---\n'poi-plugin-quest-info-2': patch\n---\n\nUpdate quest data\n" > .changeset/bot-data-update.md 54 | git add . 55 | git config user.name 'github-actions[bot]' 56 | git config user.email 'github-actions[bot]@users.noreply.github.com' 57 | git commit --message "$COMMIT_MESSAGE" 58 | git remote set-url origin "https://$GITHUB_ACTOR:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" 59 | git push --force origin HEAD:$TARGET_BRANCH 60 | echo "createPR=true" >> $GITHUB_OUTPUT 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.github_token }} 63 | COMMIT_MESSAGE: 'chore: update quest data' 64 | TARGET_BRANCH: 'bot/data' 65 | 66 | - name: Get current date 67 | id: date 68 | run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 69 | 70 | # see https://github.com/repo-sync/pull-request 71 | - name: Create Pull Request 72 | if: steps.push.outputs.createPR == 'true' 73 | uses: repo-sync/pull-request@v2 74 | with: 75 | destination_branch: "main" 76 | source_branch: "bot/data" # If blank, default: triggered branch 77 | pr_title: "chore: update quest data (${{ steps.date.outputs.date }})" # Title of pull request 78 | pr_body: "" # Full markdown support, requires pr_title to be set 79 | github_token: ${{ secrets.GITHUB_TOKEN }} 80 | -------------------------------------------------------------------------------- /src/store/quest.ts: -------------------------------------------------------------------------------- 1 | import { QUEST_DATA } from '../../build' 2 | import { usePluginTranslation } from '../poi/hooks' 3 | import { 4 | DocQuest, 5 | getQuestIdByCode, 6 | QUEST_STATUS, 7 | UnionQuest, 8 | } from '../questHelper' 9 | import { useGlobalGameQuest, useGlobalQuestStatusQuery } from './gameQuest' 10 | import { DataSource, useStore } from './store' 11 | 12 | const useLanguage = () => { 13 | const { 14 | i18n: { language }, 15 | } = usePluginTranslation() 16 | return language 17 | } 18 | 19 | export const useDataSource = () => { 20 | const { 21 | store: { dataSource }, 22 | updateStore, 23 | } = useStore() 24 | const lang = useLanguage() 25 | const setDataSource = (val: DataSource | null) => 26 | updateStore({ dataSource: val }) 27 | const isValid = 28 | dataSource && Object.values(QUEST_DATA).find((i) => i.key === dataSource) 29 | const normalizedDataSource = isValid 30 | ? dataSource 31 | : (QUEST_DATA.find((i) => i.lang === lang)?.key ?? QUEST_DATA[0].key) 32 | return { dataSource: normalizedDataSource, setDataSource } 33 | } 34 | 35 | const useQuestMap = (): Record => { 36 | const { dataSource } = useDataSource() 37 | if (!QUEST_DATA.length) { 38 | throw new Error('QUEST_DATA is empty') 39 | } 40 | const data = QUEST_DATA.find((i) => i.key === dataSource) 41 | if (!data) { 42 | return QUEST_DATA[0].res 43 | } 44 | return data.res 45 | } 46 | 47 | export const useQuest = (): UnionQuest[] => { 48 | const docQuestMap = useQuestMap() 49 | const gameQuest = useGlobalGameQuest() 50 | // TODO extract new quest from game quest 51 | // Not yet recorded quest 52 | // May be a new quest 53 | // if (!(gameId in docQuestMap)) { 54 | // return { 55 | // gameId, 56 | // gameQuest: quest, 57 | // docQuest: { 58 | // code: `${getCategory(quest.api_category).wikiSymbol}?`, 59 | // name: quest.api_title, 60 | // desc: quest.api_detail, 61 | // }, 62 | // } 63 | // } 64 | // Return all recorded quests 65 | return Object.entries(docQuestMap).map(([gameId, val]) => ({ 66 | gameId: +gameId, 67 | // Maybe empty 68 | gameQuest: gameQuest.find((quest) => quest.api_no === Number(gameId)), 69 | docQuest: val, 70 | })) 71 | } 72 | 73 | export const useQuestByCode = (code: string) => { 74 | const questMap = useQuestMap() 75 | const gameId = getQuestIdByCode(code) 76 | if (gameId && gameId in questMap) { 77 | return { 78 | gameId, 79 | docQuest: questMap[String(gameId) as keyof typeof questMap], 80 | } 81 | } 82 | return null 83 | } 84 | 85 | /** 86 | * Get the completion status of a specific game quest 87 | */ 88 | export const useQuestStatus = (gameId: number | null) => { 89 | const searcher = useGlobalQuestStatusQuery() 90 | if (!gameId) { 91 | return QUEST_STATUS.UNKNOWN 92 | } 93 | return searcher(gameId) 94 | } 95 | -------------------------------------------------------------------------------- /src/poi/types.ts: -------------------------------------------------------------------------------- 1 | import type { PluginState } from '../reducer' 2 | 3 | export enum QUEST_API_STATE { 4 | DEFAULT = 1, 5 | IN_PROGRESS = 2, 6 | COMPLETED = 3, 7 | } 8 | 9 | // See https://github.com/poooi/poi/blob/master/views/redux/info/quests.es 10 | export type GameQuest = { 11 | api_state: QUEST_API_STATE 12 | /** 13 | * Game ID, for example 101, 102 14 | */ 15 | api_no: number 16 | api_title: string 17 | api_detail: string 18 | /** 19 | * 任务类别 20 | * 21 | * 1. Composition 22 | * 1. Sortie 23 | * 1. Exercise 24 | * 1. Expedition 25 | * 1. Supply/Docking 26 | * 1. Arsenal 27 | * 1. Modernization 28 | * 29 | * @see https://github.com/poooi/plugin-quest/blob/master/index.es#L49-L57 30 | */ 31 | api_category: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 32 | /** 33 | * 任务类型 34 | * 35 | * 1. One-time 36 | * 1. Daily 37 | * 1. Weekly 38 | * 1. -3rd/-7th/-0th 39 | * 1. -2nd/-8th 40 | * 1. Monthly 41 | * 1. Quarterly 42 | * 43 | * @see https://github.com/poooi/plugin-quest/blob/master/index.es#L69-L77 44 | */ 45 | api_type: 1 | 2 | 3 | 4 | 5 | 6 | 7 46 | // Rewards 油弹钢铝 47 | api_get_material: [number, number, number, number] 48 | api_invalid_flag: 0 49 | api_label_type: 1 50 | // 0: Empty: [0.0, 0.5) 51 | // 1: 50%: [0.5, 0.8) 52 | // 2: 80%: [0.8, 1.0) 53 | api_progress_flag: 0 | 1 | 2 54 | api_select_rewards?: [ 55 | { 56 | api_count: number 57 | api_kind: number 58 | api_mst_id: number 59 | api_no: number 60 | }[], 61 | ] 62 | api_voice_id: 0 63 | api_bonus_flag: 1 64 | } 65 | 66 | export enum QuestTab { 67 | ALL = '0', 68 | IN_PROGRESS = '9', 69 | DAILY = '1', 70 | WEEKLY = '2', 71 | MONTHLY = '3', 72 | ONCE = '4', 73 | OTHERS = '5', 74 | } 75 | 76 | type QuestListAction = { 77 | type: '@@Response/kcsapi/api_get_member/questlist' 78 | path: '/kcsapi/api_get_member/questlist' 79 | postBody: { 80 | api_verno: '1' 81 | api_tab_id: QuestTab 82 | } 83 | body: { 84 | api_completed_kind: number 85 | // api_list.length 86 | api_count: number 87 | // In progress count 88 | api_exec_count: number 89 | api_exec_type: number 90 | api_list: GameQuest[] 91 | } 92 | } 93 | 94 | type OtherAction = { 95 | type: 'otherString' // TODO fix me 96 | path?: string 97 | postBody?: unknown 98 | body?: unknown 99 | } 100 | 101 | export type PoiAction = QuestListAction | OtherAction 102 | 103 | export type PoiState = { 104 | ui: { 105 | activeMainTab: string 106 | activeFleetId?: number 107 | activePluginName?: string 108 | } 109 | ext: { 110 | // TODO fix use constant PACKAGE_NAME 111 | [packageName: string]: PluginState 112 | } 113 | plugins: { id: string; enabled: boolean; [x: string]: unknown }[] 114 | [x: string]: any 115 | } 116 | 117 | export type Store = { 118 | getState: () => S 119 | subscribe: (listener: () => void) => () => void 120 | } 121 | 122 | // state.info.quests.activeQuests 123 | export type PoiQuestState = Record 124 | -------------------------------------------------------------------------------- /src/store/filterTags.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useUpdateEffect } from 'react-use' 3 | import { useGameTab } from '../poi/hooks' 4 | import { QuestTab } from '../poi/types' 5 | import type { CATEGORY_TAGS, TYPE_TAGS } from '../tags' 6 | import { ALL_CATEGORY_TAG, ALL_TYPE_TAG, PROGRESS_TAG, useStore } from './store' 7 | 8 | export const useFilterTags = () => { 9 | const { 10 | store: { categoryTags, typeTags }, 11 | updateStore, 12 | } = useStore() 13 | const setCategoryTags = useCallback( 14 | (tagName: (typeof CATEGORY_TAGS)[number]['name']) => { 15 | updateStore({ categoryTags: { [tagName]: true } }) 16 | }, 17 | [updateStore], 18 | ) 19 | 20 | const setCategoryTagsAll = useCallback(() => { 21 | setCategoryTags(ALL_CATEGORY_TAG.name) 22 | }, [setCategoryTags]) 23 | 24 | const setTypeTags = useCallback( 25 | (tagName: (typeof TYPE_TAGS)[number]['name']) => { 26 | updateStore({ typeTags: { [tagName]: true } }) 27 | }, 28 | [updateStore], 29 | ) 30 | const setMultiTypeTags = useCallback( 31 | (data: Record) => { 32 | updateStore({ typeTags: data }) 33 | }, 34 | [updateStore], 35 | ) 36 | 37 | const setTypeTagsAll = useCallback(() => { 38 | setTypeTags(ALL_TYPE_TAG.name) 39 | }, [setTypeTags]) 40 | 41 | return { 42 | categoryTags, 43 | typeTags, 44 | setCategoryTags, 45 | setCategoryTagsAll, 46 | setTypeTags, 47 | setMultiTypeTags, 48 | setTypeTagsAll, 49 | } 50 | } 51 | 52 | export const useFilterProgressTag = () => { 53 | const { 54 | store: { progressTag }, 55 | updateStore, 56 | } = useStore() 57 | 58 | const toggleTag = useCallback( 59 | (tag: PROGRESS_TAG) => { 60 | if (progressTag === tag) { 61 | updateStore({ progressTag: PROGRESS_TAG.All }) 62 | return 63 | } 64 | updateStore({ progressTag: tag }) 65 | }, 66 | [progressTag, updateStore], 67 | ) 68 | 69 | return { 70 | progressTag, 71 | toggleTag, 72 | } 73 | } 74 | 75 | /** 76 | * @deprecated Should not update state when render 77 | */ 78 | export const useSyncGameTagEffect = () => { 79 | const { progressTag } = useFilterProgressTag() 80 | const filterTags = useFilterTags() 81 | const tab = useGameTab() 82 | 83 | useUpdateEffect(() => { 84 | if (progressTag !== PROGRESS_TAG.Unlocked) { 85 | return 86 | } 87 | switch (tab) { 88 | case QuestTab.ALL: 89 | filterTags.setTypeTagsAll() 90 | break 91 | case QuestTab.DAILY: 92 | filterTags.setTypeTags('Daily') 93 | break 94 | case QuestTab.WEEKLY: 95 | filterTags.setTypeTags('Weekly') 96 | break 97 | case QuestTab.MONTHLY: 98 | filterTags.setTypeTags('Monthly') 99 | break 100 | case QuestTab.IN_PROGRESS: 101 | filterTags.setTypeTags('In Progress') 102 | break 103 | case QuestTab.OTHERS: 104 | filterTags.setMultiTypeTags({ Quarterly: true, Yearly: true }) 105 | break 106 | case QuestTab.ONCE: 107 | filterTags.setTypeTags('One-time') 108 | break 109 | default: 110 | break 111 | } 112 | }, [tab]) 113 | } 114 | -------------------------------------------------------------------------------- /src/components/QuestList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef } from 'react' 2 | import AutoSizer from 'react-virtualized-auto-sizer' 3 | // https://github.com/bvaughn/react-window 4 | import { VariableSizeList as List, ListChildComponentProps } from 'react-window' 5 | import styled from 'styled-components' 6 | import { useIsQuestPluginTab } from '../poi/hooks' 7 | import type { UnionQuest } from '../questHelper' 8 | import { QuestCard } from './QuestCard' 9 | 10 | const QuestListWrapper = styled.div` 11 | flex: 1; 12 | overflow: hidden; 13 | ` 14 | 15 | // CSS - Overflow: Scroll; - Always show vertical scroll bar? 16 | // See https://stackoverflow.com/questions/7492062/css-overflow-scroll-always-show-vertical-scroll-bar 17 | const ListWrapper = styled(List)` 18 | -webkit-overflow-scrolling: auto; 19 | 20 | ::-webkit-scrollbar { 21 | -webkit-appearance: none; 22 | width: 8px; 23 | } 24 | 25 | ::-webkit-scrollbar-thumb { 26 | border-radius: 4px; 27 | background-color: rgba(1, 1, 1, 0.3); 28 | } 29 | 30 | ::-webkit-scrollbar { 31 | border-radius: 4px; 32 | background-color: rgba(1, 1, 1, 0.1); 33 | } 34 | ` 35 | 36 | export const QuestList = ({ quests }: { quests: UnionQuest[] }) => { 37 | const activeTab = useIsQuestPluginTab() 38 | const listRef = useRef(null) 39 | const rowHeights = useRef>({}) 40 | 41 | useEffect(() => { 42 | listRef.current?.resetAfterIndex(0) 43 | }, [quests]) 44 | 45 | useEffect(() => { 46 | if (activeTab) { 47 | listRef.current?.resetAfterIndex(0) 48 | } 49 | }, [activeTab]) 50 | 51 | const setRowHeight = useCallback((index, size) => { 52 | if (rowHeights.current[index] === size) { 53 | return 54 | } 55 | rowHeights.current = { ...rowHeights.current, [index]: size } 56 | listRef.current?.resetAfterIndex(index) 57 | }, []) 58 | 59 | const getRowHeight = useCallback((index) => { 60 | return rowHeights.current[index] + 8 || 200 61 | }, []) 62 | 63 | const Row = ({ index, style }: ListChildComponentProps) => { 64 | const rowRef = useRef(null) 65 | 66 | const quest = quests[index] 67 | const { gameId } = quest 68 | const { code, name, desc, rewards, memo2 } = quest.docQuest 69 | 70 | useEffect(() => { 71 | if (rowRef.current) { 72 | setRowHeight(index, rowRef.current.clientHeight) 73 | } 74 | }, [index]) 75 | 76 | return ( 77 |
78 |
79 | 88 |
89 |
90 | ) 91 | } 92 | 93 | return ( 94 | 95 | 96 | {({ height, width }: { height: number; width: number }) => ( 97 | 105 | {Row} 106 | 107 | )} 108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/components/QuestTag.tsx: -------------------------------------------------------------------------------- 1 | import type { TooltipProps } from '@blueprintjs/core' 2 | import { Tag, Tooltip } from '@blueprintjs/core' 3 | import { IconNames } from '@blueprintjs/icons' 4 | import React, { forwardRef, useCallback } from 'react' 5 | import styled from 'styled-components' 6 | import { DocQuest, QUEST_STATUS, guessQuestCategory } from '../questHelper' 7 | import { useFilterTags } from '../store/filterTags' 8 | import { useQuestByCode, useQuestStatus } from '../store/quest' 9 | import { useSearchInput } from '../store/search' 10 | 11 | const TagWrapper = styled(Tag)` 12 | margin: 2px 4px; 13 | user-select: ${({ interactive }) => (interactive ? 'none' : 'auto')}; 14 | overflow: visible; 15 | 16 | & > span { 17 | cursor: ${({ interactive }) => (interactive ? 'pointer' : 'auto')}; 18 | } 19 | ` 20 | 21 | const QuestTooltip = forwardRef< 22 | Tooltip, 23 | Omit & { 24 | quest: DocQuest 25 | children: React.ReactNode 26 | } 27 | >(({ quest, children, ...props }, ref) => { 28 | if (!quest) { 29 | return <>{children} 30 | } 31 | return ( 32 | 36 |
{`${quest.code} - ${quest.name}`}
37 |
{quest.desc}
38 | {quest.memo2 && ( 39 |
40 | {quest.memo2} 41 |
42 | )} 43 | {quest.rewards && ( 44 |
45 | {quest.rewards} 46 |
47 | )} 48 | 49 | } 50 | placement={'top'} 51 | {...props} 52 | > 53 | {children} 54 |
55 | ) 56 | }) 57 | 58 | const getTagIcon = (questStatus: QUEST_STATUS) => { 59 | switch (questStatus) { 60 | case QUEST_STATUS.ALREADY_COMPLETED: 61 | return IconNames.TICK 62 | case QUEST_STATUS.LOCKED: 63 | return IconNames.LOCK 64 | default: 65 | return null 66 | } 67 | } 68 | 69 | export const QuestTag = ({ code }: { code: string }) => { 70 | const { setSearchInput } = useSearchInput() 71 | const { setCategoryTagsAll, setTypeTagsAll } = useFilterTags() 72 | const maybeQuest = useQuestByCode(code) 73 | const maybeGameId = maybeQuest?.gameId ?? null 74 | const questStatus = useQuestStatus(maybeGameId) 75 | const tagIcon = getTagIcon(questStatus) 76 | 77 | const handleClick = useCallback(() => { 78 | setSearchInput(code) 79 | setCategoryTagsAll() 80 | setTypeTagsAll() 81 | }, [code, setCategoryTagsAll, setSearchInput, setTypeTagsAll]) 82 | const indicatorColor = guessQuestCategory(code).color 83 | const fontColor = 84 | indicatorColor === '#fff' || indicatorColor === '#87da61' 85 | ? 'black' 86 | : 'white' 87 | 88 | if (!maybeQuest) { 89 | return ( 90 | 94 | {code} 95 | 96 | ) 97 | } 98 | 99 | const quest = maybeQuest.docQuest 100 | return ( 101 | 102 | 108 | {code} 109 | 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /shims/poi.d.ts: -------------------------------------------------------------------------------- 1 | // @see https://github.com/poooi/plugin-ship-info/blob/cb251d3858ee793e39bffd2f336b94762e62b87c/shims/poi.d.ts 2 | // @see https://github.com/poooi/poi/issues/2219 3 | 4 | declare module 'views/components/etc/window-env' { 5 | import { Context } from 'react' 6 | 7 | export const WindowEnv: Context<{ window: Window }> 8 | } 9 | 10 | declare module 'views/env-parts/i18next' { 11 | import { i18n } from 'i18next' 12 | 13 | const i18nextInstance: i18n 14 | export default i18nextInstance 15 | } 16 | 17 | declare module 'views/utils/selectors' { 18 | import { APIShip } from 'kcsapi/api_port/port/response' 19 | import { 20 | APIMstShip, 21 | APIMstShipgraph, 22 | APIMstStype, 23 | APIMstMaparea, 24 | APIMstMapinfo, 25 | } from 'kcsapi/api_start2/getData/response' 26 | import { Selector } from 'reselect' 27 | interface Dictionary { 28 | [index: string]: T 29 | } 30 | 31 | export interface IState { 32 | const: IConstState 33 | config: any 34 | } 35 | 36 | export interface IConstState { 37 | $shipgraph?: APIMstShipgraph[] 38 | $shipTypes?: Dictionary 39 | $ships?: Dictionary 40 | $maps?: Dictionary 41 | $mapareas?: Dictionary 42 | } 43 | 44 | export interface IFCD { 45 | shipavatar: { 46 | marginMagics: Dictionary 47 | } 48 | shiptag: any 49 | } 50 | 51 | export type IShipData = [APIShip?, APIMstShip?] 52 | 53 | export const configSelector: Selector 54 | export const constSelector: Selector 55 | export const extensionSelectorFactory: (id: string) => Selector 56 | export const fcdSelector: Selector 57 | export const fleetInExpeditionSelectorFactory: ( 58 | id: number, 59 | ) => Selector 60 | export const fleetShipsIdSelectorFactory: (id: number) => Selector 61 | export const inRepairShipsIdSelector: Selector 62 | export const shipDataSelectorFactory: (id: number) => Selector 63 | export const shipEquipDataSelectorFactory: (id: number) => Selector 64 | export const equipDataSelectorFactory: (id: number) => Selector 65 | export const shipsSelector: Selector> 66 | export const stateSelector: Selector 67 | export const wctfSelector: Selector 68 | } 69 | 70 | declare module 'views/components/etc/overlay' { 71 | export { Tooltip, Popover, Dialog } from '@blueprintjs/core' 72 | } 73 | 74 | declare module 'views/components/etc/avatar' { 75 | import { ComponentType } from 'react' 76 | export const Avatar: ComponentType 77 | } 78 | 79 | declare module 'views/utils/tools' { 80 | export const resolveTime: (time: number) => string 81 | } 82 | 83 | declare module 'views/components/etc/icon' { 84 | import { ComponentType } from 'react' 85 | export const SlotitemIcon: ComponentType 86 | } 87 | 88 | declare module 'views/utils/ship-img' { 89 | export const getShipImgPath: ( 90 | id: number, 91 | type: string, 92 | damagaed: boolean, 93 | ip?: string, 94 | version?: number, 95 | ) => string 96 | } 97 | 98 | declare module 'views/create-store' { 99 | export const store: any 100 | } 101 | 102 | // extra 103 | 104 | declare module 'views/services/plugin-manager/utils' { 105 | export function getNpmConfig(prefix: string): any 106 | } 107 | -------------------------------------------------------------------------------- /src/store/store.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | Dispatch, 4 | SetStateAction, 5 | useCallback, 6 | useContext, 7 | useState, 8 | } from 'react' 9 | import { useMount, useUpdateEffect } from 'react-use' 10 | import type { QUEST_DATA } from '../../build' 11 | import { PACKAGE_NAME } from '../poi/env' 12 | import { yes } from '../utils' 13 | import { GameQuestProvider } from './gameQuest' 14 | 15 | export const ALL_CATEGORY_TAG = { 16 | name: 'All', 17 | filter: yes, 18 | } as const 19 | 20 | export const ALL_TYPE_TAG = ALL_CATEGORY_TAG 21 | 22 | export enum PROGRESS_TAG { 23 | All = 'All', 24 | Unlocked = 'Unlocked', 25 | Locked = 'Locked', 26 | AlreadyCompleted = 'AlreadyCompleted', 27 | } 28 | 29 | type Unpacked = T extends (infer U)[] ? U : T 30 | export type DataSource = Unpacked['key'] 31 | 32 | export const initialState = { 33 | searchInput: '', 34 | typeTags: { 35 | [ALL_TYPE_TAG.name]: true, 36 | } as Record, 37 | categoryTags: { 38 | [ALL_CATEGORY_TAG.name]: true, 39 | } as Record, 40 | progressTag: PROGRESS_TAG.All, 41 | syncWithGame: false as const, 42 | /** 43 | * @deprecated 44 | */ 45 | preferKcwikiData: true, 46 | dataSource: null as DataSource | null, 47 | } 48 | 49 | export type State = typeof initialState 50 | 51 | // Persist state 52 | const STORAGE_KEY = PACKAGE_NAME 53 | 54 | const useStorage = (initialValue: T) => { 55 | const [state, setState] = useState(initialValue) 56 | // Load storage at mount 57 | useMount(() => { 58 | try { 59 | const stringStore = localStorage.getItem(STORAGE_KEY) 60 | if (stringStore == null) { 61 | return 62 | } 63 | const parsedStorage: T = JSON.parse(stringStore) 64 | setState({ ...initialState, ...parsedStorage }) 65 | } catch (error) { 66 | console.error('Failed to load storage', error) 67 | } 68 | }) 69 | 70 | // Save storage when store change 71 | useUpdateEffect(() => { 72 | const serializedStore = JSON.stringify(state) 73 | localStorage.setItem(STORAGE_KEY, serializedStore) 74 | }, [state]) 75 | 76 | return [state, setState] as const 77 | } 78 | 79 | export const getStorage = () => { 80 | const stringStore = localStorage.getItem(STORAGE_KEY) 81 | if (stringStore == null) { 82 | return 83 | } 84 | return JSON.parse(stringStore) as State 85 | } 86 | 87 | const StateContext = createContext(initialState) 88 | const SetStateContext = createContext>>(() => {}) 89 | 90 | export const StoreProvider = ({ children }: { children?: React.ReactNode }) => { 91 | const [state, setState] = useStorage(initialState) 92 | return ( 93 | 94 | 95 | {children} 96 | 97 | 98 | ) 99 | } 100 | 101 | export const useStore = () => { 102 | const store = useContext(StateContext) 103 | const setStore = useContext(SetStateContext) 104 | const updateStore = useCallback( 105 | (newStore: Partial) => { 106 | setStore((previousStore) => ({ ...previousStore, ...newStore })) 107 | }, 108 | [setStore], 109 | ) 110 | 111 | return { store, setStore, updateStore } 112 | } 113 | 114 | export const useRemoveStorage = () => { 115 | const { updateStore } = useStore() 116 | return () => { 117 | localStorage.removeItem(STORAGE_KEY) 118 | updateStore(initialState) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poi-plugin-quest-info-2", 3 | "version": "0.14.34", 4 | "private": false, 5 | "description": "show quest info", 6 | "homepage": "https://github.com/lawvs/poi-plugin-quest-2/", 7 | "bugs": { 8 | "url": "https://github.com/lawvs/poi-plugin-quest-2/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/lawvs/poi-plugin-quest-2.git" 13 | }, 14 | "license": "MIT", 15 | "author": { 16 | "name": "白水", 17 | "links": "https://github.com/lawvs" 18 | }, 19 | "main": "src/index.ts", 20 | "scripts": { 21 | "dev": "npm run storybook", 22 | "build": "npm run convertAssets && npm run update", 23 | "build-storybook": "storybook build", 24 | "clean": "rm -rf build", 25 | "convertAssets": "ts-node scripts/convertAssets.ts", 26 | "downloadKcQuestsData": "ts-node scripts/downloadKcQuestsData.ts", 27 | "downloadKcanotifyData": "ts-node scripts/downloadKcanotifyGamedata.ts", 28 | "downloadSprites": "ts-node scripts/downloadSprites.ts", 29 | "genQuestData": "ts-node scripts/genQuestData.ts", 30 | "lint": "eslint . --ignore-path .gitignore", 31 | "lint:fix": "npm run lint -- --fix", 32 | "storybook": "storybook dev -p 6006", 33 | "test": "jest", 34 | "typeCheck": "tsc --noEmit", 35 | "update": "npm run downloadKcanotifyData && npm run downloadKcQuestsData && npm run genQuestData", 36 | "changeset": "changeset", 37 | "release": "npm run build && changeset publish" 38 | }, 39 | "dependencies": { 40 | "moize": "^6.1.6", 41 | "react-highlight-words": "^0.20.0", 42 | "react-use": "^17.5.1", 43 | "react-virtualized-auto-sizer": "^1.0.24", 44 | "react-window": "^1.8.10", 45 | "styled-components": "^6.1.13", 46 | "stylis": "^4.3.4" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.12.10", 50 | "@babel/preset-env": "^7.23.2", 51 | "@babel/preset-react": "^7.22.15", 52 | "@babel/preset-typescript": "^7.23.2", 53 | "@blueprintjs/core": "^4.19.5", 54 | "@changesets/changelog-github": "^0.5.0", 55 | "@changesets/cli": "^2.27.7", 56 | "@storybook/addon-actions": "^7.5.2", 57 | "@storybook/addon-essentials": "^7.5.2", 58 | "@storybook/addon-links": "^7.5.2", 59 | "@storybook/react": "^7.5.2", 60 | "@storybook/react-webpack5": "^7.5.2", 61 | "@types/jest": "^27.4.1", 62 | "@types/pangu": "^3.3.0", 63 | "@types/react-highlight-words": "^0.16.4", 64 | "@types/react-virtualized-auto-sizer": "^1.0.1", 65 | "@types/react-window": "^1.8.5", 66 | "@types/sharp": "^0.27.1", 67 | "@types/styled-components": "^5.1.34", 68 | "@typescript-eslint/eslint-plugin": "^6.9.0", 69 | "@typescript-eslint/parser": "^6.9.0", 70 | "babel-loader": "^8.2.2", 71 | "eslint": "^8.52.0", 72 | "eslint-config-prettier": "^9.1.0", 73 | "eslint-plugin-prettier": "^5.2.1", 74 | "eslint-plugin-react": "^7.35.0", 75 | "eslint-plugin-react-hooks": "^4.5.0", 76 | "eslint-plugin-storybook": "^0.6.15", 77 | "i18next": "^19.8.5", 78 | "jest": "^30.0.0-alpha.1", 79 | "pangu": "^4.0.7", 80 | "poi-asset-themes": "^4.5.0", 81 | "prettier": "^3.3.3", 82 | "react": "^18.2.0", 83 | "react-dom": "^18.2.0", 84 | "react-i18next": "^11.8.5", 85 | "sharp": "^0.33.5", 86 | "storybook": "^7.5.2", 87 | "ts-jest": "^29.1.1", 88 | "ts-node": "^10.9.2", 89 | "typescript": "^4.7.4", 90 | "undici": "^6.19.8" 91 | }, 92 | "poiPlugin": { 93 | "title": "Quest Information", 94 | "description": "Show task information & enhance task panel", 95 | "icon": "fa/indent", 96 | "i18nDir": "./i18n", 97 | "priority": 2, 98 | "apiVer": {} 99 | }, 100 | "packageManager": "npm@10.7.0" 101 | } 102 | -------------------------------------------------------------------------------- /scripts/downloadSprites.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import crypto from 'crypto' 3 | import fs from 'fs' 4 | import path from 'path' 5 | // See https://sharp.pixelplumbing.com/ 6 | import sharp from 'sharp' 7 | import { fetch } from './proxyFetch' 8 | import { prepareDir } from './utils' 9 | 10 | const OUTPUT_PATH = path.resolve('build', 'dutySprites') 11 | 12 | const SERVER_URL = 'http://203.104.209.199' 13 | const JSON_PATH = '/kcs2/img/duty/duty_main.json' 14 | const SPRITES_PATH = '/kcs2/img/duty/duty_main.png' 15 | const VERSION = '5.1.2.0' 16 | const SPRITES_URL = `${SERVER_URL}${SPRITES_PATH}?version=${VERSION}` 17 | const META_URL = `${SERVER_URL}${JSON_PATH}?version=${VERSION}` 18 | 19 | const getFilename = (url: string) => { 20 | const pathname = new URL(url).pathname 21 | const index = pathname.lastIndexOf('/') 22 | return index !== -1 ? pathname.slice(index + 1) : pathname 23 | } 24 | 25 | const download = async (url: string, filename?: string) => { 26 | if (!filename) { 27 | filename = getFilename(url) 28 | } 29 | const filePath = path.resolve(OUTPUT_PATH, filename) 30 | const response = await fetch(url) 31 | const arrayBuffer = await response.arrayBuffer() 32 | const buffer = Buffer.from(arrayBuffer) 33 | fs.writeFileSync(filePath, buffer) 34 | return { filePath, buffer } 35 | } 36 | 37 | const checksumFile = (algorithm: string, path: fs.PathLike) => { 38 | return new Promise((resolve, reject) => { 39 | const hash = crypto.createHash(algorithm) 40 | const stream = fs.createReadStream(path) 41 | stream.on('error', (err) => reject(err)) 42 | stream.on('data', (chunk) => hash.update(chunk)) 43 | stream.on('end', () => resolve(hash.digest('hex'))) 44 | }) 45 | } 46 | 47 | const cropAndSaveImage = ( 48 | img: Buffer, 49 | { 50 | name, 51 | format, 52 | x, 53 | y, 54 | w, 55 | h, 56 | }: { 57 | name: string 58 | format: string 59 | x: number 60 | y: number 61 | w: number 62 | h: number 63 | }, 64 | ) => { 65 | const filename = `${name}.${format}` 66 | sharp(img) 67 | .extract({ left: x, top: y, width: w, height: h }) 68 | .toFile(path.resolve(OUTPUT_PATH, filename)) 69 | .catch((err) => { 70 | console.error('Failed to process image:', filename, err) 71 | }) 72 | } 73 | 74 | type KCS2Meta = { 75 | frames: { 76 | [name: string]: { 77 | frame: { 78 | x: number 79 | y: number 80 | w: number 81 | h: number 82 | } 83 | rotated: boolean 84 | trimmed: boolean 85 | spriteSourceSize: { 86 | x: number 87 | y: number 88 | w: number 89 | h: number 90 | } 91 | sourceSize: { 92 | w: number 93 | h: number 94 | } 95 | } 96 | } 97 | meta: any 98 | } 99 | 100 | const parseSprites = (sprites: Buffer, meta: KCS2Meta) => { 101 | const { frames } = meta 102 | for (const [name, { frame }] of Object.entries(frames)) { 103 | cropAndSaveImage(sprites, { ...frame, name, format: 'png' }) 104 | } 105 | } 106 | 107 | const main = async () => { 108 | prepareDir(OUTPUT_PATH) 109 | const [ 110 | { buffer: metaBuffer }, 111 | { filePath: spritesPath, buffer: spritesBuffer }, 112 | ] = await Promise.all([download(META_URL), download(SPRITES_URL)]) 113 | 114 | const spritesFilename = path.parse(spritesPath).base 115 | const md5 = await checksumFile('md5', spritesPath) 116 | fs.writeFileSync(path.resolve(OUTPUT_PATH, `${spritesFilename}.md5`), md5) 117 | console.log('File download complete') 118 | console.log(spritesFilename, 'MD5:', md5) 119 | 120 | parseSprites(spritesBuffer, JSON.parse(metaBuffer.toString())) 121 | } 122 | 123 | main() 124 | 125 | process.on('unhandledRejection', (up) => { 126 | throw up 127 | }) 128 | -------------------------------------------------------------------------------- /src/store/gameQuest.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import React, { createContext, useContext } from 'react' 3 | import { useGameQuest } from '../poi/hooks' 4 | import type { GameQuest } from '../poi/types' 5 | import { 6 | QUEST_STATUS, 7 | getCompletedQuest, 8 | getLockedQuest, 9 | questApiStateToQuestStatus, 10 | } from '../questHelper' 11 | 12 | export const GameQuestContext = createContext<{ 13 | gameQuest: GameQuest[] 14 | questStatusQuery: (gameId: number) => QUEST_STATUS 15 | lockedQuestNum: number 16 | unlockedQuestNum: number 17 | completedQuestNum: number 18 | alreadyCompletedQuestNum: number 19 | }>({ 20 | gameQuest: [], 21 | questStatusQuery: () => QUEST_STATUS.UNKNOWN, 22 | lockedQuestNum: 0, 23 | unlockedQuestNum: 0, 24 | completedQuestNum: 0, 25 | alreadyCompletedQuestNum: 0, 26 | }) 27 | 28 | const useQuestStatusQuery = (inProgressQuests: GameQuest[]) => { 29 | const gameQuestIds = inProgressQuests.map((quest) => quest.api_no) 30 | const unlockedGameQuest = Object.fromEntries( 31 | inProgressQuests.map((quest) => [quest.api_no, quest]), 32 | ) 33 | const alreadyCompletedQuest = getCompletedQuest(gameQuestIds) 34 | const lockedQuest = getLockedQuest(gameQuestIds) 35 | const completedQuest = inProgressQuests 36 | .map((quest) => questApiStateToQuestStatus(quest.api_state)) 37 | .filter((status) => status === QUEST_STATUS.COMPLETED) 38 | return { 39 | lockedQuestNum: Object.keys(lockedQuest).length, 40 | unlockedQuestNum: Object.keys(unlockedGameQuest).length, 41 | completedQuestNum: completedQuest.length, 42 | alreadyCompletedQuestNum: Object.keys(alreadyCompletedQuest).length, 43 | questStatusQuery: (gameId: number) => { 44 | const theGameQuest = unlockedGameQuest[gameId] 45 | if (theGameQuest) { 46 | // the quest is in game 47 | return questApiStateToQuestStatus(theGameQuest.api_state) 48 | } 49 | 50 | if (gameId in alreadyCompletedQuest) { 51 | return QUEST_STATUS.ALREADY_COMPLETED 52 | } 53 | if (gameId in lockedQuest) { 54 | return QUEST_STATUS.LOCKED 55 | } 56 | return QUEST_STATUS.UNKNOWN 57 | }, 58 | } 59 | } 60 | 61 | export const GameQuestProvider = ({ children }: { children?: ReactNode }) => { 62 | const gameQuest = useGameQuest() 63 | const { 64 | lockedQuestNum, 65 | unlockedQuestNum, 66 | completedQuestNum, 67 | alreadyCompletedQuestNum, 68 | questStatusQuery, 69 | } = useQuestStatusQuery(gameQuest) 70 | 71 | return ( 72 | 82 | {children} 83 | 84 | ) 85 | } 86 | 87 | /** 88 | * Get the questList from poi. 89 | * 90 | * Same as {@link useGameQuest}, but singleton 91 | */ 92 | export const useGlobalGameQuest = () => { 93 | const { gameQuest } = useContext(GameQuestContext) 94 | return gameQuest 95 | } 96 | 97 | /** 98 | * Get the questList from poi. 99 | * 100 | * Same as {@link useQuestStatusQuery}, but singleton 101 | */ 102 | export const useGlobalQuestStatusQuery = () => { 103 | const { questStatusQuery } = useContext(GameQuestContext) 104 | return questStatusQuery 105 | } 106 | 107 | /** 108 | * Get the number of quests in different states. 109 | */ 110 | export const useGlobalQuestStatusNum = () => { 111 | const { 112 | lockedQuestNum, 113 | unlockedQuestNum, 114 | completedQuestNum, 115 | alreadyCompletedQuestNum, 116 | } = useContext(GameQuestContext) 117 | return { 118 | lockedQuestNum, 119 | unlockedQuestNum, 120 | completedQuestNum, 121 | alreadyCompletedQuestNum, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | import type { i18n } from 'i18next' 2 | import { QUEST_DATA } from '../build' 3 | import { importFromPoi, PACKAGE_NAME } from './poi/env' 4 | import { getPoiStore } from './poi/store' 5 | import { getStorage } from './store' 6 | 7 | const LEGACY_QUEST_PLUGIN_ID = 'poi-plugin-quest-info' 8 | const HACK_KEY = `__patched-from-${PACKAGE_NAME}` 9 | 10 | /** 11 | * @env poi 12 | */ 13 | const isLegacyQuestPluginEnabled = async () => { 14 | const poiStore = await getPoiStore() 15 | const legacyQuestPlugin = poiStore 16 | .getState() 17 | .plugins?.find((i) => i.id === LEGACY_QUEST_PLUGIN_ID) 18 | if (legacyQuestPlugin && legacyQuestPlugin.enabled) { 19 | return true 20 | } 21 | return false 22 | } 23 | 24 | const getQuestState = (maybeLanguage: string) => { 25 | const dataSource = getStorage()?.dataSource 26 | const sourceData = QUEST_DATA.find((i) => i.key === dataSource) 27 | const defaultData = QUEST_DATA.find((i) => i.lang === maybeLanguage) 28 | const data = (sourceData ?? defaultData)?.res 29 | if (!data) { 30 | return {} 31 | } 32 | 33 | return Object.fromEntries( 34 | Object.entries(data).map(([apiNo, d]) => { 35 | const typedData = d as (typeof data)[keyof typeof data] 36 | return [ 37 | apiNo, 38 | { 39 | wiki_id: typedData.code, 40 | condition: [ 41 | 'memo2' in typedData ? typedData.memo2 : undefined, 42 | typedData.desc, 43 | ] 44 | .filter(Boolean) 45 | .join(' | '), 46 | }, 47 | ] 48 | }), 49 | ) 50 | } 51 | 52 | /** 53 | * Patch the reducer of `poi-plugin-quest-info` for poi's task panel tips 54 | * See https://github.com/poooi/poi/blob/da75b507e8f67615a39dc4fdb466e34ff5b5bdcf/views/components/main/parts/task-panel.es#L243 55 | * @env poi 56 | */ 57 | export const patchLegacyQuestPluginReducer = async () => { 58 | if (await isLegacyQuestPluginEnabled()) { 59 | // skip patch if legacy quest plugin enabled 60 | return 61 | } 62 | 63 | try { 64 | const i18next: i18n | { default: i18n; __esModule: true } = 65 | await importFromPoi('views/env-parts/i18next') 66 | // Fix https://github.com/poooi/poi/issues/2539 67 | const language = 68 | '__esModule' in i18next ? i18next.default.language : i18next.language 69 | 70 | const initState = { 71 | [HACK_KEY]: true, 72 | quests: getQuestState(language), 73 | questStatus: {}, 74 | } 75 | 76 | const reducer = ( 77 | state = initState, 78 | action: { type: string; [x: string]: any }, 79 | ) => { 80 | switch (action.type) { 81 | case '@@Config': 82 | // change language 83 | if (action.path === 'poi.misc.language') { 84 | const newLanguage = action.value 85 | return { 86 | ...state, 87 | quests: getQuestState(newLanguage), 88 | } 89 | } 90 | } 91 | return state 92 | } 93 | 94 | const { extendReducer } = await importFromPoi('views/create-store') 95 | extendReducer(LEGACY_QUEST_PLUGIN_ID, reducer) 96 | } catch (e) { 97 | console.error('Hack quest plugin reducer error', e) 98 | } 99 | } 100 | 101 | /** 102 | * Clear hacked reducer after unload 103 | * See https://github.com/poooi/poi/blob/3beedfa93ae347db273b7f0a5160f5ea01e9b8b7/views/services/plugin-manager/utils.es#L451 104 | * @env poi 105 | */ 106 | export const clearPatchLegacyQuestPluginReducer = async () => { 107 | if (await isLegacyQuestPluginEnabled()) { 108 | // skip clear if legacy quest plugin enabled 109 | return 110 | } 111 | try { 112 | const { extendReducer } = await importFromPoi('views/create-store') 113 | const clearReducer = undefined 114 | extendReducer(LEGACY_QUEST_PLUGIN_ID, clearReducer) 115 | } catch (e) { 116 | console.error('Clear hack quest plugin reducer error', e) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/poi/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { PACKAGE_NAME } from './env' 4 | import { 5 | exportPoiState, 6 | importPoiState, 7 | observePluginStore, 8 | observePoiStore, 9 | } from './store' 10 | import { GameQuest, PoiQuestState, PoiState, QuestTab } from './types' 11 | 12 | export const activeQuestsSelector = (state: PoiState): PoiQuestState => 13 | state?.info?.quests?.activeQuests ?? {} 14 | 15 | export const useActiveQuest = () => { 16 | const [activeQuests, setActiveQuests] = useState({}) 17 | 18 | useEffect(() => { 19 | const listener = (activeQuests: PoiQuestState) => 20 | setActiveQuests(activeQuests) 21 | 22 | return observePoiStore(listener, activeQuestsSelector) 23 | }, []) 24 | 25 | return activeQuests 26 | } 27 | 28 | export const usePluginTranslation = () => { 29 | // @ts-expect-error we declared a incorrect types in i18n/index.ts 30 | return useTranslation(PACKAGE_NAME) 31 | } 32 | 33 | const emptyArray: GameQuest[] = [] 34 | /** 35 | * Use `useGlobalGameQuest` instead 36 | * 37 | * Only use this hook to set context 38 | */ 39 | export const useGameQuest = () => { 40 | const [quests, setQuests] = useState([]) 41 | useEffect(() => { 42 | const listener = (quests: GameQuest[] | null) => 43 | setQuests(quests ?? emptyArray) 44 | // See reducer.ts 45 | return observePluginStore(listener, (i) => i?._?.questList) 46 | }, [setQuests]) 47 | return quests 48 | } 49 | 50 | export const useGameTab = () => { 51 | const [tab, setTab] = useState(QuestTab.ALL) 52 | useEffect(() => { 53 | const listener = (tabId: QuestTab | null) => setTab(tabId ?? QuestTab.ALL) 54 | return observePluginStore(listener, (i) => i?._?.tabId) 55 | }, []) 56 | return tab 57 | } 58 | 59 | const UNKNOWN_TAB = 'unknown' 60 | const useActiveTab = () => { 61 | const [activeMainTab, setActiveMainTab] = useState(UNKNOWN_TAB) 62 | 63 | useEffect(() => { 64 | const listener = (activeMainTab: string) => setActiveMainTab(activeMainTab) 65 | // poooi/poi/views/redux/ui.es 66 | return observePoiStore( 67 | listener, 68 | (state) => state?.ui?.activeMainTab ?? UNKNOWN_TAB, 69 | ) 70 | }, []) 71 | 72 | return activeMainTab 73 | } 74 | 75 | export const useIsQuestPluginTab = () => { 76 | const activeMainTab = useActiveTab() 77 | return activeMainTab === PACKAGE_NAME 78 | } 79 | 80 | const checkQuestList = (questList: unknown): questList is GameQuest[] => { 81 | if (!Array.isArray(questList)) { 82 | return false 83 | } 84 | // just a simple check 85 | return questList.every((q) => q && q.api_no) 86 | } 87 | 88 | export const useStateExporter = () => { 89 | const exportQuestDataToClipboard = async () => { 90 | const state = await exportPoiState() 91 | if (!state?.ext[PACKAGE_NAME]._.questList) { 92 | console.error('poi state', state) 93 | throw new Error('Failed to export quest data! questList not found!') 94 | } 95 | return navigator.clipboard.writeText( 96 | JSON.stringify(state?.ext[PACKAGE_NAME]._.questList), 97 | ) 98 | } 99 | const importAsPoiState = (stateString: string) => { 100 | const maybeQuestList: unknown = JSON.parse(stateString) 101 | 102 | if (!checkQuestList(maybeQuestList)) { 103 | console.error(maybeQuestList) 104 | throw new Error('Failed to import quest state! Incorrect data format!') 105 | } 106 | 107 | importPoiState({ 108 | ext: { 109 | [PACKAGE_NAME]: { 110 | _: { questList: maybeQuestList, tabId: QuestTab.ALL }, 111 | }, 112 | }, 113 | ui: { 114 | activeMainTab: '', 115 | activeFleetId: undefined, 116 | activePluginName: undefined, 117 | }, 118 | plugins: [], 119 | }) 120 | } 121 | return { 122 | exportQuestDataToClipboard, 123 | importAsPoiState, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AnchorButton, 3 | Button, 4 | HTMLSelect, 5 | Intent, 6 | Text, 7 | TextArea, 8 | } from '@blueprintjs/core' 9 | import { IconNames } from '@blueprintjs/icons' 10 | import type { ChangeEvent } from 'react' 11 | import React, { StrictMode, useCallback, useState } from 'react' 12 | import styled from 'styled-components' 13 | import { QUEST_DATA } from '../build' 14 | import { version as DATA_VERSION } from '../build/kcanotifyGamedata' 15 | import PKG from '../package.json' 16 | import { IN_POI } from './poi/env' 17 | import { usePluginTranslation, useStateExporter } from './poi/hooks' 18 | import { tips } from './poi/utils' 19 | import { StoreProvider, useDataSource, useRemoveStorage } from './store' 20 | 21 | const Container = styled.div` 22 | display: flex; 23 | flex-direction: column; 24 | align-items: flex-start; 25 | user-select: text; 26 | gap: 8px; 27 | padding: 8px; 28 | ` 29 | 30 | const DataExportArea = () => { 31 | const [text, setText] = useState('') 32 | const { t } = usePluginTranslation() 33 | const { exportQuestDataToClipboard, importAsPoiState } = useStateExporter() 34 | 35 | const handleChange = useCallback((e: ChangeEvent) => { 36 | setText(e.target.value) 37 | }, []) 38 | 39 | const handleImportData = useCallback(() => { 40 | importAsPoiState(text) 41 | setText('') 42 | tips.success(t('Import data success')) 43 | }, [importAsPoiState, t, text]) 44 | 45 | const handleExportData = useCallback(async () => { 46 | try { 47 | await exportQuestDataToClipboard() 48 | tips.success(t('Copied data to clipboard')) 49 | } catch (error) { 50 | console.error(error) 51 | tips.error(t('Failed to export quest data! Please sync quest data first')) 52 | } 53 | }, [exportQuestDataToClipboard, t]) 54 | 55 | return IN_POI ? ( 56 |