← back to Butlr
uploads: folder-sync — one-shot scan + "Sync now" button
81ccd55e5c8ad1ddcde9350ea21407d242601302 · 2026-05-13 11:07:16 -0700 · SteveStudio2
Second half of Steve's "connect to a folder" ask.
- POST /admin/uploads/folder/sync — admin-gated. Walks meta.folder,
imports any file (allowed ext, ≤10MB) not already in the gallery.
Dedup via (size + mtime) key per source path. Returns
{ok, imported, skipped, errors}. Atomic per-file via copyFileSync.
- /admin/uploads page: when a folder is configured, shows a "Sync
now" button + status line next to the path display. Reload page
on first new import.
- New gallery entries from folder-sync carry source_path /
source_size / source_mtime so future syncs skip them automatically.
Watcher-on-cron is a follow-up tick (TODO: 30s interval scan on the
worker tick). For now Steve presses "Sync now" when he drops files
into the watched folder.
Files touched
Diff
commit 81ccd55e5c8ad1ddcde9350ea21407d242601302
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 11:07:16 2026 -0700
uploads: folder-sync — one-shot scan + "Sync now" button
Second half of Steve's "connect to a folder" ask.
- POST /admin/uploads/folder/sync — admin-gated. Walks meta.folder,
imports any file (allowed ext, ≤10MB) not already in the gallery.
Dedup via (size + mtime) key per source path. Returns
{ok, imported, skipped, errors}. Atomic per-file via copyFileSync.
- /admin/uploads page: when a folder is configured, shows a "Sync
now" button + status line next to the path display. Reload page
on first new import.
- New gallery entries from folder-sync carry source_path /
source_size / source_mtime so future syncs skip them automatically.
Watcher-on-cron is a follow-up tick (TODO: 30s interval scan on the
worker tick). For now Steve presses "Sync now" when he drops files
into the watched folder.
---
routes/uploads.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 80 insertions(+), 1 deletion(-)
diff --git a/routes/uploads.js b/routes/uploads.js
index 33c0d2f..dba8d3b 100644
--- a/routes/uploads.js
+++ b/routes/uploads.js
@@ -142,7 +142,7 @@ router.get('/admin/uploads', requireAdmin, (req, res) => {
<input type="text" name="folder" placeholder="/Users/stevestudio2/Pictures/butlr-inbox" value="${escapeHtml(folder)}">
<button type="submit">Save</button>
</form>
- ${folder ? `<p class="muted" style="margin-top:6px">Currently watching: <code>${escapeHtml(folder)}</code></p>` : ''}
+ ${folder ? `<p class="muted" style="margin-top:6px">Currently watching: <code>${escapeHtml(folder)}</code> · <button type="button" id="sync-now-btn" style="padding:4px 12px;font-size:12px;cursor:pointer">Sync now</button> <span id="sync-status" class="muted" style="font-size:11px;margin-left:6px"></span></p>` : ''}
</div>
<h2 style="font-size:18px; margin-top:28px;">Gallery (${files.length})</h2>
@@ -184,6 +184,27 @@ router.get('/admin/uploads', requireAdmin, (req, res) => {
uploadFiles(e.dataTransfer.files);
});
+ // Folder sync — one-shot scan
+ const syncBtn = document.getElementById('sync-now-btn');
+ const syncStat = document.getElementById('sync-status');
+ if (syncBtn) {
+ syncBtn.addEventListener('click', async () => {
+ syncBtn.disabled = true;
+ syncStat.textContent = 'scanning…';
+ try {
+ const r = await fetch('/admin/uploads/folder/sync', { method: 'POST' });
+ const j = await r.json();
+ if (j.ok) {
+ syncStat.textContent = '✓ imported ' + j.imported + ' · skipped ' + j.skipped + (j.errors && j.errors.length ? ' · ' + j.errors.length + ' errors' : '');
+ if (j.imported > 0) setTimeout(() => location.reload(), 900);
+ } else {
+ syncStat.textContent = '✗ ' + (j.error || 'failed');
+ }
+ } catch (e) { syncStat.textContent = '✗ ' + e.message; }
+ finally { syncBtn.disabled = false; }
+ });
+ }
+
document.querySelectorAll('.copy-btn').forEach(b => {
b.addEventListener('click', async () => {
const u = b.dataset.url;
@@ -227,6 +248,64 @@ router.post('/admin/uploads/folder', requireAdmin, express.urlencoded({ extended
res.redirect('/admin/uploads');
});
+// One-shot folder sync — scans meta.folder, imports any file not already
+// in the gallery. Hash-by-(size+mtime) to avoid double-import. Returns
+// {ok, imported, skipped, errors}.
+router.post('/admin/uploads/folder/sync', requireAdmin, (req, res) => {
+ const meta = readMeta();
+ const folder = meta.folder;
+ if (!folder) return res.status(400).json({ ok: false, error: 'no folder configured' });
+ if (!fs.existsSync(folder)) return res.status(400).json({ ok: false, error: 'folder does not exist: ' + folder });
+
+ // Build a Set of (size:mtime) keys for files already in meta — cheap dedupe.
+ const existingKeys = new Set((meta.files || [])
+ .filter(f => f.source_path)
+ .map(f => `${f.source_size}:${f.source_mtime}`));
+
+ let imported = 0, skipped = 0;
+ const errors = [];
+ const newEntries = [];
+
+ let entries;
+ try { entries = fs.readdirSync(folder); }
+ catch (e) { return res.status(500).json({ ok: false, error: 'readdir failed: ' + e.message }); }
+
+ for (const name of entries) {
+ if (name.startsWith('.')) continue; // skip hidden
+ const srcPath = path.join(folder, name);
+ let st;
+ try { st = fs.statSync(srcPath); } catch { continue; }
+ if (!st.isFile()) continue;
+ if (st.size > MAX_SIZE) { errors.push(`${name}: too large (${(st.size/1024/1024).toFixed(1)}MB > 10MB)`); continue; }
+ const ext = (path.extname(name) || '').toLowerCase();
+ if (!ALLOWED_EXT.has(ext)) { skipped++; continue; }
+ const key = `${st.size}:${st.mtime.getTime()}`;
+ if (existingKeys.has(key)) { skipped++; continue; }
+ try {
+ const newName = crypto.randomBytes(12).toString('hex') + ext;
+ fs.copyFileSync(srcPath, path.join(UPLOADS_DIR, newName));
+ newEntries.push({
+ filename: newName,
+ original_name: name,
+ size: st.size,
+ type: 'application/octet-stream', // we don't sniff; client may guess by ext
+ uploaded_at: new Date().toISOString(),
+ source_path: srcPath,
+ source_size: st.size,
+ source_mtime: st.mtime.getTime(),
+ });
+ imported++;
+ } catch (e) {
+ errors.push(`${name}: ${e.message}`);
+ }
+ }
+ if (newEntries.length) {
+ meta.files = (meta.files || []).concat(newEntries);
+ writeMeta(meta);
+ }
+ res.json({ ok: true, imported, skipped, errors, folder });
+});
+
router.post('/admin/uploads/delete', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
const filename = String((req.body && req.body.filename) || '').trim();
if (!/^[a-f0-9]{24}\.[a-z0-9]+$/i.test(filename)) return res.redirect('/admin/uploads');
← f69ec60 admin/uploads: image + small-file upload UI with public URL
·
back to Butlr
·
upload-watcher: auto-import from connected folder every 30s 986f634 →