diff --git a/frontend/src/components/modals/RuleEditorModal.vue b/frontend/src/components/modals/RuleEditorModal.vue
new file mode 100644
index 000000000..0b66e4b9a
--- /dev/null
+++ b/frontend/src/components/modals/RuleEditorModal.vue
@@ -0,0 +1,585 @@
+
+
+
+
+
+
+
+
+
+ {{ rule ? store.i18n.t('editRule') : store.i18n.t('addRule') }}
+
+
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ store.i18n.t('conditionAlways') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ store.i18n.t('noActionsSelected') }}
+
+
+
+
+
+
{{ index + 1 }}.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/modals/SettingsModal.vue b/frontend/src/components/modals/SettingsModal.vue
index 2babd9015..206ca573d 100644
--- a/frontend/src/components/modals/SettingsModal.vue
+++ b/frontend/src/components/modals/SettingsModal.vue
@@ -4,6 +4,7 @@ import { ref, onMounted } from 'vue';
import GeneralTab from './settings/GeneralTab.vue';
import FeedsTab from './settings/FeedsTab.vue';
import ShortcutsTab from './settings/ShortcutsTab.vue';
+import RulesTab from './settings/RulesTab.vue';
import AboutTab from './settings/AboutTab.vue';
import DiscoverAllFeedsModal from './DiscoverAllFeedsModal.vue';
import { PhGear } from "@phosphor-icons/vue";
@@ -27,7 +28,8 @@ const settings = ref({
show_hidden_articles: false,
default_view_mode: 'original',
startup_on_boot: false,
- shortcuts: ''
+ shortcuts: '',
+ rules: ''
});
const updateInfo = ref(null);
@@ -56,7 +58,8 @@ onMounted(async () => {
show_hidden_articles: data.show_hidden_articles === 'true',
default_view_mode: data.default_view_mode || 'original',
startup_on_boot: data.startup_on_boot === 'true',
- shortcuts: data.shortcuts || ''
+ shortcuts: data.shortcuts || '',
+ rules: data.rules || ''
};
// Apply the saved language
if (data.language) {
@@ -351,6 +354,7 @@ async function handleDownloadInstallUpdate() {
+
@@ -371,6 +375,8 @@ async function handleDownloadInstallUpdate() {
@discover-all="handleDiscoverAll"
/>
+
+
+import { store } from '../../../store.js';
+import { ref, onMounted, watch } from 'vue';
+import {
+ PhLightning, PhPlus, PhTrash, PhPencil,
+ PhPlay, PhFunnel, PhListChecks
+} from "@phosphor-icons/vue";
+import RuleEditorModal from '../RuleEditorModal.vue';
+
+const props = defineProps({
+ settings: { type: Object, required: true }
+});
+
+// Rules list
+const rules = ref([]);
+
+// Modal states
+const showRuleEditor = ref(false);
+const editingRule = ref(null);
+const applyingRuleId = ref(null);
+
+// Load rules from settings
+onMounted(() => {
+ loadRules();
+});
+
+function loadRules() {
+ if (props.settings.rules) {
+ try {
+ const parsed = typeof props.settings.rules === 'string'
+ ? JSON.parse(props.settings.rules)
+ : props.settings.rules;
+ rules.value = Array.isArray(parsed) ? parsed : [];
+ } catch (e) {
+ console.error('Error parsing rules:', e);
+ rules.value = [];
+ }
+ }
+}
+
+// Watch for settings changes
+watch(() => props.settings.rules, () => {
+ loadRules();
+}, { immediate: true });
+
+// Save rules to settings
+async function saveRules() {
+ try {
+ props.settings.rules = JSON.stringify(rules.value);
+ await fetch('/api/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rules: props.settings.rules })
+ });
+ } catch (e) {
+ console.error('Error saving rules:', e);
+ }
+}
+
+// Add new rule
+function addRule() {
+ editingRule.value = null;
+ showRuleEditor.value = true;
+}
+
+// Edit existing rule
+function editRule(rule) {
+ editingRule.value = { ...rule };
+ showRuleEditor.value = true;
+}
+
+// Delete rule
+async function deleteRule(ruleId) {
+ const confirmed = await window.showConfirm({
+ title: store.i18n.t('ruleDeleteConfirmTitle'),
+ message: store.i18n.t('ruleDeleteConfirmMessage'),
+ confirmText: store.i18n.t('delete'),
+ cancelText: store.i18n.t('cancel'),
+ isDanger: true
+ });
+
+ if (!confirmed) return;
+
+ rules.value = rules.value.filter(r => r.id !== ruleId);
+ await saveRules();
+ window.showToast(store.i18n.t('ruleDeletedSuccess'), 'success');
+}
+
+// Toggle rule enabled state
+async function toggleRuleEnabled(rule) {
+ rule.enabled = !rule.enabled;
+ await saveRules();
+}
+
+// Save rule from editor
+async function handleSaveRule(rule) {
+ // Check if this is a new rule (editingRule is null or has no id)
+ const isNew = !editingRule.value || !editingRule.value.id;
+
+ if (editingRule.value && editingRule.value.id) {
+ // Update existing rule
+ const index = rules.value.findIndex(r => r.id === rule.id);
+ if (index !== -1) {
+ rules.value[index] = rule;
+ }
+ } else {
+ // Add new rule
+ rule.id = Date.now();
+ rule.enabled = true;
+ rules.value.push(rule);
+ }
+
+ await saveRules();
+ showRuleEditor.value = false;
+ window.showToast(store.i18n.t('ruleSavedSuccess'), 'success');
+
+ // Apply rule to existing articles when adding a new rule
+ if (isNew && rule.enabled) {
+ await applyRule(rule);
+ }
+}
+
+// Apply rule now
+async function applyRule(rule) {
+ applyingRuleId.value = rule.id;
+
+ try {
+ const res = await fetch('/api/rules/apply', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(rule)
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ window.showToast(store.i18n.t('ruleAppliedSuccess', { count: data.affected }), 'success');
+ store.fetchArticles();
+ store.fetchUnreadCounts();
+ } else {
+ window.showToast(store.i18n.t('errorSavingSettings'), 'error');
+ }
+ } catch (e) {
+ console.error('Error applying rule:', e);
+ window.showToast(store.i18n.t('errorSavingSettings'), 'error');
+ } finally {
+ applyingRuleId.value = null;
+ }
+}
+
+// Format condition for display
+function formatCondition(rule) {
+ if (!rule.conditions || rule.conditions.length === 0) {
+ return store.i18n.t('conditionAlways');
+ }
+
+ // Simplified display - show first condition
+ const first = rule.conditions[0];
+ let text = formatSingleCondition(first);
+
+ if (rule.conditions.length > 1) {
+ text += ` ${store.i18n.t('andNMore', { count: rule.conditions.length - 1 })}`;
+ }
+
+ return text;
+}
+
+function formatSingleCondition(condition) {
+ const fieldLabels = {
+ 'feed_name': store.i18n.t('feedName'),
+ 'feed_category': store.i18n.t('feedCategory'),
+ 'article_title': store.i18n.t('articleTitle'),
+ 'published_after': store.i18n.t('publishedAfter'),
+ 'published_before': store.i18n.t('publishedBefore'),
+ 'is_read': store.i18n.t('readStatus'),
+ 'is_favorite': store.i18n.t('favoriteStatus'),
+ 'is_hidden': store.i18n.t('hiddenStatus')
+ };
+
+ const field = fieldLabels[condition.field] || condition.field;
+ let value = condition.value || (condition.values && condition.values.length > 0 ? condition.values[0] : '');
+
+ if (condition.negate) {
+ return `${store.i18n.t('not')} ${field}: ${value}`;
+ }
+
+ return `${field}: ${value}`;
+}
+
+// Format actions for display
+function formatActions(rule) {
+ if (!rule.actions || rule.actions.length === 0) {
+ return '-';
+ }
+
+ const actionLabels = {
+ 'favorite': store.i18n.t('actionFavorite'),
+ 'unfavorite': store.i18n.t('actionUnfavorite'),
+ 'hide': store.i18n.t('actionHide'),
+ 'unhide': store.i18n.t('actionUnhide'),
+ 'mark_read': store.i18n.t('actionMarkRead'),
+ 'mark_unread': store.i18n.t('actionMarkUnread')
+ };
+
+ return rule.actions.map(a => actionLabels[a] || a).join(', ');
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ store.i18n.t('rules') }}
+
{{ store.i18n.t('rulesDesc') }}
+
+
+
+
+
+
+
+
+
{{ store.i18n.t('noRules') }}
+
{{ store.i18n.t('noRulesHint') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ rule.name || store.i18n.t('rules') + ' #' + rule.id }}
+
+
+
+
+ {{ formatCondition(rule) }}
+
+
→
+
+
+ {{ formatActions(rule) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/modals/settings/ShortcutsTab.vue b/frontend/src/components/modals/settings/ShortcutsTab.vue
index 8a00bb5c6..86dfd8067 100644
--- a/frontend/src/components/modals/settings/ShortcutsTab.vue
+++ b/frontend/src/components/modals/settings/ShortcutsTab.vue
@@ -227,47 +227,47 @@ watch(() => props.settings.shortcuts, (newVal) => {
-
-
-
-
+
+
+
+
-
{{ store.i18n.t('shortcuts') }}
-
{{ store.i18n.t('shortcutsDesc') }}
+
{{ store.i18n.t('shortcuts') }}
+
{{ store.i18n.t('shortcutsDesc') }}
-
-
-
{{ store.i18n.t('escToClear') }}
+
+
{{ store.i18n.t('escToClear') }}
-