Skip to content

Commit 8f7912e

Browse files
committed
設定のインポート / エクスポート
1 parent dd1fb78 commit 8f7912e

File tree

8 files changed

+242
-19
lines changed

8 files changed

+242
-19
lines changed

src/components/ItemButton.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const ItemButton: React.FC<{
2626
const button = (
2727
<Button
2828
className={cn(
29-
'min-w-24 shrink-0',
29+
'min-w-32 shrink-0',
3030
props.button.variant === 'flat' &&
3131
props.button.color === 'default' &&
3232
'text-foreground',
@@ -44,7 +44,7 @@ export const ItemButton: React.FC<{
4444
)
4545

4646
return (
47-
<div className="flex flex-row items-center justify-between gap-2">
47+
<div className="flex flex-row items-center justify-between gap-1">
4848
<ItemLabel title={props.title} description={props.description} />
4949

5050
{props.confirm ? (

src/components/SettingsInput/ChSelector.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ export const Input: React.FC<Omit<Props, 'type'>> = (props) => {
138138
<>
139139
<div className="py-2">
140140
<ItemButton
141-
key={props.settingsKey}
142141
title={props.label}
143142
description={props.description}
144143
button={{

src/components/SettingsInput/NgList.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,6 @@ export const Input: React.FC<Omit<Props, 'type'>> = (props) => {
200200
<>
201201
<div className="py-2">
202202
<ItemButton
203-
key={props.settingsKey}
204203
title={props.label}
205204
description={`${value.length}件`}
206205
button={{

src/constants/settings/default.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { JikkyoChannelId } from '@midra/nco-api/types/constants'
2-
import type { SettingItems } from '@/types/storage'
2+
import type { SettingsKey, SettingItems } from '@/types/storage'
33

44
import { JIKKYO_CHANNELS } from '@midra/nco-api/constants'
55
import { VOD_KEYS } from '../vods'
@@ -54,3 +54,7 @@ export const SETTINGS_DEFAULT: SettingItems = {
5454
'settings:search:genre': 'アニメ',
5555
'settings:search:lengthRange': [null, null],
5656
}
57+
58+
export const SETTINGS_DEFAULT_KEYS = Object.keys(
59+
SETTINGS_DEFAULT
60+
) as SettingsKey[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { SettingsExportItems } from '@/types/storage'
2+
3+
import { useEffect, useState, useMemo, useCallback } from 'react'
4+
import { Textarea, useDisclosure } from '@nextui-org/react'
5+
import {
6+
DownloadIcon,
7+
UploadIcon,
8+
ChevronRightIcon,
9+
ClipboardCopyIcon,
10+
} from 'lucide-react'
11+
12+
import { settings } from '@/utils/settings/extension'
13+
14+
import { ItemButton } from '@/components/ItemButton'
15+
import { Modal } from '@/components/Modal'
16+
17+
const ImportSettings: React.FC = () => {
18+
const [value, setValue] = useState('')
19+
20+
const { isOpen, onOpen, onOpenChange } = useDisclosure()
21+
22+
const isValidated = useMemo(() => {
23+
try {
24+
return !Array.isArray(JSON.parse(value))
25+
} catch {
26+
return false
27+
}
28+
}, [value])
29+
30+
const onImport = useCallback(async () => {
31+
await settings.import(value)
32+
}, [value])
33+
34+
useEffect(() => {
35+
return () => setValue('')
36+
}, [isOpen])
37+
38+
return (
39+
<>
40+
<ItemButton
41+
title="設定をインポート"
42+
button={{
43+
variant: 'flat',
44+
color: 'default',
45+
startContent: <DownloadIcon />,
46+
text: 'インポート',
47+
onPress: onOpen,
48+
}}
49+
/>
50+
51+
<Modal
52+
isOpen={isOpen}
53+
onOpenChange={onOpenChange}
54+
okText="インポート"
55+
okIcon={<DownloadIcon className="size-4" />}
56+
onOk={onImport}
57+
isOkDisabled={!isValidated}
58+
header={
59+
<div className="flex flex-row items-center gap-0.5">
60+
<span>ストレージ</span>
61+
<ChevronRightIcon className="size-5 opacity-50" />
62+
<span>設定をインポート</span>
63+
</div>
64+
}
65+
>
66+
<div className="size-full bg-content1 p-2">
67+
<Textarea
68+
classNames={{
69+
base: 'size-full',
70+
label: 'hidden',
71+
inputWrapper: [
72+
'!h-full !w-full',
73+
'border-1 border-divider shadow-none',
74+
],
75+
input: 'size-full font-mono text-tiny',
76+
}}
77+
disableAutosize
78+
label="入力"
79+
labelPlacement="outside"
80+
value={value}
81+
onValueChange={setValue}
82+
/>
83+
</div>
84+
</Modal>
85+
</>
86+
)
87+
}
88+
89+
const ExportSettings: React.FC = () => {
90+
const [values, setValues] = useState<SettingsExportItems | null>(null)
91+
92+
const { isOpen, onOpen, onOpenChange } = useDisclosure()
93+
94+
const onCopy = useCallback(async () => {
95+
await navigator.clipboard.writeText(JSON.stringify(values))
96+
}, [values])
97+
98+
useEffect(() => {
99+
if (isOpen) {
100+
settings.export().then(setValues)
101+
}
102+
103+
return () => setValues(null)
104+
}, [isOpen])
105+
106+
return (
107+
<>
108+
<ItemButton
109+
title="設定をエクスポート"
110+
button={{
111+
variant: 'flat',
112+
color: 'default',
113+
startContent: <UploadIcon />,
114+
text: 'エクスポート',
115+
onPress: onOpen,
116+
}}
117+
/>
118+
119+
<Modal
120+
isOpen={isOpen}
121+
onOpenChange={onOpenChange}
122+
okText="コピー"
123+
okIcon={<ClipboardCopyIcon className="size-4" />}
124+
onOk={onCopy}
125+
header={
126+
<div className="flex flex-row items-center gap-0.5">
127+
<span>ストレージ</span>
128+
<ChevronRightIcon className="size-5 opacity-50" />
129+
<span>設定をエクスポート</span>
130+
</div>
131+
}
132+
>
133+
<div className="size-full bg-content1 p-2">
134+
<Textarea
135+
classNames={{
136+
base: 'size-full',
137+
label: 'hidden',
138+
inputWrapper: [
139+
'!h-full !w-full',
140+
'border-1 border-divider shadow-none',
141+
],
142+
input: 'size-full font-mono text-tiny',
143+
}}
144+
disableAutosize
145+
isReadOnly
146+
label="出力"
147+
labelPlacement="outside"
148+
value={JSON.stringify(values, null, 2)}
149+
/>
150+
</div>
151+
</Modal>
152+
</>
153+
)
154+
}
155+
156+
export const ImportExport: React.FC = () => {
157+
return (
158+
<div className="flex flex-col gap-2 py-2">
159+
<ImportSettings />
160+
161+
<ExportSettings />
162+
</div>
163+
)
164+
}

src/entrypoints/popup/MainPane/Settings/index.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ItemButton } from '@/components/ItemButton'
2626

2727
import { FormsButton } from './FormsButton'
2828
import { StorageSizes } from './StorageSizes'
29+
import { ImportExport } from './ImportExport'
2930

3031
const { name, version } = webext.runtime.getManifest()
3132

@@ -115,6 +116,10 @@ const accordionItemStorage = (
115116

116117
<Divider />
117118

119+
<ImportExport />
120+
121+
<Divider />
122+
118123
<div className="flex flex-col gap-2 py-2">
119124
<ItemButton
120125
title="設定をリセット"

src/types/storage.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,6 @@ export type SettingItems = { [k in SettingsKey]: StorageItems[k] }
284284

285285
export type StateKey = Extract<StorageKey, `state:${string}`>
286286
export type StateItems = { [k in StateKey]: StorageItems[k] }
287+
288+
export type SettingsExportKey = InternalKey | SettingsKey
289+
export type SettingsExportItems = Partial<InternalItems & SettingItems>

src/utils/settings/index.ts

+63-14
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
1-
import type { StorageItems, SettingsKey } from '@/types/storage'
1+
import type {
2+
SettingsKey,
3+
SettingItems,
4+
SettingsExportKey,
5+
SettingsExportItems,
6+
} from '@/types/storage'
27
import type { StorageOnChangeCallback } from '@/utils/storage'
38

4-
import { SETTINGS_DEFAULT } from '@/constants/settings/default'
9+
import {
10+
SETTINGS_DEFAULT,
11+
SETTINGS_DEFAULT_KEYS,
12+
} from '@/constants/settings/default'
513

614
import { WebExtStorage } from '@/utils/storage'
715

16+
const SETTINGS_EXPORT_KEYS = [
17+
'_migrate_version',
18+
...SETTINGS_DEFAULT_KEYS,
19+
] as SettingsExportKey[]
20+
821
/**
922
* 設定を取得
1023
*/
1124
export type SettingsGetFunction = {
1225
/** すべての設定を取得 */
13-
(): Promise<{
14-
[key in SettingsKey]: StorageItems[key]
15-
}>
26+
(): Promise<SettingItems>
1627

1728
/** 1つの設定を取得 */
18-
<Key extends SettingsKey>(key: Key): Promise<StorageItems[Key]>
29+
<Key extends SettingsKey>(key: Key): Promise<SettingItems[Key]>
1930

2031
/** 複数の設定を取得 */
2132
<Keys extends SettingsKey[]>(
2233
...keys: Keys
2334
): Promise<{
24-
[key in Keys[number]]: StorageItems[key]
35+
[key in Keys[number]]: SettingItems[key]
2536
}>
2637
}
2738

@@ -30,7 +41,7 @@ export type SettingsGetFunction = {
3041
*/
3142
export type SettingsSetFunction = <Key extends SettingsKey>(
3243
key: Key,
33-
value: StorageItems[Key] | null | undefined
44+
value: SettingItems[Key] | null | undefined
3445
) => Promise<void>
3546

3647
/**
@@ -68,9 +79,23 @@ export type SettingsOnChangeFunction = <Key extends SettingsKey>(
6879
*/
6980
export type SettingsWatch = <Key extends SettingsKey>(
7081
key: Key,
71-
callback: (value: StorageItems[Key]) => void
82+
callback: (value: SettingItems[Key]) => void
7283
) => () => void
7384

85+
/**
86+
* 設定をインポート
87+
*/
88+
export type SettingsImportFunction = {
89+
(values: string | SettingsExportItems): Promise<void>
90+
}
91+
92+
/**
93+
* 設定をエクスポート
94+
*/
95+
export type SettingsExportFunction = {
96+
(): Promise<SettingsExportItems>
97+
}
98+
7499
export class WebExtSettings {
75100
#storage: WebExtStorage
76101

@@ -97,9 +122,7 @@ export class WebExtSettings {
97122
Object.entries(values).map(([key, val]) => {
98123
return [key, val ?? SETTINGS_DEFAULT[key as SettingsKey]]
99124
})
100-
) as {
101-
[key in SettingsKey]: StorageItems[key]
102-
}
125+
) as SettingItems
103126

104127
return keys.length
105128
? items
@@ -111,7 +134,7 @@ export class WebExtSettings {
111134

112135
readonly remove: SettingsRemoveFunction = (...keys: SettingsKey[]) => {
113136
if (!keys.length) {
114-
keys = Object.keys(SETTINGS_DEFAULT) as SettingsKey[]
137+
keys = SETTINGS_DEFAULT_KEYS
115138
}
116139

117140
return this.#storage.remove(...keys)
@@ -121,7 +144,7 @@ export class WebExtSettings {
121144
...keys: SettingsKey[]
122145
) => {
123146
if (!keys.length) {
124-
keys = Object.keys(SETTINGS_DEFAULT) as SettingsKey[]
147+
keys = SETTINGS_DEFAULT_KEYS
125148
}
126149

127150
return this.#storage.getBytesInUse(...keys)
@@ -140,4 +163,30 @@ export class WebExtSettings {
140163

141164
return () => removeListener()
142165
}
166+
167+
readonly import: SettingsImportFunction = async (values) => {
168+
const object =
169+
typeof values === 'string'
170+
? (JSON.parse(values) as SettingsExportItems)
171+
: values
172+
173+
const entries = (
174+
Object.entries(object) as [
175+
SettingsExportKey,
176+
SettingsExportItems[SettingsExportKey],
177+
][]
178+
).filter(([key]) => SETTINGS_EXPORT_KEYS.includes(key))
179+
180+
await Promise.all(
181+
entries.map(([key, value]) => {
182+
return this.#storage.set(key, value)
183+
})
184+
)
185+
}
186+
187+
readonly export: SettingsExportFunction = () => {
188+
return this.#storage.get(
189+
...SETTINGS_EXPORT_KEYS
190+
) as Promise<SettingsExportItems>
191+
}
143192
}

0 commit comments

Comments
 (0)