add export data button to user settings, this lets users export their uploads/favorites at will.

This commit is contained in:
2026-05-12 18:49:28 +02:00
parent 2269da314f
commit 173f9f9e56
7 changed files with 190 additions and 4 deletions

View File

@@ -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.';
}
});
}
})(); })();

View File

@@ -207,7 +207,13 @@
"matrix_instructions": "1. Unten ein allgemeines Link-Token generieren.<br>2. <code>!link</code> im Hauptraum senden<br>3. Token in der Bot-DM eingeben", "matrix_instructions": "1. Unten ein allgemeines Link-Token generieren.<br>2. <code>!link</code> im Hauptraum senden<br>3. Token in der Bot-DM eingeben",
"your_token": "Dein Token:", "your_token": "Dein Token:",
"one_time_use": "Einmalig verwendbar.", "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": { "filter": {
"tag_placeholder": "Tag ausschließen", "tag_placeholder": "Tag ausschließen",

View File

@@ -207,7 +207,13 @@
"matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm", "matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm",
"your_token": "Your Token:", "your_token": "Your Token:",
"one_time_use": "Valid for one-time use.", "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": { "filter": {
"tag_placeholder": "Tag to exclude", "tag_placeholder": "Tag to exclude",

View File

@@ -207,7 +207,13 @@
"matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm", "matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm",
"your_token": "Je Token:", "your_token": "Je Token:",
"one_time_use": "Valid for one-time use.", "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": { "filter": {
"tag_placeholder": "Tag om uit te sluiten", "tag_placeholder": "Tag om uit te sluiten",

View File

@@ -206,7 +206,13 @@
"matrix_instructions": "1. Erzeugen Sie unten ein allgemeines Verknüpfungskennzeichen.<br>2. Senden Sie <code>!link</code> im Hauptraum<br>3. Antworten Sie mit Ihrem Kennzeichen in der Bot-Direktnachricht", "matrix_instructions": "1. Erzeugen Sie unten ein allgemeines Verknüpfungskennzeichen.<br>2. Senden Sie <code>!link</code> im Hauptraum<br>3. Antworten Sie mit Ihrem Kennzeichen in der Bot-Direktnachricht",
"your_token": "Ihr Kennzeichen:", "your_token": "Ihr Kennzeichen:",
"one_time_use": "Gültig für den einmaligen Gebrauch.", "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": { "filter": {
"tag_placeholder": "Auszuschließendes Etikett", "tag_placeholder": "Auszuschließendes Etikett",

View File

@@ -58,6 +58,25 @@ export default (router, tpl) => {
}, req) }, 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; return router;

View File

@@ -257,6 +257,35 @@
@endif @endif
</div> </div>
<h2>{{ t('settings.export_data_title') || 'Export Data' }}</h2>
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p>{{ 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.' }}</p>
<div class="setting-item" style="margin-bottom: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="export_uploads" checked>
<span>{{ t('settings.export_uploads') || 'Your Uploads' }}</span>
</label>
</div>
<div class="setting-item" style="margin-bottom: 20px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="export_favorites" checked>
<span>{{ t('settings.export_favorites') || 'Your Favorites' }}</span>
</label>
</div>
<div id="export-progress-container" style="display: none; margin-bottom: 20px;">
<div class="progress-bar-wrapper" style="height: 20px; background: rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; margin-bottom: 5px;">
<div id="export-progress-bar" style="height: 100%; width: 0%; background: var(--accent); transition: width 0.3s;"></div>
</div>
<div id="export-status-text" style="font-size: 0.9em; color: var(--text-muted);">{{ t('settings.export_preparing') || 'Preparing...' }}</div>
</div>
<button type="button" id="btn-start-export" class="button button-primary">
<i class="fa-solid fa-download" style="margin-right: 5px;"></i> {{ t('settings.start_export') || 'Generate Export (ZIP)' }}
</button>
</div>
<h2>{{ t('settings.account') }}</h2> <h2>{{ t('settings.account') }}</h2>
<div class="account-settings-wrapper" <div class="account-settings-wrapper"
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;"> style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
@@ -358,6 +387,7 @@
</div> </div>
@endif @endif
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="/s/js/settings.js?v=@mtime(/public/s/js/settings.js)"></script> <script src="/s/js/settings.js?v=@mtime(/public/s/js/settings.js)"></script>
</div> </div>
</div> </div>