ImportExport.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <template>
  2. <SmartModal
  3. v-if="show"
  4. :title="`${t('modal.collections')}`"
  5. max-width="sm:max-w-md"
  6. @close="hideModal"
  7. >
  8. <template #actions>
  9. <ButtonSecondary
  10. v-if="importerType !== null"
  11. v-tippy="{ theme: 'tooltip' }"
  12. :title="t('action.go_back')"
  13. svg="arrow-left"
  14. @click.native="resetImport"
  15. />
  16. </template>
  17. <template #body>
  18. <div v-if="importerType !== null" class="flex flex-col">
  19. <div class="flex pb-6 flex-col px-2">
  20. <div
  21. v-for="(step, index) in importerSteps"
  22. :key="`step-${index}`"
  23. class="flex flex-col space-y-8"
  24. >
  25. <div v-if="step.name === 'FILE_IMPORT'" class="space-y-4">
  26. <p class="flex items-center">
  27. <span
  28. class="inline-flex border-4 border-primary items-center justify-center flex-shrink-0 mr-4 rounded-full text-dividerDark"
  29. :class="{
  30. '!text-green-500': hasFile,
  31. }"
  32. >
  33. <i class="material-icons">check_circle</i>
  34. </span>
  35. <span>
  36. {{ t(`${step.metadata.caption}`) }}
  37. </span>
  38. </p>
  39. <p class="flex flex-col ml-10">
  40. <input
  41. id="inputChooseFileToImportFrom"
  42. ref="inputChooseFileToImportFrom"
  43. name="inputChooseFileToImportFrom"
  44. type="file"
  45. class="transition cursor-pointer file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
  46. :accept="step.metadata.acceptedFileTypes"
  47. @change="onFileChange"
  48. />
  49. </p>
  50. </div>
  51. <div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4">
  52. <p class="flex items-center">
  53. <span
  54. class="inline-flex border-4 border-primary items-center justify-center flex-shrink-0 mr-4 rounded-full text-dividerDark"
  55. :class="{
  56. '!text-green-500': hasGist,
  57. }"
  58. >
  59. <i class="material-icons">check_circle</i>
  60. </span>
  61. <span>
  62. {{ t(`${step.metadata.caption}`) }}
  63. </span>
  64. </p>
  65. <p class="flex flex-col ml-10">
  66. <input
  67. v-model="inputChooseGistToImportFrom"
  68. type="url"
  69. class="input"
  70. :placeholder="`${$t('import.gist_url')}`"
  71. />
  72. </p>
  73. </div>
  74. <div
  75. v-else-if="step.name === 'TARGET_MY_COLLECTION'"
  76. class="flex flex-col px-2"
  77. >
  78. <div class="select-wrapper">
  79. <select
  80. v-model="mySelectedCollectionID"
  81. type="text"
  82. autocomplete="off"
  83. class="select"
  84. autofocus
  85. >
  86. <option :key="undefined" :value="undefined" disabled selected>
  87. {{ t("collection.select") }}
  88. </option>
  89. <option
  90. v-for="(collection, collectionIndex) in myCollections"
  91. :key="`collection-${collectionIndex}`"
  92. :value="collectionIndex"
  93. >
  94. {{ collection.name }}
  95. </option>
  96. </select>
  97. </div>
  98. </div>
  99. </div>
  100. </div>
  101. <ButtonPrimary
  102. :label="t('import.title')"
  103. :disabled="enableImportButton"
  104. class="mx-2"
  105. :loading="importingMyCollections"
  106. @click.native="finishImport"
  107. />
  108. </div>
  109. <div v-else class="flex flex-col px-2">
  110. <SmartExpand>
  111. <template #body>
  112. <SmartItem
  113. v-for="(importer, index) in importerModules"
  114. :key="`importer-${index}`"
  115. :svg="importer.icon"
  116. :label="t(`${importer.name}`)"
  117. @click.native="importerType = index"
  118. />
  119. </template>
  120. </SmartExpand>
  121. <hr />
  122. <div class="flex flex-col space-y-2">
  123. <SmartItem
  124. v-tippy="{ theme: 'tooltip' }"
  125. :title="t('action.download_file')"
  126. svg="download"
  127. :label="t('export.as_json')"
  128. @click.native="exportJSON"
  129. />
  130. <span
  131. v-tippy="{ theme: 'tooltip' }"
  132. :title="
  133. !currentUser
  134. ? `${t('export.require_github')}`
  135. : currentUser.provider !== 'github.com'
  136. ? `${t('export.require_github')}`
  137. : undefined
  138. "
  139. class="flex"
  140. >
  141. <SmartItem
  142. :disabled="
  143. !currentUser
  144. ? true
  145. : currentUser.provider !== 'github.com'
  146. ? true
  147. : false
  148. "
  149. svg="github"
  150. :label="t('export.create_secret_gist')"
  151. @click.native="
  152. () => {
  153. createCollectionGist()
  154. }
  155. "
  156. />
  157. </span>
  158. </div>
  159. </div>
  160. </template>
  161. </SmartModal>
  162. </template>
  163. <script setup lang="ts">
  164. import { computed, ref, watch } from "@nuxtjs/composition-api"
  165. import * as E from "fp-ts/Either"
  166. import { HoppRESTRequest } from "@hoppscotch/data"
  167. import { apolloClient } from "~/helpers/apollo"
  168. import {
  169. useAxios,
  170. useI18n,
  171. useReadonlyStream,
  172. useToast,
  173. } from "~/helpers/utils/composables"
  174. import { currentUser$ } from "~/helpers/fb/auth"
  175. import * as teamUtils from "~/helpers/teams/utils"
  176. import {
  177. appendRESTCollections,
  178. Collection,
  179. restCollections$,
  180. } from "~/newstore/collections"
  181. import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
  182. import { StepReturnValue } from "~/helpers/import-export/steps"
  183. const props = defineProps<{
  184. show: boolean
  185. collectionsType:
  186. | {
  187. type: "team-collections"
  188. selectedTeam: {
  189. id: string
  190. }
  191. }
  192. | { type: "my-collections" }
  193. }>()
  194. const emit = defineEmits<{
  195. (e: "hide-modal"): void
  196. (e: "update-team-collections"): void
  197. }>()
  198. const axios = useAxios()
  199. const toast = useToast()
  200. const t = useI18n()
  201. const myCollections = useReadonlyStream(restCollections$, [])
  202. const currentUser = useReadonlyStream(currentUser$, null)
  203. // Template refs
  204. const mode = ref("import_export")
  205. const mySelectedCollectionID = ref<undefined | number>(undefined)
  206. const collectionJson = ref("")
  207. const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
  208. const inputChooseGistToImportFrom = ref<string>("")
  209. const getJSONCollection = async () => {
  210. if (props.collectionsType.type === "my-collections") {
  211. collectionJson.value = JSON.stringify(myCollections.value, null, 2)
  212. } else {
  213. collectionJson.value = await teamUtils.exportAsJSON(
  214. apolloClient,
  215. props.collectionsType.selectedTeam.id
  216. )
  217. }
  218. return collectionJson.value
  219. }
  220. const createCollectionGist = async () => {
  221. if (!currentUser.value) {
  222. toast.error(t("profile.no_permission").toString())
  223. return
  224. }
  225. getJSONCollection()
  226. try {
  227. const res = await axios.$post(
  228. "https://api.github.com/gists",
  229. {
  230. files: {
  231. "hoppscotch-collections.json": {
  232. content: collectionJson.value,
  233. },
  234. },
  235. },
  236. {
  237. headers: {
  238. Authorization: `token ${currentUser.value.accessToken}`,
  239. Accept: "application/vnd.github.v3+json",
  240. },
  241. }
  242. )
  243. toast.success(t("export.gist_created").toString())
  244. window.open(res.html_url)
  245. } catch (e) {
  246. toast.error(t("error.something_went_wrong").toString())
  247. console.error(e)
  248. }
  249. }
  250. const fileImported = () => {
  251. toast.success(t("state.file_imported").toString())
  252. hideModal()
  253. }
  254. const failedImport = () => {
  255. toast.error(t("import.failed").toString())
  256. }
  257. const hideModal = () => {
  258. mode.value = "import_export"
  259. mySelectedCollectionID.value = undefined
  260. resetImport()
  261. emit("hide-modal")
  262. }
  263. const stepResults = ref<StepReturnValue[]>([])
  264. watch(mySelectedCollectionID, (newValue) => {
  265. if (newValue === undefined) return
  266. stepResults.value = []
  267. stepResults.value.push(newValue)
  268. })
  269. const importingMyCollections = ref(false)
  270. const importToTeams = async (content: Collection<HoppRESTRequest>) => {
  271. importingMyCollections.value = true
  272. if (props.collectionsType.type !== "team-collections") return
  273. await teamUtils
  274. .importFromJSON(
  275. apolloClient,
  276. content,
  277. props.collectionsType.selectedTeam.id
  278. )
  279. .then((status) => {
  280. if (status) {
  281. emit("update-team-collections")
  282. } else {
  283. console.error(status)
  284. }
  285. })
  286. .catch((e) => {
  287. console.error(e)
  288. })
  289. .finally(() => {
  290. importingMyCollections.value = false
  291. })
  292. }
  293. const exportJSON = () => {
  294. getJSONCollection()
  295. const dataToWrite = collectionJson.value
  296. const file = new Blob([dataToWrite], { type: "application/json" })
  297. const a = document.createElement("a")
  298. const url = URL.createObjectURL(file)
  299. a.href = url
  300. // TODO: get uri from meta
  301. a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
  302. document.body.appendChild(a)
  303. a.click()
  304. toast.success(t("state.download_started").toString())
  305. setTimeout(() => {
  306. document.body.removeChild(a)
  307. URL.revokeObjectURL(url)
  308. }, 1000)
  309. }
  310. const importerModules = computed(() =>
  311. RESTCollectionImporters.filter(
  312. (i) => i.applicableTo?.includes(props.collectionsType.type) ?? true
  313. )
  314. )
  315. const importerType = ref<number | null>(null)
  316. const importerModule = computed(() =>
  317. importerType.value !== null ? importerModules.value[importerType.value] : null
  318. )
  319. const importerSteps = computed(() => importerModule.value?.steps ?? null)
  320. const finishImport = async () => {
  321. await importerAction(stepResults.value)
  322. }
  323. const importerAction = async (stepResults: any[]) => {
  324. if (!importerModule.value) return
  325. const result = await importerModule.value?.importer(stepResults as any)()
  326. if (E.isLeft(result)) {
  327. failedImport()
  328. console.error("error", result.left)
  329. } else if (E.isRight(result)) {
  330. if (props.collectionsType.type === "team-collections") {
  331. importToTeams(result.right)
  332. fileImported()
  333. } else {
  334. appendRESTCollections(result.right)
  335. fileImported()
  336. }
  337. }
  338. }
  339. const hasFile = ref(false)
  340. const hasGist = ref(false)
  341. watch(inputChooseGistToImportFrom, (v) => {
  342. stepResults.value = []
  343. if (v === "") {
  344. hasGist.value = false
  345. } else {
  346. hasGist.value = true
  347. stepResults.value.push(inputChooseGistToImportFrom.value)
  348. }
  349. })
  350. const onFileChange = () => {
  351. stepResults.value = []
  352. if (!inputChooseFileToImportFrom.value[0]) {
  353. hasFile.value = false
  354. return
  355. }
  356. if (
  357. !inputChooseFileToImportFrom.value[0].files ||
  358. inputChooseFileToImportFrom.value[0].files.length === 0
  359. ) {
  360. inputChooseFileToImportFrom.value[0].value = ""
  361. hasFile.value = false
  362. toast.show(t("action.choose_file").toString())
  363. return
  364. }
  365. const reader = new FileReader()
  366. reader.onload = ({ target }) => {
  367. const content = target!.result as string | null
  368. if (!content) {
  369. hasFile.value = false
  370. toast.show(t("action.choose_file").toString())
  371. return
  372. }
  373. stepResults.value.push(content)
  374. hasFile.value = !!content?.length
  375. }
  376. reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
  377. }
  378. const enableImportButton = computed(
  379. () => !(stepResults.value.length === importerSteps.value?.length)
  380. )
  381. const resetImport = () => {
  382. importerType.value = null
  383. stepResults.value = []
  384. inputChooseFileToImportFrom.value = ""
  385. hasFile.value = false
  386. inputChooseGistToImportFrom.value = ""
  387. hasGist.value = false
  388. mySelectedCollectionID.value = undefined
  389. }
  390. </script>