pixelfed/pixelfed

View on GitHub
resources/assets/components/AccountImport.vue

Summary

Maintainability
Test Coverage
<template>
    <div class="h-100 pf-import">
        <div v-if="!loaded" class="d-flex justify-content-center align-items-center h-100">
            <b-spinner />
        </div>
        <template v-else>
            <input type="file" name="file" class="d-none" ref="zipInput" @change="zipInputChanged" />
            <template v-if="page === 1">
                <div class="title">
                    <h3 class="font-weight-bold">Import</h3>
                </div>
                <hr>
                <section>
                    <p class="lead">Account Import allows you to import your data from a supported service.</p>
                </section>
                <section class="mt-4">
                    <ul class="list-group">
                        <li class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
                            <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
                                <div>
                                    <p class="font-weight-bold mb-1">Import from Instagram</p>
                                    <p v-if="showDisabledWarning" class="small mb-0">This feature has been disabled by the administrators.</p>
                                    <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>
                                    <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>
                                </div>
                                <div v-if="!showDisabledWarning && !showNotAllowedWarning">
                                    <button
                                        v-if="step === 1 || invalidArchive"
                                        type="button"
                                        class="font-weight-bold btn btn-primary rounded-pill px-4 btn-lg"
                                        @click="selectArchive()"
                                        :disabled="showDisabledWarning">
                                        Import
                                    </button>

                                    <template v-else-if="step === 2">
                                        <div class="d-flex justify-content-center align-items-center flex-column">
                                            <b-spinner v-if="showUploadLoader" small />
                                            <button v-else type="button" class="font-weight-bold btn btn-outline-primary btn-sm btn-block" @click="reviewImports()">Review Imports</button>
                                            <p v-if="zipName" class="small font-weight-bold mt-2 mb-0">{{ zipName }}</p>
                                        </div>
                                    </template>
                                </div>
                            </div>
                        </li>
                    </ul>
                    <ul class="list-group mt-3">

                        <li v-if="processingCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
                            <div class="d-flex justify-content-between align-items-center">
                                <div>
                                    <p class="font-weight-bold mb-1">Processing Imported Posts</p>
                                    <p class="small mb-0">These are posts that are in the process of being imported.</p>
                                </div>
                                <div>
                                    <span class="btn btn-danger rounded-pill py-0 font-weight-bold" disabled>{{ processingCount }}</span>
                                </div>
                            </div>
                        </li>

                        <li v-if="finishedCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
                            <div class="d-flex justify-content-between align-items-center">
                                <div>
                                    <p class="font-weight-bold mb-1">Imported Posts</p>
                                    <p class="small mb-0">These are posts that have been successfully imported.</p>
                                </div>
                                <div>
                                    <button
                                        type="button"
                                        class="font-weight-bold btn btn-primary btn-sm rounded-pill px-4 btn-block"
                                        @click="handleReviewPosts()"
                                        :disabled="!finishedCount">
                                        Review {{ finishedCount }} Posts
                                    </button>
                                </div>
                            </div>
                        </li>
                    </ul>

                </section>
            </template>

            <template v-else-if="page === 2">
                <div class="d-flex justify-content-between align-items-center">

                    <div class="title">
                        <h3 class="font-weight-bold">Import from Instagram</h3>
                    </div>

                    <button
                        class="btn btn-primary font-weight-bold rounded-pill px-4"
                        :class="{ disabled: !selectedMedia || !selectedMedia.length }"
                        :disabled="!selectedMedia || !selectedMedia.length || importButtonLoading"
                        @click="handleImport()"
                        >
                        <b-spinner v-if="importButtonLoading" small />
                        <span v-else>Import</span>
                    </button>
                </div>
                <hr>
                <section>
                    <div class="d-flex justify-content-between align-items-center mb-3">
                        <div v-if="!selectedMedia || !selectedMedia.length">
                            <p class="lead mb-0">Review posts you'd like to import.</p>
                            <p class="small text-muted mb-0">Tap on posts to include them in your import.</p>
                        </div>
                        <p v-else class="lead mb-0"><span class="font-weight-bold">{{ selectedPostsCounter }}</span> posts selected for import</p>

                        <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>
                        <button v-else class="btn btn-outline-primary font-weight-bold rounded-pill" @click="handleSelectAll()">Select first 100 posts</button>
                    </div>
                </section>
                <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
                    <div v-for="media in postMeta" class="col-12 col-md-4">
                        <div
                            class="square cursor-pointer"
                            @click="toggleSelectedPost(media)">
                            <div
                                v-if="media.media[0].uri.endsWith('.mp4')"
                                :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
                                class="info-overlay-text-label rounded">
                                <h5 class="text-white m-auto font-weight-bold">
                                    <span>
                                        <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
                                    </span>
                                </h5>
                            </div>
                            <div
                                v-else
                                class="square-content"
                                :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
                                :style="{ borderRadius: '5px', backgroundImage: 'url(' + getFileNameUrl(media.media[0].uri) + ')'}">
                            </div>
                        </div>
                        <div class="d-flex mt-1 justify-content-between align-items-center">
                            <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.media[0].creation_timestamp) }}</p>
                            <p class="small font-weight-bold"><a href="#" @click.prevent="showDetailsModal(media)"><i class="far fa-info-circle"></i> Details</a></p>
                        </div>
                    </div>
                </section>
            </template>

            <template v-else-if="page === 'reviewImports'">
                <div class="d-flex justify-content-between align-items-center">

                    <div class="title">
                        <h3 class="font-weight-bold">Posts Imported from Instagram</h3>
                    </div>
                </div>
                <hr>
                <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
                    <div v-for="media in importedPosts.data" class="col-12 col-md-4">
                        <div
                            class="square cursor-pointer">
                            <div
                                v-if="media.media_attachments[0].url.endsWith('.mp4')"
                                class="info-overlay-text-label rounded">
                                <h5 class="text-white m-auto font-weight-bold">
                                    <span>
                                        <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
                                    </span>
                                </h5>
                            </div>
                            <div
                                v-else
                                class="square-content"
                                :style="{ borderRadius: '5px', backgroundImage: 'url(' + media.media_attachments[0].url + ')'}">
                            </div>
                        </div>
                        <div class="d-flex mt-1 justify-content-between align-items-center">
                            <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.created_at, false) }}</p>
                            <p class="small font-weight-bold"><a :href="media.url"><i class="far fa-info-circle"></i> View</a></p>
                        </div>
                    </div>

                    <div class="col-12 my-3">
                        <button
                            v-if="importedPosts.meta && importedPosts.meta.next_cursor"
                            class="btn btn-primary btn-block font-weight-bold"
                            @click="loadMorePosts()">
                            Load more
                        </button>
                    </div>
                </section>
            </template>
        </template>

        <b-modal
            id="detailsModal"
            title="Post Details"
            v-model="detailsModalShow"
            :ok-only="true"
            ok-title="Close"
            centered>
            <div class="">
                <div v-for="(media, idx) in modalData.media" class="mb-3">
                    <div class="list-group">
                        <div class="list-group-item d-flex justify-content-between align-items-center">
                            <p class="text-center font-weight-bold mb-0">Media #{{idx + 1}}</p>
                            <template v-if="media.uri.endsWith('.jpg') || media.uri.endsWith('.png')">
                                <img :src="getFileNameUrl(media.uri)" width="30" height="30" style="object-fit: cover; border-radius: 5px;">
                            </template>
                        </div>
                        <template v-if="media.uri.endsWith('.mp4')">
                            <div class="list-group-item">
                                <div class="embed-responsive embed-responsive-4by3">
                                    <video :src="getFileNameUrl(media.uri)" controls></video>
                                </div>
                            </div>
                        </template>
                        <div class="list-group-item">
                            <p class="small text-muted">Caption</p>
                            <p class="mb-0 small read-more" style="font-size: 12px;overflow-y: hidden;">{{ media.title ? media.title : modalData.title }}</p>
                        </div>
                        <div class="list-group-item">
                            <div class="d-flex justify-content-between align-items-center">
                                <p class="small mb-0 text-muted">Timestamp</p>
                                <p class="font-weight-bold mb-0">{{ formatDate(media.creation_timestamp) }}</p>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </b-modal>
    </div>
</template>

<script type="text/javascript">
    import * as zip from "@zip.js/zip.js";

    export default {
        data() {
            return {
                page: 1,
                step: 1,
                toggleLimit: 100,
                config: {},
                showDisabledWarning: false,
                showNotAllowedWarning: false,
                invalidArchive: false,
                loaded: false,
                existing: [],
                zipName: undefined,
                zipFiles: [],
                postMeta: [],
                imageCache: [],
                includeArchives: false,
                selectedMedia: [],
                selectedPostsCounter: 0,
                detailsModalShow: false,
                modalData: {},
                importedPosts: [],
                finishedCount: undefined,
                processingCount: undefined,
                showUploadLoader: false,
                importButtonLoading: false,
            }
        },

        mounted() {
            this.fetchConfig();
        },

        methods: {
            fetchConfig() {
                axios.get('/api/local/import/ig/config')
                .then(res => {
                    this.config = res.data;

                    if(res.data.enabled == false) {
                        this.showDisabledWarning = true;
                        this.loaded = true;
                    } else if(res.data.allowed == false) {
                        this.showNotAllowedWarning = true;
                        this.loaded = true;
                    } else {
                        this.fetchExisting();
                    }
                })
            },

            fetchExisting() {
                axios.post('/api/local/import/ig/existing')
                .then(res => {
                    this.existing = res.data;
                })
                .finally(() => {
                    this.fetchProcessing();
                })
            },

            fetchProcessing() {
                axios.post('/api/local/import/ig/processing')
                .then(res => {
                    this.processingCount = res.data.processing_count;
                    this.finishedCount = res.data.finished_count;
                })
                .finally(() => {
                    this.loaded = true;
                })
            },

            selectArchive() {
                event.currentTarget.blur();
                swal({
                    title: 'Upload Archive',
                    icon: 'success',
                    text: 'The .zip archive is probably named something like username_20230606.zip, and was downloaded from the Instagram.com website.',
                    buttons: {
                        cancel: "Cancel",
                        danger: {
                            text: "Upload zip archive",
                            value: "upload"
                        }
                    }
                })
                .then(res => {
                    this.$refs.zipInput.click();
                })
            },

            zipInputChanged(event) {
                this.step = 2;
                this.zipName = event.target.files[0].name;
                this.showUploadLoader = true;
                setTimeout(() => {
                    this.reviewImports();
                }, 1000);
                setTimeout(() => {
                    this.showUploadLoader = false;
                }, 3000);
            },

            reviewImports() {
                this.invalidArchive = false;
                this.checkZip();
            },

            model(file, options = {}) {
                return (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options);
            },

            formatDate(ts, unixt = true) {
                let date = unixt ? new Date(ts * 1000) : new Date(ts);
                return date.toLocaleDateString()
            },

            getFileNameUrl(filename) {
                return this.imageCache.filter(e => e.filename === filename).map(e => e.blob);
            },

            showDetailsModal(entry) {
                this.modalData = entry;
                this.detailsModalShow = true;
                setTimeout(() => {
                    pixelfed.readmore();
                }, 500);
            },

            async fixFacebookEncoding(string) {
                // Facebook and Instagram are encoding UTF8 characters in a weird way in their json
                // here is a good explanation what's going wrong https://sorashi.github.io/fix-facebook-json-archive-encoding
                // See https://github.com/pixelfed/pixelfed/pull/4726 for more info
                const replaced = string.replace(/\\u00([a-f0-9]{2})/g, (x) => String.fromCharCode(parseInt(x.slice(2), 16)));
                const buffer = Array.from(replaced, (c) => c.charCodeAt(0));
                return new TextDecoder().decode(new Uint8Array(buffer));
            },

            async filterPostMeta(media) {
                let fbfix = await this.fixFacebookEncoding(media);
                let json = JSON.parse(fbfix);
                let res = json.filter(j => {
                    let ids = j.media.map(m => m.uri).filter(m => {
                        if(this.config.allow_video_posts == true) {
                            return m.endsWith('.png') || m.endsWith('.jpg') || m.endsWith('.mp4');
                        } else {
                            return m.endsWith('.png') || m.endsWith('.jpg');
                        }
                    });
                    return ids.length;
                }).filter(j => {
                    let ids = j.media.map(m => m.uri);
                    return !this.existing.includes(ids[0]);
                })
                this.postMeta = res;
                return res;
            },

            async checkZip() {
                let file = this.$refs.zipInput.files[0];
                let entries = await this.model(file);
                if (entries && entries.length) {
                    let files = await entries.filter(e => e.filename === 'content/posts_1.json' || e.filename === 'your_instagram_activity/content/posts_1.json');

                    if(!files || !files.length) {
                        this.contactModal(
                            'Invalid import archive',
                            "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.",
                            'error'
                        )
                        this.invalidArchive = true;
                        return;
                    } else {
                        this.readZip();
                    }
                }
            },

            async readZip() {
                let file = this.$refs.zipInput.files[0];
                let entries = await this.model(file);
                if (entries && entries.length) {
                    this.zipFiles = entries;
                    let media = await entries.filter(e => e.filename === 'content/posts_1.json' || e.filename === 'your_instagram_activity/content/posts_1.json')[0].getData(new zip.TextWriter());
                    this.filterPostMeta(media);

                    let imgs = await Promise.all(entries.filter(entry => {
                        return (entry.filename.startsWith('media/posts/') || entry.filename.startsWith('media/other/')) && (entry.filename.endsWith('.png') || entry.filename.endsWith('.jpg') || entry.filename.endsWith('.mp4'));
                    })
                    .map(async entry => {
                        if(
                            (
                                entry.filename.startsWith('media/posts/') ||
                                entry.filename.startsWith('media/other/')
                            ) && (
                                entry.filename.endsWith('.png') ||
                                entry.filename.endsWith('.jpg') ||
                                entry.filename.endsWith('.mp4')
                            )
                        ) {
                            let types = {
                                'png': 'image/png',
                                'jpg': 'image/jpeg',
                                'jpeg': 'image/jpeg',
                                'mp4': 'video/mp4'
                            }
                            let type = types[entry.filename.split('/').pop().split('.').pop()];
                            let blob = await entry.getData(new zip.BlobWriter(type));
                            let url = URL.createObjectURL(blob);
                            return {
                                filename: entry.filename,
                                blob: url,
                                file: blob
                            }
                        } else {
                            return;
                        }
                    }));
                    this.imageCache = imgs.flat(2);
                }
                setTimeout(() => {
                    this.page = 2;
                }, 500);
            },

            toggleLimitReached() {
                this.contactModal(
                    'Limit reached',
                    "You can only import " + this.toggleLimit + " posts at a time.\nYou can import more posts after you finish importing these posts.",
                    'error'
                )
            },

            toggleSelectedPost(media) {
                let filename;
                let self = this;
                if(media.media.length === 1) {
                    filename = media.media[0].uri
                    if(this.selectedMedia.indexOf(filename) == -1) {
                        if(this.selectedPostsCounter >= this.toggleLimit) {
                            this.toggleLimitReached();
                            return;
                        }
                        this.selectedMedia.push(filename);
                        this.selectedPostsCounter++;
                    } else {
                        let idx = this.selectedMedia.indexOf(filename);
                        this.selectedMedia.splice(idx, 1);
                        this.selectedPostsCounter--;
                    }
                } else {
                    filename = media.media[0].uri
                    if(this.selectedMedia.indexOf(filename) == -1) {
                        if(this.selectedPostsCounter >= this.toggleLimit) {
                            this.toggleLimitReached();
                            return;
                        }
                        this.selectedPostsCounter++;
                    } else {
                        this.selectedPostsCounter--;
                    }
                    media.media.forEach(function(m) {
                        filename = m.uri
                        if(self.selectedMedia.indexOf(filename) == -1) {
                            self.selectedMedia.push(filename);
                        } else {
                            let idx = self.selectedMedia.indexOf(filename);
                            self.selectedMedia.splice(idx, 1);
                        }
                    })
                }
            },

            sliceIntoChunks(arr, chunkSize) {
                const res = [];
                for (let i = 0; i < arr.length; i += chunkSize) {
                    const chunk = arr.slice(i, i + chunkSize);
                    res.push(chunk);
                }
                return res;
            },

            handleImport() {
                swal('Importing...', "Please wait while we upload your imported posts.\n Keep this page open and do not navigate away.", 'success');
                this.importButtonLoading = true;
                let ic = this.imageCache.filter(e => {
                    return this.selectedMedia.indexOf(e.filename) != -1;
                })
                let chunks = this.sliceIntoChunks(ic, 10);
                chunks.forEach(c => {
                    let formData = new FormData();
                    c.map((e, idx) => {
                        let file = new File([e.file], e.filename);
                        formData.append('file['+ idx +']', file, e.filename.split('/').pop());
                    })
                    axios.post(
                        '/api/local/import/ig/media',
                        formData,
                        {
                            headers: {
                                'Content-Type': `multipart/form-data`,
                            },
                        }
                    )
                    .catch(err => {
                        this.contactModal(
                            'Error',
                            err.response.data.message,
                            'error'
                        )
                    });
                })
                axios.post('/api/local/import/ig', {
                    files: this.postMeta.filter(e => this.selectedMedia.includes(e.media[0].uri)).map(e => {
                        if(e.hasOwnProperty('title')) {
                            return {
                                title: e.title,
                                'creation_timestamp': e.creation_timestamp,
                                uri: e.uri,
                                media: e.media
                            }
                        } else {
                            return {
                                title: null,
                                'creation_timestamp': null,
                                uri: null,
                                media: e.media
                            }
                        }
                    })
                }).then(res => {
                    if(res) {
                        setTimeout(() => {
                            window.location.reload()
                        }, 5000);
                    }
                }).catch(err => {
                    this.contactModal(
                        'Error',
                        err.response.data.error,
                        'error'
                    )
                })
            },

            handleReviewPosts() {
                this.page = 'reviewImports';

                axios.post('/api/local/import/ig/posts')
                .then(res => {
                    this.importedPosts = res.data;
                })
            },

            loadMorePosts() {
                event.currentTarget.blur();

                axios.post('/api/local/import/ig/posts', {
                    cursor: this.importedPosts.meta.next_cursor
                })
                .then(res => {
                    let data = res.data;
                    data.data = [...this.importedPosts.data, ...res.data.data];
                    this.importedPosts = data;
                })
            },

            contactModal(title = 'Error', text, icon, closeButton = 'Close') {
                swal({
                    title: title,
                    text: text,
                    icon: icon,
                    dangerMode: true,
                    buttons: {
                        ok: closeButton,
                        danger: {
                            text: 'Contact Support',
                            value: 'contact'
                        }
                    }
                })
                .then(res => {
                    if(res === 'contact') {
                        window.location.href = '/site/contact'
                    }
                });
            },

            handleSelectAll() {
                let medias = this.postMeta.slice(0, 100);
                for (var i = medias.length - 1; i >= 0; i--) {
                    let m = medias[i];
                    this.toggleSelectedPost(m);
                }
            },

            handleClearAll() {
                this.selectedMedia = []
                this.selectedPostsCounter = 0;
            }
        }
    }
</script>

<style lang="scss" scoped>
    .pf-import {
        .media-selector {
            .selected {
                border: 5px solid red;
            }
        }
    }
</style>