diff --git a/public/s/js/settings.js b/public/s/js/settings.js
index 8d7b589..97c7b9f 100644
--- a/public/s/js/settings.js
+++ b/public/s/js/settings.js
@@ -1397,4 +1397,117 @@
});
}
+ // ==== Export Data Logic ====
+ const btnStartExport = document.getElementById('btn-start-export');
+ const exportProgressContainer = document.getElementById('export-progress-container');
+ const exportProgressBar = document.getElementById('export-progress-bar');
+ const exportStatusText = document.getElementById('export-status-text');
+ const chkExportUploads = document.getElementById('export_uploads');
+ const chkExportFavorites = document.getElementById('export_favorites');
+
+ if (btnStartExport) {
+ btnStartExport.addEventListener('click', async () => {
+ const exportUploads = chkExportUploads.checked;
+ const exportFavorites = chkExportFavorites.checked;
+
+ if (!exportUploads && !exportFavorites) {
+ alert('Please select at least one option to export.');
+ return;
+ }
+
+ btnStartExport.disabled = true;
+ exportProgressContainer.style.display = 'block';
+ exportProgressBar.style.width = '0%';
+ exportStatusText.textContent = 'Fetching data list...';
+
+ try {
+ const res = await fetch('/settings/export-data');
+ const data = await res.json();
+
+ let filesToDownload = [];
+ if (exportUploads) {
+ filesToDownload = filesToDownload.concat(data.uploads.map(f => ({ ...f, exportType: 'uploads' })));
+ }
+ if (exportFavorites) {
+ filesToDownload = filesToDownload.concat(data.favorites.map(f => ({ ...f, exportType: 'favorites' })));
+ }
+
+ if (filesToDownload.length === 0) {
+ alert('No data found to export.');
+ btnStartExport.disabled = false;
+ exportProgressContainer.style.display = 'none';
+ return;
+ }
+
+ const zip = new JSZip();
+ const uploadsFolder = zip.folder("uploads");
+ const favoritesFolder = zip.folder("favorites");
+
+ const metadata = {
+ exported_at: new Date().toISOString(),
+ user: window.f0ckSession?.user,
+ uploads: exportUploads ? data.uploads : [],
+ favorites: exportFavorites ? data.favorites : []
+ };
+ zip.file("metadata.json", JSON.stringify(metadata, null, 2));
+
+ const total = filesToDownload.length;
+ let completed = 0;
+
+ const downloadFile = async (fileInfo) => {
+ const folder = fileInfo.exportType === 'uploads' ? uploadsFolder : favoritesFolder;
+
+ if (fileInfo.mime === 'video/youtube') {
+ const ytId = fileInfo.dest.replace(/^yt:/, '');
+ folder.file(`${fileInfo.id}_youtube_${ytId}.txt`, `https://www.youtube.com/watch?v=${ytId}`);
+ completed++;
+ const percent = Math.round((completed / total) * 100);
+ exportProgressBar.style.width = percent + '%';
+ exportStatusText.textContent = `Processing files: ${completed} / ${total}`;
+ return;
+ }
+
+ try {
+ const url = (window.f0ckMediaBase || '/b') + '/' + fileInfo.dest;
+ const response = await fetch(url);
+ const blob = await response.blob();
+ folder.file(`${fileInfo.id}_${fileInfo.dest}`, blob);
+ } catch (err) {
+ console.error('Failed to download file:', fileInfo.id, err);
+ } finally {
+ completed++;
+ const percent = Math.round((completed / total) * 100);
+ exportProgressBar.style.width = percent + '%';
+ exportStatusText.textContent = `Processing files: ${completed} / ${total}`;
+ }
+ };
+
+ // Download in batches to avoid overwhelming the browser/server
+ const batchSize = 5;
+ for (let i = 0; i < filesToDownload.length; i += batchSize) {
+ const batch = filesToDownload.slice(i, i + batchSize);
+ await Promise.all(batch.map(downloadFile));
+ }
+
+ exportStatusText.textContent = 'Generating ZIP file...';
+ const content = await zip.generateAsync({ type: 'blob' });
+
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(content);
+ link.download = `f0ckm_export_${new Date().toISOString().split('T')[0]}.zip`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ exportStatusText.textContent = 'Export complete!';
+ btnStartExport.disabled = false;
+ } catch (err) {
+ console.error('Export failed:', err);
+ alert('Export failed. See console for details.');
+ btnStartExport.disabled = false;
+ exportStatusText.textContent = 'Export failed.';
+ }
+ });
+ }
+
})();
diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json
index 9552ee2..2a156f0 100644
--- a/src/inc/locales/de.json
+++ b/src/inc/locales/de.json
@@ -207,7 +207,13 @@
"matrix_instructions": "1. Unten ein allgemeines Link-Token generieren.
2. !link im Hauptraum senden
3. Token in der Bot-DM eingeben",
"your_token": "Dein Token:",
"one_time_use": "Einmalig verwendbar.",
- "generate_token": "Link-Token generieren"
+ "generate_token": "Link-Token generieren",
+ "export_data_title": "Daten exportieren",
+ "export_data_desc": "Lade eine Kopie deiner Daten herunter. Dieser Vorgang findet vollständig in deinem Browser statt.",
+ "export_uploads": "Meine Uploads",
+ "export_favorites": "Meine Favoriten",
+ "export_preparing": "Wird vorbereitet...",
+ "start_export": "Export generieren (ZIP)"
},
"filter": {
"tag_placeholder": "Tag ausschließen",
diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json
index d557c55..8f62c09 100644
--- a/src/inc/locales/en.json
+++ b/src/inc/locales/en.json
@@ -207,7 +207,13 @@
"matrix_instructions": "1. Generate a generic link token below.
2. Send !link in the general
3. Reply with your token in the bot dm",
"your_token": "Your Token:",
"one_time_use": "Valid for one-time use.",
- "generate_token": "Generate Link Token"
+ "generate_token": "Generate Link Token",
+ "export_data_title": "Export Data",
+ "export_data_desc": "Download a copy of your data. This process happens entirely in your browser.",
+ "export_uploads": "My Uploads",
+ "export_favorites": "My Favorites",
+ "export_preparing": "Preparing...",
+ "start_export": "Generate Export (ZIP)"
},
"filter": {
"tag_placeholder": "Tag to exclude",
diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json
index cd0038e..a8ab557 100644
--- a/src/inc/locales/nl.json
+++ b/src/inc/locales/nl.json
@@ -207,7 +207,13 @@
"matrix_instructions": "1. Generate a generic link token below.
2. Send !link in the general
3. Reply with your token in the bot dm",
"your_token": "Je Token:",
"one_time_use": "Valid for one-time use.",
- "generate_token": "Koppelingstoken Genereren"
+ "generate_token": "Koppelingstoken Genereren",
+ "export_data_title": "Gegevens exporteren",
+ "export_data_desc": "Download een kopie van je gegevens. Dit proces vindt volledig plaats in je browser om je privacy te beschermen en serverbronnen te besparen.",
+ "export_uploads": "Jouw uploads",
+ "export_favorites": "Jouw favorieten",
+ "export_preparing": "Voorbereiden...",
+ "start_export": "Export genereren (ZIP)"
},
"filter": {
"tag_placeholder": "Tag om uit te sluiten",
diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json
index b571acb..ccfa187 100644
--- a/src/inc/locales/zange.json
+++ b/src/inc/locales/zange.json
@@ -206,7 +206,13 @@
"matrix_instructions": "1. Erzeugen Sie unten ein allgemeines Verknüpfungskennzeichen.
2. Senden Sie !link im Hauptraum
3. Antworten Sie mit Ihrem Kennzeichen in der Bot-Direktnachricht",
"your_token": "Ihr Kennzeichen:",
"one_time_use": "Gültig für den einmaligen Gebrauch.",
- "generate_token": "Verknüpfungskennzeichen erzeugen"
+ "generate_token": "Verknüpfungskennzeichen erzeugen",
+ "export_data_title": "Daten hinaustransportieren",
+ "export_data_desc": "Laden Sie eine Kopie Ihrer Daten hinunter. Dieser Vorgang findet vollumfänglich in Ihrem Brauser statt.",
+ "export_uploads": "Meine Aufladungen",
+ "export_favorites": "Meine Favorisierungen",
+ "export_preparing": "Vorbereitung wird getroffen...",
+ "start_export": "Paket schnüren"
},
"filter": {
"tag_placeholder": "Auszuschließendes Etikett",
diff --git a/src/inc/routes/settings.mjs b/src/inc/routes/settings.mjs
index cb7521e..2e5a7ec 100644
--- a/src/inc/routes/settings.mjs
+++ b/src/inc/routes/settings.mjs
@@ -58,6 +58,25 @@ export default (router, tpl) => {
}, req)
});
});
+ group.get('/export-data', auth, async (req, res) => {
+ const uploads = await db`
+ select id, dest, mime from items
+ where username = ${req.session.user} and active = true
+ order by id desc
+ `;
+
+ const favorites = await db`
+ select i.id, i.dest, i.mime from items i
+ join favorites f on f.item_id = i.id
+ where f.user_id = ${+req.session.id} and i.active = true
+ order by f.item_id desc
+ `;
+
+ res.reply({
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ uploads, favorites })
+ });
+ });
});
return router;
diff --git a/views/settings.html b/views/settings.html
index 500d9f4..b19d240 100644
--- a/views/settings.html
+++ b/views/settings.html
@@ -257,6 +257,35 @@
@endif
+
{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}
+ +