feat: renommage de fichiers + gestion des conflits dans le redimensionnement

- Route POST /rename : renomme un fichier CDN avec validation sécurité,
  retourne JSON (name, path, browse_url)
- Route /resize : accepte param `conflict` (backup | overwrite | rename | skip)
  backup  → renomme l'existant en {stem}_bak_{timestamp}{ext} avant création
  rename  → auto-incrémente le nom de la copie ({stem}_1, _2…)
  overwrite → écrase silencieusement
  skip    → ignore (signalé dans les erreurs)
- browse.html : bouton ✏️ par fichier, renommage inline avec Entrée/Échap
- preview_image.html : bouton ✏️ dans l'en-tête, champ inline + redirect
  après validation ; radio segmenté pour la stratégie de conflit
- app.css : styles btn-rename, rename-inline, radio-chips segmentés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 09:26:26 +02:00
parent c503f5e074
commit 14259c59f1
4 changed files with 268 additions and 16 deletions

View file

@ -97,6 +97,20 @@ def _humansize(n: int) -> str:
return f"{n:.1f} To" return f"{n:.1f} To"
def _backup_path(p: Path) -> Path:
ts = datetime.now().strftime("%Y%m%d%H%M%S")
return p.parent / f"{p.stem}_bak_{ts}{p.suffix}"
def _auto_rename(p: Path) -> Path:
i = 1
while True:
candidate = p.parent / f"{p.stem}_{i}{p.suffix}"
if not candidate.exists():
return candidate
i += 1
def _folder_stats(path: Path) -> dict: def _folder_stats(path: Path) -> dict:
files, size = 0, 0 files, size = 0, 0
for f in path.rglob("*"): for f in path.rglob("*"):
@ -472,6 +486,51 @@ def delete_file():
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
# ── Renommage de fichiers ─────────────────────────────────────────────
@app.route("/rename", methods=["POST"])
def rename_file():
redir = _require_admin()
if redir:
return redir
subpath = request.form.get("path", "").strip()
new_name = request.form.get("new_name", "").strip()
if not subpath or not new_name:
return jsonify({"error": "Paramètres manquants"}), 400
if "/" in new_name or "\\" in new_name or new_name in (".", ".."):
return jsonify({"error": "Nom invalide"}), 400
new_name = secure_filename(new_name)
if not new_name:
return jsonify({"error": "Nom invalide après nettoyage"}), 400
target = _safe_path(subpath)
if not target.is_file():
return jsonify({"error": "Fichier introuvable"}), 404
top = Path(subpath).parts[0]
if top in _HIDDEN or top.startswith("."):
return jsonify({"error": "Accès refusé"}), 403
dest = target.parent / new_name
if not dest.is_relative_to(ASSETS_ROOT):
return jsonify({"error": "Destination invalide"}), 400
if dest.exists():
return jsonify({"error": f"« {new_name} » existe déjà"}), 409
target.rename(dest)
new_path = str(dest.relative_to(ASSETS_ROOT))
return jsonify({
"name": new_name,
"path": new_path,
"browse_url": url_for("browse", subpath=new_path),
})
# ── Upload de fichiers ──────────────────────────────────────────────── # ── Upload de fichiers ────────────────────────────────────────────────
@app.route("/upload", methods=["POST"]) @app.route("/upload", methods=["POST"])
@ -540,6 +599,10 @@ def resize_image():
if not sizes or not formats: if not sizes or not formats:
return jsonify({"error": "Tailles ou formats invalides"}), 400 return jsonify({"error": "Tailles ou formats invalides"}), 400
conflict = request.form.get("conflict", "skip")
if conflict not in ("backup", "overwrite", "rename", "skip"):
conflict = "skip"
is_svg = ext == ".svg" is_svg = ext == ".svg"
stem = target.stem stem = target.stem
parent = target.parent parent = target.parent
@ -550,6 +613,24 @@ def resize_image():
out_name = f"{stem}_{size}x{size}.{fmt}" out_name = f"{stem}_{size}x{size}.{fmt}"
out_path = parent / out_name out_path = parent / out_name
out_rel = str(out_path.relative_to(ASSETS_ROOT)) out_rel = str(out_path.relative_to(ASSETS_ROOT))
if out_path.exists():
if conflict == "skip":
errors.append({"name": out_name, "reason": "Fichier existant, ignoré"})
continue
elif conflict == "backup":
try:
bak = _backup_path(out_path)
out_path.rename(bak)
except Exception as exc:
errors.append({"name": out_name, "reason": f"Backup impossible : {exc}"})
continue
elif conflict == "rename":
out_path = _auto_rename(out_path)
out_name = out_path.name
out_rel = str(out_path.relative_to(ASSETS_ROOT))
# conflict == "overwrite" : on continue sans rien faire
try: try:
if fmt == "svg" and is_svg: if fmt == "svg" and is_svg:
shutil.copy2(target, out_path) shutil.copy2(target, out_path)

View file

@ -135,9 +135,11 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.empty { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; } .empty { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; }
/* ── Vues / suppression (browse) ─────────────────────────────────────── */ /* ── Vues / actions (browse) ─────────────────────────────────────────── */
.col-hits { width: 4.5rem; text-align: right; white-space: nowrap; } .col-hits { width: 4.5rem; text-align: right; white-space: nowrap; }
.col-del { width: 2.4rem; text-align: center; } .col-actions { width: 5.5rem; text-align: right; }
.row-actions { display: flex; justify-content: flex-end; align-items: center; gap: .1rem; }
.hits-badge { display: inline-block; font-size: .75rem; border-radius: 10px; padding: .1rem .45rem; font-weight: 600; } .hits-badge { display: inline-block; font-size: .75rem; border-radius: 10px; padding: .1rem .45rem; font-weight: 600; }
.hits-active { background: #dcfce7; color: #15803d; } .hits-active { background: #dcfce7; color: #15803d; }
@ -145,6 +147,21 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.btn-del { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .2rem; line-height: 1; transition: opacity .15s; } .btn-del { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .2rem; line-height: 1; transition: opacity .15s; }
.btn-del:hover { opacity: 1; } .btn-del:hover { opacity: 1; }
.btn-rename { background: none; border: none; cursor: pointer; font-size: .85rem; opacity: .35; padding: .2rem; line-height: 1; transition: opacity .15s; }
.btn-rename:hover { opacity: 1; }
/* ── Rename inline ───────────────────────────────────────────────────── */
.btn-icon { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .15rem; line-height: 1; transition: opacity .15s; }
.btn-icon:hover { opacity: 1; }
.rename-inline { display: flex; align-items: center; gap: .4rem; padding: .4rem 0; flex-wrap: wrap; }
.rename-input { border: 1px solid var(--blue); border-radius: 5px; padding: .3rem .6rem; font-size: .88rem; outline: none; min-width: 200px; }
.rename-input:focus { box-shadow: 0 0 0 2px rgba(26,107,191,.18); }
.btn-rename-ok { background: none; border: none; cursor: pointer; color: #15803d; font-size: 1rem; padding: .2rem .4rem; border-radius: 4px; }
.btn-rename-ok:hover { background: #dcfce7; }
.btn-rename-cancel { background: none; border: none; cursor: pointer; color: #b91c1c; font-size: 1rem; padding: .2rem .4rem; border-radius: 4px; }
.btn-rename-cancel:hover { background: #fef2f2; }
.rename-error { font-size: .8rem; color: #b91c1c; font-style: italic; }
/* ── Upload ───────────────────────────────────────────────────────── */ /* ── Upload ───────────────────────────────────────────────────────── */
.upload-card h2 { margin-bottom: .8rem; } .upload-card h2 { margin-bottom: .8rem; }
@ -175,6 +192,11 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.chip--disabled { cursor: not-allowed; opacity: .45; } .chip--disabled { cursor: not-allowed; opacity: .45; }
.chip--disabled span { background: #f3f4f6; color: #9ca3af; } .chip--disabled span { background: #f3f4f6; color: #9ca3af; }
.resize-chips--radio { gap: 0; }
.resize-chips--radio .chip span { border-radius: 0; border-right-width: 0; }
.resize-chips--radio .chip:first-child span { border-radius: 20px 0 0 20px; }
.resize-chips--radio .chip:last-child span { border-radius: 0 20px 20px 0; border-right-width: 2px; }
.resize-actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } .resize-actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.resize-hint { font-size: .82rem; color: var(--muted); font-style: italic; } .resize-hint { font-size: .82rem; color: var(--muted); font-style: italic; }

View file

@ -26,7 +26,7 @@
<th class="col-size">Taille</th> <th class="col-size">Taille</th>
<th class="col-date">Modifié le</th> <th class="col-date">Modifié le</th>
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %} {% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
<th class="col-del"></th> <th class="col-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -86,12 +86,17 @@
{% endif %} {% endif %}
</td> </td>
{% endif %} {% endif %}
<td class="col-del"> <td class="col-actions">
{% if not e.is_dir %} {% if not e.is_dir %}
<form method="post" action="{{ url_for('delete_file') }}"> <div class="row-actions">
<button type="button" class="btn-rename"
data-path="{{ e.path }}" data-name="{{ e.name }}"
title="Renommer">✏️</button>
<form method="post" action="{{ url_for('delete_file') }}" style="display:contents">
<input type="hidden" name="path" value="{{ e.path }}"> <input type="hidden" name="path" value="{{ e.path }}">
<button type="submit" class="btn-del" title="Supprimer du CDN">🗑</button> <button type="submit" class="btn-del" title="Supprimer du CDN">🗑</button>
</form> </form>
</div>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -129,4 +134,69 @@
</script> </script>
</section> </section>
<script>
(function () {
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function doRename(path, newName, nameCell, origHtml) {
if (!newName || newName === path.split('/').pop()) {
nameCell.innerHTML = origHtml;
return;
}
const fd = new FormData();
fd.append('path', path);
fd.append('new_name', newName);
try {
const resp = await fetch('/rename', { method: 'POST', body: fd });
const data = await resp.json();
if (data.error) {
const inp = nameCell.querySelector('.rename-input');
if (inp) { inp.style.borderColor = '#b91c1c'; inp.title = data.error; }
return;
}
location.reload();
} catch (_) {
nameCell.innerHTML = origHtml;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.btn-rename');
if (!btn) return;
const row = btn.closest('tr');
const nameCell = row.querySelector('.col-name');
const path = btn.dataset.path;
const name = btn.dataset.name;
const origHtml = nameCell.innerHTML;
const thumb = nameCell.querySelector('.thumb-sm');
const thumbHtml = thumb ? thumb.outerHTML : '';
nameCell.innerHTML =
thumbHtml +
'<input type="text" class="rename-input" value="' + escHtml(name) + '">' +
'<button class="btn-rename-ok" title="Valider"></button>' +
'<button class="btn-rename-cancel" title="Annuler"></button>' +
'<span class="rename-error"></span>';
const inp = nameCell.querySelector('.rename-input');
inp.focus(); inp.select();
nameCell.querySelector('.btn-rename-cancel').addEventListener('click', () => {
nameCell.innerHTML = origHtml;
});
nameCell.querySelector('.btn-rename-ok').addEventListener('click', () => {
doRename(path, inp.value.trim(), nameCell, origHtml);
});
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter') doRename(path, inp.value.trim(), nameCell, origHtml);
if (ev.key === 'Escape') nameCell.innerHTML = origHtml;
});
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -7,9 +7,16 @@
<div class="preview-header"> <div class="preview-header">
<a href="{{ url_for('browse', subpath=parent_path) if parent_path else url_for('browse') }}" <a href="{{ url_for('browse', subpath=parent_path) if parent_path else url_for('browse') }}"
class="back-link">← Retour</a> class="back-link">← Retour</a>
<h1>{{ filename }}</h1> <h1 id="preview-title">{{ filename }}</h1>
<button type="button" id="rename-toggle" class="btn-icon" title="Renommer ce fichier">✏️</button>
{% include '_preview_nav.html' %} {% include '_preview_nav.html' %}
</div> </div>
<div id="rename-inline" class="rename-inline" style="display:none">
<input type="text" id="rename-input" class="rename-input" value="{{ filename }}">
<button type="button" id="rename-save" class="btn-rename-ok" title="Valider"></button>
<button type="button" id="rename-cancel" class="btn-rename-cancel" title="Annuler"></button>
<span id="rename-error" class="rename-error"></span>
</div>
<div class="preview-meta"> <div class="preview-meta">
<span>Taille : <strong>{{ filesize }}</strong></span> <span>Taille : <strong>{{ filesize }}</strong></span>
<span>Modifié : <strong>{{ mtime.strftime('%d/%m/%Y %H:%M') }}</strong></span> <span>Modifié : <strong>{{ mtime.strftime('%d/%m/%Y %H:%M') }}</strong></span>
@ -57,6 +64,28 @@
</div> </div>
</div> </div>
<div class="resize-group">
<div class="resize-group-label">Si le fichier existe déjà</div>
<div class="resize-chips resize-chips--radio">
<label class="chip">
<input type="radio" name="conflict" value="backup" checked>
<span>Backup</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="overwrite">
<span>Écraser</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="rename">
<span>Renommer la copie</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="skip">
<span>Ignorer</span>
</label>
</div>
</div>
<div class="resize-actions"> <div class="resize-actions">
<button id="resize-btn" class="btn btn-primary" disabled>Générer les copies</button> <button id="resize-btn" class="btn btn-primary" disabled>Générer les copies</button>
<span class="resize-hint">Les fichiers sont créés dans le même dossier</span> <span class="resize-hint">Les fichiers sont créés dans le même dossier</span>
@ -69,12 +98,60 @@
<script> <script>
(function () { (function () {
/* ── Rename ── */
const RENAME_URL = {{ url_for('rename_file') | tojson }};
const FILE_PATH = {{ subpath | tojson }};
const renameToggle = document.getElementById('rename-toggle');
const renameInline = document.getElementById('rename-inline');
const renameInput = document.getElementById('rename-input');
const renameSave = document.getElementById('rename-save');
const renameCancel = document.getElementById('rename-cancel');
const renameError = document.getElementById('rename-error');
renameToggle.addEventListener('click', () => {
const open = renameInline.style.display !== 'none';
renameInline.style.display = open ? 'none' : 'flex';
if (!open) { renameInput.focus(); renameInput.select(); }
renameError.textContent = '';
});
renameCancel.addEventListener('click', () => {
renameInline.style.display = 'none';
renameError.textContent = '';
});
async function doRename() {
const newName = renameInput.value.trim();
if (!newName) return;
renameSave.disabled = true;
const fd = new FormData();
fd.append('path', FILE_PATH);
fd.append('new_name', newName);
try {
const resp = await fetch(RENAME_URL, { method: 'POST', body: fd });
const data = await resp.json();
if (data.error) { renameError.textContent = data.error; return; }
window.location.href = data.browse_url;
} catch (_) {
renameError.textContent = 'Erreur réseau.';
} finally {
renameSave.disabled = false;
}
}
renameSave.addEventListener('click', doRename);
renameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') doRename();
if (e.key === 'Escape') renameCancel.click();
});
/* ── Resize ── */
const szCbs = document.querySelectorAll('.resize-sz'); const szCbs = document.querySelectorAll('.resize-sz');
const fmtCbs = document.querySelectorAll('.resize-fmt'); const fmtCbs = document.querySelectorAll('.resize-fmt');
const btn = document.getElementById('resize-btn'); const btn = document.getElementById('resize-btn');
const result = document.getElementById('resize-result'); const result = document.getElementById('resize-result');
const PATH = {{ subpath | tojson }}; const RESIZE_URL = {{ url_for('resize_image') | tojson }};
const URL_RESIZE = {{ url_for('resize_image') | tojson }};
function canSubmit() { function canSubmit() {
return Array.from(szCbs).some(c => c.checked) return Array.from(szCbs).some(c => c.checked)
@ -85,8 +162,10 @@
})); }));
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const conflict = document.querySelector('input[name="conflict"]:checked')?.value || 'skip';
const fd = new FormData(); const fd = new FormData();
fd.append('path', PATH); fd.append('path', FILE_PATH);
fd.append('conflict', conflict);
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); }); szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); }); fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
@ -95,7 +174,7 @@
result.style.display = 'none'; result.style.display = 'none';
try { try {
const resp = await fetch(URL_RESIZE, { method: 'POST', body: fd }); const resp = await fetch(RESIZE_URL, { method: 'POST', body: fd });
const data = await resp.json(); const data = await resp.json();
let html = ''; let html = '';