diff --git a/admin.html b/admin.html index 52dbff7..d324eb9 100644 --- a/admin.html +++ b/admin.html @@ -56,6 +56,7 @@ @@ -206,6 +222,15 @@

โœŽ POST EDITOR

+ +
+ + + +
+
@@ -1008,10 +1033,9 @@
-

โ›‹ BACKUPS & EXPORT

+

โ›‹ BACKUPS & DATA

-
โ–ค
POSTS DATA
@@ -1035,11 +1059,22 @@

โ›‹ FULL EXPORT

-

Download all site data (posts, tracks, settings) as a single ZIP archive.

+

Download all site data as a single ZIP archive.

- + +
+

โฌก IMPORT / RESTORE

+

Upload a previously exported ZIP to restore all JSON data files.

+
+
+ +
+
+ +
+
@@ -1181,6 +1216,155 @@
+ + + + +
+

๐Ÿ“‹ CHANGELOG

+ + +
+

๏ผ‹ ADD ENTRY

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + + +
+ + +
+ +
+
+ + + + +
+

๐Ÿ“ก SITREP โ€” DAILY BRIEFING

+ + +
+
+
TOTAL REPORTS
+
--
+
+
+
LATEST DATE
+
--
+
+
+
STATUS
+
--
+
+
+ + +
+ +
+ + + + + +

โ›‹ ARCHIVE

+
+ +
+
+ + + + +
+

โŸณ DATA SYNC

+ +
+ +
+

๐Ÿšซ CONTRABAND (FMHY)

+
+
ENTRIES: --
+
CATEGORIES: --
+
STARRED: --
+
LAST SYNC: --
+
+ +
+ + +
+

๐Ÿ” RECON (AWESOMELIST)

+
+
SECTORS: --
+
LISTS: --
+
ENTRIES: --
+
LAST SYNC: --
+
+ +
+
+ + +
+ + + + +
+

โฑ CRON JOBS

+ +
+ +
+
+
diff --git a/api/app.py b/api/app.py index 6459121..f0cc7b4 100644 --- a/api/app.py +++ b/api/app.py @@ -24,7 +24,7 @@ ADMIN_PASS = 'HUDAdmin2026!' ARRAY_FILES = { 'posts.json', 'tracks.json', 'navigation.json', 'links.json', - 'managed_services.json', 'messages.json' + 'managed_services.json', 'messages.json', 'changelog.json' } # โ”€โ”€โ”€ JSON Error Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @app.errorhandler(400) @@ -898,14 +898,252 @@ def contraband_search(): # โ”€โ”€โ”€ Run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# โ”€โ”€โ”€ Changelog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€โ”€ Changelog CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @app.route('/api/changelog') def get_changelog(): + return jsonify(load_json('changelog.json')) + +@app.route('/api/changelog', methods=['POST']) +@require_auth +def add_changelog_entry(): try: - with open(os.path.join(DATA_DIR, 'changelog.json'), 'r') as f: - return json.load(f) + d = request.get_json(force=True) + entries = load_json('changelog.json') + if not isinstance(entries, list): + entries = [] + entry = { + 'id': max((e.get('id', 0) for e in entries), default=0) + 1, + 'version': d.get('version', ''), + 'date': d.get('date', datetime.date.today().isoformat()), + 'title': d.get('title', ''), + 'description': d.get('description', ''), + 'changes': d.get('changes', []), + 'type': d.get('type', 'update') + } + entries.insert(0, entry) + save_json('changelog.json', entries) + return jsonify(entry), 201 except Exception as e: - return {"error": str(e)}, 500 + return jsonify({'error': str(e)}), 500 + +@app.route('/api/changelog/', methods=['PUT']) +@require_auth +def update_changelog_entry(entry_id): + try: + d = request.get_json(force=True) + entries = load_json('changelog.json') + for i, e in enumerate(entries): + if e.get('id') == entry_id: + entries[i] = {**e, **d, 'id': entry_id} + save_json('changelog.json', entries) + return jsonify(entries[i]) + abort(404, 'Changelog entry not found') + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/changelog/', methods=['DELETE']) +@require_auth +def delete_changelog_entry(entry_id): + entries = load_json('changelog.json') + entries = [e for e in entries if e.get('id') != entry_id] + save_json('changelog.json', entries) + return jsonify({'ok': True}) + +# โ”€โ”€โ”€ Data Sync (Contraband + Awesomelist) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@app.route('/api/sync/status') +@require_auth +def sync_status(): + """Return last sync times and stats for contraband and awesomelist.""" + result = {} + # Contraband stats + cb_path = DATA_DIR / 'contraband.json' + if cb_path.exists(): + cb_data = _load_contraband() + result['contraband'] = { + 'last_sync': datetime.datetime.fromtimestamp(cb_path.stat().st_mtime).isoformat(), + 'total_entries': cb_data.get('total_entries', 0), + 'total_categories': cb_data.get('total_categories', 0), + 'total_starred': cb_data.get('total_starred', 0), + 'file_size': cb_path.stat().st_size + } + else: + result['contraband'] = {'last_sync': None, 'total_entries': 0, 'total_categories': 0, 'total_starred': 0} + + # Awesomelist stats + al_index = DATA_DIR / 'awesomelist_index.json' + al_dir = DATA_DIR / 'awesomelist' + if al_index.exists(): + al_data = _load_awesomelist_index() or {} + sector_count = len(list(al_dir.glob('sector_*.json'))) if al_dir.exists() else 0 + result['awesomelist'] = { + 'last_sync': datetime.datetime.fromtimestamp(al_index.stat().st_mtime).isoformat(), + 'total_sectors': al_data.get('total_sectors', sector_count), + 'total_lists': al_data.get('total_lists', 0), + 'total_entries': al_data.get('total_entries', 0), + } + else: + result['awesomelist'] = {'last_sync': None, 'total_sectors': 0, 'total_lists': 0, 'total_entries': 0} + + return jsonify(result) + +@app.route('/api/sync/contraband', methods=['POST']) +@require_auth +def sync_contraband(): + """Trigger contraband sync.""" + try: + script = Path(__file__).parent / 'contraband_sync.py' + if not script.exists(): + return jsonify({'error': 'contraband_sync.py not found'}), 500 + result = subprocess.run( + ['python3', str(script)], + capture_output=True, text=True, timeout=300, + cwd=str(script.parent) + ) + if result.returncode == 0: + return jsonify({'status': 'ok', 'message': 'Contraband sync completed', 'log': result.stdout[-500:] if result.stdout else ''}) + else: + return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500 + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Sync timed out (300s)'}), 504 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/sync/awesomelist', methods=['POST']) +@require_auth +def sync_awesomelist(): + """Trigger awesomelist sync.""" + try: + script = Path(__file__).parent / 'awesomelist_sync.py' + if not script.exists(): + return jsonify({'error': 'awesomelist_sync.py not found'}), 500 + result = subprocess.run( + ['python3', str(script)], + capture_output=True, text=True, timeout=600, + cwd=str(script.parent) + ) + if result.returncode == 0: + return jsonify({'status': 'ok', 'message': 'Awesomelist sync completed', 'log': result.stdout[-500:] if result.stdout else ''}) + else: + return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500 + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Sync timed out (600s)'}), 504 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# โ”€โ”€โ”€ Cron Jobs Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +CRON_JOBS = [ + {'id': 'contraband', 'name': 'Contraband Sync', 'pattern': 'contraband_sync', 'schedule_default': '0 3 * * 0'}, + {'id': 'awesomelist', 'name': 'Awesomelist Sync', 'pattern': 'awesomelist_sync', 'schedule_default': '0 4 * * *'}, + {'id': 'sitrep', 'name': 'SITREP Generator', 'pattern': 'sitrep_generator', 'schedule_default': '0 7 * * *'}, +] + +@app.route('/api/crons') +@require_auth +def get_crons(): + """Parse crontab and return status of known cron jobs.""" + try: + raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5) + crontab_lines = raw.stdout.strip().split('\n') if raw.returncode == 0 else [] + except Exception: + crontab_lines = [] + + results = [] + for job in CRON_JOBS: + found = False + enabled = False + schedule = job['schedule_default'] + full_line = '' + for line in crontab_lines: + if job['pattern'] in line: + found = True + full_line = line + stripped = line.lstrip() + enabled = not stripped.startswith('#') + # Parse schedule from the line + parts = stripped.lstrip('#').strip().split(None, 5) + if len(parts) >= 5: + schedule = ' '.join(parts[:5]) + break + results.append({ + 'id': job['id'], + 'name': job['name'], + 'found': found, + 'enabled': enabled, + 'schedule': schedule, + 'full_line': full_line + }) + return jsonify(results) + +@app.route('/api/crons//toggle', methods=['POST']) +@require_auth +def toggle_cron(job_id): + """Enable or disable a cron job by commenting/uncommenting its line.""" + job = next((j for j in CRON_JOBS if j['id'] == job_id), None) + if not job: + return jsonify({'error': 'Unknown cron job'}), 404 + + try: + raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5) + if raw.returncode != 0: + return jsonify({'error': 'Cannot read crontab'}), 500 + lines = raw.stdout.strip().split('\n') + + new_lines = [] + toggled = False + for line in lines: + if job['pattern'] in line: + stripped = line.lstrip() + if stripped.startswith('#'): + # Enable it - remove leading # + new_lines.append(stripped.lstrip('#').strip()) + else: + # Disable it - add # + new_lines.append('# ' + line) + toggled = True + else: + new_lines.append(line) + + if not toggled: + return jsonify({'error': 'Cron job line not found in crontab'}), 404 + + # Write new crontab + new_crontab = '\n'.join(new_lines) + '\n' + proc = subprocess.run(['crontab', '-'], input=new_crontab, capture_output=True, text=True, timeout=5) + if proc.returncode != 0: + return jsonify({'error': 'Failed to write crontab: ' + proc.stderr}), 500 + + return jsonify({'ok': True, 'job_id': job_id}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# โ”€โ”€โ”€ Backup Import โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@app.route('/api/backups/import', methods=['POST']) +@require_auth +def backup_import(): + """Accept a ZIP file and restore JSON data files from it.""" + try: + if 'file' not in request.files: + return jsonify({'error': 'No file uploaded'}), 400 + f = request.files['file'] + if not f.filename.endswith('.zip'): + return jsonify({'error': 'Only ZIP files accepted'}), 400 + + buf = io.BytesIO(f.read()) + restored = [] + with zipfile.ZipFile(buf, 'r') as zf: + for name in zf.namelist(): + if name.endswith('.json') and '/' not in name: + # Only restore top-level JSON files + target = DATA_DIR / name + target.write_bytes(zf.read(name)) + restored.append(name) + + return jsonify({'ok': True, 'restored': restored, 'count': len(restored)}) + except zipfile.BadZipFile: + return jsonify({'error': 'Invalid ZIP file'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + # โ”€โ”€โ”€ Awesome Lists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/js/admin.js b/js/admin.js index 18b8d6c..e900e6a 100644 --- a/js/admin.js +++ b/js/admin.js @@ -235,7 +235,11 @@ const AdminApp = { 'section-dashboard': 'Dashboard', 'section-posts': 'Posts', 'section-editor': 'Editor', + 'section-changelog': 'Changelog', 'section-tracks': 'Tracks', + 'section-sitrep': 'SITREP', + 'section-datasync': 'Data Sync', + 'section-cronjobs': 'Cron Jobs', 'section-settings': 'Settings', 'section-homepage': 'Homepage', 'section-services': 'Services', @@ -256,6 +260,7 @@ const AdminApp = { switch (name) { case 'section-dashboard': this.loadDashboard(); break; case 'section-posts': this.loadPosts(); break; + case 'section-editor': this.loadEditorPostList(); break; case 'section-tracks': this.loadTracks(); break; case 'section-settings': this.loadSettings(); break; case 'section-homepage': this.loadHomepage(); break; @@ -268,6 +273,10 @@ const AdminApp = { case 'section-contact': this.loadContactSettings(); break; case 'section-globe': this.loadGlobe(); break; case 'section-chatai': this.loadChatAI(); break; + case 'section-changelog': this.loadChangelog(); break; + case 'section-sitrep': this.loadSitrep(); break; + case 'section-datasync': this.loadDataSync(); break; + case 'section-cronjobs': this.loadCronJobs(); break; } // Close mobile sidebar @@ -638,6 +647,12 @@ const AdminApp = { const preview = document.getElementById('editorPreview'); if (preview) preview.innerHTML = ''; + // Reset post selector + const sel = document.getElementById('editorPostSelect'); + if (sel) sel.value = ''; + const delBtn = document.getElementById('editorDeleteBtn'); + if (delBtn) delBtn.style.display = 'none'; + // Reset HUD const moodEl = document.getElementById('hudMood'); if (moodEl) moodEl.value = 'focused'; @@ -658,9 +673,6 @@ const AdminApp = { if (bpmEl) bpmEl.value = 80; const threatEl = document.getElementById('hudThreat'); if (threatEl) threatEl.value = 'low'; - - const heading = document.getElementById('editorTitle'); - if (heading) heading.textContent = 'NEW POST'; }, /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ EDITOR TOOLBAR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ @@ -1763,6 +1775,443 @@ const AdminApp = { const el = document.getElementById(id); return el ? el.checked : false; } + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ EDITOR POST LIST โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async loadEditorPostList() { + try { + const res = await fetch(this.API + '/posts', { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load posts'); + const data = await res.json(); + const posts = Array.isArray(data) ? data : (data.posts || []); + posts.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)); + + const sel = document.getElementById('editorPostSelect'); + if (!sel) return; + const currentVal = sel.value; + sel.innerHTML = ''; + posts.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.slug; + opt.textContent = `${p.title || 'Untitled'} (${p.slug})`; + sel.appendChild(opt); + }); + if (currentVal) sel.value = currentVal; + } catch (err) { + console.error('Editor post list error:', err); + } + }, + + async loadPostForEdit(slug) { + if (!slug) { + this.clearEditor(); + return; + } + try { + const res = await fetch(this.API + '/posts/' + encodeURIComponent(slug), { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load post'); + const post = await res.json(); + + document.getElementById('editorPostId').value = post.slug || slug; + document.getElementById('editorTitle').value = post.title || ''; + document.getElementById('editorSlug').value = post.slug || ''; + + if (post.date) { + const dt = new Date(post.date); + document.getElementById('editorDate').value = dt.toISOString().split('T')[0]; + document.getElementById('editorTime').value = dt.toTimeString().slice(0, 5); + } + + document.getElementById('editorTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || ''); + document.getElementById('editorExcerpt').value = post.excerpt || ''; + document.getElementById('editorContent').value = post.content || ''; + + // Operator HUD + const moodEl = document.getElementById('hudMood'); + if (moodEl) moodEl.value = post.mood || 'focused'; + + const hudMap = { hudEnergy: 'energy', hudMotivation: 'motivation', hudFocus: 'focus', hudDifficulty: 'difficulty' }; + Object.entries(hudMap).forEach(([id, key]) => { + const el = document.getElementById(id); + if (el) { + el.value = post[key] || 3; + const valEl = document.getElementById(id + 'Val'); + if (valEl) valEl.textContent = el.value; + } + }); + + const coffeeEl = document.getElementById('hudCoffee'); + if (coffeeEl) { coffeeEl.value = post.coffee || 0; const v = document.getElementById('hudCoffeeVal'); if (v) v.textContent = coffeeEl.value; } + const bpmEl = document.getElementById('hudBPM'); + if (bpmEl) { bpmEl.value = post.bpm || post.heart_rate || 72; const v = document.getElementById('hudBPMVal'); if (v) v.textContent = bpmEl.value; } + const threatEl = document.getElementById('hudThreat'); + if (threatEl) threatEl.value = post.threat_level || 'low'; + + // Show delete button + const delBtn = document.getElementById('editorDeleteBtn'); + if (delBtn) delBtn.style.display = 'inline-block'; + + this.updatePreview(); + this.notify('Post loaded: ' + (post.title || slug)); + } catch (err) { + console.error('Load post for edit error:', err); + this.notify('Failed to load post', 'error'); + } + }, + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ CHANGELOG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async loadChangelog() { + try { + const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load changelog'); + const entries = await res.json(); + const list = document.getElementById('changelogList'); + if (!list) return; + + if (!Array.isArray(entries) || entries.length === 0) { + list.innerHTML = '
No changelog entries yet
'; + return; + } + + const typeColors = { feature: '#00ffc8', fix: '#ffcc00', security: '#ff4444', breaking: '#ff0066', update: '#888' }; + + list.innerHTML = entries.map(e => { + const changes = Array.isArray(e.changes) ? e.changes : []; + const typeColor = typeColors[e.type] || '#888'; + return `
+
+
+ [${(e.type || 'update').toUpperCase()}] + ${this.escapeHtml(e.version || '')} โ€” ${this.escapeHtml(e.title || '')} + ${e.date || ''} +
+
+ + +
+
+ ${e.description ? `

${this.escapeHtml(e.description)}

` : ''} + ${changes.length ? `
    ${changes.map(c => `
  • ${this.escapeHtml(c)}
  • `).join('')}
` : ''} +
`; + }).join(''); + } catch (err) { + console.error('Changelog load error:', err); + this.notify('Failed to load changelog', 'error'); + } + }, + + async saveChangelog() { + const editId = document.getElementById('clEditId').value; + const version = this.getVal('clVersion'); + const date = this.getVal('clDate') || new Date().toISOString().split('T')[0]; + const type = this.getVal('clType') || 'update'; + const title = this.getVal('clTitle'); + const description = this.getVal('clDescription'); + const changesRaw = this.getVal('clChanges'); + const changes = changesRaw.split('\n').map(c => c.trim()).filter(c => c); + + if (!title) { this.notify('Title is required', 'error'); return; } + + const payload = { version, date, type, title, description, changes }; + + try { + let url, method; + if (editId) { + url = this.API + '/changelog/' + editId; + method = 'PUT'; + } else { + url = this.API + '/changelog'; + method = 'POST'; + } + const res = await fetch(url, { method, headers: this.authHeaders(), body: JSON.stringify(payload) }); + if (!res.ok) throw new Error('Save failed'); + this.notify(editId ? 'Entry updated' : 'Entry added'); + this.clearChangelog(); + this.loadChangelog(); + } catch (err) { + console.error('Changelog save error:', err); + this.notify('Failed to save changelog entry', 'error'); + } + }, + + clearChangelog() { + document.getElementById('clEditId').value = ''; + document.getElementById('clVersion').value = ''; + document.getElementById('clDate').value = ''; + document.getElementById('clType').value = 'update'; + document.getElementById('clTitle').value = ''; + document.getElementById('clDescription').value = ''; + document.getElementById('clChanges').value = ''; + }, + + async editChangelog(id) { + try { + const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load'); + const entries = await res.json(); + const entry = entries.find(e => e.id === id); + if (!entry) { this.notify('Entry not found', 'error'); return; } + + document.getElementById('clEditId').value = entry.id; + document.getElementById('clVersion').value = entry.version || ''; + document.getElementById('clDate').value = entry.date || ''; + document.getElementById('clType').value = entry.type || 'update'; + document.getElementById('clTitle').value = entry.title || ''; + document.getElementById('clDescription').value = entry.description || ''; + document.getElementById('clChanges').value = (entry.changes || []).join('\n'); + + window.scrollTo({ top: 0, behavior: 'smooth' }); + } catch (err) { + this.notify('Failed to load entry for editing', 'error'); + } + }, + + async deleteChangelog(id) { + if (!confirm('Delete this changelog entry?')) return; + try { + const res = await fetch(this.API + '/changelog/' + id, { method: 'DELETE', headers: this.authHeaders() }); + if (!res.ok) throw new Error('Delete failed'); + this.notify('Entry deleted'); + this.loadChangelog(); + } catch (err) { + this.notify('Failed to delete entry', 'error'); + } + }, + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ SITREP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async loadSitrep() { + try { + // Load list + const listRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() }); + if (listRes.ok) { + const listData = await listRes.json(); + const dates = listData.dates || []; + + document.getElementById('sitrepTotal').textContent = listData.total || 0; + document.getElementById('sitrepLatestDate').textContent = dates.length ? dates[0].date : 'NONE'; + } + + // Load today's sitrep + const todayRes = await fetch(this.API + '/sitrep', { headers: this.authHeaders() }); + if (todayRes.ok) { + const sitrep = await todayRes.json(); + if (sitrep && !sitrep.error) { + document.getElementById('sitrepStatus').textContent = sitrep.is_today ? 'TODAY โœ“' : 'STALE'; + document.getElementById('sitrepStatus').style.color = sitrep.is_today ? '#00ffc8' : '#ffcc00'; + + const summary = document.getElementById('sitrepTodaySummary'); + if (summary) { + summary.style.display = 'block'; + document.getElementById('sitrepHeadline').textContent = sitrep.headline || ''; + document.getElementById('sitrepSummaryText').innerHTML = (sitrep.sections || []).map(s => + `
${this.escapeHtml(s.title || '')}

${this.escapeHtml(s.content || '')}

` + ).join(''); + } + } else { + document.getElementById('sitrepStatus').textContent = 'NONE'; + document.getElementById('sitrepStatus').style.color = '#ff4444'; + } + } else { + document.getElementById('sitrepStatus').textContent = 'NONE'; + document.getElementById('sitrepStatus').style.color = '#ff4444'; + } + + // Archive list + const archRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() }); + if (archRes.ok) { + const archData = await archRes.json(); + const archEl = document.getElementById('sitrepArchive'); + if (archEl) { + const dates = archData.dates || []; + if (dates.length === 0) { + archEl.innerHTML = '
No reports in archive
'; + } else { + archEl.innerHTML = dates.map(d => `
+
+ ${d.date} + ${this.escapeHtml(d.headline || '')} +
+
+ ${d.sources_used || 0} sources ยท ${d.model || 'unknown'} +
+
`).join(''); + } + } + } + } catch (err) { + console.error('SITREP load error:', err); + this.notify('Failed to load SITREP data', 'error'); + } + }, + + async generateSitrep() { + const btn = document.getElementById('sitrepGenerateBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'โณ GENERATING...'; } + + try { + const res = await fetch(this.API + '/sitrep/generate', { method: 'POST', headers: this.authHeaders() }); + const data = await res.json(); + if (res.ok && data.status === 'ok') { + this.notify('SITREP generated for ' + (data.date || 'today')); + this.loadSitrep(); + } else { + this.notify('Generation failed: ' + (data.message || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('SITREP generate error:', err); + this.notify('Failed to generate SITREP', 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'โšก GENERATE NOW'; } + } + }, + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DATA SYNC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async loadDataSync() { + try { + const res = await fetch(this.API + '/sync/status', { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load sync status'); + const data = await res.json(); + + // Contraband stats + const cb = data.contraband || {}; + document.getElementById('syncCbEntries').textContent = (cb.total_entries || 0).toLocaleString(); + document.getElementById('syncCbCategories').textContent = cb.total_categories || 0; + document.getElementById('syncCbStarred').textContent = (cb.total_starred || 0).toLocaleString(); + document.getElementById('syncCbLastSync').textContent = cb.last_sync ? new Date(cb.last_sync).toLocaleString('en-GB') : 'NEVER'; + + // Awesomelist stats + const al = data.awesomelist || {}; + document.getElementById('syncAlSectors').textContent = al.total_sectors || 0; + document.getElementById('syncAlLists').textContent = al.total_lists || 0; + document.getElementById('syncAlEntries').textContent = (al.total_entries || 0).toLocaleString(); + document.getElementById('syncAlLastSync').textContent = al.last_sync ? new Date(al.last_sync).toLocaleString('en-GB') : 'NEVER'; + } catch (err) { + console.error('Sync status error:', err); + this.notify('Failed to load sync status', 'error'); + } + }, + + async runSync(type) { + const btnId = type === 'contraband' ? 'syncCbBtn' : 'syncAlBtn'; + const btn = document.getElementById(btnId); + if (btn) { btn.disabled = true; btn.textContent = 'โณ SYNCING...'; } + + try { + const res = await fetch(this.API + '/sync/' + type, { method: 'POST', headers: this.authHeaders() }); + const data = await res.json(); + + // Show log + const logEl = document.getElementById('syncLog'); + const logContent = document.getElementById('syncLogContent'); + if (logEl && logContent) { + logEl.style.display = 'block'; + logContent.textContent = data.log || data.stderr || data.message || 'No output'; + } + + if (res.ok && data.status === 'ok') { + this.notify(type + ' sync completed'); + this.loadDataSync(); + } else { + this.notify('Sync failed: ' + (data.message || data.stderr || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('Sync error:', err); + this.notify('Sync failed: ' + err.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'โšก SYNC NOW'; } + } + }, + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ CRON JOBS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async loadCronJobs() { + try { + const res = await fetch(this.API + '/crons', { headers: this.authHeaders() }); + if (!res.ok) throw new Error('Failed to load cron jobs'); + const jobs = await res.json(); + const list = document.getElementById('cronJobsList'); + if (!list) return; + + if (!Array.isArray(jobs) || jobs.length === 0) { + list.innerHTML = '
No cron jobs configured
'; + return; + } + + list.innerHTML = jobs.map(j => { + const statusColor = j.enabled ? '#00ffc8' : (j.found ? '#ffcc00' : '#ff4444'); + const statusText = j.enabled ? 'ACTIVE' : (j.found ? 'DISABLED' : 'NOT FOUND'); + return `
+
+ ${this.escapeHtml(j.name)} + [${statusText}] +
+ Schedule: ${this.escapeHtml(j.schedule)} +
+
+
+ ${j.found ? `` : 'Not in crontab'} +
+
`; + }).join(''); + } catch (err) { + console.error('Cron jobs load error:', err); + this.notify('Failed to load cron jobs', 'error'); + } + }, + + async toggleCron(jobId) { + try { + const res = await fetch(this.API + '/crons/' + jobId + '/toggle', { method: 'POST', headers: this.authHeaders() }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Toggle failed'); + } + this.notify('Cron job toggled'); + this.loadCronJobs(); + } catch (err) { + console.error('Cron toggle error:', err); + this.notify('Failed to toggle cron: ' + err.message, 'error'); + } + }, + + /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ BACKUP IMPORT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + async importBackup() { + const fileInput = document.getElementById('backupImportFile'); + if (!fileInput || !fileInput.files.length) { + this.notify('Please select a ZIP file first', 'error'); + return; + } + + if (!confirm('This will overwrite existing data files. Are you sure?')) return; + + const formData = new FormData(); + formData.append('file', fileInput.files[0]); + + try { + const res = await fetch(this.API + '/backups/import', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + localStorage.getItem('jaeswift_token') }, + body: formData + }); + const data = await res.json(); + if (res.ok && data.ok) { + this.notify(`Backup restored: ${data.count} files (${data.restored.join(', ')})`); + fileInput.value = ''; + } else { + this.notify('Import failed: ' + (data.error || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('Import error:', err); + this.notify('Failed to import backup', 'error'); + } + }, + }; /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ BOOTSTRAP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */