Description

This is a self-contained page consisting of a pair of client-side scripts used to export/import subscriptions from and to Kbin/Mbin instances.

  • Uses vanilla JavaScript with no additional dependencies

  • Ephemeral, one-time invocation: code runs locally in browser and is discarded upon use

  • Tested on Firefox/Chromium

Setup

  1. Open two browser tabs: one for the origin instance (site you are exporting subscriptions from) and one for the destination (site you are importing subscriptions to). You must be logged in to your accounts on both tabs. For example, if you are exporting subscriptions from kbin.social to fedia.io, the "origin tab" is kbin.social and the "destination tab" is fedia.io.

  2. In the origin tab, open the browser console using one of the keybindings below:

    • Firefox: Ctrl + Shift + k (Mac: Cmd + Option + k)

    • Chromium: Ctrl + Shift + j (Mac: Cmd + Option + j)

  3. Click the button below to copy the Export Tool code to the clipboard:

  4. Navigate to the browser console you just opened on the origin tab and paste the contents of the clipboard from step 3 above.

  5. Push the Enter key to execute the code.

  6. This will spawn a dialog window that you will revisit later.

  7. You can now close the browser console that you executed the code in, leaving the Kbin/Mbin instance open with a dialog window in it.

  8. Now, repeat steps 2-6 on the destination tab, but when you reach step 4, use the button below to copy the Import Tool code:

  9. This completes initial setup, and you are now ready to interact with the dialogs you just created. You should have the Export Tool open on the origin tab and the Import Tool open on the destination tab.

  10. Proceed to Exporting subscriptions.

Note
This process is intended to be used to migrate subscriptions from an account on one instance to an account on another, different instance. However, you can also use it to copy subscriptions between two accounts on the same instance. Because of the way browsers generally handle sessions, you will probably need to open the destination instance in a separate browser, or log out of the first account before importing subscriptions. You cannot migrate subscriptions to the same account on the same instance.

Exporting subscriptions

On the origin tab, the Export Tool should have detected the origin instance and username and displayed them for you.

If this is not a valid Kbin/Mbin instance or you are not logged in, a warning will appear.

  1. Click the Export subscriptions button in the dialog to begin exporting. If you have over 48 subscriptions, it may take a moment to fetch the entire list.

  2. Once complete, the dialog will inform you of how many subscriptions it detected and will prompt you to copy the relevant information to the clipboard via a button.

  3. Upon clicking the button, your system clipboard will contain the relevant data that you will use in the next step.

  4. After clicking exit button, the page will reload (this is done for convenience, in order to clear out the code you just ran)

  5. Proceed to Importing subscriptions.

Importing subscriptions

Navigate to the destination tab, making sure you are logged in to the desired account.

  1. Paste the clipboard contents from the step above into the field and click Submit.

  2. The Import Tool will detect the origin instance and username and the destination instance and username and list these. You can use this as a final confirmation to make sure you are importing from and to the right accounts.

  3. Upon clicking Import subscriptions, the tool will begin subscribing to each of them in sequence, informing you of its progress.

  4. After the subscription process is complete, two buttons will appear:

    • Click to exit: closes the dialog and navigates to the subscriptions page, where you can visually confirm that the subscriptions were imported

    • View log: expands a list of subscriptions and indicates their pass/fail status (whether or not the magazine could be subscribed to). In rare cases, the magazine may not be federated on the destination instance, or a communication error could have occurred, so this list serves as a breakdown of which, if any, subscriptions may be missing.

Reference

Click to expand export tool code
//0.1.3
const modalHTML = `
<span id="close-button" style="float: right;font-size:15px; cursor:pointer">✖</span>
<br>
<h3>Export subscriptions</h3>
<div id="holder">
    <div>
        <p style="margin-bottom: 0rem">ORIGIN</p>
        <hr>
        <p>
            Instance: <span id="origin-instance"></span><br>
            Username: <span id="origin-user"></span><br>
        </p>
    </div>
</div>
<div>
<button id="export-button">Export subscriptions</button>
</div>
<div>
    <br>
    <p style="margin-bottom: 0rem">STATUS</p>
    <hr>
</div>
<div>
    <p id="status-msg"></p>
</div>
`;

const modalCSS = `
background-color: gray;
width: 500px;
height: 500px;
marginLeft: 100px;
`;

function setupModal () {
    const migrationModal = document.createElement('dialog');
    migrationModal.id = 'exit-modal';
    migrationModal.innerHTML = modalHTML;
    migrationModal.style.cssText = modalCSS;
    migrationModal.querySelector('#close-button').addEventListener('click', () =>{
        migrationModal.remove();
    })
    const header = document.querySelector('#header')
    if (header){
        header.appendChild(migrationModal);
    } else {
        const outer = document.body.firstElementChild
        outer.appendChild(migrationModal);
    }
    migrationModal.showModal();
    init();
}
const subs_arr = []

function addExitButton () {
    const modal = document.querySelector('#exit-modal')
    const closeButton = document.createElement('button')
    closeButton.innerText = 'Click to exit'
    closeButton.addEventListener('click', () => {
        modal.remove();
        window.location.reload();
    })
    modal.appendChild(closeButton)
}
async function fetchSubs (host, username, page) {

    updateTooltip('#status-msg', `Fetching subscriptions page ${page}...`)
    try {
        const resp = await fetch (`https://${host}/u/${username}/subscriptions?p=${page}`, {
            signal: AbortSignal.timeout(8000),
            "credentials": "include",
            "method": "GET",
            "mode": "cors"
        });
        const respText = await resp.text()
        const parser = new DOMParser();
        const XML = parser.parseFromString(respText, "text/html");
        const mags = XML.querySelector('#content .magazines')
        if (!mags) {
            updateTooltip('#status-msg', `ERROR: User '${username}' has no subscriptions`)
            return
        }
        const subs = mags.querySelectorAll('.stretched-link')
        subs.forEach((sub) => {
            subs_arr.push(sub.innerText)
        });
        const subCount = XML.querySelector('.options__main a[href$="/subscriptions"]').innerText
        const rawSubCount = subCount.split('(')[1].split(')')[0];
        const subInt = Number(rawSubCount);
        const totalPages = Math.ceil((subInt / 48))

        if (page < totalPages) {
            page++
            fetchSubs(host, username, page)
            return
        }

        const sorted = subs_arr.sort()

        const obj = {
            "origin_username": username,
            "origin_instance": host,
            "origin_subs": sorted
        }

        const j = JSON.stringify(obj)

        const copyButton = document.createElement('button')
        const separator = document.createElement('br')
        const modal = document.querySelector('#exit-modal')
        copyButton.innerText = "Copy subscriptions"
        copyButton.style.cssText = "margin-bottom: 5px"
        modal.appendChild(copyButton)
        modal.appendChild(separator)

        copyButton.addEventListener('click', () => {
            navigator.clipboard.writeText(j);
            updateTooltip('#status-msg', `Copied to clipboard. Paste contents into Import Tool`)
        });

        updateTooltip('#status-msg', `Found ${sorted.length} subscriptions. Click below to copy to clipboard`)
        disableButton(true)
        addExitButton()
    } catch (e) {
        console.log(e)
        updateTooltip('#status-msg', `Failed to fetch subscriptions. Please try again`)
    }
}
function updateTooltip (id, msg) {
    const modal = document.querySelector('#exit-modal');
    modal.querySelector(id).innerText = msg
}
function invalidInstance () {
    updateTooltip('#status-msg', "ERROR: is this a valid kbin/mbin instance?")
    updateTooltip('#origin-instance', "invalid")
    updateTooltip('#origin-user', "not found")
    disableButton(true)
}
function init () {
    const host = window.location.hostname;
    const keyw = document.querySelector('meta[name="keywords"]')

    if (!keyw) {
        invalidInstance()
        return
    }
    const instance = keyw.content.split(',')[0]
    if ((instance != 'kbin') && (instance != 'mbin')) {
        invalidInstance()
        return
    }

    const user = document.querySelector('.login');
    const username = user.href.split('/')[4];

    if (!username) {
        updateTooltip('#origin-instance', host)
        updateTooltip('#origin-user', "not found")
        updateTooltip('#status-msg', "ERROR: Must be logged in")
        disableButton(true)
        return
    }

    updateTooltip('#origin-instance', host)
    updateTooltip('#origin-user', username)
    updateTooltip('#status-msg', "Waiting for user input")
    disableButton(false)

    document.querySelector('#export-button').addEventListener('click', () =>{
        fetchSubs(host, username, 1)
    })
}
function disableButton (bool) {
    const button = document.querySelector('#export-button')
    button.disabled = bool
}

setupModal();
Click to expand import tool code
//0.1.3
const modalHTML = `
<span id="close-button" style="float: right;font-size:15px; cursor:pointer">✖</span>
<br>
<h3>Import subscriptions</h3>
<p>Paste the output of the Export Tool into the field below.
<div id="pasteField">
    <input id="pasteBox" type="text"></input>
    <br>
    <button value="null" id="importButton">Import subscriptions</button>
</div>
<div id="holder">
    <div>
        <p style="margin-bottom: 0rem">ORIGIN</p>
        <hr>
        <p>
            Instance: <span id="origin-instance"></span><br>
            Username: <span id="origin-user"></span><br>
            Subscriptions: <span id="origin-subs"></span>
        </p>
    </div>
    <div>
        <p style="margin-bottom: 0rem">DESTINATION</p>
        <hr>
        <p style="margin-bottom: 0rem">
            Instance: <span id="destination-instance"></span><br>
            Username: <span id="destination-user"></span>
        </p>
    </div>
</div>
    <br>
    <p style="margin-bottom: 0rem">STATUS</p>
    <hr>
<div>
    <p id="status-msg">Waiting for user input</p>
</div>
`

const modalCSS = `
background-color: gray;
width: 600px;
height: 700px;
marginLeft: 100px;
`;

const pass = []
const fail = []

function disableButton (bool) {
    const button = document.querySelector('#import-button')
    button.disabled = bool
}

function makeModal () {
    const migrationModal = document.createElement('dialog');
    migrationModal.id = 'exit-modal'
    migrationModal.innerHTML = modalHTML;
    migrationModal.style.cssText = modalCSS
    const holder = migrationModal.querySelector('#holder')
    const button = migrationModal.querySelector('#importButton')
    const paste = migrationModal.querySelector('#pasteBox')

    button.disabled = true
    paste.disabled = true

    holder.style.display = "none"
    paste.addEventListener('input', (e) =>{
        if (e.target.value != "") {
            button.disabled = false
        } else {
            button.disabled = true
        }
    });

    button.addEventListener('click', ()=>{
        const val = paste.value
        try {
            const json = JSON.parse(val)
            migrationModal.querySelector('#pasteField').remove()
            setup(json);
        } catch (e) {
            console.error("JSON error:", e.message)
            updateTooltip("#status-msg", "ERROR: Error in pasted input")
        }
    })
    migrationModal.querySelector('#close-button').addEventListener('click', () =>{
        migrationModal.remove();
    })

    const outer = document.body.firstElementChild
    outer.appendChild(migrationModal)
    migrationModal.showModal();

    const destination_instance = window.location.hostname;
    const keyw = document.querySelector('meta[name="keywords"]')

    if (!keyw) {
        invalidInstance()
        return
    }
    const instance = keyw.content.split(',')[0]
    if ((instance != 'kbin') && (instance != 'mbin')) {
        invalidInstance()
        return
    }
    const destination_user = document.querySelector('.login');
    const destination_username = destination_user.href.split('/')[4];

    if (!destination_username) {
        updateTooltip('#destination-instance', destination_instance)
        updateTooltip('#destination-user', "not found")
        updateTooltip('#status-msg', "ERROR: Must be logged in")
        return
    }
    paste.disabled = false

}
function setup (json) {
    const origin_instance = json.origin_instance
    const origin_username = json.origin_username
    const destination_instance = window.location.hostname;
    const destination_user = document.querySelector('.login');
    const destination_username = destination_user.href.split('/')[4];
    const arr = json.origin_subs

    updateTooltip('#origin-instance', origin_instance)
    updateTooltip('#origin-user', origin_username)
    updateTooltip('#origin-subs', arr.length)
    updateTooltip('#destination-instance', destination_instance)
    updateTooltip('#destination-user', destination_username)

    document.querySelector('#exit-modal #holder').style.display = ""

    getMagToken(origin_instance, destination_instance, destination_username, arr);
}
function addExitButton (page) {
    const modal = document.querySelector('#exit-modal')
    const closeButton = document.createElement('button')
    closeButton.innerText = 'Click to exit and view subs'
    closeButton.addEventListener('click', () => {
        modal.remove();
        if (page === 'subs') {
            const user = document.querySelector('.login');
            const username = user.href.split('/')[4];
            window.location = `/u/${username}/subscriptions`
        } else {
            window.location.reload();
        }
    })
    modal.appendChild(closeButton)
}
async function getMagToken (origin, host, destination_username, arr) {
    let to_import
    updateTooltip('#status-msg', "Starting subscription process...")
    for (let i = 0; i < arr.length; ++i) {
        let split = arr[i].split('@')
        if ((!split[1]) && (origin != host)) {
            to_import = `${arr[i]}@${origin}`
        } else if (split[1] == host) {
            to_import = split[0]
        } else {
            to_import = arr[i]
        }
        // <sub> from instance1 to instance1 -> <sub>
        // <sub> from instance1 to instance2 -> <sub@instance1>
        // <sub@instance2> from instance1 to instance2 -> <sub>
        // <sub@instance3> from instance1 to instance2 -> <sub@instance3>
        //n.b. some mags may be unfederated/disabled at the destination

        const resp = await fetch(`https://${host}/m/${to_import}`, {
            "credentials": "include",
            "method": "GET",
            "mode": "cors"
        });

        switch (await resp.status) {
        case 200:
        {
            const respText = await resp.text()
            const parser = new DOMParser();
            const XML = parser.parseFromString(respText, "text/html");
            const form = XML.querySelector('[name="magazine_subscribe"]')
            if (form) {
                const token = form.querySelector('input').value
                await subscribeToMag(host, to_import, token, (i+1), arr.length)
            }
            break
        }
        default:
        {
            updateTooltip('#status-msg', `Failed to fetch the page '${to_import}' (${i+1}/${arr.length})`)
            fail.push(to_import)
            break
        }
        }
    }
    updateTooltip('#status-msg', `Subscription process complete.`)
    addExitButton('subs');
    addLogButton();
}

function toggleLog () {
    const resultsHTML = `
    <br>
    <p style="margin-bottom: 0rem">PASS (successfully subscribed)</p>
    <hr>
    <div id="pass">
    </div>
    <br>
    <p style="margin-bottom: 0rem">FAIL (nonexistent/unfederated magazine or communication error)</p>
    <hr>
    <div id="fail">
    </div>
    `
    if (document.querySelector('#exit-results-log')) {
        document.querySelector('#exit-results-log').remove();
    } else {
        const results = document.createElement('div')
        results.id = 'exit-results-log'
        results.innerHTML = resultsHTML
        results.querySelector('#pass').innerText = pass.join('\n')
        results.querySelector('#fail').innerText = fail.join('\n')
        document.querySelector('#exit-modal').appendChild(results)
        results.scrollIntoView();
    }
}
function addLogButton () {
    const button = document.createElement('button')
    const hr = document.createElement('hr')
    button.id = 'exit-log-button'
    button.innerText = 'Show/hide log'
    document.querySelector('#exit-modal').appendChild(hr)
    document.querySelector('#exit-modal').appendChild(button)
    button.scrollIntoView();
    button.addEventListener('click', () => {
        toggleLog();
    })
}
async function subscribeToMag (host, mag, token, iter, total) {
    let msg
    const suffix = `'${mag}' (${iter}/${total})`
    const pass_msg = 'Subscribed to ' + suffix
    const fail_msg = 'Failed to subscribe to ' + suffix

    try {
        const resp = await fetch(`https://${host}/m/${mag}/subscribe`, {
            signal: AbortSignal.timeout(8000),
            "credentials": "include",
            "headers": {
                "Content-Type": "multipart/form-data; boundary=---------------------------11111111111111111111111111111"
            },
            "body": `-----------------------------11111111111111111111111111111\r\nContent-Disposition: form-data; name="token"\r\n\r\n${token}\r\n-----------------------------11111111111111111111111111111--\r\n`,
            "method": "POST",
            "mode": "cors"
        });
        switch (await resp.status) {
        case 200:
            msg = pass_msg
            pass.push(mag)
            break
        default:
            msg = fail_msg
            fail.push(mag)
            break
        }
    } catch (e) {
        console.log(e)
        msg = fail_msg
        fail.push(mag)
    }
    updateTooltip('#status-msg', msg)
}
function updateTooltip (id, msg) {
    const modal = document.querySelector('#exit-modal');
    modal.querySelector(id).innerText = msg
}
function invalidInstance () {
    updateTooltip('#status-msg', "ERROR: is this a valid kbin/mbin instance?");
    updateTooltip('#destination-instance', "invalid");
    updateTooltip('#destination-user', "not found");
    disableButton(true);
}

makeModal();