[object Object]

← 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 →