Files
Trilium/apps/client/src/widgets/ribbon/SearchDefinitionTab.tsx

307 lines
10 KiB
TypeScript
Raw Normal View History

import { ComponentChildren, VNode } from "preact";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import { TabContext } from "./ribbon-interface";
import Dropdown from "../react/Dropdown";
import ActionButton from "../react/ActionButton";
import FormTextArea from "../react/FormTextArea";
import { AttributeType, OptionNames } from "@triliumnext/commons";
import attributes, { removeOwnedAttributesByNameOrType } from "../../services/attributes";
import { note } from "mermaid/dist/rendering-util/rendering-elements/shapes/note.js";
import FNote from "../../entities/fnote";
import toast from "../../services/toast";
import froca from "../../services/froca";
2025-08-24 16:17:10 +03:00
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { ParentComponent } from "../react/react_utils";
import { useNoteLabel, useSpacedUpdate, useTooltip, useTriliumEventBeta } from "../react/hooks";
import appContext from "../../components/app_context";
import server from "../../services/server";
import { tooltip } from "leaflet";
interface SearchOption {
attributeName: string;
attributeType: "label" | "relation";
icon: string;
label: string;
tooltip?: string;
// TODO: Make mandatory once all components are ported.
component?: (props: SearchOptionProps) => VNode;
}
interface SearchOptionProps {
note: FNote;
refreshResults: () => void;
attributeName: string;
attributeType: "label" | "relation";
error?: { message: string };
}
const SEARCH_OPTIONS: SearchOption[] = [
{
attributeName: "searchString",
attributeType: "label",
icon: "bx bx-text",
label: t("search_definition.search_string"),
component: SearchStringOption
},
{
attributeName: "searchScript",
attributeType: "relation",
icon: "bx bx-code",
label: t("search_definition.search_script")
},
{
attributeName: "ancestor",
attributeType: "relation",
icon: "bx bx-filter-alt",
label: t("search_definition.ancestor")
},
{
attributeName: "fastSearch",
attributeType: "label",
icon: "bx bx-run",
label: t("search_definition.fast_search"),
tooltip: t("search_definition.fast_search_description")
},
{
attributeName: "includeArchivedNotes",
attributeType: "label",
icon: "bx bx-archive",
label: t("search_definition.include_archived"),
tooltip: t("search_definition.include_archived_notes_description")
},
{
attributeName: "orderBy",
attributeType: "label",
icon: "bx bx-arrow-from-top",
label: t("search_definition.order_by")
},
{
attributeName: "limit",
attributeType: "label",
icon: "bx bx-stop",
label: t("search_definition.limit"),
tooltip: t("search_definition.limit_description")
},
{
attributeName: "debug",
attributeType: "label",
icon: "bx bx-bug",
label: t("search_definition.debug"),
tooltip: t("search_definition.debug_description")
}
];
export default function SearchDefinitionTab({ note, ntxId }: TabContext) {
const parentComponent = useContext(ParentComponent);
2025-08-24 16:17:10 +03:00
const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>();
const [ error, setError ] = useState<{ message: string }>();
2025-08-24 16:17:10 +03:00
function refreshOptions() {
if (!note) return;
const availableOptions: SearchOption[] = [];
const activeOptions: SearchOption[] = [];
for (const searchOption of SEARCH_OPTIONS) {
const attr = note.getAttribute(searchOption.attributeType, searchOption.attributeName);
if (attr && searchOption.component) {
activeOptions.push(searchOption);
} else {
availableOptions.push(searchOption);
}
}
setSearchOptions({ availableOptions, activeOptions });
}
async function refreshResults() {
const noteId = note?.noteId;
if (!noteId) {
return;
}
try {
const result = await froca.loadSearchNote(noteId);
if (result?.error) {
setError({ message: result?.error})
} else {
setError(undefined);
}
} catch (e: any) {
toast.showError(e.message);
}
parentComponent?.triggerEvent("searchRefreshed", { ntxId });
}
2025-08-24 16:17:10 +03:00
// Refresh the list of available and active options.
useEffect(refreshOptions, [ note ]);
useTriliumEventBeta("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().find((attrRow) => attributes.isAffecting(attrRow, note))) {
refreshOptions();
}
});
return (
<div className="search-definition-widget">
<div className="search-settings">
{note &&
<table className="search-setting-table">
<tr>
<td className="title-column">{t("search_definition.add_search_option")}</td>
<td colSpan={2} className="add-search-option">
{searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType }) => (
<Button
icon={icon}
text={label}
title={tooltip}
onClick={() => attributes.setAttribute(note, attributeType, attributeName, "")}
/>
))}
</td>
</tr>
<tbody className="search-options">
2025-08-24 16:17:10 +03:00
{searchOptions?.activeOptions.map(({ attributeType, attributeName, component }) => {
return component?.({
attributeName,
attributeType,
note,
refreshResults,
error
2025-08-24 16:17:10 +03:00
});
})}
</tbody>
<tbody className="action-options">
</tbody>
<tbody>
<tr>
<td colSpan={3}>
<div style={{ display: "flex", justifyContent: "space-evenly" }}>
<Button
icon="bx bx-search"
text={t("search_definition.search_button")}
keyboardShortcut="Enter"
onClick={refreshResults}
/>
<Button
icon="bx bxs-zap"
text={t("search_definition.search_execute")}
onClick={async () => {
await server.post(`search-and-execute-note/${note.noteId}`);
refreshResults();
toast.showMessage(t("search_definition.actions_executed"), 3000);
}}
/>
</div>
</td>
</tr>
</tbody>
</table>
}
</div>
</div>
)
}
function SearchOption({ note, title, children, help, attributeName, attributeType }: {
note: FNote;
title: string,
children: ComponentChildren,
help: ComponentChildren,
attributeName: string,
attributeType: AttributeType
}) {
return (
<tr>
<td className="title-column">{title}</td>
<td>{children}</td>
<td className="button-column">
{help && <Dropdown buttonClassName="bx bx-help-circle icon-action" hideToggleArrow>{help}</Dropdown>}
<ActionButton
icon="bx bx-x"
className="search-option-del"
onClick={() => removeOwnedAttributesByNameOrType(note, attributeType, attributeName)}
/>
</td>
</tr>
)
}
function SearchStringOption({ note, refreshResults, error, ...restProps }: SearchOptionProps) {
const [ searchString, setSearchString ] = useNoteLabel(note, "searchString");
const inputRef = useRef<HTMLTextAreaElement>(null);
const currentValue = useRef(searchString ?? "");
const spacedUpdate = useSpacedUpdate(async () => {
const searchString = currentValue.current;
appContext.lastSearchString = searchString;
setSearchString(searchString);
if (note.title.startsWith(t("search_string.search_prefix"))) {
await server.put(`notes/${note.noteId}/title`, {
title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}`}`
});
}
}, 1000);
// React to errors
const { showTooltip, hideTooltip } = useTooltip(inputRef, {
trigger: "manual",
title: `${t("search_string.error", { error: error?.message })}`,
html: true,
placement: "bottom"
});
// Auto-focus.
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
if (error) {
showTooltip();
setTimeout(() => hideTooltip(), 4000);
} else {
hideTooltip();
}
}, [ error ]);
2025-08-24 16:17:10 +03:00
return <SearchOption
title={t("search_string.title_column")}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
<li><code>#year = 2019</code> - {t("search_string.label_year")}</li>
<li><code>#rock #pop</code> - {t("search_string.label_rock_pop")}</li>
<li><code>#rock or #pop</code> - {t("search_string.label_rock_or_pop")}</li>
<li><code>#year &lt;= 2000</code> - {t("search_string.label_year_comparison")}</li>
<li><code>note.dateCreated &gt;= MONTH-1</code> - {t("search_string.label_date_created")}</li>
</ul>
</>}
2025-08-24 16:17:10 +03:00
note={note} {...restProps}
>
<FormTextArea
inputRef={inputRef}
className="search-string"
placeholder={t("search_string.placeholder")}
currentValue={searchString ?? ""}
onChange={text => {
currentValue.current = text;
spacedUpdate.scheduleUpdate();
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
// this also in effect disallows new lines in query string.
// on one hand, this makes sense since search string is a label
// on the other hand, it could be nice for structuring long search string. It's probably a niche case though.
await spacedUpdate.updateNowIfNecessary();
refreshResults();
}
}}
/>
</SearchOption>
}