AccountImport.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <template>
  2. <div class="h-100 pf-import">
  3. <div v-if="!loaded" class="d-flex justify-content-center align-items-center h-100">
  4. <b-spinner />
  5. </div>
  6. <template v-else>
  7. <input type="file" name="file" class="d-none" ref="zipInput" @change="zipInputChanged" />
  8. <template v-if="page === 1">
  9. <div class="title">
  10. <h3 class="font-weight-bold">Import</h3>
  11. </div>
  12. <hr>
  13. <section>
  14. <p class="lead">Account Import allows you to import your data from a supported service.</p>
  15. </section>
  16. <section class="mt-4">
  17. <ul class="list-group">
  18. <li class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
  19. <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
  20. <div>
  21. <p class="font-weight-bold mb-1">Import from Instagram</p>
  22. <p v-if="showDisabledWarning" class="small mb-0">This feature has been disabled by the administrators.</p>
  23. <p v-else-if="showNotAllowedWarning" class="small mb-0">You have not been permitted to use this feature, or have reached the maximum limits. For more info, view the <a href="/site/kb/import" class="font-weight-bold">Import Help Center</a> page.</p>
  24. <p v-else class="small mb-0">Upload the JSON export from Instagram in .zip format.<br />For more information click <a href="/site/kb/import">here</a>.</p>
  25. </div>
  26. <div v-if="!showDisabledWarning && !showNotAllowedWarning">
  27. <button
  28. v-if="step === 1 || invalidArchive"
  29. type="button"
  30. class="font-weight-bold btn btn-primary rounded-pill px-4 btn-lg"
  31. @click="selectArchive()"
  32. :disabled="showDisabledWarning">
  33. Import
  34. </button>
  35. <template v-else-if="step === 2">
  36. <div class="d-flex justify-content-center align-items-center flex-column">
  37. <b-spinner v-if="showUploadLoader" small />
  38. <button v-else type="button" class="font-weight-bold btn btn-outline-primary btn-sm btn-block" @click="reviewImports()">Review Imports</button>
  39. <p v-if="zipName" class="small font-weight-bold mt-2 mb-0">{{ zipName }}</p>
  40. </div>
  41. </template>
  42. </div>
  43. </div>
  44. </li>
  45. </ul>
  46. <ul class="list-group mt-3">
  47. <li v-if="processingCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
  48. <div class="d-flex justify-content-between align-items-center">
  49. <div>
  50. <p class="font-weight-bold mb-1">Processing Imported Posts</p>
  51. <p class="small mb-0">These are posts that are in the process of being imported.</p>
  52. </div>
  53. <div>
  54. <span class="btn btn-danger rounded-pill py-0 font-weight-bold" disabled>{{ processingCount }}</span>
  55. </div>
  56. </div>
  57. </li>
  58. <li v-if="finishedCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
  59. <div class="d-flex justify-content-between align-items-center">
  60. <div>
  61. <p class="font-weight-bold mb-1">Imported Posts</p>
  62. <p class="small mb-0">These are posts that have been successfully imported.</p>
  63. </div>
  64. <div>
  65. <button
  66. type="button"
  67. class="font-weight-bold btn btn-primary btn-sm rounded-pill px-4 btn-block"
  68. @click="handleReviewPosts()"
  69. :disabled="!finishedCount">
  70. Review {{ finishedCount }} Posts
  71. </button>
  72. </div>
  73. </div>
  74. </li>
  75. </ul>
  76. </section>
  77. </template>
  78. <template v-else-if="page === 2">
  79. <div class="d-flex justify-content-between align-items-center">
  80. <div class="title">
  81. <h3 class="font-weight-bold">Import from Instagram</h3>
  82. </div>
  83. <button
  84. class="btn btn-primary font-weight-bold rounded-pill px-4"
  85. :class="{ disabled: !selectedMedia || !selectedMedia.length }"
  86. :disabled="!selectedMedia || !selectedMedia.length || importButtonLoading"
  87. @click="handleImport()"
  88. >
  89. <b-spinner v-if="importButtonLoading" small />
  90. <span v-else>Import</span>
  91. </button>
  92. </div>
  93. <hr>
  94. <section>
  95. <div class="d-flex justify-content-between align-items-center mb-3">
  96. <div v-if="!selectedMedia || !selectedMedia.length">
  97. <p class="lead mb-0">Review posts you'd like to import.</p>
  98. <p class="small text-muted mb-0">Tap on posts to include them in your import.</p>
  99. </div>
  100. <p v-else class="lead mb-0"><span class="font-weight-bold">{{ selectedPostsCounter }}</span> posts selected for import</p>
  101. <button v-if="selectedMedia.length" class="btn btn-outline-danger font-weight-bold rounded-pill btn-sm my-1" @click="handleClearAll()">Clear all selected</button>
  102. <button v-else class="btn btn-outline-primary font-weight-bold rounded-pill" @click="handleSelectAll()">Select first 100 posts</button>
  103. </div>
  104. </section>
  105. <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
  106. <div v-for="media in postMeta" class="col-12 col-md-4">
  107. <div
  108. class="square cursor-pointer"
  109. @click="toggleSelectedPost(media)">
  110. <div
  111. v-if="media.media[0].uri.endsWith('.mp4')"
  112. :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
  113. class="info-overlay-text-label rounded">
  114. <h5 class="text-white m-auto font-weight-bold">
  115. <span>
  116. <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
  117. </span>
  118. </h5>
  119. </div>
  120. <div
  121. v-else
  122. class="square-content"
  123. :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
  124. :style="{ borderRadius: '5px', backgroundImage: 'url(' + getFileNameUrl(media.media[0].uri) + ')'}">
  125. </div>
  126. </div>
  127. <div class="d-flex mt-1 justify-content-between align-items-center">
  128. <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.media[0].creation_timestamp) }}</p>
  129. <p class="small font-weight-bold"><a href="#" @click.prevent="showDetailsModal(media)"><i class="far fa-info-circle"></i> Details</a></p>
  130. </div>
  131. </div>
  132. </section>
  133. </template>
  134. <template v-else-if="page === 'reviewImports'">
  135. <div class="d-flex justify-content-between align-items-center">
  136. <div class="title">
  137. <h3 class="font-weight-bold">Posts Imported from Instagram</h3>
  138. </div>
  139. </div>
  140. <hr>
  141. <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
  142. <div v-for="media in importedPosts.data" class="col-12 col-md-4">
  143. <div
  144. class="square cursor-pointer">
  145. <div
  146. v-if="media.media_attachments[0].url.endsWith('.mp4')"
  147. class="info-overlay-text-label rounded">
  148. <h5 class="text-white m-auto font-weight-bold">
  149. <span>
  150. <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
  151. </span>
  152. </h5>
  153. </div>
  154. <div
  155. v-else
  156. class="square-content"
  157. :style="{ borderRadius: '5px', backgroundImage: 'url(' + media.media_attachments[0].url + ')'}">
  158. </div>
  159. </div>
  160. <div class="d-flex mt-1 justify-content-between align-items-center">
  161. <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.created_at, false) }}</p>
  162. <p class="small font-weight-bold"><a :href="media.url"><i class="far fa-info-circle"></i> View</a></p>
  163. </div>
  164. </div>
  165. <div class="col-12 my-3">
  166. <button
  167. v-if="importedPosts.meta && importedPosts.meta.next_cursor"
  168. class="btn btn-primary btn-block font-weight-bold"
  169. @click="loadMorePosts()">
  170. Load more
  171. </button>
  172. </div>
  173. </section>
  174. </template>
  175. </template>
  176. <b-modal
  177. id="detailsModal"
  178. title="Post Details"
  179. v-model="detailsModalShow"
  180. :ok-only="true"
  181. ok-title="Close"
  182. centered>
  183. <div class="">
  184. <div v-for="(media, idx) in modalData.media" class="mb-3">
  185. <div class="list-group">
  186. <div class="list-group-item d-flex justify-content-between align-items-center">
  187. <p class="text-center font-weight-bold mb-0">Media #{{idx + 1}}</p>
  188. <img :src="getFileNameUrl(media.uri)" width="30" height="30" style="object-fit: cover; border-radius: 5px;">
  189. </div>
  190. <div class="list-group-item">
  191. <p class="small text-muted">Caption</p>
  192. <p class="mb-0 small read-more" style="font-size: 12px;overflow-y: hidden;">{{ media.title ? media.title : modalData.title }}</p>
  193. </div>
  194. <div class="list-group-item">
  195. <div class="d-flex justify-content-between align-items-center">
  196. <p class="small mb-0 text-muted">Timestamp</p>
  197. <p class="font-weight-bold mb-0">{{ formatDate(media.creation_timestamp) }}</p>
  198. </div>
  199. </div>
  200. </div>
  201. </div>
  202. </div>
  203. </b-modal>
  204. </div>
  205. </template>
  206. <script type="text/javascript">
  207. import * as zip from "@zip.js/zip.js";
  208. export default {
  209. data() {
  210. return {
  211. page: 1,
  212. step: 1,
  213. toggleLimit: 100,
  214. config: {},
  215. showDisabledWarning: false,
  216. showNotAllowedWarning: false,
  217. invalidArchive: false,
  218. loaded: false,
  219. existing: [],
  220. zipName: undefined,
  221. zipFiles: [],
  222. postMeta: [],
  223. imageCache: [],
  224. includeArchives: false,
  225. selectedMedia: [],
  226. selectedPostsCounter: 0,
  227. detailsModalShow: false,
  228. modalData: {},
  229. importedPosts: [],
  230. finishedCount: undefined,
  231. processingCount: undefined,
  232. showUploadLoader: false,
  233. importButtonLoading: false,
  234. }
  235. },
  236. mounted() {
  237. this.fetchConfig();
  238. },
  239. methods: {
  240. fetchConfig() {
  241. axios.get('/api/local/import/ig/config')
  242. .then(res => {
  243. this.config = res.data;
  244. if(res.data.enabled == false) {
  245. this.showDisabledWarning = true;
  246. this.loaded = true;
  247. } else if(res.data.allowed == false) {
  248. this.showNotAllowedWarning = true;
  249. this.loaded = true;
  250. } else {
  251. this.fetchExisting();
  252. }
  253. })
  254. },
  255. fetchExisting() {
  256. axios.post('/api/local/import/ig/existing')
  257. .then(res => {
  258. this.existing = res.data;
  259. })
  260. .finally(() => {
  261. this.fetchProcessing();
  262. })
  263. },
  264. fetchProcessing() {
  265. axios.post('/api/local/import/ig/processing')
  266. .then(res => {
  267. this.processingCount = res.data.processing_count;
  268. this.finishedCount = res.data.finished_count;
  269. })
  270. .finally(() => {
  271. this.loaded = true;
  272. })
  273. },
  274. selectArchive() {
  275. event.currentTarget.blur();
  276. swal({
  277. title: 'Upload Archive',
  278. icon: 'success',
  279. text: 'The .zip archive is probably named something like username_20230606.zip, and was downloaded from the Instagram.com website.',
  280. buttons: {
  281. cancel: "Cancel",
  282. danger: {
  283. text: "Upload zip archive",
  284. value: "upload"
  285. }
  286. }
  287. })
  288. .then(res => {
  289. this.$refs.zipInput.click();
  290. })
  291. },
  292. zipInputChanged(event) {
  293. this.step = 2;
  294. this.zipName = event.target.files[0].name;
  295. this.showUploadLoader = true;
  296. setTimeout(() => {
  297. this.reviewImports();
  298. }, 1000);
  299. setTimeout(() => {
  300. this.showUploadLoader = false;
  301. }, 3000);
  302. },
  303. reviewImports() {
  304. this.invalidArchive = false;
  305. this.checkZip();
  306. },
  307. model(file, options = {}) {
  308. return (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options);
  309. },
  310. formatDate(ts, unixt = true) {
  311. let date = unixt ? new Date(ts * 1000) : new Date(ts);
  312. return date.toLocaleDateString()
  313. },
  314. getFileNameUrl(filename) {
  315. return this.imageCache.filter(e => e.filename === filename).map(e => e.blob);
  316. },
  317. showDetailsModal(entry) {
  318. this.modalData = entry;
  319. this.detailsModalShow = true;
  320. setTimeout(() => {
  321. pixelfed.readmore();
  322. }, 500);
  323. },
  324. async fixFacebookEncoding(string) {
  325. // Facebook and Instagram are encoding UTF8 characters in a weird way in their json
  326. // here is a good explanation what's going wrong https://sorashi.github.io/fix-facebook-json-archive-encoding
  327. // See https://github.com/pixelfed/pixelfed/pull/4726 for more info
  328. const replaced = string.replace(/\\u00([a-f0-9]{2})/g, (x) => String.fromCharCode(parseInt(x.slice(2), 16)));
  329. const buffer = Array.from(replaced, (c) => c.charCodeAt(0));
  330. return new TextDecoder().decode(new Uint8Array(buffer));
  331. },
  332. async filterPostMeta(media) {
  333. let fbfix = await this.fixFacebookEncoding(media);
  334. let json = JSON.parse(fbfix);
  335. let res = json.filter(j => {
  336. let ids = j.media.map(m => m.uri).filter(m => {
  337. if(this.config.allow_video_posts == true) {
  338. return m.endsWith('.png') || m.endsWith('.jpg') || m.endsWith('.mp4');
  339. } else {
  340. return m.endsWith('.png') || m.endsWith('.jpg');
  341. }
  342. });
  343. return ids.length;
  344. }).filter(j => {
  345. let ids = j.media.map(m => m.uri);
  346. return !this.existing.includes(ids[0]);
  347. })
  348. this.postMeta = res;
  349. return res;
  350. },
  351. async checkZip() {
  352. let file = this.$refs.zipInput.files[0];
  353. let entries = await this.model(file);
  354. if (entries && entries.length) {
  355. let files = await entries.filter(e => e.filename === 'content/posts_1.json');
  356. if(!files || !files.length) {
  357. this.contactModal(
  358. 'Invalid import archive',
  359. "The .zip archive you uploaded is corrupted, or is invalid. We cannot process your import at this time.\n\nIf this issue persists, please contact an administrator.",
  360. 'error'
  361. )
  362. this.invalidArchive = true;
  363. return;
  364. } else {
  365. this.readZip();
  366. }
  367. }
  368. },
  369. async readZip() {
  370. let file = this.$refs.zipInput.files[0];
  371. let entries = await this.model(file);
  372. if (entries && entries.length) {
  373. this.zipFiles = entries;
  374. let media = await entries.filter(e => e.filename === 'content/posts_1.json')[0].getData(new zip.TextWriter());
  375. this.filterPostMeta(media);
  376. let imgs = await Promise.all(entries.filter(entry => {
  377. return (entry.filename.startsWith('media/posts/') || entry.filename.startsWith('media/other/')) && (entry.filename.endsWith('.png') || entry.filename.endsWith('.jpg') || entry.filename.endsWith('.mp4'));
  378. })
  379. .map(async entry => {
  380. if(
  381. (
  382. entry.filename.startsWith('media/posts/') ||
  383. entry.filename.startsWith('media/other/')
  384. ) && (
  385. entry.filename.endsWith('.png') ||
  386. entry.filename.endsWith('.jpg') ||
  387. entry.filename.endsWith('.mp4')
  388. )
  389. ) {
  390. let types = {
  391. 'png': 'image/png',
  392. 'jpg': 'image/jpeg',
  393. 'jpeg': 'image/jpeg',
  394. 'mp4': 'video/mp4'
  395. }
  396. let type = types[entry.filename.split('/').pop().split('.').pop()];
  397. let blob = await entry.getData(new zip.BlobWriter(type));
  398. let url = URL.createObjectURL(blob);
  399. return {
  400. filename: entry.filename,
  401. blob: url,
  402. file: blob
  403. }
  404. } else {
  405. return;
  406. }
  407. }));
  408. this.imageCache = imgs.flat(2);
  409. }
  410. setTimeout(() => {
  411. this.page = 2;
  412. }, 500);
  413. },
  414. toggleLimitReached() {
  415. this.contactModal(
  416. 'Limit reached',
  417. "You can only import " + this.toggleLimit + " posts at a time.\nYou can import more posts after you finish importing these posts.",
  418. 'error'
  419. )
  420. },
  421. toggleSelectedPost(media) {
  422. let filename;
  423. let self = this;
  424. if(media.media.length === 1) {
  425. filename = media.media[0].uri
  426. if(this.selectedMedia.indexOf(filename) == -1) {
  427. if(this.selectedPostsCounter >= this.toggleLimit) {
  428. this.toggleLimitReached();
  429. return;
  430. }
  431. this.selectedMedia.push(filename);
  432. this.selectedPostsCounter++;
  433. } else {
  434. let idx = this.selectedMedia.indexOf(filename);
  435. this.selectedMedia.splice(idx, 1);
  436. this.selectedPostsCounter--;
  437. }
  438. } else {
  439. filename = media.media[0].uri
  440. if(this.selectedMedia.indexOf(filename) == -1) {
  441. if(this.selectedPostsCounter >= this.toggleLimit) {
  442. this.toggleLimitReached();
  443. return;
  444. }
  445. this.selectedPostsCounter++;
  446. } else {
  447. this.selectedPostsCounter--;
  448. }
  449. media.media.forEach(function(m) {
  450. filename = m.uri
  451. if(self.selectedMedia.indexOf(filename) == -1) {
  452. self.selectedMedia.push(filename);
  453. } else {
  454. let idx = self.selectedMedia.indexOf(filename);
  455. self.selectedMedia.splice(idx, 1);
  456. }
  457. })
  458. }
  459. },
  460. sliceIntoChunks(arr, chunkSize) {
  461. const res = [];
  462. for (let i = 0; i < arr.length; i += chunkSize) {
  463. const chunk = arr.slice(i, i + chunkSize);
  464. res.push(chunk);
  465. }
  466. return res;
  467. },
  468. handleImport() {
  469. swal('Importing...', "Please wait while we upload your imported posts.\n Keep this page open and do not navigate away.", 'success');
  470. this.importButtonLoading = true;
  471. let ic = this.imageCache.filter(e => {
  472. return this.selectedMedia.indexOf(e.filename) != -1;
  473. })
  474. let chunks = this.sliceIntoChunks(ic, 10);
  475. chunks.forEach(c => {
  476. let formData = new FormData();
  477. c.map((e, idx) => {
  478. let file = new File([e.file], e.filename);
  479. formData.append('file['+ idx +']', file, e.filename.split('/').pop());
  480. })
  481. axios.post(
  482. '/api/local/import/ig/media',
  483. formData,
  484. {
  485. headers: {
  486. 'Content-Type': `multipart/form-data`,
  487. },
  488. }
  489. )
  490. .catch(err => {
  491. this.contactModal(
  492. 'Error',
  493. err.response.data.message,
  494. 'error'
  495. )
  496. });
  497. })
  498. axios.post('/api/local/import/ig', {
  499. files: this.postMeta.filter(e => this.selectedMedia.includes(e.media[0].uri)).map(e => {
  500. if(e.hasOwnProperty('title')) {
  501. return {
  502. title: e.title,
  503. 'creation_timestamp': e.creation_timestamp,
  504. uri: e.uri,
  505. media: e.media
  506. }
  507. } else {
  508. return {
  509. title: null,
  510. 'creation_timestamp': null,
  511. uri: null,
  512. media: e.media
  513. }
  514. }
  515. })
  516. }).then(res => {
  517. if(res) {
  518. setTimeout(() => {
  519. window.location.reload()
  520. }, 5000);
  521. }
  522. }).catch(err => {
  523. this.contactModal(
  524. 'Error',
  525. err.response.data.error,
  526. 'error'
  527. )
  528. })
  529. },
  530. handleReviewPosts() {
  531. this.page = 'reviewImports';
  532. axios.post('/api/local/import/ig/posts')
  533. .then(res => {
  534. this.importedPosts = res.data;
  535. })
  536. },
  537. loadMorePosts() {
  538. event.currentTarget.blur();
  539. axios.post('/api/local/import/ig/posts', {
  540. cursor: this.importedPosts.meta.next_cursor
  541. })
  542. .then(res => {
  543. let data = res.data;
  544. data.data = [...this.importedPosts.data, ...res.data.data];
  545. this.importedPosts = data;
  546. })
  547. },
  548. contactModal(title = 'Error', text, icon, closeButton = 'Close') {
  549. swal({
  550. title: title,
  551. text: text,
  552. icon: icon,
  553. dangerMode: true,
  554. buttons: {
  555. ok: closeButton,
  556. danger: {
  557. text: 'Contact Support',
  558. value: 'contact'
  559. }
  560. }
  561. })
  562. .then(res => {
  563. if(res === 'contact') {
  564. window.location.href = '/site/contact'
  565. }
  566. });
  567. },
  568. handleSelectAll() {
  569. let medias = this.postMeta.slice(0, 100);
  570. for (var i = medias.length - 1; i >= 0; i--) {
  571. let m = medias[i];
  572. this.toggleSelectedPost(m);
  573. }
  574. },
  575. handleClearAll() {
  576. this.selectedMedia = []
  577. this.selectedPostsCounter = 0;
  578. }
  579. }
  580. }
  581. </script>
  582. <style lang="scss" scoped>
  583. .pf-import {
  584. .media-selector {
  585. .selected {
  586. border: 5px solid red;
  587. }
  588. }
  589. }
  590. </style>