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, SaveSearchNoteResponse } from "@triliumnext/commons"; import attributes, { removeOwnedAttributesByNameOrType } from "../../services/attributes"; import FNote from "../../entities/fnote"; import toast from "../../services/toast"; import froca from "../../services/froca"; import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ParentComponent } from "../react/react_utils"; import { useNoteLabel, useNoteRelation, useSpacedUpdate, useTooltip, useTriliumEventBeta } from "../react/hooks"; import appContext from "../../components/app_context"; import server from "../../services/server"; import ws from "../../services/ws"; import tree from "../../services/tree"; import NoteAutocomplete from "../react/NoteAutocomplete"; import FormSelect from "../react/FormSelect"; 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; additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]; } interface SearchOptionProps { note: FNote; refreshResults: () => void; attributeName: string; attributeType: "label" | "relation"; additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]; 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"), component: SearchScriptOption }, { attributeName: "ancestor", attributeType: "relation", icon: "bx bx-filter-alt", label: t("search_definition.ancestor"), component: AncestorOption, additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ] }, { 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); const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>(); const [ error, setError ] = useState<{ message: string }>(); 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 }); } // 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 (
{note && {searchOptions?.activeOptions.map(({ attributeType, attributeName, component, additionalAttributesToDelete }) => { return component?.({ attributeName, attributeType, note, refreshResults, error, additionalAttributesToDelete }); })}
{t("search_definition.add_search_option")} {searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType }) => (
}
) } function SearchOption({ note, title, children, help, attributeName, attributeType, additionalAttributesToDelete }: { note: FNote; title: string, children: ComponentChildren, help: ComponentChildren, attributeName: string, attributeType: AttributeType, additionalAttributesToDelete: { type: "label" | "relation", name: string }[] }) { return ( {title} {children} {help && {help}} { removeOwnedAttributesByNameOrType(note, attributeType, attributeName); if (additionalAttributesToDelete) { for (const { type, name } of additionalAttributesToDelete) { removeOwnedAttributesByNameOrType(note, type, name); } } }} /> ) } function SearchStringOption({ note, refreshResults, error, ...restProps }: SearchOptionProps) { const [ searchString, setSearchString ] = useNoteLabel(note, "searchString"); const inputRef = useRef(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 ]); return {t("search_string.search_syntax")} - {t("search_string.also_see")} {t("search_string.complete_help")}
  • {t("search_string.full_text_search")}
  • #abc - {t("search_string.label_abc")}
  • #year = 2019 - {t("search_string.label_year")}
  • #rock #pop - {t("search_string.label_rock_pop")}
  • #rock or #pop - {t("search_string.label_rock_or_pop")}
  • #year <= 2000 - {t("search_string.label_year_comparison")}
  • note.dateCreated >= MONTH-1 - {t("search_string.label_date_created")}
} note={note} {...restProps} > { 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(); } }} />
} function SearchScriptOption({ note, ...restProps }: SearchOptionProps) { const [ searchScript, setSearchScript ] = useNoteRelation(note, "searchScript"); return

{t("search_script.description1")}

{t("search_script.description2")}

{t("search_script.example_title")}

{t("search_script.example_code")}
{t("search_script.note")} } note={note} {...restProps} > setSearchScript(noteId ?? "root")} placeholder={t("search_script.placeholder")} />
} function AncestorOption({ note, ...restProps}: SearchOptionProps) { const [ ancestor, setAncestor ] = useNoteRelation(note, "ancestor"); const [ depth, setDepth ] = useNoteLabel(note, "ancestorDepth"); const options = useMemo(() => { const options: { value: string | undefined; label: string }[] = [ { value: "", label: t("ancestor.depth_doesnt_matter") }, { value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` } ]; for (let i=2; i<=9; i++) options.push({ value: "eq" + i, label: t("ancestor.depth_eq", { count: i }) }); for (let i=0; i<=9; i++) options.push({ value: "gt" + i, label: t("ancestor.depth_gt", { count: i }) }); for (let i=2; i<=9; i++) options.push({ value: "lt" + i, label: t("ancestor.depth_lt", { count: i }) }); return options; }, []); return
setAncestor(noteId ?? "root")} placeholder={t("ancestor.placeholder")} />
{t("ancestor.depth_label")}:
setDepth(value ? value : null)} style={{ flexShrink: 3 }} />
; }