+
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',
+ ]);
+ });
+});