mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
A filter can now file the mail it matches under one of the reader's own categories. RuleCategoryFiler decides whether it may: only a message whose sender passes authentication (a forged From line must not sit in a tab named after the real sender), only into a category the rule's owner owns, and, on a Gmail label, only when the reader asked plMail to overrule the provider. A refusal leaves the message to the ordinary rules. When two filters match, the one that runs last decides. The editor offers the action only to somebody who has a category (or whose rule already carries one), so nobody else's editor changes. Ownership voting, the plain-English restatement and the three catalogues learn the new action. RuleActionExecutor takes the filer as a constructor argument, so the one test that builds it by hand passes a real one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
795 lines
31 KiB
JavaScript
795 lines
31 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
|
|
/**
|
|
* Boolean condition-tree editor for mail rules.
|
|
*
|
|
* The tree is rendered as nested, indented groups rather than a canvas or a
|
|
* two-pane arrangement. That is a deliberate mobile decision: vertical flow
|
|
* needs no horizontal room, so a phone gets the *same* editor with tighter
|
|
* indentation and a wrapped control row, not a cut-down one. Depth is shown by
|
|
* a left rail rather than whitespace alone, which survives the narrower indent.
|
|
*
|
|
* The tree lives in JS and is serialised into a hidden input on submit, because
|
|
* its shape is recursive and its depth is chosen by the author — something a
|
|
* server-rendered form tree models badly. FilterAstValidator re-checks whatever
|
|
* arrives; nothing here is trusted.
|
|
*
|
|
* Two feedback affordances hang off the same debounce: a plain-English sentence
|
|
* and a live count of matching mail. The count is what makes a filter something
|
|
* you can see rather than something you write and hope about.
|
|
*
|
|
* The one thing this editor does not draw itself is the folder a
|
|
* "save attachments to" action targets. That is the destination picker the
|
|
* reading pane already uses, opened in the shared modal and answered with a
|
|
* `integration--destination-picker:chosen` event — a rule that saves to
|
|
* Nextcloud and a hand-saved attachment browse the same folders, because there
|
|
* is one chooser rather than a second, thinner one built here.
|
|
*/
|
|
|
|
const OPERATORS = [
|
|
{ value: "AND", labelKey: "all" },
|
|
{ value: "OR", labelKey: "any" },
|
|
{ value: "NOT", labelKey: "none" },
|
|
]
|
|
|
|
/** Field kind decides which input is rendered and how the value is coerced. */
|
|
const FIELDS = {
|
|
from: "text", fromAddress: "text", fromDomain: "text",
|
|
to: "text", cc: "text", bcc: "text",
|
|
subject: "text", body: "text", text: "text",
|
|
filename: "text", listId: "text",
|
|
hasLabel: "label", notLabel: "label",
|
|
minSize: "number", maxSize: "number",
|
|
before: "date", after: "date",
|
|
hasAttachment: "bool",
|
|
hasKeyword: "keyword", notKeyword: "keyword",
|
|
}
|
|
|
|
const KEYWORDS = ["$seen", "$flagged", "$draft", "$answered"]
|
|
|
|
export default class extends Controller {
|
|
static targets = ["tree", "conditions", "actions", "actionList", "summary", "count", "form"]
|
|
static values = {
|
|
conditions: Object,
|
|
actions: Array,
|
|
labels: Array,
|
|
categories: Array,
|
|
integrations: Array,
|
|
previewUrl: String,
|
|
csrf: String,
|
|
/** Open a fresh editor with one condition row to fill in. */
|
|
seedCondition: Boolean,
|
|
i18n: Object,
|
|
}
|
|
|
|
connect() {
|
|
this.tree = this._seed(this.conditionsValue)
|
|
this.actions = Array.isArray(this.actionsValue) ? [...this.actionsValue] : []
|
|
|
|
// Folder id → the readable name the picker gave it. Only the id is
|
|
// stored on the rule, so a saved rule reopened later starts with an
|
|
// empty map and shows ids until something is picked again — see
|
|
// _folderControls() for why the id is what is stored.
|
|
this._folderLabels = new Map()
|
|
|
|
this.render()
|
|
this.renderActions()
|
|
this.schedulePreview()
|
|
}
|
|
|
|
disconnect() {
|
|
clearTimeout(this._previewTimer)
|
|
}
|
|
|
|
// ── Rendering ───────────────────────────────────────────────────────────
|
|
|
|
render() {
|
|
this.treeTarget.replaceChildren(this._group(this.tree, [], 0))
|
|
this.serialise()
|
|
}
|
|
|
|
/**
|
|
* A group: operator selector, its children, and the two add buttons.
|
|
* `path` addresses this node from the root so handlers can mutate in place
|
|
* without needing node identity.
|
|
*/
|
|
_group(node, path, depth) {
|
|
const wrap = document.createElement("div")
|
|
wrap.className =
|
|
depth === 0
|
|
? "rounded-lg border border-line bg-sunken/40"
|
|
: "rounded-lg border border-line bg-sunken/40 mt-2"
|
|
|
|
const head = document.createElement("div")
|
|
head.className = "flex flex-wrap items-center gap-2 px-3 py-2 border-b border-line"
|
|
|
|
const seg = document.createElement("div")
|
|
seg.className = "inline-flex rounded-lg border border-field overflow-hidden"
|
|
|
|
for (const op of OPERATORS) {
|
|
const b = document.createElement("button")
|
|
b.type = "button"
|
|
b.textContent = this._t(op.labelKey)
|
|
b.className =
|
|
"px-2.5 h-7 text-xs font-medium transition-colors cursor-pointer " +
|
|
(node.operator === op.value
|
|
? "bg-accent text-accent-ink"
|
|
: "text-ink-soft hover:bg-hover")
|
|
b.addEventListener("click", () => {
|
|
this._at(path).operator = op.value
|
|
this.render()
|
|
this.schedulePreview()
|
|
})
|
|
seg.append(b)
|
|
}
|
|
|
|
head.append(seg)
|
|
|
|
const hint = document.createElement("span")
|
|
hint.className = "text-xs text-ink-faint"
|
|
hint.textContent = this._t("of_the_following")
|
|
head.append(hint)
|
|
|
|
const spacer = document.createElement("div")
|
|
spacer.className = "flex-1"
|
|
head.append(spacer)
|
|
|
|
head.append(this._smallButton("fa-plus", this._t("add_condition"), () => {
|
|
this._at(path).conditions.push({ field: "subject", value: "" })
|
|
this.render()
|
|
this.schedulePreview()
|
|
}))
|
|
|
|
// Depth is capped well below the validator's limit: past three levels a
|
|
// nested filter is easier to express as two rules.
|
|
if (depth < 3) {
|
|
head.append(this._smallButton("fa-code-branch", this._t("add_group"), () => {
|
|
this._at(path).conditions.push({ operator: "AND", conditions: [{ field: "subject", value: "" }] })
|
|
this.render()
|
|
this.schedulePreview()
|
|
}))
|
|
}
|
|
|
|
if (depth > 0) {
|
|
head.append(this._smallButton("fa-xmark", this._t("remove_group"), () => {
|
|
this._removeAt(path)
|
|
this.render()
|
|
this.schedulePreview()
|
|
}, true))
|
|
}
|
|
|
|
wrap.append(head)
|
|
|
|
const body = document.createElement("div")
|
|
// The left rail is what carries depth once the indent shrinks on narrow
|
|
// screens; pl-2 on mobile, pl-4 from sm up.
|
|
body.className = "px-2 sm:px-3 py-2 space-y-2"
|
|
|
|
node.conditions.forEach((child, i) => {
|
|
const childPath = [...path, i]
|
|
const railed = document.createElement("div")
|
|
railed.className = depth > 0 ? "border-l-2 border-line/70 pl-2 sm:pl-3" : ""
|
|
railed.append(
|
|
child.operator ? this._group(child, childPath, depth + 1) : this._condition(child, childPath),
|
|
)
|
|
body.append(railed)
|
|
})
|
|
|
|
// Removing the last condition is a decision, not an accident, so the
|
|
// empty state says what the rule now does rather than sitting blank.
|
|
if (node.conditions.length === 0) {
|
|
const empty = document.createElement("p")
|
|
empty.className = "px-1 py-1 text-xs text-ink-faint"
|
|
empty.textContent = this._t(depth === 0 ? "no_conditions" : "empty_group")
|
|
body.append(empty)
|
|
}
|
|
|
|
wrap.append(body)
|
|
|
|
return wrap
|
|
}
|
|
|
|
_condition(node, path) {
|
|
const row = document.createElement("div")
|
|
row.className = "flex flex-wrap items-center gap-2"
|
|
|
|
const field = this._selectElement("w-auto min-w-[8.5rem]")
|
|
|
|
for (const name of Object.keys(FIELDS)) {
|
|
const o = document.createElement("option")
|
|
o.value = name
|
|
o.textContent = this._t(`field.${name}`)
|
|
o.selected = node.field === name
|
|
field.append(o)
|
|
}
|
|
|
|
field.addEventListener("change", () => {
|
|
const target = this._at(path)
|
|
target.field = field.value
|
|
target.value = this._defaultFor(field.value)
|
|
this.render()
|
|
this.schedulePreview()
|
|
})
|
|
|
|
row.append(field)
|
|
row.append(this._valueInput(node, path))
|
|
|
|
row.append(this._smallButton("fa-xmark", this._t("remove_condition"), () => {
|
|
this._removeAt(path)
|
|
this.render()
|
|
this.schedulePreview()
|
|
}, true))
|
|
|
|
return row
|
|
}
|
|
|
|
_valueInput(node, path) {
|
|
const kind = FIELDS[node.field] ?? "text"
|
|
const commit = (value) => {
|
|
this._at(path).value = value
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
}
|
|
|
|
if (kind === "bool" || kind === "keyword" || kind === "label") {
|
|
const select = this._selectElement("flex-1 min-w-[9rem]")
|
|
|
|
const options =
|
|
kind === "bool"
|
|
? [{ v: true, t: this._t("yes") }, { v: false, t: this._t("no") }]
|
|
: kind === "keyword"
|
|
? KEYWORDS.map((k) => ({ v: k, t: this._t(`keyword.${k.slice(1)}`) }))
|
|
: this.labelsValue.map((l) => ({ v: l.id, t: l.name }))
|
|
|
|
for (const opt of options) {
|
|
const o = document.createElement("option")
|
|
o.value = String(opt.v)
|
|
o.textContent = opt.t
|
|
o.selected = String(node.value) === String(opt.v)
|
|
select.append(o)
|
|
}
|
|
|
|
select.addEventListener("change", () => {
|
|
commit(kind === "bool" ? select.value === "true" : kind === "label" ? Number(select.value) : select.value)
|
|
})
|
|
|
|
return select
|
|
}
|
|
|
|
const input = document.createElement("input")
|
|
input.className = this._inputClass() + " flex-1 min-w-[9rem]"
|
|
// Names the condition's value field, so it can be found without asking
|
|
// for "the text input in this row". The row also contains the search
|
|
// box Tom Select injects into any select with more than a handful of
|
|
// options — an unnamed, unlabelled <input type="text"> belonging to the
|
|
// widget, not to the rule — and a positional query over both picks
|
|
// whichever the widget happened to render first.
|
|
input.dataset.ruleValue = ""
|
|
input.type = kind === "number" ? "number" : kind === "date" ? "date" : "text"
|
|
input.value = node.value ?? ""
|
|
input.placeholder = this._t(`placeholder.${kind}`)
|
|
input.addEventListener("input", () => {
|
|
commit(kind === "number" ? Number(input.value) : input.value)
|
|
})
|
|
|
|
return input
|
|
}
|
|
|
|
// ── Actions ─────────────────────────────────────────────────────────────
|
|
|
|
renderActions() {
|
|
this.actionListTarget.replaceChildren()
|
|
|
|
this.actions.forEach((action, i) => {
|
|
const row = document.createElement("div")
|
|
row.className = "flex flex-wrap items-center gap-2"
|
|
|
|
const select = this._selectElement("w-auto min-w-[10rem]")
|
|
|
|
for (const type of this.i18nValue.actionTypes) {
|
|
const o = document.createElement("option")
|
|
o.value = type
|
|
o.textContent = this._t(`action.${type}`)
|
|
o.selected = action.type === type
|
|
select.append(o)
|
|
}
|
|
|
|
select.addEventListener("change", () => {
|
|
this.actions[i] = { type: select.value }
|
|
|
|
if (select.value === "applyLabel" || select.value === "removeLabel") {
|
|
this.actions[i].labelId = this._assignableLabels()[0]?.id
|
|
}
|
|
|
|
if (select.value === "saveToIntegration") {
|
|
this.actions[i].integrationId = this.integrationsValue[0]?.id
|
|
}
|
|
|
|
if (select.value === "fileInCategory") {
|
|
this.actions[i].categoryId = this.categoriesValue[0]?.id
|
|
}
|
|
|
|
this.renderActions()
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
})
|
|
|
|
row.append(select)
|
|
|
|
if (action.type === "applyLabel" || action.type === "removeLabel") {
|
|
const label = this._selectElement("flex-1 min-w-[9rem]")
|
|
|
|
// System labels are excluded here but not from conditions:
|
|
// "has label Inbox" is a sensible test, while "apply label
|
|
// Inbox" is not — that is what the archive action means.
|
|
for (const l of this._assignableLabels()) {
|
|
const o = document.createElement("option")
|
|
o.value = String(l.id)
|
|
o.textContent = l.name
|
|
o.selected = Number(action.labelId) === l.id
|
|
label.append(o)
|
|
}
|
|
|
|
label.addEventListener("change", () => {
|
|
this.actions[i].labelId = Number(label.value)
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
})
|
|
|
|
row.append(label)
|
|
}
|
|
|
|
if (action.type === "fileInCategory") {
|
|
if (0 === this.categoriesValue.length) {
|
|
// Every category was deleted since this rule was written.
|
|
// Said plainly rather than rendering an empty select that
|
|
// would store no target and file nothing at run time.
|
|
const warn = document.createElement("span")
|
|
warn.className = "text-xs text-danger"
|
|
warn.textContent = this._t("no_categories")
|
|
row.append(warn)
|
|
} else {
|
|
const target = this._selectElement("flex-1 min-w-[9rem]")
|
|
|
|
for (const category of this.categoriesValue) {
|
|
const o = document.createElement("option")
|
|
o.value = String(category.id)
|
|
o.textContent = category.name
|
|
o.selected = Number(action.categoryId) === category.id
|
|
target.append(o)
|
|
}
|
|
|
|
target.addEventListener("change", () => {
|
|
this.actions[i].categoryId = Number(target.value)
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
})
|
|
|
|
row.append(target)
|
|
}
|
|
}
|
|
|
|
if (action.type === "saveToIntegration") {
|
|
if (0 === this.integrationsValue.length) {
|
|
// Nothing to save to. Said plainly here rather than
|
|
// rendering an empty select that would silently store no
|
|
// target and do nothing at run time.
|
|
const warn = document.createElement("span")
|
|
warn.className = "text-xs text-danger"
|
|
warn.textContent = this._t("no_integrations")
|
|
row.append(warn)
|
|
} else {
|
|
const target = this._selectElement("w-auto min-w-[9rem]")
|
|
|
|
for (const integration of this.integrationsValue) {
|
|
const o = document.createElement("option")
|
|
o.value = String(integration.id)
|
|
o.textContent = integration.name
|
|
o.selected = Number(action.integrationId) === integration.id
|
|
target.append(o)
|
|
}
|
|
|
|
target.addEventListener("change", () => {
|
|
this.actions[i].integrationId = Number(target.value)
|
|
|
|
// The folder belongs to the connection it was browsed
|
|
// in. Left behind on a switch it would still look
|
|
// chosen, still be stored, and be handed to a service
|
|
// that has never heard of it — the upload would land in
|
|
// the new connection's default and the editor would go
|
|
// on claiming otherwise.
|
|
delete this.actions[i].folder
|
|
|
|
this.renderActions()
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
})
|
|
|
|
row.append(target)
|
|
row.append(...this._folderControls(action, i))
|
|
}
|
|
}
|
|
|
|
row.append(this._smallButton("fa-xmark", this._t("remove_action"), () => {
|
|
this.actions.splice(i, 1)
|
|
this.renderActions()
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
}, true))
|
|
|
|
this.actionListTarget.append(row)
|
|
})
|
|
}
|
|
|
|
addAction() {
|
|
const assignable = this._assignableLabels()
|
|
|
|
// With no user labels to apply, "apply label" would be an action that
|
|
// cannot be completed — offer something that works instead.
|
|
this.actions.push(
|
|
assignable.length > 0
|
|
? { type: "applyLabel", labelId: assignable[0].id }
|
|
: { type: "markRead" },
|
|
)
|
|
this.renderActions()
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
}
|
|
|
|
/**
|
|
* A folder came back from the destination picker.
|
|
*
|
|
* Wired as a `@document` action on the form — see
|
|
* settings/filters/_editor.html.twig — because the picker renders into the
|
|
* body-level modal frame and cannot reach this one through the DOM.
|
|
*
|
|
* Which row it belongs to is remembered rather than carried in the event:
|
|
* there is exactly one dialog and it is modal, so the row that opened it is
|
|
* the row that is still waiting when the answer arrives, and the picker
|
|
* would otherwise have to be told about an editor it knows nothing about.
|
|
*/
|
|
folderChosen(event) {
|
|
const index = this._folderPickFor
|
|
const action = this.actions[index]
|
|
|
|
if (undefined === action || action.type !== "saveToIntegration") {
|
|
return
|
|
}
|
|
|
|
const { folder = "", label = "" } = event.detail ?? {}
|
|
|
|
if ("" === folder) {
|
|
// The root of a file store and the "Library" row of a photo library
|
|
// both answer with an empty id, and an empty folder is not stored —
|
|
// MailRuleController drops it, and the upload handler then falls
|
|
// back to the connection's own default. So choosing the top level
|
|
// IS how a rule is put back on the default, and the readout says
|
|
// so rather than showing a blank.
|
|
delete action.folder
|
|
} else {
|
|
action.folder = folder
|
|
|
|
// Only an id is stored, so this is the one chance to learn what it
|
|
// is called. Kept by id rather than by row: rows are rebuilt on
|
|
// every render and renumbered when one is removed, while the id is
|
|
// what the label describes.
|
|
if ("" !== label) {
|
|
this._folderLabels.set(folder, label)
|
|
}
|
|
}
|
|
|
|
this.renderActions()
|
|
this.serialise()
|
|
this.schedulePreview()
|
|
}
|
|
|
|
/**
|
|
* The chosen folder, and the button that opens the picker to change it.
|
|
*
|
|
* Returned as a list rather than appended here so renderActions() keeps
|
|
* deciding the row's order in one place.
|
|
*/
|
|
_folderControls(action, index) {
|
|
const integration = this._integration(action.integrationId)
|
|
|
|
if (undefined === integration) {
|
|
return []
|
|
}
|
|
|
|
const chosen = typeof action.folder === "string" ? action.folder : ""
|
|
|
|
const readout = document.createElement("span")
|
|
readout.className = "inline-flex min-w-0 max-w-[16rem] items-center gap-1.5 text-xs text-ink-muted"
|
|
readout.innerHTML = '<i class="fa-solid fa-folder text-[10px] text-amber-500" aria-hidden="true"></i>'
|
|
|
|
const name = document.createElement("span")
|
|
name.className = "truncate"
|
|
// The stored id is the fallback, not a second stored name. A remembered
|
|
// label can go stale — a folder renamed in the service would leave the
|
|
// editor naming somewhere that no longer exists — while the id is
|
|
// exactly what the upload will be given. For a service whose ids are
|
|
// paths, "Documents/Invoices", it also reads perfectly well on its own.
|
|
name.textContent = "" === chosen
|
|
? this._t("folder.default")
|
|
: (this._folderLabels.get(chosen) ?? chosen)
|
|
name.title = name.textContent
|
|
readout.append(name)
|
|
|
|
const browse = document.createElement("button")
|
|
browse.type = "button"
|
|
browse.className =
|
|
"shrink-0 inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg border border-field bg-field " +
|
|
"text-xs font-medium text-ink-soft hover:bg-hover transition-colors cursor-pointer"
|
|
browse.innerHTML = '<i class="fa-solid fa-folder-open text-[10px]" aria-hidden="true"></i>'
|
|
browse.append(document.createTextNode(this._t("folder.choose")))
|
|
|
|
// The one dialog shell, opened the way every other trigger in the app
|
|
// opens it: mount ui--modal on the button and let it point the frame at
|
|
// the picker. setAttribute rather than dataset, for the reason
|
|
// _selectElement() gives — the double dash does not survive dataset's
|
|
// camel-casing.
|
|
browse.setAttribute("data-controller", "ui--modal")
|
|
browse.setAttribute("data-ui--modal-src-value", this._browseUrl(integration, chosen))
|
|
browse.setAttribute("data-ui--modal-title-value", integration.name)
|
|
browse.setAttribute("data-ui--modal-size-value", integration.mediaLibrary === true ? "full" : "wide")
|
|
browse.setAttribute("data-action", "click->ui--modal#open")
|
|
browse.addEventListener("click", () => {
|
|
this._folderPickFor = index
|
|
})
|
|
|
|
return [readout, browse]
|
|
}
|
|
|
|
// ── Feedback ────────────────────────────────────────────────────────────
|
|
|
|
schedulePreview() {
|
|
clearTimeout(this._previewTimer)
|
|
this.countTarget.dataset.state = "pending"
|
|
this._previewTimer = setTimeout(() => this.preview(), 450)
|
|
}
|
|
|
|
/**
|
|
* Asks the server both questions at once: how many messages this catches,
|
|
* and how it reads in words. The sentence is built server-side so there is
|
|
* only one describer and it is translated — see FilterDescriber.
|
|
*/
|
|
async preview() {
|
|
const ast = this._toAst(this.tree)
|
|
|
|
// Numbered so only the newest answer is drawn. A count over a broad
|
|
// rule is slower than one over the narrower rule typed a moment later,
|
|
// and without this the slow one landed last and the readout described
|
|
// a rule that was no longer on screen.
|
|
const seq = (this._previewSeq = (this._previewSeq ?? 0) + 1)
|
|
const stale = () => seq !== this._previewSeq
|
|
|
|
try {
|
|
const response = await fetch(this.previewUrlValue, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
|
|
// The account goes with it: a rule scoped to one account only
|
|
// ever acts on that account, and a count over all of them
|
|
// would be most wrong exactly where it matters most — a rule
|
|
// with no conditions at all.
|
|
body: JSON.stringify({
|
|
conditions: ast,
|
|
actions: this.actions,
|
|
account: this.element.querySelector('[name="account"]')?.value ?? "",
|
|
}),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (stale()) return
|
|
|
|
if (data.ok !== true) {
|
|
this.countTarget.dataset.state = "error"
|
|
this.countTarget.textContent = data.error ?? this._t("count.error")
|
|
|
|
return
|
|
}
|
|
|
|
this.summaryTarget.textContent = data.description ?? ""
|
|
this.countTarget.dataset.state = "ok"
|
|
this.countTarget.textContent = this._plural(
|
|
this._t(data.capped ? "count.capped" : "count.exact"),
|
|
data.count,
|
|
).replace("%count%", String(data.count))
|
|
} catch {
|
|
if (stale()) return
|
|
|
|
// A failed probe is only a missing hint — never block the save.
|
|
this.countTarget.dataset.state = "error"
|
|
this.countTarget.textContent = this._t("count.error")
|
|
}
|
|
}
|
|
|
|
// ── Serialisation ───────────────────────────────────────────────────────
|
|
|
|
serialise() {
|
|
this.conditionsTarget.value = JSON.stringify(this._toAst(this.tree))
|
|
this.actionsTarget.value = JSON.stringify(this.actions)
|
|
}
|
|
|
|
/** Editor shape → the stored/compiled AST shape. */
|
|
_toAst(node) {
|
|
if (node.operator) {
|
|
// An operator node with no conditions is invalid; the empty tree
|
|
// is how "no conditions" is stored, and the compiler reads it as
|
|
// every message.
|
|
if (node.conditions.length === 0) {
|
|
return {}
|
|
}
|
|
|
|
return {
|
|
operator: node.operator,
|
|
conditions: node.conditions.map((c) => this._toAst(c)),
|
|
}
|
|
}
|
|
|
|
return { [node.field]: node.value }
|
|
}
|
|
|
|
/** Stored AST → editor shape, which carries field/value separately. */
|
|
_seed(ast) {
|
|
// An empty tree is a rule with no conditions: it acts on everything in
|
|
// its scope. A brand-new editor gets one row to fill in instead, which
|
|
// is what the vast majority of rules want — see seedConditionValue.
|
|
if (!ast || Object.keys(ast).length === 0) {
|
|
return {
|
|
operator: "AND",
|
|
conditions: this.seedConditionValue ? [{ field: "subject", value: "" }] : [],
|
|
}
|
|
}
|
|
|
|
const fromAst = (n) => {
|
|
if (n && typeof n === "object" && n.operator) {
|
|
return { operator: n.operator, conditions: (n.conditions ?? []).map(fromAst) }
|
|
}
|
|
|
|
const [field, value] = Object.entries(n ?? {})[0] ?? ["subject", ""]
|
|
|
|
return { field, value }
|
|
}
|
|
|
|
const seeded = fromAst(ast)
|
|
|
|
// The root is always a group, so the operator control has somewhere to
|
|
// live even for a brand-new single-condition rule.
|
|
return seeded.operator ? seeded : { operator: "AND", conditions: [seeded] }
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/** Labels a rule may apply or remove — user labels only. */
|
|
_assignableLabels() {
|
|
return this.labelsValue.filter((l) => l.system !== true)
|
|
}
|
|
|
|
/** The connection an action names, or undefined once it is disconnected. */
|
|
_integration(id) {
|
|
return this.integrationsValue.find((entry) => entry.id === Number(id))
|
|
}
|
|
|
|
/**
|
|
* Where the picker should open for this connection.
|
|
*
|
|
* Reopened at the folder already chosen, so changing a rule's destination
|
|
* starts where it currently points rather than at the top of the account —
|
|
* the same courtesy a manual save gets from save.last_destination.
|
|
*
|
|
* Built through URL so the folder is encoded properly: ids are paths for
|
|
* some services, and a "Mail/Invoices & co" appended by hand would split
|
|
* the query string.
|
|
*/
|
|
_browseUrl(integration, folder) {
|
|
const url = new URL(integration.browseUrl, window.location.origin)
|
|
|
|
if ("" !== folder) {
|
|
url.searchParams.set("folder", folder)
|
|
}
|
|
|
|
return url.pathname + url.search
|
|
}
|
|
|
|
_at(path) {
|
|
return path.reduce((node, i) => node.conditions[i], this.tree)
|
|
}
|
|
|
|
_removeAt(path) {
|
|
const parentPath = path.slice(0, -1)
|
|
const parent = this._at(parentPath)
|
|
parent.conditions.splice(path[path.length - 1], 1)
|
|
|
|
if (parent.conditions.length > 0) {
|
|
return
|
|
}
|
|
|
|
// A nested group with nothing left in it is a tree the validator
|
|
// rejects and a box on screen that means nothing, so it goes too.
|
|
// The root is different: empty there is the rule that acts on
|
|
// everything, which is exactly what removing the last condition is
|
|
// for.
|
|
if (parentPath.length > 0) {
|
|
this._removeAt(parentPath)
|
|
}
|
|
}
|
|
|
|
_defaultFor(field) {
|
|
const kind = FIELDS[field]
|
|
|
|
if (kind === "bool") return true
|
|
if (kind === "keyword") return KEYWORDS[0]
|
|
if (kind === "label") return this.labelsValue[0]?.id
|
|
if (kind === "number") return 0
|
|
|
|
return ""
|
|
}
|
|
|
|
_smallButton(icon, title, onClick, danger = false) {
|
|
const b = document.createElement("button")
|
|
b.type = "button"
|
|
b.title = title
|
|
b.setAttribute("aria-label", title)
|
|
b.className =
|
|
"shrink-0 w-7 h-7 flex items-center justify-center rounded-lg text-xs transition-colors cursor-pointer " +
|
|
(danger
|
|
? "text-ink-faint hover:text-danger hover:bg-danger-soft"
|
|
: "text-ink-faint hover:text-ink hover:bg-hover")
|
|
b.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`
|
|
b.addEventListener("click", onClick)
|
|
|
|
return b
|
|
}
|
|
|
|
_inputClass() {
|
|
return "h-8 rounded-lg border border-field bg-field px-2 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
}
|
|
|
|
/**
|
|
* A <select> that ui--select will pick up.
|
|
*
|
|
* Every other select in the app is written in Twig and gets these
|
|
* attributes from the _select.html.twig macro. This editor builds its rows
|
|
* imperatively, so it has to stamp them itself — Stimulus watches the DOM,
|
|
* so an element created here connects as soon as it is appended.
|
|
*
|
|
* `select-field` rather than _inputClass(): it is the same box, but it is
|
|
* also the class the widget's stylesheet knows to undo once it has copied
|
|
* the list onto its wrapper. Left as _inputClass(), the wrapper would draw
|
|
* a second border around the control's own.
|
|
*/
|
|
_selectElement(extraClass = "") {
|
|
const select = document.createElement("select")
|
|
select.className = `select-field h-8 text-xs ${extraClass}`.trim()
|
|
// setAttribute, not dataset: the double dash in the identifier does not
|
|
// survive the round trip through dataset's camel-casing.
|
|
select.setAttribute("data-controller", "ui--select")
|
|
select.setAttribute(
|
|
"data-ui--select-i18n-value",
|
|
JSON.stringify({ noResults: this._t("selectNoResults") }),
|
|
)
|
|
|
|
return select
|
|
}
|
|
|
|
_t(key) {
|
|
return this.i18nValue[key] ?? key
|
|
}
|
|
|
|
/**
|
|
* Picks the singular or plural half of a "one|many" string.
|
|
*
|
|
* The count changes as the rule is typed and the number is substituted
|
|
* here rather than fetched, so the translation has to arrive carrying both
|
|
* shapes — hence the pipe, which is Symfony's own separator. A string
|
|
* without one is returned untouched, so a translation that has not been
|
|
* split yet still reads.
|
|
*/
|
|
_plural(message, count) {
|
|
const forms = message.split("|")
|
|
|
|
return forms.length < 2 ? message : (count === 1 ? forms[0] : forms[1])
|
|
}
|
|
}
|