355 lines
14 KiB
Cheetah
355 lines
14 KiB
Cheetah
{{define "upload"}}
|
||
<div class="container">
|
||
<p class="text-muted text-small mb-24"><a href="/dossier/{{.TargetDossier.DossierID}}">← Back to {{.TargetDossier.Name}}</a></p>
|
||
|
||
<h1 class="small">Upload health data</h1>
|
||
<p class="text-muted text-small mb-24">Files are automatically deleted after 7 days</p>
|
||
|
||
<div class="form-group mb-16">
|
||
<label class="form-label">Category</label>
|
||
<select id="category-select" class="form-select" required>
|
||
<option value="" selected disabled>-- Select category --</option>
|
||
<option value="imaging">Medical Imaging (DICOM, X-ray, MRI, CT)</option>
|
||
<option value="labs">Lab Results (Quest, LabCorp, bloodwork)</option>
|
||
<option value="genetics">Genetics (VCF, AncestryDNA, 23andMe)</option>
|
||
<option value="vitals">Vitals (pulse ox, BP, device exports)</option>
|
||
<option value="documents">Documents (clinical notes, reports)</option>
|
||
<option value="other">Other</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="upload-area" id="upload-area">
|
||
<div class="upload-icon">
|
||
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||
</svg>
|
||
</div>
|
||
<p class="upload-text">Click or drag files/folders here</p>
|
||
<p class="upload-hint">DICOM folders, PDF, CSV, VCF, and more</p>
|
||
</div>
|
||
<input type="file" id="file-input" multiple webkitdirectory style="display:none">
|
||
<input type="file" id="file-input-files" multiple style="display:none">
|
||
|
||
<p class="text-muted text-small mt-24 mb-8">Or paste lab results, markdown tables, or other health data:</p>
|
||
<div class="paste-section">
|
||
<textarea id="paste-input" class="form-textarea" rows="5" placeholder="Paste health data here..."></textarea>
|
||
<button type="button" class="btn btn-sm mt-8" id="paste-submit" onclick="submitPaste()">Submit</button>
|
||
</div>
|
||
|
||
{{if .UploadList}}
|
||
<p class="section-label mt-24">Recent uploads</p>
|
||
<div class="file-table">
|
||
{{range .UploadList}}
|
||
<div class="file-row {{if .Deleted}}file-deleted{{end}}">
|
||
<div class="file-info">
|
||
<span class="file-name">{{.FileName}}</span>
|
||
<span class="file-meta">
|
||
{{if and (not .Deleted) (eq .Status "uploaded")}}
|
||
<select class="category-inline" onchange="updateCategory({{.ID}}, this.value)">
|
||
<option value="imaging" {{if eq .Category "imaging"}}selected{{end}}>imaging</option>
|
||
<option value="labs" {{if eq .Category "labs"}}selected{{end}}>labs</option>
|
||
<option value="genetics" {{if eq .Category "genetics"}}selected{{end}}>genetics</option>
|
||
<option value="vitals" {{if eq .Category "vitals"}}selected{{end}}>vitals</option>
|
||
<option value="documents" {{if eq .Category "documents"}}selected{{end}}>documents</option>
|
||
<option value="other" {{if eq .Category "other"}}selected{{end}}>other</option>
|
||
</select>
|
||
{{else}}
|
||
{{.Category}}
|
||
{{end}}
|
||
· {{.SizeHuman}} · {{.UploadedAt}}
|
||
</span>
|
||
</div>
|
||
<div class="file-status">
|
||
{{if .Deleted}}
|
||
<span class="status-deleted">{{.DeletedReason}}</span>
|
||
{{else}}
|
||
{{if ne .Status "uploaded"}}<span class="status-badge">{{.Status}}</span>{{end}}
|
||
{{if .CanUndo}}<button type="button" class="btn-icon" title="Undo import" onclick="undoImport({{.ID}})">↩</button>{{end}}
|
||
<span class="status-expires">Expires {{.ExpiresAt}}</span>
|
||
<button type="button" class="btn-icon" title="Delete" onclick="deleteFile({{.ID}})">×</button>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<div class="empty-state mt-24">
|
||
No files uploaded yet
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
<div class="progress-overlay" id="progress-overlay">
|
||
<div class="progress-modal">
|
||
<p class="progress-label">Importing</p>
|
||
<p id="upload-text"></p>
|
||
<div class="progress-bar-wrap">
|
||
<div class="progress-bar" id="upload-bar"></div>
|
||
</div>
|
||
<p class="progress-detail" id="upload-detail"></p>
|
||
<p class="progress-label" id="process-label" style="margin-top:16px;display:none">Processing</p>
|
||
<div class="progress-track" id="process-track" style="display:none">
|
||
<div class="progress-bar" id="process-bar"></div>
|
||
</div>
|
||
<p id="process-text"></p>
|
||
<p class="progress-detail" id="process-detail"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const area = document.getElementById('upload-area');
|
||
const inputFolder = document.getElementById('file-input');
|
||
const inputFiles = document.getElementById('file-input-files');
|
||
const overlay = document.getElementById('progress-overlay');
|
||
const uploadBar = document.getElementById('upload-bar');
|
||
const uploadText = document.getElementById('upload-text');
|
||
const uploadDetail = document.getElementById('upload-detail');
|
||
const processText = document.getElementById('process-text');
|
||
const processDetail = document.getElementById('process-detail');
|
||
const processLabel = document.getElementById('process-label');
|
||
const processBar = document.getElementById('process-bar');
|
||
const processTrack = document.getElementById('process-track');
|
||
const categorySelect = document.getElementById('category-select');
|
||
const dossierGUID = '{{.TargetDossier.DossierID}}';
|
||
let totalFileCount = 0;
|
||
|
||
area.onclick = e => {
|
||
if (e.shiftKey) { inputFolder.click(); } else { inputFiles.click(); }
|
||
};
|
||
area.ondragover = e => { e.preventDefault(); area.classList.add('dragover'); };
|
||
area.ondragleave = () => area.classList.remove('dragover');
|
||
area.ondrop = async e => {
|
||
e.preventDefault();
|
||
area.classList.remove('dragover');
|
||
const items = e.dataTransfer.items;
|
||
if (items && items.length > 0) {
|
||
overlay.style.display = 'flex';
|
||
uploadBar.style.width = '0%';
|
||
uploadText.textContent = 'Reading files...';
|
||
uploadDetail.textContent = '';
|
||
processText.textContent = '';
|
||
processDetail.textContent = '';
|
||
processLabel.style.display = 'none';
|
||
const files = await getAllFiles(items);
|
||
if (files.length > 0) handleFiles(files);
|
||
else overlay.style.display = 'none';
|
||
}
|
||
};
|
||
inputFolder.onchange = e => handleFiles(Array.from(e.target.files));
|
||
inputFiles.onchange = e => handleFiles(Array.from(e.target.files));
|
||
|
||
async function getAllFiles(items) {
|
||
const files = [];
|
||
const entries = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry) entries.push(entry);
|
||
}
|
||
async function readEntry(entry, path) {
|
||
if (entry.isFile) {
|
||
return new Promise(resolve => {
|
||
entry.file(f => {
|
||
Object.defineProperty(f, 'relativePath', { value: path + f.name });
|
||
files.push(f);
|
||
resolve();
|
||
});
|
||
});
|
||
} else if (entry.isDirectory) {
|
||
const reader = entry.createReader();
|
||
const readAll = () => new Promise(resolve => {
|
||
reader.readEntries(async entries => {
|
||
if (entries.length === 0) { resolve(); return; }
|
||
for (const e of entries) await readEntry(e, path + entry.name + '/');
|
||
await readAll();
|
||
resolve();
|
||
});
|
||
});
|
||
await readAll();
|
||
}
|
||
}
|
||
for (const entry of entries) await readEntry(entry, '');
|
||
return files;
|
||
}
|
||
|
||
async function submitPaste() {
|
||
const text = document.getElementById('paste-input').value.trim();
|
||
if (!text) return;
|
||
const blob = new Blob([text], { type: 'text/plain' });
|
||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
const file = new File([blob], 'paste-' + ts + '.txt', { type: 'text/plain' });
|
||
handleFiles([file]);
|
||
}
|
||
|
||
async function handleFiles(files) {
|
||
if (!files.length) return;
|
||
const category = categorySelect.value || '';
|
||
overlay.style.display = 'flex';
|
||
uploadBar.style.width = '0%';
|
||
uploadText.textContent = '';
|
||
processText.textContent = '';
|
||
processDetail.textContent = '';
|
||
processLabel.style.display = 'none';
|
||
|
||
let geneticsFileId = null;
|
||
let pdfFileIds = [];
|
||
totalFileCount = files.length;
|
||
let completed = 0;
|
||
const concurrency = 10;
|
||
|
||
async function uploadOne(file) {
|
||
const name = file.relativePath || file.webkitRelativePath || file.name;
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
form.append('path', name);
|
||
form.append('category', category);
|
||
try {
|
||
const resp = await fetch('/dossier/' + dossierGUID + '/upload', { method: 'POST', body: form });
|
||
const data = await resp.json();
|
||
if (category === 'genetics' && data.id) geneticsFileId = data.id;
|
||
if (category === 'pdf' && data.id) pdfFileIds.push(data.id);
|
||
} catch (e) { console.error('Upload failed:', e); }
|
||
completed++;
|
||
uploadText.textContent = completed + ' / ' + files.length;
|
||
uploadBar.style.width = (completed / files.length * 100) + '%';
|
||
}
|
||
|
||
// Upload with concurrency limit
|
||
const queue = [...files];
|
||
const workers = [];
|
||
for (let w = 0; w < Math.min(concurrency, queue.length); w++) {
|
||
workers.push((async () => {
|
||
while (queue.length > 0) {
|
||
await uploadOne(queue.shift());
|
||
}
|
||
})());
|
||
}
|
||
await Promise.all(workers);
|
||
|
||
if (geneticsFileId) {
|
||
processText.textContent = 'Processing genetics...';
|
||
processDetail.textContent = 'Analyzing genetic variants...';
|
||
await pollStatus(geneticsFileId);
|
||
overlay.style.display = 'none';
|
||
location.reload();
|
||
return;
|
||
}
|
||
|
||
// PDF: poll for processing completion
|
||
if (pdfFileIds.length > 0) {
|
||
processText.textContent = 'Processing documents...';
|
||
processDetail.textContent = 'OCR + extracting data...';
|
||
processLabel.style.display = '';
|
||
await Promise.all(pdfFileIds.map(id => pollStatus(id)));
|
||
overlay.style.display = 'none';
|
||
location.reload();
|
||
return;
|
||
}
|
||
|
||
// Process all uploaded files in one batch (DICOM only)
|
||
if (category !== 'genetics') {
|
||
fetch('/dossier/' + dossierGUID + '/process-imaging', { method: 'POST' });
|
||
await pollProcessing();
|
||
}
|
||
overlay.style.display = 'none';
|
||
location.reload();
|
||
}
|
||
|
||
async function pollStatus(fileId) {
|
||
const maxAttempts = 60;
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
try {
|
||
const resp = await fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/status');
|
||
const data = await resp.json();
|
||
if (data.status === 'completed' || data.status === 'processed') {
|
||
showToast('Processing complete!', 'success');
|
||
return;
|
||
} else if (data.status === 'failed') {
|
||
showToast('Processing failed: ' + data.details, 'error');
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.error('Status check failed:', e);
|
||
}
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
showToast('Processing timed out', 'error');
|
||
}
|
||
|
||
function showToast(message, type) {
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast toast-' + type;
|
||
toast.textContent = message;
|
||
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:16px 24px;border-radius:8px;color:white;font-weight:500;z-index:9999;animation:slideIn 0.3s ease';
|
||
toast.style.background = type === 'success' ? '#10b981' : '#ef4444';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 5000);
|
||
}
|
||
function updateCategory(fileId, newCategory) {
|
||
const form = new FormData();
|
||
form.append('category', newCategory);
|
||
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/update', { method: 'POST', body: form })
|
||
.then(() => {})
|
||
.catch(e => console.error('Update failed:', e));
|
||
}
|
||
|
||
function deleteFile(fileId) {
|
||
if (!confirm('Delete this file?')) return;
|
||
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/delete', { method: 'POST' })
|
||
.then(() => location.reload())
|
||
.catch(e => console.error('Delete failed:', e));
|
||
}
|
||
|
||
function undoImport(fileId) {
|
||
if (!confirm('Undo this import? All imported entries will be deleted.')) return;
|
||
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/undo', { method: 'POST' })
|
||
.then(resp => resp.json())
|
||
.then(data => {
|
||
if (data.status === 'ok') showToast('Import undone', 'success');
|
||
else showToast('Undo failed', 'error');
|
||
location.reload();
|
||
})
|
||
.catch(e => { console.error('Undo failed:', e); showToast('Undo failed', 'error'); });
|
||
}
|
||
|
||
async function pollProcessing() {
|
||
while (true) {
|
||
await new Promise(r => setTimeout(r, 300));
|
||
let s;
|
||
try {
|
||
const resp = await fetch('/dossier/' + dossierGUID + '/process-status');
|
||
s = await resp.json();
|
||
} catch (e) { continue; }
|
||
if (s.stage === 'idle' || s.stage === 'starting') continue;
|
||
processLabel.style.display = '';
|
||
processTrack.style.display = '';
|
||
const total = totalFileCount || s.total || 1;
|
||
if (s.stage === 'decrypting') {
|
||
processText.textContent = 'Preparing ' + (s.processed || 0) + ' / ' + total;
|
||
processDetail.textContent = '';
|
||
processBar.style.width = ((s.processed || 0) / total * 100) + '%';
|
||
} else if (s.stage === 'importing') {
|
||
processText.textContent = 'Importing ' + (s.processed || 0) + ' / ' + total;
|
||
const study = (s.study || '').replace(/&/g, '&').replace(/"/g, '"').trim();
|
||
const series = (s.series || '').replace(/&/g, '&').replace(/"/g, '"').trim();
|
||
processDetail.textContent = study + ' — ' + series;
|
||
processBar.style.width = ((s.processed || 0) / total * 100) + '%';
|
||
} else if (s.stage === 'importing_json') {
|
||
processText.textContent = 'Analyzing ' + (s.processed || 0) + ' / ' + (s.total || total);
|
||
processDetail.textContent = 'Extracting lab results...';
|
||
processBar.style.width = ((s.processed || 0) / (s.total || total) * 100) + '%';
|
||
} else if (s.stage === 'normalizing') {
|
||
const nt = s.total || 1;
|
||
processText.textContent = 'Normalizing ' + (s.processed || 0) + ' / ' + nt + ' test names';
|
||
processDetail.textContent = 'Mapping to LOINC codes...';
|
||
processBar.style.width = ((s.processed || 0) / nt * 100) + '%';
|
||
} else if (s.stage === 'done') {
|
||
if (s.error) showToast('Import error: ' + s.error, 'error');
|
||
else if (s.slices > 0) showToast('Imported ' + s.studies + ' studies, ' + s.series_count + ' series, ' + s.slices + ' slices', 'success');
|
||
else if (s.json_imported > 0) showToast('Imported ' + s.json_imported + ' files', 'success');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{{end}}
|