Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .agents/docs/09-feature-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type AppSettings = {
assistantSource: string; // 'claude-code' for the local binary, else a provider id
assistantCliPath: string; // empty: look where Claude Code installs itself
engineScript: string; // the user's own patch for the delete engine; empty = built-in
siteUrls: Record<string, string>; // page overrides, keyed `platform.group`; absent = built-in
timeouts: {
waitAfterDelete: number; // ms — pause between individual delete actions
waitBetweenRetryDeleteAttempts: number;
Expand All @@ -52,7 +53,7 @@ A form in the chrome UI at `/settings`. It calls `settings.get` on load and `set
on every change. The host pushes a `settingsChanged` event whenever settings change so
other views stay in sync.

Four cards — **Appearance, General, Assistant, Automation** — each with a `CardDescription`
Five cards — **Appearance, General, Assistant, Automation, Pages** — each with a `CardDescription`
saying what the group is for, and `SettingRow`s inside.

- **Appearance**: the mode switch (System / Light / Dark), the colour preset picker and the
Expand All @@ -71,6 +72,13 @@ saying what the group is for, and `SettingRow`s inside.
provider with a button into the API-keys dialog.
- **Automation**: everything that decides how a run behaves — the confirmation, the cookie
banners, the three waits, and the engine script.
- **Pages**: the address every action runs on, one row per action. Only overrides are stored
(`siteUrls`, keyed `platform.group`, `{user}` for the handle); clearing a field or matching
the default deletes the key, and "Reset pages" drops them all. The Rust host resolves the
override in `resolve_target_url` (`src-tauri/src/commands/site.rs`); the defaults the view
prints beside the fields live in `src/lib/site-urls.ts`, kept in step by hand. The
extension mirrors the card in its popup (`siteUrls` in `PopupSettings`), resolved in its
`background.ts` the same way.

`notifications` and `telemetry` are two switches that could be mistaken for each other and
are not. A toast is a courtesy; the log is the record, and the diagnostics switch is enforced
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "CleanMyPosts",
"version": "1.0.1",
"version": "1.1.0",
"description": "Bulk-delete your posts, replies, reposts, likes and followings on X, and your comments and liked videos on YouTube.",
"icons": {
"32": "icons/32x32.png",
Expand Down
18 changes: 16 additions & 2 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ async function getTimeouts(): Promise<Timeouts> {
return (stored[SETTINGS_KEY] as PopupSettings | undefined)?.timeouts ?? DEFAULT_SETTINGS.timeouts;
}

/** The built-in page, unless the popup's settings carry an override under `platform.group`. */
async function resolveTargetUrl(
platform: Platform,
action: Action,
userName: string
): Promise<string | undefined> {
const stored = await browser.storage.local.get(SETTINGS_KEY);
const overrides = (stored[SETTINGS_KEY] as PopupSettings | undefined)?.siteUrls ?? {};
const group = action.replace(/^delete/, '').toLowerCase();
const template = overrides[`${platform}.${group}`]?.trim();
if (template) return template.replace('{user}', userName.replace(/[^A-Za-z0-9_]/g, ''));
return targetUrl(platform, action, userName);
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down Expand Up @@ -226,7 +240,7 @@ async function runNext(): Promise<void> {
return;
}

const url = targetUrl(state.platform, action, state.userName ?? '');
const url = await resolveTargetUrl(state.platform, action, state.userName ?? '');
if (!url) throw new Error(`unknown action "${state.platform}:${action}"`);

const timeouts = await getTimeouts();
Expand Down Expand Up @@ -285,7 +299,7 @@ async function show(platform: Platform, action: Action): Promise<void> {
userName = known ?? '';
}

const url = targetUrl(platform, action, userName);
const url = await resolveTargetUrl(platform, action, userName);
if (!url) throw new Error(`unknown action "${platform}:${action}"`);

await navigate(tabId, url);
Expand Down
49 changes: 49 additions & 0 deletions extension/src/popup/Popup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { Component } from 'svelte';
import type { Platform } from '$lib/engine/protocol';
import { X_GROUPS, YOUTUBE_GROUPS, type ActionGroupDef } from '$lib/actions';
import { SITE_URL_DEFAULTS } from '$lib/site-urls';
import ActionRow from '$lib/components/action-row.svelte';
import { ConfirmDialog } from '$lib/components/ui/alert-dialog';
import XIcon from '$lib/components/icons/x-icon.svelte';
Expand Down Expand Up @@ -107,6 +108,24 @@
save({ ...settings, timeouts: { ...settings.timeouts, [key]: Math.round(ms) } });
}

/** One row per action, mirroring the app's Settings → Pages. */
const URL_FIELDS = PLATFORMS.flatMap((p) =>
p.groups.map((group) => ({
key: `${p.id}.${group.key}`,
platform: p.label,
label: group.label
}))
);

/** Only overrides are stored: a field left empty or put back to the default is deleted. */
function setSiteUrl(key: string, value: string): void {
const next = { ...settings.siteUrls };
const trimmed = value.trim();
if (trimmed === '' || trimmed === SITE_URL_DEFAULTS[key]) delete next[key];
else next[key] = trimmed;
save({ ...settings, siteUrls: next });
}

// Two states, not three: `Default` is what it starts as, and the first press is a choice
// away from whatever it happens to be showing.
const isDark = $derived(
Expand Down Expand Up @@ -305,6 +324,36 @@
</label>
{/each}
</div>

<div class="flex flex-col gap-1 border-t pt-2">
<p class="text-[10px] leading-snug text-muted-foreground">
{t('settings.urls.description')}
</p>
{#each URL_FIELDS as field (field.key)}
<label class="flex items-center gap-2 text-xs">
<span class="w-28 shrink-0 truncate text-muted-foreground">
{field.platform} · {t(field.label)}
</span>
<input
type="text"
spellcheck="false"
value={settings.siteUrls[field.key] ?? SITE_URL_DEFAULTS[field.key]}
onchange={(e) => setSiteUrl(field.key, e.currentTarget.value)}
class="h-6 min-w-0 flex-1 rounded-md border border-input bg-background px-1.5 font-mono
text-[10px] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
/>
</label>
{/each}
<button
type="button"
disabled={Object.keys(settings.siteUrls).length === 0}
onclick={() => save({ ...settings, siteUrls: {} })}
class="cursor-pointer self-end text-[10px] text-muted-foreground hover:text-foreground
disabled:cursor-default disabled:opacity-50"
>
{t('settings.urls.reset')}
</button>
</div>
</div>
{/if}

Expand Down
9 changes: 8 additions & 1 deletion extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ export interface PopupSettings {
theme: 'Default' | 'Light' | 'Dark';
/** A `Language` from `$lib/i18n`; `System` reads `navigator.language`. */
language: string;
/**
* Overrides for the pages the actions run on, keyed `platform.group` (`x.reposts`), with
* `{user}` standing for the handle — the same scheme as the app's Settings → Pages. Only
* overrides live here; an absent key means the built-in page.
*/
siteUrls: Record<string, string>;
}

export const SETTINGS_KEY = 'popupSettings';
Expand All @@ -126,7 +132,8 @@ export const DEFAULT_SETTINGS: PopupSettings = {
},
welcomed: false,
theme: 'Default',
language: 'System'
language: 'System',
siteUrls: {}
};

/** Popup -> background. One action or all of them is the same request with a longer list. */
Expand Down
15 changes: 15 additions & 0 deletions release-notes/v3.6.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
### What's Changed

**The pages are yours to point**

- New: **Settings → Pages** lists the page every action runs on — X posts, replies,
reposts, likes and following, YouTube comments and likes — and lets you change any of
them. `{user}` stands for the signed-in handle. Clearing a field brings back the
built-in page, and one button resets them all.
- The point of it: when a platform moves a page again — the way X just moved reposts —
the fix is a settings field, not a wait for the next release.
- The extension gets the same fields in its popup's settings panel, stored beside its
waits, as v1.1.0.

The assistant's troubleshooting notes know about the new card, so asking it about an
action that lands on an empty page now points at the settings field.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cleanmyposts"
version = "3.5.1"
version = "3.6.0"
description = "Bulk-delete your posts, reposts, replies, likes and comments on X and YouTube"
authors = ["thorstenalpers"]
repository = "https://github.com/thorstenalpers/CleanMyPosts"
Expand Down
53 changes: 50 additions & 3 deletions src-tauri/src/commands/site.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ fn target_url(platform: &str, action: &str, user_name: &str) -> Option<String> {
Some(url)
}

/// The built-in page, unless the settings carry an override under `platform.group` — which
/// is how a platform moving a page again gets fixed from Settings rather than waited out.
fn resolve_target_url(
overrides: &std::collections::HashMap<String, String>,
platform: &str,
action: &str,
user_name: &str,
) -> Option<String> {
let key = format!("{platform}.{}", subject(action));
if let Some(template) = overrides
.get(&key)
.map(|t| t.trim())
.filter(|t| !t.is_empty())
{
return Some(template.replace("{user}", &urlencoding_minimal(user_name)));
}
target_url(platform, action, user_name)
}

fn effective_target_url(app: &AppHandle, platform: &str, action: &str) -> Option<String> {
let overrides = app.state::<AppState>().settings.get().site_urls;
resolve_target_url(&overrides, platform, action, &read_user_name(app))
}

/// X handles are `[A-Za-z0-9_]`, so percent-encoding only has to defend against a handle
/// that never should have got this far rather than implement general URL encoding.
fn urlencoding_minimal(input: &str) -> String {
Expand Down Expand Up @@ -95,8 +119,7 @@ pub fn navigate(app: &AppHandle, params: &Value) -> Result<Value> {
.and_then(Value::as_str)
.ok_or(Error::MissingParam("action"))?;

let user = read_user_name(app);
let url = target_url(platform, action, &user).ok_or_else(|| Error::NoTarget {
let url = effective_target_url(app, platform, action).ok_or_else(|| Error::NoTarget {
platform: platform.to_string(),
action: action.to_string(),
})?;
Expand Down Expand Up @@ -416,7 +439,7 @@ pub async fn run_action(app: AppHandle, params: &Value) -> Result<Value> {
// same outcome, with the same empty log.
// Scoped so the webview handle is dropped before the wait below: a `Webview` is not
// `Send`, and holding one across an `await` makes the whole command un-spawnable.
if let Some(url) = target_url(platform, action, &read_user_name(&app)) {
if let Some(url) = effective_target_url(&app, platform, action) {
{
let site = app
.get_webview(crate::site_webview_label(platform))
Expand Down Expand Up @@ -675,6 +698,30 @@ mod tests {
);
}

#[test]
fn an_override_beats_the_built_in_page() {
let mut overrides = std::collections::HashMap::new();
overrides.insert(
"x.reposts".to_string(),
"https://x.com/{user}/rt".to_string(),
);
overrides.insert("x.likes".to_string(), " ".to_string());
assert_eq!(
resolve_target_url(&overrides, "x", "deleteReposts", "someuser").unwrap(),
"https://x.com/someuser/rt"
);
// Blank overrides read as "not set", and an untouched action keeps its built-in page.
assert_eq!(
resolve_target_url(&overrides, "x", "deleteLikes", "someuser").unwrap(),
"https://x.com/someuser/likes"
);
assert_eq!(
resolve_target_url(&overrides, "x", "showReplies", "someuser").unwrap(),
"https://x.com/someuser/with_replies"
);
assert!(resolve_target_url(&overrides, "x", "whatever", "someuser").is_none());
}

/// `show*` and `delete*` land on the same page; deleting happens where the items are
/// listed, so a divergence here would send a delete run to a page with nothing on it.
#[test]
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ pub struct AppSettings {
#[serde(default)]
pub custom_actions: Vec<CustomAction>,
pub timeouts: TimeoutSettings,
/// Overrides for the pages the actions run on, keyed `platform.group` (`x.reposts`),
/// with `{user}` standing for the handle. Only overrides live here — an absent key means
/// the built-in page in `commands::site::target_url`.
#[serde(default)]
pub site_urls: std::collections::HashMap<String, String>,
}

impl Default for AppSettings {
Expand All @@ -120,6 +125,7 @@ impl Default for AppSettings {
assistant_effort: medium(),
custom_actions: Vec::new(),
timeouts: TimeoutSettings::default(),
site_urls: Default::default(),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CleanMyPosts",
"version": "3.5.1",
"version": "3.6.0",
"identifier": "com.thorstenalpers.cleanmyposts",
"build": {
"frontendDist": "../build",
Expand Down
4 changes: 4 additions & 0 deletions src/lib/assistant-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ function describeTroubleshooting(): string {
'- "Deletion failed." on every item: the platform changed its markup and the engine’s',
' buttons moved. Update to the latest release; if it persists it is a bug worth',
' reporting with the log.',
'- An action opens a page that no longer lists its items: the platform moved the page.',
' Settings → Pages holds the address every action runs on and can point it somewhere',
' else; `{user}` stands for the signed-in handle, clearing a field brings back the',
' built-in page, and "Reset pages" clears every override at once.',
'- Some likes or followings survive: not everything is reachable from the list',
' (protected accounts, items behind "Show more"). Running the action again clears',
' most of it.',
Expand Down
1 change: 1 addition & 0 deletions src/lib/bridge/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('BridgeClient', () => {
assistantModel: '',
assistantEffort: 'medium' as const,
customActions: [],
siteUrls: {},
timeouts: {
waitAfterDelete: 100,
waitBetweenRetryDeleteAttempts: 200,
Expand Down
8 changes: 7 additions & 1 deletion src/lib/bridge/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,13 @@ export const AppSettingsSchema = z.object({
assistantEffort: AssistantEffortSchema,
/** Scripts the assistant wrote that the user kept. Shown in each platform's action panel. */
customActions: z.array(CustomActionSchema),
timeouts: TimeoutSettingsSchema
timeouts: TimeoutSettingsSchema,
/**
* Overrides for the pages the actions run on, keyed `platform.group` (`x.reposts`), with
* `{user}` standing for the handle. Only overrides live here — an absent key means the
* built-in page, so resetting is deleting. See `$lib/site-urls.ts` for the defaults.
*/
siteUrls: z.record(z.string(), z.string()).default({})
});
export type AppSettings = z.infer<typeof AppSettingsSchema>;

Expand Down
1 change: 1 addition & 0 deletions src/lib/bridge/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function mockSettings(): AppSettings {
assistantModel: '',
assistantEffort: 'medium',
customActions: [],
siteUrls: {},
timeouts: {
waitAfterDelete: 500,
waitBetweenRetryDeleteAttempts: 500,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ export const ar: Record<MessageKey, string> = {

'header.url': 'أنت هنا',
'header.reload': 'إعادة تحميل الصفحة',
'settings.urls': 'الصفحات',
'settings.urls.description':
'أين يعمل كل إجراء. {user} يمثل اسم المستخدم المسجّل؛ إفراغ الحقل يعيده إلى الصفحة المدمجة.',
'settings.urls.reset': 'إعادة تعيين الصفحات',

'log.column.time': 'الوقت',
'log.column.level': 'المستوى',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ export const de: Record<MessageKey, string> = {

'header.url': 'Du bist hier',
'header.reload': 'Seite neu laden',
'settings.urls': 'Seiten',
'settings.urls.description':
'Wo jede Aktion läuft. {user} steht für den angemeldeten Handle; ein geleertes Feld kehrt zur eingebauten Seite zurück.',
'settings.urls.reset': 'Seiten zurücksetzen',

'log.column.time': 'Zeit',
'log.column.level': 'Stufe',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ export const en = {

'header.url': 'You are here',
'header.reload': 'Reload page',
'settings.urls': 'Pages',
'settings.urls.description':
'Where each action runs. {user} stands for the signed-in handle; clearing a field returns it to the built-in page.',
'settings.urls.reset': 'Reset pages',

'log.column.time': 'Time',
'log.column.level': 'Level',
Expand Down
Loading