ImportExport.vue 12 KB

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