diff --git a/public/index.html b/public/index.html index e01dce8..9a8baac 100644 --- a/public/index.html +++ b/public/index.html @@ -942,6 +942,109 @@ border-color: rgba(120, 150, 110, .25); } + .subreddit-editor { + display: flex; + flex-direction: column; + gap: 6px; + } + + .subreddit-editor-row { + display: grid; + grid-template-columns: minmax(140px, 1.5fr) 90px 1fr 70px 32px; + gap: 8px; + align-items: center; + padding: 6px 8px; + background: var(--card); + border: 1px solid var(--rule); + border-radius: var(--r-sm); + } + + .subreddit-editor-row.new { + border-style: dashed; + border-color: var(--terracotta-soft); + } + + .subreddit-editor-row input, + .subreddit-editor-row select { + width: 100%; + padding: 5px 8px; + font-size: 12px; + background: var(--bg); + color: var(--ink); + border: 1px solid var(--rule); + border-radius: var(--r-sm); + font-family: inherit; + } + + .subreddit-editor-row input[readonly] { + background: transparent; + border-color: transparent; + font-weight: 600; + color: var(--ink); + padding-left: 4px; + } + + .subreddit-editor-row .remove-btn { + background: transparent; + color: var(--ink-faint); + border: 1px solid var(--rule); + border-radius: var(--r-sm); + cursor: pointer; + padding: 4px; + font-size: 14px; + line-height: 1; + } + + .subreddit-editor-row .remove-btn:hover { + background: var(--terracotta-soft); + color: var(--terracotta-deep); + border-color: rgba(201, 123, 94, .3); + } + + .subreddit-editor-actions { + display: flex; + gap: 10px; + margin-top: 12px; + align-items: center; + } + + .subreddit-editor-actions button { + padding: 7px 14px; + font-size: 12px; + font-weight: 500; + border-radius: var(--r-sm); + cursor: pointer; + border: 1px solid var(--rule); + background: var(--card); + color: var(--ink); + font-family: inherit; + } + + .subreddit-editor-actions button.primary { + background: var(--terracotta-deep); + color: white; + border-color: var(--terracotta-deep); + } + + .subreddit-editor-actions button.primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .subreddit-editor-status { + font-size: 12px; + color: var(--ink-faint); + margin-left: auto; + } + + .subreddit-editor-status.error { + color: var(--terracotta-deep); + } + + .subreddit-editor-status.success { + color: var(--sage-deep); + } + .empty-state { padding: 48px 22px; text-align: center; @@ -2418,9 +2521,14 @@
-
+

Monitoring subreddits

-
+
+
+ + + +

Target genders

@@ -2615,8 +2723,6 @@ controlBtn.className = 'control-btn start'; } - document.getElementById('config-subreddits').innerHTML = stats.subreddits - .map(s => `r/${s.name}`).join(''); document.getElementById('config-genders').innerHTML = stats.targetGenders .map(g => `${g}`).join(''); @@ -3691,11 +3797,142 @@ const res = await fetch('/api/config'); currentConfig = await res.json(); populateSettingsForm(); + renderSubredditEditor(); } catch (err) { console.error('Failed to fetch config:', err); } } + // ---------- Subreddit editor ---------- + function renderSubredditEditor() { + const container = document.getElementById('subreddit-editor'); + if (!container || !currentConfig?.subreddits) return; + const tones = currentConfig.tones || ['default']; + container.innerHTML = ''; + for (const sub of currentConfig.subreddits) { + container.appendChild(buildSubredditRow(sub, tones, false)); + } + } + + function buildSubredditRow(sub, tones, isNew) { + const row = document.createElement('div'); + row.className = 'subreddit-editor-row' + (isNew ? ' new' : ''); + row.dataset.original = sub.name || ''; + + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = sub.name || ''; + nameInput.placeholder = 'subreddit name'; + nameInput.dataset.field = 'name'; + if (!isNew) nameInput.readOnly = true; + row.appendChild(nameInput); + + const typeSelect = document.createElement('select'); + typeSelect.dataset.field = 'type'; + for (const t of ['image', 'chat']) { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t; + if ((sub.type || 'image') === t) opt.selected = true; + typeSelect.appendChild(opt); + } + row.appendChild(typeSelect); + + const toneSelect = document.createElement('select'); + toneSelect.dataset.field = 'tone'; + for (const t of tones) { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t; + if ((sub.tone || 'default') === t) opt.selected = true; + toneSelect.appendChild(opt); + } + row.appendChild(toneSelect); + + const weightInput = document.createElement('input'); + weightInput.type = 'number'; + weightInput.min = '1'; + weightInput.step = '1'; + weightInput.value = sub.weight ?? ''; + weightInput.placeholder = '1'; + weightInput.dataset.field = 'weight'; + row.appendChild(weightInput); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-btn'; + removeBtn.textContent = '×'; + removeBtn.title = 'Remove'; + removeBtn.onclick = () => row.remove(); + row.appendChild(removeBtn); + + return row; + } + + function addSubredditRow() { + const container = document.getElementById('subreddit-editor'); + if (!container) return; + const tones = currentConfig?.tones || ['default']; + container.appendChild(buildSubredditRow({ tone: 'default', type: 'image', weight: 1 }, tones, true)); + } + + function readSubredditEditor() { + const rows = document.querySelectorAll('#subreddit-editor .subreddit-editor-row'); + const out = []; + for (const row of rows) { + const name = row.querySelector('[data-field="name"]').value.trim().replace(/^\/?r\//i, ''); + if (!name) continue; + const weightStr = row.querySelector('[data-field="weight"]').value.trim(); + const weight = weightStr === '' ? undefined : Number(weightStr); + out.push({ + name, + type: row.querySelector('[data-field="type"]').value, + tone: row.querySelector('[data-field="tone"]').value, + ...(Number.isFinite(weight) ? { weight } : {}), + }); + } + return out; + } + + function setSubredditStatus(text, kind) { + const el = document.getElementById('subreddit-editor-status'); + if (!el) return; + el.textContent = text || ''; + el.className = 'subreddit-editor-status' + (kind ? ' ' + kind : ''); + } + + async function saveSubreddits() { + const btn = document.getElementById('save-subreddits-btn'); + const subreddits = readSubredditEditor(); + if (subreddits.length === 0) { + setSubredditStatus('At least one subreddit required.', 'error'); + return; + } + btn.disabled = true; + setSubredditStatus('Saving…'); + try { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subreddits }), + }); + const result = await res.json(); + if (!res.ok || !result.success) { + setSubredditStatus('Save failed: ' + (result.error || res.statusText), 'error'); + } else { + currentConfig.subreddits = result.subreddits; + renderSubredditEditor(); + setSubredditStatus('Saved.', 'success'); + setTimeout(() => setSubredditStatus(''), 3000); + fetchStats(); + } + } catch (err) { + setSubredditStatus('Save failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + } + } + function populateSettingsForm() { if (!currentConfig) return; diff --git a/src/config/persistence.ts b/src/config/persistence.ts new file mode 100644 index 0000000..8ee0613 --- /dev/null +++ b/src/config/persistence.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import path from 'path'; +import YAML from 'yaml'; +import type { SubredditConfig } from './index.js'; + +/** + * Writes an updated subreddit list back to `config.yaml`, preserving the + * surrounding YAML structure, comment blocks, and per-row inline comments + * for entries that survive the update. + * + * Strategy: + * - Parse with `YAML.parseDocument` so the CST (including comments) is + * intact. + * - For each updated entry that matches an existing row by name, mutate + * the existing node's fields in place — its `.comment` (the trailing + * `# ...` annotation on the line) is preserved. + * - New entries become fresh flow-style maps appended in the caller's + * order; entries no longer in the list are dropped (and their comments + * go with them, since the comment is attached to the row). + * - Atomic write via tmp file + rename so a crash mid-write can't leave + * a half-written config on disk. + */ +export function persistSubreddits( + subreddits: SubredditConfig[], + configPath: string = path.resolve(process.cwd(), 'config.yaml'), +): void { + const raw = fs.readFileSync(configPath, 'utf-8'); + const doc = YAML.parseDocument(raw); + + const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined; + if (!seq || !YAML.isSeq(seq)) { + throw new Error('reddit.subreddits is not a sequence in config.yaml'); + } + + // Map existing items by their `name` so we can reuse YAMLMap nodes + // (and the trailing comments attached to them). + const existingByName = new Map(); + for (const item of seq.items) { + if (!YAML.isMap(item)) continue; + const name = item.get('name'); + if (typeof name === 'string') existingByName.set(name, item); + } + + const nextItems: YAML.YAMLMap[] = []; + for (const sub of subreddits) { + const existing = existingByName.get(sub.name); + const node = existing ?? new YAML.YAMLMap(); + if (!existing) { + // Match the on-disk style: flow-mapped, single-quoted strings. + node.flow = true; + node.set('name', sub.name); + } + setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type); + setOrDelete(node, 'weight', sub.weight); + setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone); + setOrDelete(node, 'scripted', sub.scripted); + nextItems.push(node); + } + + seq.items = nextItems; + + const out = doc.toString(); + const tmp = configPath + '.tmp'; + fs.writeFileSync(tmp, out, 'utf-8'); + fs.renameSync(tmp, configPath); +} + +function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void { + if (value === undefined || value === null || value === '') { + if (node.has(key)) node.delete(key); + return; + } + node.set(key, value); +} diff --git a/src/web/routes/config.ts b/src/web/routes/config.ts index e6aa165..df93fec 100644 --- a/src/web/routes/config.ts +++ b/src/web/routes/config.ts @@ -1,5 +1,11 @@ import { Router } from 'express'; import type { WebContext } from '../context.js'; +import { SubredditConfigSchema, type SubredditConfig } from '../../config/index.js'; +import { TONE_KEYS } from '../../config/tone-profiles.js'; +import { persistSubreddits } from '../../config/persistence.js'; +import { z } from 'zod'; + +const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1); export function createConfigRouter(ctx: WebContext): Router { const router = Router(); @@ -9,6 +15,8 @@ export function createConfigRouter(ctx: WebContext): Router { rateLimit: ctx.runtimeConfig.rateLimit, browser: { dryRun: ctx.runtimeConfig.browser.dryRun }, reddit: ctx.runtimeConfig.reddit, + subreddits: ctx.config.reddit.subreddits, + tones: TONE_KEYS, }); }); @@ -57,12 +65,36 @@ export function createConfigRouter(ctx: WebContext): Router { ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts; ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes; + if (updates.subreddits !== undefined) { + const parsed = SubredditsUpdateSchema.safeParse(updates.subreddits); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid subreddits payload', detail: parsed.error.format() }); + return; + } + const next: SubredditConfig[] = parsed.data; + const seen = new Set(); + for (const s of next) { + const key = s.name.toLowerCase(); + if (seen.has(key)) { + res.status(400).json({ error: `Duplicate subreddit: ${s.name}` }); + return; + } + seen.add(key); + } + persistSubreddits(next); + ctx.config.reddit.subreddits = next; + } + ctx.getOnConfigChange()?.(ctx.runtimeConfig); ctx.activity.push('info', 'Config updated via dashboard'); ctx.broadcaster.push('info', 'Configuration updated via dashboard', updates); - res.json({ success: true, config: ctx.runtimeConfig }); + res.json({ + success: true, + config: ctx.runtimeConfig, + subreddits: ctx.config.reddit.subreddits, + }); } catch (error) { ctx.logger.error('Failed to update config:', error); res.status(400).json({ error: String(error) }); diff --git a/tests/config-persistence.test.ts b/tests/config-persistence.test.ts new file mode 100644 index 0000000..c3dc9d2 --- /dev/null +++ b/tests/config-persistence.test.ts @@ -0,0 +1,128 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import YAML from 'yaml'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { persistSubreddits } from '../src/config/persistence.js'; + +const SAMPLE = `# top-level note that must survive +reddit: + # weights bias picking + subreddits: + - { name: AverageNudes, weight: 4, tone: 'direct' } # validation seekers + - { name: gonewild40plus, weight: 4, tone: 'gentle' } # mature + - { name: normalnudes, weight: 3 } # real bodies + - { name: tightandtoned, weight: 1 } + + pollIntervalMs: 30000 + userAgent: "ua" +`; + +describe('persistSubreddits', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-')); + configPath = path.join(tmpDir, 'config.yaml'); + fs.writeFileSync(configPath, SAMPLE, 'utf-8'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('mutates an existing row in place and preserves its inline comment', () => { + persistSubreddits( + [ + { name: 'AverageNudes', type: 'image', weight: 4, tone: 'playful' }, + { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' }, + { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' }, + { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' }, + ], + configPath, + ); + const out = fs.readFileSync(configPath, 'utf-8'); + expect(out).toMatch(/tone:\s*['"]?playful['"]?/); + expect(out).toContain('# validation seekers'); + expect(out).toContain('# top-level note that must survive'); + expect(out).toContain('# weights bias picking'); + expect(out).toContain('# mature'); + expect(out).toContain('pollIntervalMs: 30000'); + }); + + it('drops removed rows (and their attached comments)', () => { + persistSubreddits( + [ + { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' }, + { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' }, + ], + configPath, + ); + const out = fs.readFileSync(configPath, 'utf-8'); + expect(out).not.toContain('gonewild40plus'); + expect(out).not.toContain('normalnudes'); + expect(out).not.toContain('# mature'); + expect(out).toContain('AverageNudes'); + expect(out).toContain('tightandtoned'); + expect(out).toContain('# validation seekers'); + }); + + it('appends new rows in caller order', () => { + persistSubreddits( + [ + { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' }, + { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' }, + { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' }, + { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' }, + { name: 'NewSub', type: 'chat', weight: 2, tone: 'playful' }, + ], + configPath, + ); + const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8')); + const subs = parsed.reddit.subreddits; + expect(subs[subs.length - 1]).toMatchObject({ + name: 'NewSub', + type: 'chat', + weight: 2, + tone: 'playful', + }); + }); + + it("omits 'default' tone and 'image' type on write to match on-disk style", () => { + persistSubreddits( + [{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }], + configPath, + ); + const out = fs.readFileSync(configPath, 'utf-8'); + expect(out).not.toMatch(/tone:\s*default/); + expect(out).not.toMatch(/type:\s*image/); + }); + + it('atomic write — tmp file not left behind on success', () => { + persistSubreddits( + [{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }], + configPath, + ); + expect(fs.existsSync(configPath + '.tmp')).toBe(false); + }); + + it('reorders rows according to input order', () => { + persistSubreddits( + [ + { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' }, + { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' }, + { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' }, + { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' }, + ], + configPath, + ); + const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8')); + expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([ + 'tightandtoned', + 'AverageNudes', + 'gonewild40plus', + 'normalnudes', + ]); + }); +});