Browse Source

Merge branch 'feature/export-and-import' into develop

oldj 4 years ago
parent
commit
9310db6300

+ 1 - 0
scripts/make.js

@@ -112,6 +112,7 @@ const makeDefault = async () => {
   await builder.build({
     //targets: Platform.MAC.createTarget(),
     //...TARGET_PLATFORMS_configs.mac,
+    //...TARGET_PLATFORMS_configs.win,
     ...TARGET_PLATFORMS_configs.all,
     config: {
       ...cfg_common,

+ 4 - 0
src/common/i18n/languages/en.ts

@@ -39,6 +39,7 @@ export default {
   download: 'Download',
   edit: 'Edit',
   export: 'Export',
+  export_done: 'The export is complete.',
   fail: 'Fail!',
   feedback: 'Feedback',
   file: 'File',
@@ -63,6 +64,9 @@ export default {
   hour: 'hour',
   hours: 'hours',
   import: 'Import',
+  import_done: 'The import is complete.',
+  import_fail: 'Import failed!',
+  import_from_url: 'Import from URL',
   is_latest_version_inform: 'Great, you are running the latest version!',
   language: 'Language',
   last_refresh: 'Last refresh: ',

+ 4 - 0
src/common/i18n/languages/zh.ts

@@ -41,6 +41,7 @@ const lang: LanguageDict = {
   download: '下载',
   edit: '编辑',
   export: '导出',
+  export_done: '导出已完成。',
   fail: '操作失败!',
   feedback: '意见反馈',
   file: '文件',
@@ -65,6 +66,9 @@ const lang: LanguageDict = {
   hour: '小时',
   hours: '小时',
   import: '导入',
+  import_done: '导入已完成。',
+  import_fail: '导入失败!',
+  import_from_url: '从 URL 导入',
   is_latest_version_inform: '太棒了,你正在运行的是最新版本!',
   language: '语言',
   last_refresh: '最后刷新:',

+ 3 - 0
src/main/actions/index.ts

@@ -47,3 +47,6 @@ export { default as quit } from './quit'
 
 export { default as migrateCheck } from './migrate/checkIfMigration'
 export { default as migrateData } from './migrate/migrateData'
+export { default as exportData } from './migrate/export'
+export { default as importData } from './migrate/import'
+export { default as importDataFromUrl } from './migrate/importFromUrl'

+ 44 - 0
src/main/actions/migrate/export.ts

@@ -0,0 +1,44 @@
+/**
+ * export
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import getI18N from '@main/core/getI18N'
+import { swhdb } from '@main/data'
+import { dialog } from 'electron'
+import { promises as fs } from 'fs'
+import * as path from 'path'
+import version from '@root/version.json'
+
+export default async (): Promise<string | null | false> => {
+  let { lang } = await getI18N()
+
+  let result = await dialog.showSaveDialog({
+    title: lang.import,
+    defaultPath: path.join(global.last_path || '', 'swh_data.json'),
+    properties: [
+      'createDirectory',
+      'showOverwriteConfirmation',
+    ],
+  })
+
+  if (result.canceled || !result.filePath) {
+    return null
+  }
+
+  let target_dir = result.filePath
+
+  let data = await swhdb.toJSON()
+  try {
+    await fs.writeFile(target_dir, JSON.stringify({
+      data,
+      version,
+    }), 'utf-8')
+  } catch (e) {
+    console.error(e)
+    return false
+  }
+
+  return target_dir
+}

+ 72 - 0
src/main/actions/migrate/import.ts

@@ -0,0 +1,72 @@
+/**
+ * import
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import importV3Data from '@main/actions/migrate/importV3Data'
+import getI18N from '@main/core/getI18N'
+import { swhdb } from '@main/data'
+import { dialog } from 'electron'
+import { promises as fs } from 'fs'
+
+export default async (): Promise<boolean | null | string> => {
+  let { lang } = await getI18N()
+
+  let result = await dialog.showOpenDialog({
+    title: lang.import,
+    defaultPath: global.last_path,
+    filters: [
+      { name: 'JSON', extensions: ['json'] },
+      { name: 'All Files', extensions: ['*'] },
+    ],
+    properties: [
+      'openFile',
+    ],
+  })
+
+  if (result.canceled) {
+    return null
+  }
+
+  let paths = result.filePaths
+  let fn = paths[0]
+  let content = await fs.readFile(fn, 'utf-8')
+
+  let data: any
+  try {
+    data = JSON.parse(content)
+  } catch (e) {
+    console.error(e)
+    return 'parse_error'
+  }
+
+  if (typeof data !== 'object' || !data.version || !Array.isArray(data.version)) {
+    return 'invalid_data'
+  }
+
+  let { version } = data
+  if (version[0] === 3) {
+    // import v3 data
+    try {
+      await importV3Data(data)
+    } catch (e) {
+      console.error(e)
+      return 'invalid_v3_data'
+    }
+
+    return true
+  }
+
+  if (version[0] > 4) {
+    return 'new_version'
+  }
+
+  if (!data.data || typeof data.data !== 'object') {
+    return 'invalid_data_key'
+  }
+
+  await swhdb.loadJSON(data.data)
+
+  return true
+}

+ 66 - 0
src/main/actions/migrate/importFromUrl.ts

@@ -0,0 +1,66 @@
+/**
+ * importFromUrl
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import importV3Data from '@main/actions/migrate/importV3Data'
+import { swhdb } from '@main/data'
+import { GET } from '@main/libs/request'
+
+export default async (url: string): Promise<boolean | null | string> => {
+  console.log(`import from url: ${url}`)
+  let res
+  try {
+    res = await GET(url)
+  } catch (e) {
+    console.error(e)
+    return e.message
+  }
+
+  // console.log(res)
+  if (res.status !== 200) {
+    return `error_${res.status}`
+  }
+
+  let data: any
+  if (typeof res.data === 'string') {
+    try {
+      data = JSON.parse(res.data)
+    } catch (e) {
+      console.error(e)
+      return 'parse_error'
+    }
+  } else {
+    data = res.data
+  }
+
+  if (typeof data !== 'object' || !data.version || !Array.isArray(data.version)) {
+    return 'invalid_data'
+  }
+
+  let { version } = data
+  if (version[0] === 3) {
+    // import v3 data
+    try {
+      await importV3Data(data)
+    } catch (e) {
+      console.error(e)
+      return 'invalid_v3_data'
+    }
+
+    return true
+  }
+
+  if (version[0] > 4) {
+    return 'new_version'
+  }
+
+  if (!data.data || typeof data.data !== 'object') {
+    return 'invalid_data_key'
+  }
+
+  await swhdb.loadJSON(data.data)
+
+  return true
+}

+ 36 - 0
src/main/actions/migrate/importV3Data.ts

@@ -0,0 +1,36 @@
+/**
+ * importV3Data
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+// import data from v3 to v4
+
+import { swhdb } from '@main/data'
+import { cleanHostsList, flatten } from '@root/common/hostsFn'
+import version from '@root/version.json'
+
+export default async (old_data: any) => {
+  old_data = cleanHostsList(old_data)
+
+  await swhdb.collection.hosts.remove()
+  await swhdb.list.tree.remove()
+
+  let { list } = old_data
+  let hosts = flatten(list)
+
+  for (let h of hosts) {
+    if (h.refresh_interval) {
+      h.refresh_interval *= 3600
+    }
+
+    h.type = h.where
+    delete h.where
+
+    await swhdb.collection.hosts.insert(h)
+    h.content = ''
+  }
+
+  await swhdb.list.tree.extend(...list)
+  await swhdb.dict.meta.set('version', version)
+}

+ 3 - 20
src/main/actions/migrate/migrateData.ts

@@ -6,10 +6,10 @@
 
 // migrate data from v3 to v4
 
-import { swhdb } from '@main/data'
+import importV3Data from '@main/actions/migrate/importV3Data'
 import getDataFolder from '@main/libs/getDataFolder'
 import { IHostsBasicData, VersionType } from '@root/common/data'
-import { cleanHostsList, flatten } from '@root/common/hostsFn'
+import { cleanHostsList } from '@root/common/hostsFn'
 import version from '@root/version.json'
 import * as fs from 'fs'
 import path from 'path'
@@ -38,22 +38,5 @@ const readOldData = async (): Promise<IHostsBasicData> => {
 
 export default async () => {
   let old_data = await readOldData()
-
-  let { list } = old_data
-  let hosts = flatten(list)
-
-  for (let h of hosts) {
-    if (h.refresh_interval) {
-      h.refresh_interval *= 3600
-    }
-
-    h.type = h.where
-    delete h.where
-
-    await swhdb.collection.hosts.insert(h)
-    h.content = ''
-  }
-
-  await swhdb.list.tree.extend(...list)
-  await swhdb.dict.meta.set('version', version)
+  await importV3Data(old_data)
 }

+ 17 - 0
src/main/core/getI18N.ts

@@ -0,0 +1,17 @@
+/**
+ * getLang
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { configGet } from '@main/actions'
+import { LocaleName } from '@root/common/i18n'
+import { I18N } from '@root/common/i18n'
+
+export default async (locale?: LocaleName): Promise<I18N> => {
+  if (!locale) {
+    locale = await configGet('locale')
+  }
+
+  return new I18N(locale)
+}

+ 1 - 1
src/main/tray/index.ts

@@ -134,7 +134,7 @@ const show = () => {
   // win.focus()
 }
 
-app.whenReady().then(() => {
+app && app.whenReady().then(() => {
   if (!tray) {
     makeTray()
   }

+ 1 - 0
src/main/types.d.ts

@@ -29,6 +29,7 @@ declare global {
       ua: string; // user agent
       session_id: string; // A random value, refreshed every time the app starts, used to identify different startup sessions.
       main_win: BrowserWindow;
+      last_path?: string; // the last path opened by SwitchHosts
     }
   }
 }

+ 0 - 1
src/renderer/components/MainPanel/index.tsx

@@ -6,7 +6,6 @@
 
 import { useModel } from '@@/plugin-model/useModel'
 import HostsEditor from '@renderer/components/Editor/HostsEditor'
-import HostsViewer from '@renderer/components/HostsViewer'
 import { actions } from '@renderer/core/agent'
 import useOnBroadcast from '@renderer/core/useOnBroadcast'
 import React, { useEffect, useState } from 'react'

+ 140 - 60
src/renderer/components/TopBar/ConfigMenu.tsx

@@ -6,10 +6,21 @@
 
 import { useModel } from '@@/plugin-model/useModel'
 import { Button, Menu, MenuButton, MenuDivider, MenuItem, MenuList, useToast } from '@chakra-ui/react'
+import ImportFromUrl from '@renderer/components/TopBar/ImportFromUrl'
 import { actions, agent } from '@renderer/core/agent'
 import { feedback_url, homepage_url } from '@root/common/constants'
-import React from 'react'
-import { BiCog, BiExit, BiHomeCircle, BiInfoCircle, BiMessageDetail, BiRefresh, BiSliderAlt } from 'react-icons/bi'
+import React, { useState } from 'react'
+import {
+  BiCog,
+  BiExit,
+  BiExport,
+  BiHomeCircle,
+  BiImport,
+  BiInfoCircle,
+  BiMessageDetail,
+  BiRefresh,
+  BiSliderAlt,
+} from 'react-icons/bi'
 
 interface Props {
 
@@ -17,72 +28,141 @@ interface Props {
 
 const ConfigMenu = (props: Props) => {
   const { lang } = useModel('useI18n')
+  const { loadHostsData, setCurrentHosts } = useModel('useHostsData')
+  const [show_import_from_url, setShowImportFromUrl] = useState(false)
   const toast = useToast()
 
   return (
-    <Menu>
-      <MenuButton
-        as={Button}
-        variant="ghost"
-        width="35px"
-      >
-        <BiCog/>
-      </MenuButton>
-      <MenuList borderColor="var(--swh-border-color-0)">
-        <MenuItem
-          icon={<BiInfoCircle/>}
-          onClick={() => agent.broadcast('show_about')}
+    <>
+      <Menu>
+        <MenuButton
+          as={Button}
+          variant="ghost"
+          width="35px"
         >
-          {lang.about}
-        </MenuItem>
+          <BiCog/>
+        </MenuButton>
+        <MenuList borderColor="var(--swh-border-color-0)">
+          <MenuItem
+            icon={<BiInfoCircle/>}
+            onClick={() => agent.broadcast('show_about')}
+          >
+            {lang.about}
+          </MenuItem>
 
-        <MenuDivider/>
+          <MenuDivider/>
 
-        <MenuItem
-          icon={<BiRefresh/>}
-          onClick={async () => {
-            let r = await actions.checkUpdate()
-            if (r === false) {
-              toast({
-                description: lang.is_latest_version_inform,
-                status: 'info',
-                duration: 3000,
-                isClosable: true,
-              })
-            }
-          }}
-        >
-          {lang.check_update}
-        </MenuItem>
-        <MenuItem
-          icon={<BiMessageDetail/>}
-          onClick={() => actions.openUrl(feedback_url)}
-        >
-          {lang.feedback}
-        </MenuItem>
-        <MenuItem
-          icon={<BiHomeCircle/>}
-          onClick={() => actions.openUrl(homepage_url)}
-        >
-          {lang.homepage}
-        </MenuItem>
+          <MenuItem
+            icon={<BiRefresh/>}
+            onClick={async () => {
+              let r = await actions.checkUpdate()
+              if (r === false) {
+                toast({
+                  description: lang.is_latest_version_inform,
+                  status: 'info',
+                  duration: 3000,
+                  isClosable: true,
+                })
+              }
+            }}
+          >
+            {lang.check_update}
+          </MenuItem>
+          <MenuItem
+            icon={<BiMessageDetail/>}
+            onClick={() => actions.openUrl(feedback_url)}
+          >
+            {lang.feedback}
+          </MenuItem>
+          <MenuItem
+            icon={<BiHomeCircle/>}
+            onClick={() => actions.openUrl(homepage_url)}
+          >
+            {lang.homepage}
+          </MenuItem>
 
-        <MenuDivider/>
+          <MenuDivider/>
 
-        <MenuItem
-          icon={<BiSliderAlt/>}
-          onClick={() => agent.broadcast('show_preferences')}
-        >
-          {lang.preferences}
-        </MenuItem>
-        <MenuItem
-          icon={<BiExit/>}
-          onClick={() => actions.quit()}
-        >
-          {lang.quit}
-        </MenuItem>
-      </MenuList>
-    </Menu>
+          <MenuItem
+            icon={<BiExport/>}
+            onClick={async () => {
+              let r = await actions.exportData()
+              if (r === null) {
+                return
+              } else if (r === false) {
+                toast({
+                  status: 'error',
+                  description: lang.import_fail,
+                  isClosable: true,
+                })
+              } else {
+                toast({
+                  status: 'success',
+                  description: lang.export_done,
+                  isClosable: true,
+                })
+              }
+            }}
+          >
+            {lang.export}
+          </MenuItem>
+          <MenuItem
+            icon={<BiImport/>}
+            onClick={async () => {
+              let r = await actions.importData()
+              if (r === null) {
+                return
+              } else if (r === true) {
+                toast({
+                  status: 'success',
+                  description: lang.import_done,
+                  isClosable: true,
+                })
+                await loadHostsData()
+                setCurrentHosts(null)
+              } else {
+                let description = lang.import_fail
+                if (typeof r === 'string') {
+                  description += ` [${r}]`
+                }
+
+                toast({
+                  status: 'error',
+                  description,
+                  isClosable: true,
+                })
+              }
+            }}
+          >
+            {lang.import}
+          </MenuItem>
+          <MenuItem
+            icon={<BiImport/>}
+            onClick={async () => {
+              setShowImportFromUrl(true)
+            }}
+          >
+            {lang.import_from_url}
+          </MenuItem>
+
+          <MenuDivider/>
+
+          <MenuItem
+            icon={<BiSliderAlt/>}
+            onClick={() => agent.broadcast('show_preferences')}
+          >
+            {lang.preferences}
+          </MenuItem>
+          <MenuItem
+            icon={<BiExit/>}
+            onClick={() => actions.quit()}
+          >
+            {lang.quit}
+          </MenuItem>
+        </MenuList>
+      </Menu>
+      <ImportFromUrl is_show={show_import_from_url} setIsShow={setShowImportFromUrl}/>
+    </>
   )
 }
 

+ 8 - 0
src/renderer/components/TopBar/ImportFromUrl.less

@@ -0,0 +1,8 @@
+@import "../../styles/common";
+
+.root {
+}
+
+.label {
+  margin: 10px 0 20px 0;
+}

+ 128 - 0
src/renderer/components/TopBar/ImportFromUrl.tsx

@@ -0,0 +1,128 @@
+/**
+ * SudoPasswordInput
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { useModel } from '@@/plugin-model/useModel'
+import {
+  Button,
+  Input,
+  Modal,
+  ModalBody,
+  ModalCloseButton,
+  ModalContent,
+  ModalFooter,
+  ModalOverlay,
+  ToastId,
+  useToast,
+} from '@chakra-ui/react'
+import { actions } from '@renderer/core/agent'
+import React, { useRef, useState } from 'react'
+import styles from './ImportFromUrl.less'
+
+interface Props {
+  is_show: boolean;
+  setIsShow: (show: boolean) => void;
+}
+
+const ImportFromUrl = (props: Props) => {
+  const { is_show, setIsShow } = props
+  const { lang } = useModel('useI18n')
+  const { loadHostsData, setCurrentHosts } = useModel('useHostsData')
+  const [url, setUrl] = useState('')
+  const ipt_ref = React.useRef<HTMLInputElement>(null)
+  const toast = useToast()
+  const toast_ref = useRef<ToastId>()
+
+  const onCancel = () => {
+    setIsShow(false)
+    setUrl('')
+  }
+
+  const onOk = async () => {
+    setIsShow(false)
+    console.log(`url: ${url}`)
+    toast_ref.current = toast({
+      description: 'loading...',
+      duration: null,
+      isClosable: true,
+    })
+
+    let t0 = (new Date()).getTime()
+
+    if (url) {
+      let r = await actions.importDataFromUrl(url)
+      console.log(r)
+
+      if (r === true) {
+        // import success
+        toast({
+          status: 'success',
+          description: lang.import_done,
+          isClosable: true,
+        })
+        await loadHostsData()
+        setCurrentHosts(null)
+
+      } else {
+        let description = lang.import_fail
+        if (typeof r === 'string') {
+          description += ` [${r}]`
+        }
+
+        toast({
+          status: 'error',
+          description,
+          isClosable: true,
+        })
+      }
+    }
+
+    let t1 = (new Date()).getTime()
+    setTimeout(() => {
+      if (toast_ref.current) {
+        toast.close(toast_ref.current)
+      }
+    }, t1 - t0 > 1000 ? 0 : 1000)
+    setUrl('')
+  }
+
+  if (!is_show) return null
+
+  return (
+    <Modal
+      initialFocusRef={ipt_ref}
+      isOpen={is_show}
+      onClose={onCancel}
+    >
+      <ModalOverlay/>
+      <ModalContent>
+        <ModalCloseButton/>
+        <ModalBody pb={6}>
+          <div className={styles.label}>{lang.import_from_url}</div>
+          <Input
+            ref={ipt_ref}
+            value={url}
+            onChange={e => setUrl(e.target.value)}
+            autoFocus={true}
+            onKeyDown={e => {
+              if (e.key === 'Enter') onOk()
+            }}
+            placeholder={'http:// or https://'}
+          />
+        </ModalBody>
+        <ModalFooter>
+          <Button variant="outline" onClick={onCancel} mr={3}>{lang.btn_cancel}</Button>
+          <Button
+            colorScheme="blue"
+            onClick={onOk}
+            isDisabled={!url || !url.match(/^https?:\/\/\w+/i)}
+          >{lang.btn_ok}</Button>
+        </ModalFooter>
+      </ModalContent>
+    </Modal>
+  )
+}
+
+export default ImportFromUrl

+ 0 - 1
test/tmp/db/data/collection/hosts/data/1.json

@@ -1 +0,0 @@
-{"id":"1","_id":"1","content":"# 111"}

+ 0 - 1
test/tmp/db/data/collection/hosts/ids.json

@@ -1 +0,0 @@
-[]

+ 0 - 1
test/tmp/db/data/collection/hosts/meta.json

@@ -1 +0,0 @@
-{"index":11}

+ 0 - 1
test/tmp/db/data/list/trashcan.json

@@ -1 +0,0 @@
-[{"data":{"id":"1","on":false},"add_time_ms":1616318366233,"parent_id":null},{"data":{"id":"2","on":false},"add_time_ms":1616318366234,"parent_id":null},{"data":{"id":"3","type":"folder","children":[{"id":"3.1"},{"id":"3.2"},{"id":"3.3","type":"folder","children":[{"id":"3.3.1"},{"id":"3.3.2"},{"id":"3.3.3"}]},{"id":"3.4"}],"on":false},"add_time_ms":1616318366234,"parent_id":null},{"data":{"id":"4","on":false},"add_time_ms":1616318366234,"parent_id":null}]

+ 0 - 1
test/tmp/db/data/list/tree.json

@@ -1 +0,0 @@
-[]