tangled
alpha
login
or
join now
indexx.dev
/
tweets2bsky
forked from
j4ck.xyz/tweets2bsky
0
fork
atom
A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
0
fork
atom
overview
issues
pulls
pipelines
Feat: Add full configuration export/import functionality
jack
2 months ago
c2ae4f73
268c2bcb
+119
2 changed files
expand all
collapse all
unified
split
public
index.html
src
server.ts
+75
public/index.html
···
394
}
395
};
396
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
397
if (view === 'login' || view === 'register' || !token) {
398
return (
399
<div className="container d-flex justify-content-center align-items-center min-vh-100">
···
783
</div>
784
</div>
785
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
786
</div>
787
</div>
788
</div>
···
394
}
395
};
396
397
+
const handleExportConfig = async () => {
398
+
try {
399
+
const response = await axios.get('/api/config/export', {
400
+
headers: { Authorization: `Bearer ${token}` },
401
+
responseType: 'blob',
402
+
});
403
+
const url = window.URL.createObjectURL(new Blob([response.data]));
404
+
const link = document.createElement('a');
405
+
link.href = url;
406
+
link.setAttribute('download', `tweets-2-bsky-config-${new Date().toISOString().split('T')[0]}.json`);
407
+
document.body.appendChild(link);
408
+
link.click();
409
+
link.remove();
410
+
} catch (err) {
411
+
alert('Failed to export configuration');
412
+
}
413
+
};
414
+
415
+
const handleImportConfig = async (e) => {
416
+
const file = e.target.files[0];
417
+
if (!file) return;
418
+
419
+
if (!confirm('This will OVERWRITE your current accounts and settings (except login). Are you sure?')) {
420
+
e.target.value = ''; // Reset input
421
+
return;
422
+
}
423
+
424
+
const reader = new FileReader();
425
+
reader.onload = async (event) => {
426
+
try {
427
+
const config = JSON.parse(event.target.result);
428
+
await axios.post('/api/config/import', config, {
429
+
headers: { Authorization: `Bearer ${token}` }
430
+
});
431
+
alert('Configuration imported successfully! Reloading...');
432
+
fetchData();
433
+
} catch (err) {
434
+
alert('Failed to import configuration: ' + (err.response?.data?.error || err.message));
435
+
}
436
+
e.target.value = ''; // Reset input
437
+
};
438
+
reader.readAsText(file);
439
+
};
440
+
441
if (view === 'login' || view === 'register' || !token) {
442
return (
443
<div className="container d-flex justify-content-center align-items-center min-vh-100">
···
827
</div>
828
</div>
829
</div>
830
+
</div>
831
+
</div>
832
+
</div>
833
+
834
+
{/* Data Management */}
835
+
<div className="card mt-4">
836
+
<div className="card-header">
837
+
<span className="d-flex align-items-center gap-2">
838
+
<span className="material-icons text-muted">save</span> Data Management
839
+
</span>
840
+
</div>
841
+
<div className="card-body">
842
+
<div className="d-grid gap-2">
843
+
<button onClick={handleExportConfig} className="btn btn-outline-secondary btn-sm d-flex align-items-center justify-content-center gap-2">
844
+
<span className="material-icons" style={{fontSize: '18px'}}>download</span> Export Configuration
845
+
</button>
846
+
<div className="position-relative">
847
+
<input
848
+
type="file"
849
+
accept=".json"
850
+
className="form-control d-none"
851
+
id="importConfigInput"
852
+
onChange={handleImportConfig}
853
+
/>
854
+
<button onClick={() => document.getElementById('importConfigInput').click()} className="btn btn-outline-primary btn-sm w-100 d-flex align-items-center justify-content-center gap-2">
855
+
<span className="material-icons" style={{fontSize: '18px'}}>upload</span> Import Configuration
856
+
</button>
857
+
</div>
858
+
</div>
859
+
<div className="form-text small mt-2 text-center">
860
+
Exports accounts, Twitter keys, and AI settings. User logins are NOT exported.
861
</div>
862
</div>
863
</div>
+44
src/server.ts
···
301
res.json({ success: true, message: 'All backfills cleared' });
302
});
303
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
304
app.get('/api/recent-activity', authenticateToken, (req, res) => {
305
const limit = req.query.limit ? Number(req.query.limit) : 50;
306
const tweets = dbService.getRecentProcessedTweets(limit);
···
301
res.json({ success: true, message: 'All backfills cleared' });
302
});
303
304
+
// --- Config Management Routes ---
305
+
306
+
app.get('/api/config/export', authenticateToken, requireAdmin, (_req, res) => {
307
+
const config = getConfig();
308
+
// Create a copy without user data (passwords)
309
+
const { users, ...cleanConfig } = config;
310
+
311
+
res.setHeader('Content-Type', 'application/json');
312
+
res.setHeader('Content-Disposition', 'attachment; filename=tweets-2-bsky-config.json');
313
+
res.json(cleanConfig);
314
+
});
315
+
316
+
app.post('/api/config/import', authenticateToken, requireAdmin, (req, res) => {
317
+
try {
318
+
const importData = req.body;
319
+
const currentConfig = getConfig();
320
+
321
+
// Validate minimal structure
322
+
if (!importData.mappings || !Array.isArray(importData.mappings)) {
323
+
res.status(400).json({ error: 'Invalid config format: missing mappings array' });
324
+
return;
325
+
}
326
+
327
+
// Merge logic:
328
+
// 1. Keep current users (don't overwrite admin/passwords)
329
+
// 2. Overwrite mappings, twitter, ai config from import
330
+
// 3. Keep current values if import is missing them (optional, but safer to just replace sections)
331
+
332
+
const newConfig = {
333
+
...currentConfig,
334
+
mappings: importData.mappings,
335
+
twitter: importData.twitter || currentConfig.twitter,
336
+
ai: importData.ai || currentConfig.ai,
337
+
checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes
338
+
};
339
+
340
+
saveConfig(newConfig);
341
+
res.json({ success: true, message: 'Configuration imported successfully' });
342
+
} catch (err) {
343
+
console.error('Import failed:', err);
344
+
res.status(500).json({ error: 'Failed to process import file' });
345
+
}
346
+
});
347
+
348
app.get('/api/recent-activity', authenticateToken, (req, res) => {
349
const limit = req.query.limit ? Number(req.query.limit) : 50;
350
const tweets = dbService.getRecentProcessedTweets(limit);