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 @@ + + + + + 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(', '); +} + + + + + 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) => {