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
+
+
+
+
+
+
+
+
+
+
+
+
โฌก TODAY'S SITREP
+
+
+
+
+
+
โ 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 โโโโโโโโโโโโโโโโโโโ */