mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-22 10:49:57 +00:00
feat(sieve-filters): implement Sieve Filters page and integrate notification suppression logic
This commit is contained in:
@@ -1119,6 +1119,9 @@ export default {
|
||||
if (this.config?.do_not_disturb_enabled) {
|
||||
break;
|
||||
}
|
||||
if (json.sieve_suppress_notifications) {
|
||||
break;
|
||||
}
|
||||
|
||||
// show notification for new messages if window is not focussed
|
||||
// only for incoming messages from people (with content)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
|
||||
<div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
|
||||
>
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
class="p-2 bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 rounded-lg shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="filter-variant" class="size-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
</div>
|
||||
<h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{{ $t("tools.sieve_filters.title") }}
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
|
||||
{{ $t("tools.sieve_filters.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/tools"
|
||||
class="inline-flex items-center gap-2 text-sm text-violet-600 dark:text-violet-300 hover:underline shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
|
||||
{{ $t("tools.sieve_filters.back_tools") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 items-start">
|
||||
<div class="space-y-4 min-w-0 order-2 xl:order-1">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("tools.sieve_filters.rules_heading") }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-violet-600 text-white hover:bg-violet-700 transition-colors"
|
||||
@click="addRule"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="size-4" />
|
||||
{{ $t("tools.sieve_filters.add_rule") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.sieve_filters.order_hint") }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="filters.length === 0"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center"
|
||||
>
|
||||
{{ $t("tools.sieve_filters.empty_rules") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in filters"
|
||||
:key="rule.id"
|
||||
class="rounded-lg border border-gray-200 dark:border-zinc-800 p-3 space-y-3 bg-gray-50/80 dark:bg-zinc-900/40"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<label
|
||||
class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<input
|
||||
v-model="rule.enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
{{ $t("tools.sieve_filters.enabled") }}
|
||||
</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
|
||||
:title="$t('tools.sieve_filters.move_up')"
|
||||
:disabled="index === 0"
|
||||
@click="moveRule(index, -1)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-up" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
|
||||
:title="$t('tools.sieve_filters.move_down')"
|
||||
:disabled="index === filters.length - 1"
|
||||
@click="moveRule(index, 1)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-down" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40"
|
||||
:title="$t('tools.sieve_filters.remove_rule')"
|
||||
@click="removeRule(index)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-outline" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
|
||||
>{{ $t("tools.sieve_filters.terms_label") }}</label
|
||||
>
|
||||
<textarea
|
||||
:value="termsText(rule)"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white font-mono"
|
||||
:placeholder="$t('tools.sieve_filters.terms_placeholder')"
|
||||
@input="onTermsInput(rule, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
|
||||
>{{ $t("tools.sieve_filters.scope_label") }}</label
|
||||
>
|
||||
<select
|
||||
v-model="rule.scope"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="everyone">
|
||||
{{ $t("tools.sieve_filters.scope_everyone") }}
|
||||
</option>
|
||||
<option value="contacts">
|
||||
{{ $t("tools.sieve_filters.scope_contacts") }}
|
||||
</option>
|
||||
<option value="non_contacts">
|
||||
{{ $t("tools.sieve_filters.scope_non_contacts") }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
{{ $t("tools.sieve_filters.match_targets_label") }}
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
v-model="rule.match_peer_fields"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300"
|
||||
@change="onMatchTargetsChange(rule)"
|
||||
/>
|
||||
{{ $t("tools.sieve_filters.match_peer_fields") }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
v-model="rule.match_message"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300"
|
||||
@change="onMatchTargetsChange(rule)"
|
||||
/>
|
||||
{{ $t("tools.sieve_filters.match_message") }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.sieve_filters.match_targets_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
|
||||
>{{ $t("tools.sieve_filters.match_mode_label") }}</label
|
||||
>
|
||||
<select
|
||||
v-model="rule.match_mode"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="substring">
|
||||
{{ $t("tools.sieve_filters.match_mode_substring") }}
|
||||
</option>
|
||||
<option value="regex">
|
||||
{{ $t("tools.sieve_filters.match_mode_regex") }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
|
||||
>{{ $t("tools.sieve_filters.action_label") }}</label
|
||||
>
|
||||
<select
|
||||
v-model="rule.action"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
|
||||
@change="onActionChange(rule)"
|
||||
>
|
||||
<option value="hide">
|
||||
{{ $t("tools.sieve_filters.action_hide") }}
|
||||
</option>
|
||||
<option value="ignore">
|
||||
{{ $t("tools.sieve_filters.action_ignore") }}
|
||||
</option>
|
||||
<option value="folder">
|
||||
{{ $t("tools.sieve_filters.action_folder") }}
|
||||
</option>
|
||||
<option value="banish">
|
||||
{{ $t("tools.sieve_filters.action_banish") }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="rule.action === 'folder'">
|
||||
<label
|
||||
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
|
||||
>{{ $t("tools.sieve_filters.folder_label") }}</label
|
||||
>
|
||||
<select
|
||||
v-model.number="rule.folder_id"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">
|
||||
{{ f.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-gray-900 text-white dark:bg-zinc-100 dark:text-zinc-900 hover:opacity-90"
|
||||
:disabled="isSaving"
|
||||
@click="save"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
v-if="!isSaving"
|
||||
icon-name="content-save-outline"
|
||||
class="size-4"
|
||||
/>
|
||||
<span v-if="isSaving">{{ $t("tools.sieve_filters.saving") }}</span>
|
||||
<span v-else>{{ $t("tools.sieve_filters.save") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-gray-300 dark:border-zinc-600 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
|
||||
:disabled="isSaving"
|
||||
@click="reload"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="restore" class="size-4" />
|
||||
{{ $t("tools.sieve_filters.revert") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 order-1 xl:order-2 space-y-2">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white px-1">
|
||||
{{ $t("tools.sieve_filters.flow_heading") }}
|
||||
</h2>
|
||||
<SieveFlowNetwork :filters="filters" :folders="folders" :labels="flowLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import SieveFlowNetwork from "./internal/SieveFlowNetwork.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
function newRuleId() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `r-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "SieveFiltersPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
SieveFlowNetwork,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
filters: [],
|
||||
folders: [],
|
||||
isSaving: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
flowLabels() {
|
||||
return {
|
||||
sourceNode: this.$t("tools.sieve_filters.flow_source"),
|
||||
sourceHint: this.$t("tools.sieve_filters.flow_source_hint"),
|
||||
rulePrefix: this.$t("tools.sieve_filters.flow_if"),
|
||||
hide: this.$t("tools.sieve_filters.flow_hide"),
|
||||
ignore: this.$t("tools.sieve_filters.action_ignore"),
|
||||
banish: this.$t("tools.sieve_filters.flow_banish"),
|
||||
folder: this.$t("tools.sieve_filters.flow_folder"),
|
||||
noRules: this.$t("tools.sieve_filters.flow_no_rules"),
|
||||
graphScopeEveryone: this.$t("tools.sieve_filters.graph_scope_everyone"),
|
||||
graphScopeContacts: this.$t("tools.sieve_filters.graph_scope_contacts"),
|
||||
graphScopeNonContacts: this.$t("tools.sieve_filters.graph_scope_non_contacts"),
|
||||
graphMatchPeer: this.$t("tools.sieve_filters.graph_match_peer"),
|
||||
graphMatchMessage: this.$t("tools.sieve_filters.graph_match_message"),
|
||||
graphMatchModeSubstring: this.$t("tools.sieve_filters.graph_match_mode_substring"),
|
||||
graphMatchModeRegex: this.$t("tools.sieve_filters.graph_match_mode_regex"),
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.reload();
|
||||
},
|
||||
methods: {
|
||||
termsText(rule) {
|
||||
return (rule.terms || []).join("\n");
|
||||
},
|
||||
onTermsInput(rule, e) {
|
||||
const raw = (e.target && e.target.value) || "";
|
||||
rule.terms = raw
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
},
|
||||
onActionChange(rule) {
|
||||
if (rule.action === "folder" && this.folders.length && (rule.folder_id == null || rule.folder_id === "")) {
|
||||
rule.folder_id = this.folders[0].id;
|
||||
}
|
||||
if (rule.action !== "folder") {
|
||||
rule.folder_id = null;
|
||||
}
|
||||
},
|
||||
onMatchTargetsChange(rule) {
|
||||
if (!rule.match_peer_fields && !rule.match_message) {
|
||||
rule.match_peer_fields = true;
|
||||
}
|
||||
},
|
||||
mapRuleFromApi(r) {
|
||||
let action = r.action || "ignore";
|
||||
if (action === "block") {
|
||||
action = "hide";
|
||||
}
|
||||
return {
|
||||
id: r.id || newRuleId(),
|
||||
enabled: r.enabled !== false,
|
||||
scope: r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone",
|
||||
terms: Array.isArray(r.terms) ? [...r.terms] : [],
|
||||
action,
|
||||
folder_id: r.folder_id ?? null,
|
||||
match_peer_fields: r.match_peer_fields !== false,
|
||||
match_message: !!r.match_message,
|
||||
match_mode: r.match_mode === "regex" ? "regex" : "substring",
|
||||
};
|
||||
},
|
||||
addRule() {
|
||||
const base = {
|
||||
id: newRuleId(),
|
||||
enabled: true,
|
||||
scope: "everyone",
|
||||
terms: [],
|
||||
action: "ignore",
|
||||
folder_id: this.folders.length ? this.folders[0].id : null,
|
||||
match_peer_fields: true,
|
||||
match_message: false,
|
||||
match_mode: "substring",
|
||||
};
|
||||
this.filters.push(base);
|
||||
},
|
||||
removeRule(index) {
|
||||
this.filters.splice(index, 1);
|
||||
},
|
||||
moveRule(index, delta) {
|
||||
const j = index + delta;
|
||||
if (j < 0 || j >= this.filters.length) {
|
||||
return;
|
||||
}
|
||||
const copy = this.filters.slice();
|
||||
const t = copy[index];
|
||||
copy[index] = copy[j];
|
||||
copy[j] = t;
|
||||
this.filters = copy;
|
||||
},
|
||||
normalizeForSave() {
|
||||
return this.filters.map((r) => {
|
||||
const scope = r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone";
|
||||
const match_peer_fields = r.match_peer_fields !== false;
|
||||
const match_message = !!r.match_message;
|
||||
const targets_ok = match_peer_fields || match_message;
|
||||
return {
|
||||
id: r.id,
|
||||
enabled: !!r.enabled,
|
||||
scope,
|
||||
terms: Array.isArray(r.terms) ? r.terms : [],
|
||||
action: r.action === "block" ? "hide" : r.action,
|
||||
folder_id: r.action === "folder" ? r.folder_id : null,
|
||||
match_peer_fields: targets_ok ? match_peer_fields : true,
|
||||
match_message: targets_ok ? match_message : false,
|
||||
match_mode: r.match_mode === "regex" ? "regex" : "substring",
|
||||
};
|
||||
});
|
||||
},
|
||||
async reload() {
|
||||
try {
|
||||
const [fRes, foldersRes] = await Promise.all([
|
||||
window.api.get("/api/v1/lxmf/sieve-filters"),
|
||||
window.api.get("/api/v1/lxmf/folders"),
|
||||
]);
|
||||
const raw = fRes.data.filters || [];
|
||||
this.filters = raw.map((r) => this.mapRuleFromApi(r));
|
||||
this.folders = foldersRes.data || [];
|
||||
this.onActionChangeForAll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("tools.sieve_filters.load_failed"));
|
||||
}
|
||||
},
|
||||
onActionChangeForAll() {
|
||||
this.filters.forEach((r) => this.onActionChange(r));
|
||||
},
|
||||
async save() {
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const payload = { filters: this.normalizeForSave() };
|
||||
const res = await window.api.put("/api/v1/lxmf/sieve-filters", payload);
|
||||
this.filters = (res.data.filters || []).map((r) => this.mapRuleFromApi(r));
|
||||
ToastUtils.success(this.$t("tools.sieve_filters.saved"));
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e.response && e.response.data && e.response.data.message) ||
|
||||
e.message ||
|
||||
this.$t("tools.sieve_filters.save_failed");
|
||||
ToastUtils.error(msg);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -206,6 +206,14 @@ export default {
|
||||
titleKey: "tools.forwarder.title",
|
||||
descriptionKey: "tools.forwarder.description",
|
||||
},
|
||||
{
|
||||
name: "sieve-filters",
|
||||
route: { name: "sieve-filters" },
|
||||
icon: "filter-variant",
|
||||
iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
|
||||
titleKey: "tools.sieve_filters.title",
|
||||
descriptionKey: "tools.sieve_filters.description",
|
||||
},
|
||||
{
|
||||
name: "documentation",
|
||||
route: { name: "documentation" },
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="host"
|
||||
class="sieve-flow-host rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "vis-network/styles/vis-network.css";
|
||||
import { DataSet } from "vis-data";
|
||||
import { Network } from "vis-network";
|
||||
|
||||
export default {
|
||||
name: "SieveFlowNetwork",
|
||||
props: {
|
||||
filters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
folders: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
network: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.rebuild();
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.rebuild();
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.rebuild();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => this.rebuild());
|
||||
window.addEventListener("resize", this.onResize);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
this.destroyNetwork();
|
||||
},
|
||||
methods: {
|
||||
onResize() {
|
||||
this.network?.redraw();
|
||||
this.network?.fit({ animation: false });
|
||||
},
|
||||
destroyNetwork() {
|
||||
if (this.network) {
|
||||
this.network.destroy();
|
||||
this.network = null;
|
||||
}
|
||||
},
|
||||
folderName(folderId) {
|
||||
const f = this.folders.find((x) => x.id === folderId);
|
||||
return f ? f.name : String(folderId);
|
||||
},
|
||||
rebuild() {
|
||||
this.destroyNetwork();
|
||||
const el = this.$refs.host;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const L = this.labels || {};
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const palette = {
|
||||
src: { background: "#2563eb", border: "#1d4ed8", font: "#ffffff" },
|
||||
rule: { background: "#f4f4f5", border: "#a1a1aa", font: "#18181b" },
|
||||
ruleDark: { background: "#27272a", border: "#52525b", font: "#fafafa" },
|
||||
hide: { background: "#b91c1c", border: "#991b1b", font: "#ffffff" },
|
||||
ignore: { background: "#ca8a04", border: "#a16207", font: "#ffffff" },
|
||||
folder: { background: "#15803d", border: "#166534", font: "#ffffff" },
|
||||
banish: { background: "#4c1d95", border: "#5b21b6", font: "#ffffff" },
|
||||
};
|
||||
const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
||||
const ruleColors = isDark ? palette.ruleDark : palette.rule;
|
||||
|
||||
nodes.push({
|
||||
id: "sieve-src",
|
||||
label: L.sourceNode || "Peers",
|
||||
title: L.sourceHint || "",
|
||||
level: 0,
|
||||
shape: "box",
|
||||
margin: 12,
|
||||
font: { color: palette.src.font, multi: true },
|
||||
color: { background: palette.src.background, border: palette.src.border, highlight: palette.src },
|
||||
});
|
||||
|
||||
const enabled = (this.filters || []).filter((r) => r && r.enabled !== false);
|
||||
const outcomes = new Set();
|
||||
|
||||
enabled.forEach((rule, ruleIndex) => {
|
||||
const rid = `sieve-rule-${rule.id || ruleIndex}`;
|
||||
const sc = rule.scope === "contacts" || rule.scope === "non_contacts" ? rule.scope : "everyone";
|
||||
const scopeLine =
|
||||
sc === "contacts"
|
||||
? L.graphScopeContacts || "Contacts"
|
||||
: sc === "non_contacts"
|
||||
? L.graphScopeNonContacts || "Non-contacts"
|
||||
: L.graphScopeEveryone || "Everyone";
|
||||
const terms = (rule.terms || []).slice(0, 4).join(", ");
|
||||
const more = (rule.terms || []).length > 4 ? "…" : "";
|
||||
const matchPeer = rule.match_peer_fields !== false;
|
||||
const matchMsg = !!rule.match_message;
|
||||
const modeLine =
|
||||
rule.match_mode === "regex"
|
||||
? L.graphMatchModeRegex || "regex"
|
||||
: L.graphMatchModeSubstring || "substring";
|
||||
const targetBits = [];
|
||||
if (matchPeer) {
|
||||
targetBits.push(L.graphMatchPeer || "peer");
|
||||
}
|
||||
if (matchMsg) {
|
||||
targetBits.push(L.graphMatchMessage || "msg");
|
||||
}
|
||||
const targetLine = targetBits.length ? targetBits.join("+") : L.graphMatchPeer || "peer";
|
||||
nodes.push({
|
||||
id: rid,
|
||||
label: `${scopeLine}\n${targetLine} · ${modeLine}\n${L.rulePrefix || "If"}:\n${terms || "…"}${more}`,
|
||||
title: (rule.terms || []).join("\n"),
|
||||
level: 1,
|
||||
shape: "box",
|
||||
margin: 10,
|
||||
font: { color: ruleColors.font, multi: true, size: 13 },
|
||||
color: {
|
||||
background: ruleColors.background,
|
||||
border: ruleColors.border,
|
||||
highlight: ruleColors,
|
||||
},
|
||||
});
|
||||
edges.push({
|
||||
from: "sieve-src",
|
||||
to: rid,
|
||||
arrows: "to",
|
||||
color: { color: "#94a3b8" },
|
||||
});
|
||||
|
||||
let outId = "sieve-out-hide";
|
||||
let outLabel = L.hide || "Hide";
|
||||
let outColor = palette.hide;
|
||||
const act = rule.action === "block" ? "hide" : rule.action;
|
||||
if (act === "ignore") {
|
||||
outId = "sieve-out-ignore";
|
||||
outLabel = L.ignore || "Ignore";
|
||||
outColor = palette.ignore;
|
||||
} else if (act === "banish") {
|
||||
outId = "sieve-out-banish";
|
||||
outLabel = L.banish || "Banish";
|
||||
outColor = palette.banish;
|
||||
} else if (act === "folder" && rule.folder_id != null) {
|
||||
outId = `sieve-out-folder-${rule.folder_id}`;
|
||||
outLabel = `${L.folder || "Folder"}:\n${this.folderName(rule.folder_id)}`;
|
||||
outColor = palette.folder;
|
||||
}
|
||||
outcomes.add(
|
||||
JSON.stringify({
|
||||
id: outId,
|
||||
label: outLabel,
|
||||
bg: outColor.background,
|
||||
bd: outColor.border,
|
||||
fg: outColor.font,
|
||||
})
|
||||
);
|
||||
edges.push({
|
||||
from: rid,
|
||||
to: outId,
|
||||
arrows: "to",
|
||||
color: { color: "#64748b" },
|
||||
});
|
||||
});
|
||||
|
||||
outcomes.forEach((enc) => {
|
||||
const o = JSON.parse(enc);
|
||||
nodes.push({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
level: 2,
|
||||
shape: "box",
|
||||
margin: 12,
|
||||
font: { color: o.fg, multi: true },
|
||||
color: {
|
||||
background: o.bg,
|
||||
border: o.bd,
|
||||
highlight: { background: o.bg, border: o.bd },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (enabled.length === 0) {
|
||||
nodes.push({
|
||||
id: "sieve-out-none",
|
||||
label: L.noRules || "No rules",
|
||||
level: 2,
|
||||
shape: "box",
|
||||
margin: 12,
|
||||
font: { color: "#64748b" },
|
||||
color: { background: "#f1f5f9", border: "#cbd5e1" },
|
||||
});
|
||||
edges.push({
|
||||
from: "sieve-src",
|
||||
to: "sieve-out-none",
|
||||
arrows: "to",
|
||||
color: { color: "#94a3b8" },
|
||||
});
|
||||
}
|
||||
|
||||
const data = { nodes: new DataSet(nodes), edges: new DataSet(edges) };
|
||||
this.network = new Network(el, data, {
|
||||
layout: {
|
||||
hierarchical: {
|
||||
direction: "LR",
|
||||
sortMethod: "directed",
|
||||
levelSeparation: 140,
|
||||
nodeSpacing: 110,
|
||||
},
|
||||
},
|
||||
physics: false,
|
||||
interaction: { hover: true, zoomView: true, dragView: true },
|
||||
edges: { smooth: { type: "cubicBezier", forceDirection: "horizontal" } },
|
||||
});
|
||||
this.network.once("stabilizationIterationsDone", () => {
|
||||
this.network.fit({ animation: false });
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sieve-flow-host {
|
||||
width: 100%;
|
||||
height: min(420px, 55vh);
|
||||
min-height: 280px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import SieveFiltersPage from "@/components/tools/SieveFiltersPage.vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SieveFiltersPage.vue", () => {
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: "/tools", name: "tools", component: { template: "<div/>" } }],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.api.get = vi.fn((url) => {
|
||||
if (url.includes("sieve-filters")) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
filters: [
|
||||
{
|
||||
id: "r1",
|
||||
enabled: true,
|
||||
terms: ["spam"],
|
||||
action: "ignore",
|
||||
folder_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("folders")) {
|
||||
return Promise.resolve({ data: [{ id: 1, name: "Inbox" }] });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
global.api.put = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
filters: [
|
||||
{
|
||||
id: "r1",
|
||||
enabled: true,
|
||||
terms: ["spam"],
|
||||
action: "ignore",
|
||||
folder_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
window.api = global.api;
|
||||
});
|
||||
|
||||
it("loads filters and folders from the API", async () => {
|
||||
const wrapper = mount(SieveFiltersPage, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
mocks: { $t: (k) => k },
|
||||
stubs: {
|
||||
MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
|
||||
SieveFlowNetwork: { template: "<div class='sieve-flow-stub'/>" },
|
||||
RouterLink: { template: "<a><slot/></a>", props: ["to"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await wrapper.vm.$nextTick();
|
||||
await Promise.resolve();
|
||||
expect(global.api.get).toHaveBeenCalled();
|
||||
expect(wrapper.vm.filters.length).toBe(1);
|
||||
expect(wrapper.vm.filters[0].terms).toEqual(["spam"]);
|
||||
expect(wrapper.vm.folders.length).toBe(1);
|
||||
});
|
||||
|
||||
it("saves filters via PUT", async () => {
|
||||
const wrapper = mount(SieveFiltersPage, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
mocks: { $t: (k) => k },
|
||||
stubs: {
|
||||
MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
|
||||
SieveFlowNetwork: { template: "<div/>" },
|
||||
RouterLink: { template: "<a><slot/></a>", props: ["to"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await wrapper.vm.$nextTick();
|
||||
await Promise.resolve();
|
||||
await wrapper.vm.save();
|
||||
expect(global.api.put).toHaveBeenCalledWith(
|
||||
"/api/v1/lxmf/sieve-filters",
|
||||
expect.objectContaining({
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
match_peer_fields: true,
|
||||
match_message: false,
|
||||
match_mode: "substring",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
expect(ToastUtils.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => {
|
||||
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
|
||||
{ path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
|
||||
{ path: "/mesh-server", name: "mesh-server", component: { template: "div" } },
|
||||
{ path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -53,7 +54,7 @@ describe("ToolsPage.vue", () => {
|
||||
it("renders all tool rows", () => {
|
||||
const wrapper = mountToolsPage();
|
||||
const toolRows = wrapper.findAll(".tool-row");
|
||||
expect(toolRows.length).toBe(19);
|
||||
expect(toolRows.length).toBe(20);
|
||||
});
|
||||
|
||||
it("filters tools based on search query", async () => {
|
||||
@@ -78,6 +79,6 @@ describe("ToolsPage.vue", () => {
|
||||
await clearButton.trigger("click");
|
||||
|
||||
expect(wrapper.vm.searchQuery).toBe("");
|
||||
expect(wrapper.vm.filteredTools.length).toBe(19);
|
||||
expect(wrapper.vm.filteredTools.length).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user