Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Dorifor committed Nov 23, 2024
0 parents commit 13a0c5f
Show file tree
Hide file tree
Showing 7 changed files with 680 additions and 0 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Undiscord Package Util

Easily export desired channel and messages IDs from your Discord personal data package from your browser.

> Nothing is sent anywhere, everything is done inside YOUR browser, your data stays on your computer and your computer only.
![screenshot](screenshot.png)

## Credits
- [zip.js](https://gildas-lormeau.github.io/zip.js/) - A JavaScript library to zip and unzip files by [Gildas Lormeau](https://github.com/gildas-lormeau)
72 changes: 72 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Undiscord Package Util</title>
<link rel="stylesheet" href="style.css">
<script src="zip.min.js"></script>
</head>

<body>
<h1>Undiscord Package Util</h1>

<p>A simple tool to parse through your archive and export only the selected channels / groups / private messages. May be useful for people who want to make data deletion requests to Discord's support team.</p>

<p><i><b>Nothing is sent to anyone</b>, it's all in YOUR browser, your data stays on your computer and your computer
only</i>.</p>
<p><i>Most 'Unknown Channels' are from servers you're not on anymore</i>.</p>


<label for="package-file" class="file-input">
<input type="file" name="package" id="package-file" accept=".zip">
<span>Load Archive</span>
</label>

<hr>

<section class="package-channels hidden">
<section class="tips">
<input type="checkbox" id="tip-none" disabled>
<label for="">No channel selected</label>
<input type="checkbox" checked="false" class="partial" id="tip-partial" disabled>
<label for="">Some channels selected</label>
<input type="checkbox" checked id="tip-full" disabled>
<label for="">All channels selected</label>
</section>

<details class="root">
<summary>Servers</summary>

<ul class="channels"></ul>
</details>

<details class="root">
<summary>Group Chats</summary>
<ul class="group-chats"></ul>
</details>

<details class="root">
<summary>Direct Messages</summary>
<ul class="direct-messages"></ul>
</details>

<hr>
</section>

<section class="export hidden">
<input type="checkbox" name="export-channels" id="export-channels">
<label for="export-channels">Only export channels</label>
<button id="export-button">Export</button>
<button id="download-button">Download</button>
</section>

<textarea readonly rows="32" class="hidden"></textarea>

<script type="module" src="index.js">

</script>
</body>

</html>
311 changes: 311 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
/**
* @typedef Channel
* @type { object }
* @property { string } id - Channel ID
* @property { string } name - Channel Name
*/

/**
* @typedef Group
* @type { object }
* @property { string } id - Group UUID (generated)
* @property { string } name - Group Name
* @property { Array.<Channel> } channels - Channels inside this group
*/

import { _new, _get, _getAll } from './utils.js';

const fileInput = _get('#package-file');
const channelsContainer = _get('.channels');
const directMessagesContainer = _get('.direct-messages');
const groupChatsContainer = _get('.group-chats');

_get('#export-button').addEventListener('click', exportChannelsAndMessages);

fileInput.addEventListener('change', onFilePicked);

/** @type { Array } */
let archiveRoot;

/** @type { Set.<string> } */
const channelsToDelete = new Set();

async function onFilePicked() {
let channelsList;

try {
channelsList = await getChannelsListFromArchive();
channelsList.sort((a, b) => a.name.localeCompare(b.name));
showFilePickerFeedback(true);
} catch (error) {
console.error("Archive Loading Error: ", error);
showFilePickerFeedback(false);
return;
}

populateChannelsList(channelsList)
_get('section.package-channels').classList.remove('hidden');
}

/**
* Get raw channels list from archive
* @param {File} file
* @returns { { id: string, name: string }[] }
*/
async function getChannelsListFromArchive() {
const file = fileInput.files[0];
archiveRoot = await (new zip.ZipReader(new zip.BlobReader(file))).getEntries();
const channelsListFile = archiveRoot.find(file => file.filename === 'messages/index.json');
let channelsList = JSON.parse(await channelsListFile.getData(new zip.TextWriter()));
return Object.entries(channelsList).map(channel => { return { id: 'c' + channel[0], name: channel[1] } })
}

/**
* Show visual feedback for success or fail of file load
* @param {boolean} isSuccess has file loaded successfully
* @returns { null } nothing
*/
function showFilePickerFeedback(isSuccess) {
const fileInputLabel = _get('label.file-input');
if (!isSuccess) {
fileInputLabel.classList.remove('loaded')
fileInputLabel.classList.add('error');
fileInputLabel.querySelector('span').textContent = "Loading Error";
return;
}

fileInputLabel.classList.remove('error')
fileInputLabel.classList.add('loaded');
fileInputLabel.querySelector('span').textContent = "Archive Loaded";
}

/**
* @param { Group } group
* @returns { boolean }
*/
function isGroupChat(group) {
return group.channels.length == 1 && group.channels[0].name == group.name;
}

function populateChannelsList(channels) {
/** @type { Group[] } */
let groupedChannels = Object.groupBy(channels, ({ name }) => {
if (name.includes('Direct Message with')) {
return "Direct Messages"
} else {
return name.split(' in ').at(-1)
}
});

groupedChannels = Object.entries(groupedChannels).map(group => { return { id: 'g' + self.crypto.randomUUID(), name: group[0], channels: group[1] } });
groupedChannels.sort((a, b) => a.name.localeCompare(b.name));

groupedChannels.forEach(group => {
if (isGroupChat(group)) {
addChannelCheckbox(group.channels[0], groupChatsContainer);
return;
}

group.channels.sort((a, b) => a.name.localeCompare(b.name));

if (group.name == 'Direct Messages') {
for (let channel of group.channels)
addChannelCheckbox(channel, directMessagesContainer);
return;
}

const details = _new('details', { parent: channelsContainer });
const summary = _new('summary', { parent: details }, `${group.name} (${group.channels.length})`);

const selectAllDiv = _new('.select-all', { parent: summary, parentPosition: 'afterbegin' });
_new('input', {
parent: selectAllDiv, attr: { type: 'checkbox', id: group.id }, events: {
click: e => {
if (e.target.checked)
selectAllChannelsOfGroup(group);
else
unselectAllChannelsOfGroup(group);
}
}
});
_new('label', { parent: selectAllDiv, attr: { for: group.id, title: 'Select All' } });

const list = _new('ul', { parent: details });

group.channels.forEach(channel => {
addChannelCheckbox(channel, list, group);
});
});
}

/**
* @param {string} channelId
*/
function addChannelToDelete(channelId) {
channelsToDelete.add(channelId);

_get('section.export').classList.remove('hidden');
_get('section.export + textarea').classList.add('hidden');
}

/**
* @param {string} channelId
*/
function removeChannelToDelete(channelId) {
channelsToDelete.delete(channelId);

if (channelsToDelete.length === 0)
_get('section.export').classList.add('hidden');


_get('section.export + textarea').classList.add('hidden');
}

/**
* @param {Group} group the group containing the channels to be selected
*/
function selectAllChannelsOfGroup(group) {
for (let channel of group.channels) {
const channelCheckbox = _get(`#${channel.id}`);
channelCheckbox.checked = true;
channelCheckbox.dispatchEvent(new Event('change'));
}
}

/**
* @param {Group} group the group containing the channels to be unselected
*/
function unselectAllChannelsOfGroup(group) {
for (let channel of group.channels) {
const channelCheckbox = _get(`#${channel.id}`);
channelCheckbox.checked = false;
channelCheckbox.dispatchEvent(new Event('change'));
}
}

/**
* @param {Group} group
*/
function updateGroupCheckbox(group) {
const groupCheckbox = _get(`#${group.id}`);

const checkedChannels = group.channels.filter(channel => _get(`#${channel.id}`).checked)

if (checkedChannels.length == 0) {
groupCheckbox.checked = false;
groupCheckbox.classList.remove('partial');
}
else if (checkedChannels.length == group.channels.length) {
groupCheckbox.checked = true;
groupCheckbox.classList.remove('partial');
}
else {
groupCheckbox.checked = false;
groupCheckbox.classList.add('partial');
}
}

/**
* Add a new checkbox element to the specified parent
* @param { Channel } channel
* @param { HTMLElement } parent
* @param { Group } group
*/
function addChannelCheckbox(channel, parent, group = null) {
const newListItem = _new('li', { parent: parent });

_new(
'input',
{
attr: {
type: 'checkbox',
id: channel.id
},
parent: newListItem,
events: {
change: (e) => {
if (e.target.checked)
addChannelToDelete(channel.id);
else
removeChannelToDelete(channel.id);

if (group)
updateGroupCheckbox(group);
}
}
}
);

let channelName = channel.name.trim();
if (group)
channelName = channel.name.replace(`in ${group.name}`, '').trim();

const isUnknown = channelName == "Unknown channel";

_new(
'label',
{
attr: { for: channel.id },
parent: newListItem,
classNames: isUnknown ? ['unknown'] : []
},
channelName
);
}

/**
* Export channel IDs and Message IDs formatted as needed
*/
async function exportChannelsAndMessages() {
const onlyExportChannels = _get('#export-channels').checked;
const channels = Array.from(channelsToDelete);

_get('textarea').textContent = '';
_get('#download-button').addEventListener('click', downloadExport);

if (onlyExportChannels) {
_get('textarea').textContent = channels.map(channel => channel.slice(1)).join(', ');
_get('section.export + textarea').classList.remove('hidden');
return;
}

for (let channel of channels) {
_get('textarea').textContent += `${channel.slice(1)}:\n`;
_get('textarea').textContent += (await getChannelMessagesIds(channel)).join(', ') + '\n\n';
}

_get('section.export + textarea').classList.remove('hidden');
}

function downloadExport() {
const textFile = new File([_get('textarea').textContent], 'undiscord.txt', {
type: 'text/plain'
});

const url = URL.createObjectURL(textFile)

const link = _new('a', {
attr: {
href: url,
download: textFile.name,
target: '_blank',
parent: document.body
}
});

link.click();
link.remove();
window.URL.revokeObjectURL(url);
}

/**
* Return an array of messages IDs for this channel
* @param {Channel} channel
* @returns { int[] }
*/
async function getChannelMessagesIds(channel) {
console.log(`messages/${channel}/messages.json`);
const channelMessagesFile = archiveRoot.find(file => file.filename === `messages/${channel}/messages.json`);
let messagesList = JSON.parse(await channelMessagesFile.getData(new zip.TextWriter()));
return messagesList.map(message => message['ID'].toString());
}
Binary file added screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 13a0c5f

Please sign in to comment.