feat: Ordner als ZIP herunterladen + Share-Status visuell anzeigen
Ordner-Download: - Ordner koennen jetzt als ZIP heruntergeladen werden (rekursiv mit allen Unterordnern und Dateien) - ZIP-Icon statt Download-Icon bei Ordnern in der Dateiliste - Backend erstellt ZIP mit ZIP_DEFLATED und allowZip64 fuer grosse Ordner Share-Status visuell: - Share-Button zeigt gruenes Personen-Icon (pi-users) wenn die Datei/ der Ordner bereits Freigaben hat (Links oder Benutzer-Berechtigungen) - Normales Share-Icon (pi-share-alt) wenn keine Freigaben existieren - Backend liefert has_shares und has_permissions pro Datei mit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import request, jsonify, send_file, current_app
|
from flask import request, jsonify, send_file, current_app, Response
|
||||||
|
|
||||||
from app.api import api_bp
|
from app.api import api_bp
|
||||||
from app.api.auth import token_required
|
from app.api.auth import token_required
|
||||||
@@ -74,7 +77,12 @@ def list_files():
|
|||||||
File.parent_id.is_(None)
|
File.parent_id.is_(None)
|
||||||
).order_by(File.is_folder.desc(), File.name).all()
|
).order_by(File.is_folder.desc(), File.name).all()
|
||||||
|
|
||||||
result = [f.to_dict() for f in files]
|
result = []
|
||||||
|
for f in files:
|
||||||
|
d = f.to_dict()
|
||||||
|
d['has_shares'] = ShareLink.query.filter_by(file_id=f.id).count() > 0
|
||||||
|
d['has_permissions'] = FilePermission.query.filter_by(file_id=f.id).count() > 0
|
||||||
|
result.append(d)
|
||||||
for f in shared:
|
for f in shared:
|
||||||
d = f.to_dict()
|
d = f.to_dict()
|
||||||
d['shared'] = True
|
d['shared'] = True
|
||||||
@@ -240,8 +248,9 @@ def download_file(file_id):
|
|||||||
f, err = _get_file_or_403(file_id, user, 'read')
|
f, err = _get_file_or_403(file_id, user, 'read')
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
if f.is_folder:
|
if f.is_folder:
|
||||||
return jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400
|
return _download_folder_as_zip(f)
|
||||||
|
|
||||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||||
if not filepath.exists():
|
if not filepath.exists():
|
||||||
@@ -251,6 +260,36 @@ def download_file(file_id):
|
|||||||
download_name=f.name)
|
download_name=f.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_folder_as_zip(folder):
|
||||||
|
"""Stream a folder as ZIP download."""
|
||||||
|
upload_path = Path(current_app.config['UPLOAD_PATH'])
|
||||||
|
|
||||||
|
def _add_folder_to_zip(zf, folder_obj, prefix):
|
||||||
|
children = File.query.filter_by(parent_id=folder_obj.id).all()
|
||||||
|
for child in children:
|
||||||
|
if child.is_folder:
|
||||||
|
_add_folder_to_zip(zf, child, f'{prefix}{child.name}/')
|
||||||
|
else:
|
||||||
|
filepath = upload_path / str(child.owner_id) / child.storage_path
|
||||||
|
if filepath.exists():
|
||||||
|
zf.write(str(filepath), f'{prefix}{child.name}')
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||||
|
tmp_path = tmp.name
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
|
||||||
|
_add_folder_to_zip(zf, folder, '')
|
||||||
|
|
||||||
|
return send_file(tmp_path, mimetype='application/zip', as_attachment=True,
|
||||||
|
download_name=f'{folder.name}.zip')
|
||||||
|
finally:
|
||||||
|
# Cleanup after response is sent (Flask handles this)
|
||||||
|
import atexit
|
||||||
|
atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
# --- Rename / Move ---
|
# --- Rename / Move ---
|
||||||
|
|
||||||
@api_bp.route('/files/<int:file_id>', methods=['PUT'])
|
@api_bp.route('/files/<int:file_id>', methods=['PUT'])
|
||||||
|
|||||||
@@ -78,14 +78,16 @@
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<Button
|
<Button
|
||||||
v-if="!data.is_folder"
|
:icon="data.is_folder ? 'pi pi-file-zip' : 'pi pi-download'"
|
||||||
icon="pi pi-download"
|
|
||||||
text rounded size="small"
|
text rounded size="small"
|
||||||
|
:title="data.is_folder ? 'Als ZIP herunterladen' : 'Herunterladen'"
|
||||||
@click.stop="downloadFile(data)"
|
@click.stop="downloadFile(data)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-share-alt"
|
:icon="(data.has_shares || data.has_permissions) ? 'pi pi-users' : 'pi pi-share-alt'"
|
||||||
text rounded size="small"
|
text rounded size="small"
|
||||||
|
:severity="(data.has_shares || data.has_permissions) ? 'success' : undefined"
|
||||||
|
:title="(data.has_shares || data.has_permissions) ? 'Freigaben verwalten' : 'Teilen'"
|
||||||
@click.stop="openShare(data)"
|
@click.stop="openShare(data)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user