Rocket Loader (Cloudflare) reecrit les balises script inline en type=...-text/javascript et les execute de facon differee, ce qui casse l'enregistrement des composants Alpine et l'init des pages lors d'un acces direct via le tunnel (ex. /settings ne se chargeait pas au complet). Ajout de data-cfasync=false sur les scripts inline des pages completes et des partiels du shell (settings, accounts, trash, workspace, board, gitea_workspace, welcome, import, page_editor_collection/embed, _database_table_scripts, _notification_bell, public_page, local_workspace). Les fragments charges via HTMX restent inchanges.
374 lines
19 KiB
HTML
374 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr" data-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Importer — FlowDeck</title>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
|
<style>
|
|
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
|
*{margin:0;padding:0;box-sizing:border-box;}
|
|
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:32px 20px;}
|
|
.wrap{width:860px;max-width:96vw;margin:0 auto;}
|
|
.brand{font-size:15px;font-weight:700;margin-bottom:22px;display:flex;align-items:center;gap:8px;color:var(--dim);}
|
|
.brand a{color:var(--dim);text-decoration:none;}
|
|
.brand a:hover{color:var(--text);}
|
|
h1{font-size:24px;font-weight:700;margin-bottom:6px;}
|
|
.sub{color:var(--dim);font-size:14px;margin-bottom:24px;line-height:1.5;}
|
|
.panel{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:22px;margin-bottom:18px;}
|
|
.label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-bottom:10px;}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
|
|
.src{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s;}
|
|
.src:hover{border-color:var(--accent);}
|
|
.src.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
|
|
.src .t{font-size:13px;font-weight:600;}
|
|
.src .d{font-size:11px;color:var(--dim);margin-top:3px;line-height:1.35;}
|
|
.drop{border:2px dashed var(--border);border-radius:12px;padding:26px;text-align:center;color:var(--dim);cursor:pointer;transition:border-color .15s,background .15s;}
|
|
.drop:hover,.drop.over{border-color:var(--accent);background:rgba(35,131,226,.06);color:var(--text);}
|
|
.drop strong{color:var(--text);}
|
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
|
|
.btn-primary{background:var(--accent);color:#fff;}
|
|
.btn-primary:hover{background:var(--accent-h);}
|
|
.btn-ghost{background:transparent;color:var(--dim);border:1px solid var(--border);}
|
|
.btn-ghost:hover{color:var(--text);}
|
|
.btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
.actions{display:flex;gap:10px;align-items:center;margin-top:16px;flex-wrap:wrap;}
|
|
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dim);margin-top:12px;cursor:pointer;}
|
|
.bar{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-top:16px;}
|
|
.bar>i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s;}
|
|
table{width:100%;border-collapse:collapse;font-size:13px;}
|
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);}
|
|
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;}
|
|
.pill{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--bg3);color:var(--dim);}
|
|
.pill.coll{background:rgba(35,131,226,.16);color:#79b8ff;}
|
|
.pill.ok{background:rgba(15,123,108,.2);color:#4fd1c5;}
|
|
.pill.err{background:rgba(224,62,62,.18);color:#f28b82;}
|
|
.pill.run{background:rgba(217,115,13,.18);color:#e3a15b;}
|
|
select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;}
|
|
.warnings{font-size:12px;color:var(--warn);margin-top:10px;line-height:1.5;}
|
|
.report{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;}
|
|
.stat{background:var(--bg3);border-radius:10px;padding:12px;text-align:center;}
|
|
.stat .n{font-size:22px;font-weight:700;}
|
|
.stat .l{font-size:11px;color:var(--dim);margin-top:2px;}
|
|
.error{color:var(--danger);font-size:13px;margin-top:10px;}
|
|
.x-cloak,[x-cloak]{display:none!important;}
|
|
</style>
|
|
</head>
|
|
<body x-data="importWizard()" x-init="init()">
|
|
<div class="wrap">
|
|
<div class="brand">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
|
|
<a href="/">FlowDeck</a> · Import
|
|
</div>
|
|
<h1>Importer des données</h1>
|
|
<p class="sub">Notes, vaults, bases de données, documents ou signets — choisissez une source ou laissez la détection automatique.</p>
|
|
|
|
<div class="panel">
|
|
<div class="label">1 · Source</div>
|
|
<div class="grid">
|
|
<div class="src" :class="{selected: sourceId===''}" @click="sourceId=''">
|
|
<div class="t">✨ Détection auto</div>
|
|
<div class="d">Reconnaît le format d'après le fichier.</div>
|
|
</div>
|
|
<template x-for="s in sources" :key="s.source_id">
|
|
<div class="src" :class="{selected: sourceId===s.source_id}" @click="sourceId=s.source_id">
|
|
<div class="t" x-text="s.label"></div>
|
|
<div class="d" x-text="s.description"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="label">2 · Fichiers <span style="text-transform:none;font-weight:400">(sélection multiple possible)</span></div>
|
|
<div class="drop" :class="{over: dragOver}"
|
|
@click="$refs.input.click()"
|
|
@dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false"
|
|
@drop.prevent="onDrop($event)">
|
|
<span>Glissez un ou plusieurs fichiers ici ou <strong>cliquez pour parcourir</strong><br><small>Markdown, .zip, .csv, .tsv, .xlsx, .json, .html, .docx, .pdf, .ics, .opml…</small></span>
|
|
</div>
|
|
<input x-ref="input" type="file" multiple style="display:none"
|
|
accept=".md,.markdown,.txt,.zip,.csv,.tsv,.xlsx,.xlsm,.json,.html,.htm,.docx,.docm,.pdf,.ics,.ical,.opml"
|
|
@change="onFiles($event)">
|
|
<template x-if="files.length">
|
|
<table style="margin-top:12px;">
|
|
<thead><tr><th>Fichier</th><th>Taille</th><th>Statut</th><th></th></tr></thead>
|
|
<tbody>
|
|
<template x-for="(f,i) in files" :key="i">
|
|
<tr>
|
|
<td x-text="f.name"></td>
|
|
<td x-text="humanSize(f.size)"></td>
|
|
<td><span class="pill" :class="f.status==='done'?'ok':(f.status==='error'?'err':(f.status==='running'?'run':''))" x-text="f.status"></span></td>
|
|
<td><button class="btn btn-ghost" style="padding:2px 8px" @click="files.splice(i,1)" :disabled="busy">✕</button></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:12px;">
|
|
<label class="check" style="margin:0">Mode de ré-import :
|
|
<select x-model="mode">
|
|
<option value="skip">Ignorer l'existant</option>
|
|
<option value="update">Mettre à jour (re-sync)</option>
|
|
<option value="duplicate">Toujours dupliquer</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-ghost" @click="doPreview()" :disabled="!files.length || busy">Aperçu (dry-run)</button>
|
|
<button class="btn btn-primary" @click="doImport()" :disabled="!files.length || busy">Importer</button>
|
|
</div>
|
|
<div class="bar" x-show="busy || progress>0"><i :style="'width:'+progress+'%'"></i></div>
|
|
<div class="error" x-show="error" x-text="error"></div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="label">Ou · importer une page web (URL)</div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
|
<input type="text" style="flex:1;min-width:260px;" x-model="urlValue" placeholder="https://exemple.com/article" @keydown.enter="doUrl()">
|
|
<button class="btn btn-ghost" @click="doUrl()" :disabled="!urlValue || busy">Importer l'URL</button>
|
|
</div>
|
|
<div class="error" x-show="urlError" x-text="urlError"></div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="label">Ou · importer depuis une forge</div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
|
<select x-model="forgeProvider">
|
|
<option value="gitea">Gitea</option>
|
|
<option value="github">GitHub</option>
|
|
</select>
|
|
<input type="text" style="width:150px;" x-model="forgeOwner" placeholder="owner">
|
|
<input type="text" style="width:150px;" x-model="forgeRepo" placeholder="repo">
|
|
<select x-model="forgeState">
|
|
<option value="all">Toutes</option>
|
|
<option value="open">Ouvertes</option>
|
|
<option value="closed">Fermées</option>
|
|
</select>
|
|
<button class="btn btn-ghost" @click="doForge('issues')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les issues</button>
|
|
<button class="btn btn-ghost" @click="doForge('repo')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les fichiers</button>
|
|
</div>
|
|
<div class="error" x-show="forgeError" x-text="forgeError"></div>
|
|
</div>
|
|
|
|
<div class="panel" x-show="preview" x-cloak>
|
|
<div class="label">Aperçu — <span x-text="preview && preview.source_label"></span></div>
|
|
<table>
|
|
<thead><tr><th>Titre</th><th>Type</th><th>Éléments</th><th>Colonnes</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="(p,i) in (preview ? preview.pages : [])" :key="i">
|
|
<tr>
|
|
<td x-text="p.title"></td>
|
|
<td><span class="pill" :class="{coll: p.type==='collection'}" x-text="p.type==='collection' ? 'database' : 'page'"></span></td>
|
|
<td x-text="p.type==='collection' ? (p.rows+' lignes') : (p.blocks+' blocs')"></td>
|
|
<td>
|
|
<template x-for="col in (p.schema||[])" :key="col.name">
|
|
<span style="display:inline-flex;align-items:center;gap:4px;margin:2px 6px 2px 0;">
|
|
<span x-text="col.name" style="font-size:12px;"></span>
|
|
<select @change="mapping[col.name]=$event.target.value">
|
|
<template x-for="t in types" :key="t">
|
|
<option :value="t" :selected="t===col.type" x-text="t"></option>
|
|
</template>
|
|
</select>
|
|
</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<div class="warnings" x-show="preview && preview.warnings && preview.warnings.length">
|
|
<template x-for="(w,i) in (preview ? preview.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" x-show="report" x-cloak>
|
|
<div class="label">Rapport d'import</div>
|
|
<div class="report">
|
|
<div class="stat"><div class="n" x-text="report ? report.pages_created : 0"></div><div class="l">Pages créées</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? report.pages_updated : 0"></div><div class="l">Mises à jour</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? report.collections_created : 0"></div><div class="l">Collections</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? report.rows_created : 0"></div><div class="l">Lignes</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? report.attachments : 0"></div><div class="l">Fichiers</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? report.skipped : 0"></div><div class="l">Ignorés</div></div>
|
|
<div class="stat"><div class="n" x-text="report ? (report.errors ? report.errors.length : 0) : 0"></div><div class="l">Erreurs</div></div>
|
|
</div>
|
|
<div class="warnings" x-show="report && report.warnings && report.warnings.length">
|
|
<template x-for="(w,i) in (report ? report.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
|
|
</div>
|
|
<div class="warnings" x-show="report && report.errors && report.errors.length">
|
|
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
|
</div>
|
|
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
|
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
|
<a class="btn btn-primary" href="/workspaces">Retour au workspace</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script data-cfasync="false">
|
|
function importWizard() {
|
|
return {
|
|
sources: [],
|
|
sourceId: '',
|
|
files: [],
|
|
dragOver: false,
|
|
mode: 'skip',
|
|
busy: false,
|
|
progress: 0,
|
|
error: '',
|
|
preview: null,
|
|
report: null,
|
|
mapping: {},
|
|
csrf: '',
|
|
urlValue: '',
|
|
urlError: '',
|
|
forgeProvider: 'gitea',
|
|
forgeOwner: '',
|
|
forgeRepo: '',
|
|
forgeState: 'all',
|
|
forgeError: '',
|
|
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
|
async init() {
|
|
try {
|
|
const r = await fetch('/api/import/sources');
|
|
this.sources = (await r.json()).sources || [];
|
|
} catch(e) {}
|
|
try {
|
|
const c = await fetch('/api/csrf-token');
|
|
this.csrf = (await c.json()).csrf_token;
|
|
} catch(e) {}
|
|
},
|
|
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
|
addFiles(list) {
|
|
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
|
this.preview = null; this.report = null; this.error = '';
|
|
},
|
|
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
|
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
|
buildForm(f) {
|
|
const fd = new FormData();
|
|
fd.append('file', f.file);
|
|
if (this.sourceId) fd.append('source', this.sourceId);
|
|
fd.append('mode', this.mode);
|
|
return fd;
|
|
},
|
|
async doPreview() {
|
|
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
|
try {
|
|
let merged = null;
|
|
for (let i=0;i<this.files.length;i++) {
|
|
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
|
const d = await r.json();
|
|
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
|
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
|
merged.pages.push(...(d.pages||[]));
|
|
merged.warnings.push(...(d.warnings||[]));
|
|
this.progress = Math.round(((i+1)/this.files.length)*100);
|
|
}
|
|
this.preview = merged;
|
|
this.mapping = {};
|
|
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
|
} catch(e) { this.error = 'Erreur réseau'; }
|
|
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
|
},
|
|
async importOne(f) {
|
|
f.status = 'running';
|
|
const fd = this.buildForm(f);
|
|
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
|
const big = f.size > 5*1024*1024;
|
|
try {
|
|
if (big) {
|
|
fd.append('async','true');
|
|
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
|
const d = await r.json();
|
|
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
|
return await this.pollJob(d.job_id, f);
|
|
}
|
|
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
|
const d = await r.json();
|
|
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
|
f.status = (d.status==='partial' ? 'partial' : 'done');
|
|
return d;
|
|
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
|
},
|
|
async doImport() {
|
|
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
|
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
|
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
|
for (let i=0;i<this.files.length;i++) {
|
|
const d = await this.importOne(this.files[i]);
|
|
if (d) {
|
|
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
|
aggregate[k] += (d[k]||0);
|
|
aggregate.warnings.push(...(d.warnings||[]));
|
|
aggregate.errors.push(...(d.errors||[]));
|
|
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
|
} else {
|
|
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
|
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
|
}
|
|
this.progress = Math.round(((i+1)/this.files.length)*100);
|
|
}
|
|
this.report = aggregate;
|
|
this.busy = false;
|
|
},
|
|
async doUrl() {
|
|
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
|
try {
|
|
const r = await fetch('/api/import/url', {
|
|
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
|
body: JSON.stringify({url:this.urlValue.trim()})
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
|
this.report = d; this.progress = 100;
|
|
} catch(e) { this.urlError = 'Erreur réseau'; }
|
|
finally { this.busy = false; }
|
|
},
|
|
async doForge(kind) {
|
|
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
|
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
|
try {
|
|
const r = await fetch(endpoint, {
|
|
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
|
body: JSON.stringify({
|
|
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
|
repo:this.forgeRepo.trim(), state:this.forgeState
|
|
})
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
|
this.report = d; this.progress = 100;
|
|
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
|
finally { this.busy = false; }
|
|
},
|
|
async pollJob(jobId, f) {
|
|
for (let i=0;i<600;i++) {
|
|
await new Promise(res => setTimeout(res, 700));
|
|
const r = await fetch('/api/import/jobs/'+jobId);
|
|
const j = await r.json();
|
|
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
|
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
|
}
|
|
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
|
return null;
|
|
},
|
|
downloadReport() {
|
|
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'flowdeck-import-report.json';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|