Merge branch 'main' into feat/snapshot-etapi-notes-too

This commit is contained in:
Elian Doran
2025-08-13 15:17:27 +03:00
committed by GitHub
194 changed files with 14018 additions and 11192 deletions

View File

@@ -59,6 +59,102 @@ WantedBy=multi-user.target</code></pre>
<li>You can now open a browser to http://[your-server-hostname]:8080 and you
should see the Trilium initialization page.</li>
</ul>
<h2>Simple Autoupdate for Server</h2>
<p>Run as the same User Trilium runs</p>
<p>if you run as root please remove 'sudo' from the commands</p>
<p>requires "jq" <code>apt install jq</code>
</p>
<p>It will stop the service above, overwrite everything (i expect no config.ini),
and start service It also creates a version file in the Trilium directory
so it updates only with a newer Version</p><pre><code class="language-text-x-trilium-auto">#!/bin/bash
# Configuration
REPO="TriliumNext/Trilium"
PATTERN="TriliumNotes-Server-.*-linux-x64.tar.xz"
DOWNLOAD_DIR="/var/tmp/trilium_download"
OUTPUT_DIR="/opt/trilium"
SERVICE_NAME="trilium"
VERSION_FILE="$OUTPUT_DIR/version.txt"
# Ensure dependencies are installed
command -v curl &gt;/dev/null 2&gt;&amp;1 || { echo "Error: curl is required"; exit 1; }
command -v jq &gt;/dev/null 2&gt;&amp;1 || { echo "Error: jq is required"; exit 1; }
command -v tar &gt;/dev/null 2&gt;&amp;1 || { echo "Error: tar is required"; exit 1; }
# Create download directory
mkdir -p "$DOWNLOAD_DIR" || { echo "Error: Cannot create $DOWNLOAD_DIR"; exit 1; }
# Get the latest release version
LATEST_VERSION=$(curl -sL https://api.github.com/repos/$REPO/releases/latest | jq -r '.tag_name')
if [ -z "$LATEST_VERSION" ]; then
echo "Error: Could not fetch latest release version"
exit 1
fi
# Check current installed version (from version.txt or existing tarball)
CURRENT_VERSION=""
if [ -f "$VERSION_FILE" ]; then
CURRENT_VERSION=$(cat "$VERSION_FILE")
elif [ -f "$DOWNLOAD_DIR/TriliumNotes-Server-$LATEST_VERSION-linux-x64.tar.xz" ]; then
CURRENT_VERSION="$LATEST_VERSION"
fi
# Compare versions
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "Latest version ($LATEST_VERSION) is already installed"
exit 0
fi
# Download the latest release
LATEST_URL=$(curl -sL https://api.github.com/repos/$REPO/releases/latest | jq -r ".assets[] | select(.name | test(\"$PATTERN\")) | .browser_download_url")
if [ -z "$LATEST_URL" ]; then
echo "Error: No asset found matching pattern '$PATTERN'"
exit 1
fi
FILE_NAME=$(basename "$LATEST_URL")
FILE_PATH="$DOWNLOAD_DIR/$FILE_NAME"
# Download if not already present
if [ -f "$FILE_PATH" ]; then
echo "Latest release $FILE_NAME already downloaded"
else
curl -LO --output-dir "$DOWNLOAD_DIR" "$LATEST_URL" || { echo "Error: Download failed"; exit 1; }
echo "Downloaded $FILE_NAME to $DOWNLOAD_DIR"
fi
# Extract the tarball
EXTRACT_DIR="$DOWNLOAD_DIR/extracted"
mkdir -p "$EXTRACT_DIR"
tar -xJf "$FILE_PATH" -C "$EXTRACT_DIR" || { echo "Error: Extraction failed"; exit 1; }
# Find the extracted directory (e.g., TriliumNotes-Server-0.97.2-linux-x64)
INNER_DIR=$(find "$EXTRACT_DIR" -maxdepth 1 -type d -name "TriliumNotes-Server-*-linux-x64" | head -n 1)
if [ -z "$INNER_DIR" ]; then
echo "Error: Could not find extracted directory matching TriliumNotes-Server-*-linux-x64"
exit 1
fi
# Stop the trilium-server service
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo "Stopping $SERVICE_NAME service..."
sudo systemctl stop "$SERVICE_NAME" || { echo "Error: Failed to stop $SERVICE_NAME"; exit 1; }
fi
# Copy contents to /opt/trilium, overwriting existing files
echo "Copying contents from $INNER_DIR to $OUTPUT_DIR..."
sudo mkdir -p "$OUTPUT_DIR"
sudo cp -r "$INNER_DIR"/* "$OUTPUT_DIR"/ || { echo "Error: Copy failed"; exit 1; }
echo "$LATEST_VERSION" | sudo tee "$VERSION_FILE" &gt;/dev/null
echo "Files copied to $OUTPUT_DIR"
# Start the trilium-server service
echo "Starting $SERVICE_NAME service..."
sudo systemctl start "$SERVICE_NAME" || { echo "Error: Failed to start $SERVICE_NAME"; exit 1; }
# Clean up
rm -rf "$EXTRACT_DIR"
echo "Cleanup complete. Trilium updated to $LATEST_VERSION."</code></pre>
<h2>Common issues</h2>
<h3>Outdated glibc</h3><pre><code class="language-text-x-trilium-auto">Error: /usr/lib64/libstdc++.so.6: version `GLIBCXX_3.4.21' not found (required by /var/www/virtual/.../node_modules/@mlink/scrypt/build/Release/scrypt.node)
at Object.Module._extensions..node (module.js:681:18)

View File

@@ -1,6 +1,6 @@
<p>Official docker images are published on docker hub for <strong>AMD64</strong>, <strong>ARMv7</strong> and <strong>ARM64/v8</strong>:
<a
href="https://hub.docker.com/r/triliumnext/notes/">https://hub.docker.com/r/triliumnext/notes/</a>
href="https://hub.docker.com/r/triliumnext/trilium/">https://hub.docker.com/r/triliumnext/trilium/</a>
</p>
<h2>Prerequisites</h2>
<p>Ensure Docker is installed on your system.</p>
@@ -15,7 +15,7 @@
mounting your SMB share.</p>
</aside>
<h2>Running with Docker Compose</h2>
<h3>Grab the latest docker-compose.yml:</h3><pre><code class="language-text-x-trilium-auto">wget https://raw.githubusercontent.com/TriliumNext/Notes/master/docker-compose.yml</code></pre>
<h3>Grab the latest docker-compose.yml:</h3><pre><code class="language-text-x-trilium-auto">wget https://raw.githubusercontent.com/TriliumNext/Trilium/master/docker-compose.yml</code></pre>
<p>Optionally, edit the <code>docker-compose.yml</code> file to configure the
container settings prior to starting it. Unless configured otherwise, the
data directory will be <code>~/trilium-data</code> and the container will
@@ -26,7 +26,7 @@
<h3>Pulling the Docker Image</h3>
<p>To pull the image, use the following command, replacing <code>[VERSION]</code> with
the desired version or tag, such as <code>v0.91.6</code> or just <code>latest</code>.
(See published tag names at <a href="https://hub.docker.com/r/triliumnext/notes/tags">https://hub.docker.com/r/triliumnext/notes/tags</a>.):</p><pre><code class="language-text-x-trilium-auto">docker pull triliumnext/notes:v0.91.6</code></pre>
(See published tag names at <a href="https://hub.docker.com/r/triliumnext/trilium/tags">https://hub.docker.com/r/triliumnext/trilium/tags</a>.):</p><pre><code class="language-text-x-trilium-auto">docker pull triliumnext/trilium:v0.91.6</code></pre>
<p><strong>Warning:</strong> Avoid using the "latest" tag, as it may automatically
upgrade your instance to a new minor version, potentially disrupting sync
setups or causing other issues.</p>
@@ -37,7 +37,7 @@
<h4>Local Access Only</h4>
<p>Run the container to make it accessible only from the localhost. This
setup is suitable for testing or when using a proxy server like Nginx or
Apache.</p><pre><code class="language-text-x-trilium-auto">sudo docker run -t -i -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/notes:[VERSION]</code></pre>
Apache.</p><pre><code class="language-text-x-trilium-auto">sudo docker run -t -i -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]</code></pre>
<ol>
<li>Verify the container is running using <code>docker ps</code>.</li>
<li>Access Trilium via a web browser at <code>127.0.0.1:8080</code>.</li>
@@ -45,20 +45,20 @@
<h4>Local Network Access</h4>
<p>To make the container accessible only on your local network, first create
a new Docker network:</p><pre><code class="language-text-x-trilium-auto">docker network create -d macvlan -o parent=eth0 --subnet 192.168.2.0/24 --gateway 192.168.2.254 --ip-range 192.168.2.252/27 mynet</code></pre>
<p>Then, run the container with the network settings:</p><pre><code class="language-text-x-trilium-auto">docker run --net=mynet -d -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/notes:-latest</code></pre>
<p>Then, run the container with the network settings:</p><pre><code class="language-text-x-trilium-auto">docker run --net=mynet -d -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:-latest</code></pre>
<p>To set a different user ID (UID) and group ID (GID) for the saved data,
use the <code>USER_UID</code> and <code>USER_GID</code> environment variables:</p><pre><code class="language-text-x-trilium-auto">docker run --net=mynet -d -p 127.0.0.1:8080:8080 -e "USER_UID=1001" -e "USER_GID=1001" -v ~/trilium-data:/home/node/trilium-data triliumnext/notes:-latest</code></pre>
use the <code>USER_UID</code> and <code>USER_GID</code> environment variables:</p><pre><code class="language-text-x-trilium-auto">docker run --net=mynet -d -p 127.0.0.1:8080:8080 -e "USER_UID=1001" -e "USER_GID=1001" -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:-latest</code></pre>
<p>Find the local IP address using <code>docker inspect [container_name]</code> and
access the service from devices on the local network.</p><pre><code class="language-text-x-trilium-auto">docker ps
docker inspect [container_name]</code></pre>
<h4>Global Access</h4>
<p>To allow access from any IP address, run the container as follows:</p><pre><code class="language-text-x-trilium-auto">docker run -d -p 0.0.0.0:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/notes:[VERSION]</code></pre>
<p>To allow access from any IP address, run the container as follows:</p><pre><code class="language-text-x-trilium-auto">docker run -d -p 0.0.0.0:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]</code></pre>
<p>Stop the container with <code>docker stop &lt;CONTAINER ID&gt;</code>,
where the container ID is obtained from <code>docker ps</code>.</p>
<h3>Custom Data Directory</h3>
<p>For a custom data directory, use:</p><pre><code class="language-text-x-trilium-auto">-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/notes:[VERSION]</code></pre>
<p>For a custom data directory, use:</p><pre><code class="language-text-x-trilium-auto">-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/trilium:[VERSION]</code></pre>
<p>If you want to run your instance in a non-default way, please use the
volume switch as follows: <code>-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/notes:&lt;VERSION&gt;</code>.
volume switch as follows: <code>-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/trilium:&lt;VERSION&gt;</code>.
It is important to be aware of how Docker works for volumes, with the first
path being your own and the second the one to virtually bind to. <a href="https://docs.docker.com/storage/volumes/">https://docs.docker.com/storage/volumes/</a> The
path before the colon is the host directory, and the path after the colon
@@ -89,10 +89,10 @@ docker inspect [container_name]</code></pre>
<p><em><strong>If you're unsure, stick to the “rootful” Docker image referenced above.</strong></em>
</p>
<p>Below are some commands to pull the rootless images:</p><pre><code class="language-text-x-trilium-auto"># For Debian-based image
docker pull triliumnext/notes:rootless
docker pull triliumnext/trilium:rootless
# For Alpine-based image
docker pull triliumnext/notes:rootless-alpine</code></pre>
docker pull triliumnext/trilium:rootless-alpine</code></pre>
<h3>Why Rootless?</h3>
<p>Running containers as non-root is a security best practice that reduces
the potential impact of container breakouts. If an attacker manages to
@@ -117,13 +117,13 @@ TRILIUM_UID=$(id -u) TRILIUM_GID=$(id -g) docker-compose -f docker-compose.rootl
TRILIUM_DATA_DIR=/path/to/your/data TRILIUM_UID=$(id -u) TRILIUM_GID=$(id -g) docker-compose -f docker-compose.rootless.yml up -d
</code></pre>
<h4><strong>Using Docker CLI</strong></h4><pre><code class="language-text-x-trilium-auto"># Build the image
docker build -t triliumnext/notes:rootless -f apps/server/Dockerfile.rootless .
docker build -t triliumnext/trilium:rootless -f apps/server/Dockerfile.rootless .
# Run with default UID/GID (1000:1000)
docker run -d --name trilium -p 8080:8080 -v ~/trilium-data:/home/trilium/trilium-data triliumnext/notes:rootless
docker run -d --name trilium -p 8080:8080 -v ~/trilium-data:/home/trilium/trilium-data triliumnext/trilium:rootless
# Run with custom UID/GID
docker run -d --name trilium -p 8080:8080 --user $(id -u):$(id -g) -v ~/trilium-data:/home/trilium/trilium-data triliumnext/notes:rootless
docker run -d --name trilium -p 8080:8080 --user $(id -u):$(id -g) -v ~/trilium-data:/home/trilium/trilium-data triliumnext/trilium:rootless
</code></pre>
<h3>Environment Variables</h3>
<ul>
@@ -176,11 +176,11 @@ TRILIUM_UID=1001 TRILIUM_GID=1001 docker-compose -f docker-compose.rootless.yml
<h3>Building Custom Rootless Images</h3>
<p>If you would prefer, you can also customize the UID/GID at build time:</p><pre><code class="language-text-x-trilium-auto"># For Debian-based image with custom UID/GID
docker build --build-arg USER=myuser --build-arg UID=1001 --build-arg GID=1001 \
-t triliumnext/notes:rootless-custom -f apps/server/Dockerfile.rootless .
-t triliumnext/trilium:rootless-custom -f apps/server/Dockerfile.rootless .
# For Alpine-based image with custom UID/GID
docker build --build-arg USER=myuser --build-arg UID=1001 --build-arg GID=1001 \
-t triliumnext/notes:alpine-rootless-custom -f apps/server/Dockerfile.alpine.rootless .
-t triliumnext/trilium:alpine-rootless-custom -f apps/server/Dockerfile.alpine.rootless .
</code></pre>
<p>Available build arguments:</p>
<ul>

View File

@@ -27,36 +27,43 @@ class="admonition warning">
</aside>
<h3>TOTP</h3>
<ol>
<li>Go to "Menu" -&gt; "Options" -&gt; "MFA"</li>
<li>Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
<li>Choose “Time-Based One-Time Password (TOTP)” under MFA Method</li>
<li>Click the "Generate TOTP Secret" button</li>
<li>Copy the generated secret to your authentication app/extension</li>
<li>Click the "Generate Recovery Codes" button</li>
<li>Save the recovery codes. Recovery codes can be used once in place of the
TOTP if you loose access to your authenticator. After a rerecovery code
is used, it will show the unix timestamp when it was used in the MFA options
tab.</li>
<li>Re-login will be required after TOTP setup is finished (After you refreshing
the page).</li>
<li data-list-item-id="ee190226d19e91a9330c263fa05fc61e7">Go to "Menu" -&gt; "Options" -&gt; "MFA"</li>
<li data-list-item-id="ec7573505a7c9607c44a6a525a063fd3d">Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
<li
data-list-item-id="e49b476d39ceb086ac8ffab93be7ddb46">Choose “Time-Based One-Time Password (TOTP)” under MFA Method</li>
<li
data-list-item-id="e8104db62f8a7b835cba5c79377ea441d">Click the "Generate TOTP Secret" button</li>
<li data-list-item-id="e4928e65314a99efe44ee2806c989ac45">Copy the generated secret to your authentication app/extension</li>
<li
data-list-item-id="ea96afadbac44638a6ec6e13733e23b53">Click the "Generate Recovery Codes" button</li>
<li data-list-item-id="e67fffe2e3d945b23f93668c3ead03da7">Save the recovery codes. Recovery codes can be used once in place of the
TOTP if you loose access to your authenticator. After a rerecovery code
is used, it will show the unix timestamp when it was used in the MFA options
tab.</li>
<li data-list-item-id="ee94c4493042bb4d50ef6e07a30c65b95">Re-login will be required after TOTP setup is finished (After you refreshing
the page).</li>
</ol>
<h3>OpenID</h3>
<p>In order to setup OpenID, you will need to setup a authentication provider.
This requires a bit of extra setup. Follow <a href="https://developers.google.com/identity/openid-connect/openid-connect">these instructions</a> to
setup an OpenID service through google.</p>
setup an OpenID service through google. The Redirect URL of Trilium is <code>https://&lt;your-trilium-domain&gt;/callback</code>.</p>
<ol>
<li>Set the <code>oauthBaseUrl</code>, <code>oauthClientId</code> and <code>oauthClientSecret</code> in
<li data-list-item-id="e12ea6450b407f0bbcb4109ef082bdfe3">Set the <code>oauthBaseUrl</code>, <code>oauthClientId</code> and <code>oauthClientSecret</code> in
the <code>config.ini</code> file (check&nbsp;<a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a>&nbsp;for
more information).
<ol>
<li>You can also setup through environment variables (<code>TRILIUM_OAUTH_BASE_URL</code>, <code>TRILIUM_OAUTH_CLIENT_ID</code> and <code>TRILIUM_OAUTH_CLIENT_SECRET</code>).</li>
</ol>
<li data-list-item-id="ed369d1f114cb20a128dc286729d8370d">You can also setup through environment variables (<code>TRILIUM_OAUTH_BASE_URL</code>, <code>TRILIUM_OAUTH_CLIENT_ID</code> and <code>TRILIUM_OAUTH_CLIENT_SECRET</code>).</li>
<li
data-list-item-id="e1b13f1b5f3be3cf1d2cb4f26da326b60"><code>oauthBaseUrl</code> should be the link of your Trilium instance server,
for example, <code>https://&lt;your-trilium-domain&gt;</code>.</li>
</ol>
</li>
<li>Restart the server</li>
<li>Go to "Menu" -&gt; "Options" -&gt; "MFA"</li>
<li>Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
<li>Choose “OAuth/OpenID” under MFA Method</li>
<li>Refresh the page and login through OpenID provider</li>
<li data-list-item-id="e7e03745ea93c9ce8d79cfb4bd2815db2">Restart the server</li>
<li data-list-item-id="edbb2231e1ec4b4d1296245db1ab87f8d">Go to "Menu" -&gt; "Options" -&gt; "MFA"</li>
<li data-list-item-id="e1300f72967b25817d5944b27afa26182">Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
<li
data-list-item-id="ea1290a6b9568d1f4bf44c803d366248d">Choose “OAuth/OpenID” under MFA Method</li>
<li data-list-item-id="e1801298cdda474547d810959fc3e79ef">Refresh the page and login through OpenID provider</li>
</ol>
<aside class="admonition note">
<p>The default OAuth issuer is Google. To use other services such as Authentik
@@ -65,4 +72,25 @@ class="admonition warning">
these values can be set using environment variables: <code>TRILIUM_OAUTH_ISSUER_BASE_URL</code>, <code>TRILIUM_OAUTH_ISSUER_NAME</code>,
and <code>TRILIUM_OAUTH_ISSUER_ICON</code>. <code>oauthIssuerName</code> and <code>oauthIssuerIcon</code> are
required for displaying correct issuer information at the Login page.</p>
</aside>
</aside>
<h4>Authentik</h4>
<p>If you dont already have a running Authentik instance, please follow
<a
href="https://docs.goauthentik.io/docs/install-config/install/docker-compose">these instructions</a>to set one up.</p>
<ol>
<li data-list-item-id="eedb3ea2a0107b0bc34a61a088fba1b2d">In the Authentik admin dashboard, create a new OAuth2 application by following
<a
href="https://docs.goauthentik.io/docs/add-secure-apps/providers/oauth2/create-oauth2-provider">these steps</a>. Make sure to set the Redirect URL to: <code>https://&lt;your-trilium-domain&gt;/callback</code>.</li>
<li
data-list-item-id="eb98f26412740c574a384128637681e7d">In your config.ini file, set the relevant OAuth variables:
<ol>
<li data-list-item-id="e12ec552e0c5ce7f6a12af520dc6d8aa2"><code>oauthIssuerBaseUrl</code> → Use the <code>OpenID Configuration Issuer</code> URL
from your application's overview page.</li>
<li data-list-item-id="e3f6d6bbf6cf4cdee38be3a7d53bd57b9"><code>oauthIssuerName</code> and <code>oauthIssuerIcon</code> → Set these
to customize the name and icon displayed on the login page. If omitted,
Googles name and icon will be shown by default.</li>
</ol>
</li>
<li data-list-item-id="eeae084919db88733646612f3c4f55a6e">Apply the changes by restarting your server.</li>
<li data-list-item-id="ec71ab917862af5f96f2a5567d82ac0da">Proceed with the remaining steps starting from Step 3 in the OpenID section.</li>
</ol>

View File

@@ -32,7 +32,7 @@
"open-new-window": "Neues leeres Fenster öffnen",
"toggle-tray": "Anwendung im Systemtray anzeigen/verstecken",
"first-tab": "Ersten Tab in der Liste aktivieren",
"second-tab": " Zweiten Tab in der Liste aktivieren",
"second-tab": "Zweiten Tab in der Liste aktivieren",
"third-tab": "Dritten Tab in der Liste aktivieren",
"fourth-tab": "Vierten Tab in der Liste aktivieren",
"fifth-tab": "Fünften Tab in der Liste aktivieren",
@@ -92,7 +92,18 @@
"toggle-book-properties": "Buch-Eigenschaften umschalten",
"clone-notes-to": "Ausgewählte Notizen duplizieren",
"open-command-palette": "Kommandopalette öffnen",
"export-as-pdf": "Aktuelle Notiz als PDF exportieren"
"export-as-pdf": "Aktuelle Notiz als PDF exportieren",
"back-in-note-history": "Navigiere zur vorherigen Notiz im Verlauf",
"forward-in-note-history": "Navigiere zur nächsten Notiz im Verlauf",
"scroll-to-active-note": "Zum aktiven Notizbaumeintrag springen",
"quick-search": "Schnellsuche öffnen",
"create-note-after": "Erstelle eine neue Notiz nach der aktuellen Notiz",
"create-note-into": "Unternotiz zur aktiven Notiz anlegen",
"move-notes-to": "Ausgewählte Notizen verschieben",
"show-cheatsheet": "Übersicht der Tastenkombinationen anzeigen",
"find-in-text": "Suchleiste umschalten",
"toggle-classic-editor-toolbar": "Schalte um zum Formatierungs-Tab für den Editor mit fester Werkzeugleiste",
"toggle-zen-mode": "Zen-Modus ein-/ausschalten (reduzierte Benutzeroberfläche für ablenkungsfreies Arbeiten)"
},
"login": {
"title": "Anmeldung",
@@ -272,6 +283,23 @@
},
"keyboard_action_names": {
"copy-notes-to-clipboard": "Notizen in Zwischenablage kopieren",
"paste-notes-from-clipboard": "Notizen aus Zwischenablage einfügen"
"paste-notes-from-clipboard": "Notizen aus Zwischenablage einfügen",
"back-in-note-history": "Zurück im Notizverlauf",
"forward-in-note-history": "Vorwärts im Notizverlauf",
"jump-to-note": "Wechseln zu...",
"command-palette": "Befehlsübersicht",
"scroll-to-active-note": "Zur aktiven Notiz scrollen",
"quick-search": "Schnellsuche",
"search-in-subtree": "In Unterzweig suchen",
"expand-subtree": "Unterzweig aufklappen",
"collapse-tree": "Baumstruktur einklappen",
"collapse-subtree": "Unterzweig einklappen",
"sort-child-notes": "Unternotizen sortieren",
"create-note-after": "Erstelle eine neue Notiz dahinter",
"create-note-into": "Erstelle eine neue Notiz davor",
"create-note-into-inbox": "Neue Notiz in Inbox erstellen",
"delete-notes": "Notizen löschen",
"move-note-up": "Notiz nach oben verschieben",
"move-note-down": "Notiz nach unten verschieben"
}
}

View File

@@ -21,8 +21,6 @@
"move-note-down-in-hierarchy": "Bajar nota en la jerarquía",
"edit-note-title": "Saltar del árbol al detalle de la nota y editar el título",
"edit-branch-prefix": "Mostrar cuadro de diálogo Editar prefijo de rama",
"cloneNotesTo": "Clonar notas seleccionadas",
"moveNotesTo": "Mover notas seleccionadas",
"note-clipboard": "Portapapeles de notas",
"copy-notes-to-clipboard": "Copiar las notas seleccionadas al portapapeles",
"paste-notes-from-clipboard": "Pegar las notas del portapapeles en una nota activa",

View File

@@ -152,6 +152,13 @@
"add-new-label": "Crea una nuova etichetta",
"create-new-relation": "Crea una nuova relazione",
"ribbon-tabs": "Nastro delle schede",
"toggle-basic-properties": "Mostra/nascondi le Proprietà di Base"
"toggle-basic-properties": "Mostra/nascondi le Proprietà di Base",
"toggle-file-properties": "Attiva Proprietà del file",
"toggle-image-properties": "Attiva Proprietà Immagine",
"toggle-owned-attributes": "Attiva Attributi Propri",
"toggle-inherited-attributes": "Attiva Attributi Ereditati",
"toggle-promoted-attributes": "Attiva Attributi Promossi",
"toggle-link-map": "Attiva Mappa Link",
"toggle-note-info": "Attiva Informazioni Nota"
}
}

View File

@@ -1 +1,320 @@
{}
{
"keyboard_actions": {
"back-in-note-history": "履歴内の前のノートに移動",
"forward-in-note-history": "履歴内の次のノートに移動",
"open-jump-to-note-dialog": "「ノートへ移動」 ダイアログを開く",
"open-command-palette": "コマンドパレットを開く",
"scroll-to-active-note": "ノートツリーをアクティブなノートまでスクロールする",
"search-in-subtree": "アクティブなノートのサブツリー内でノートを検索する",
"expand-subtree": "現在のノートのサブツリーを展開する",
"collapse-tree": "ノートツリー全体を折りたたむ",
"collapse-subtree": "現在のノートのサブツリーを折りたたむ",
"creating-and-moving-notes": "ノートの作成と移動",
"create-note-after": "アクティブなノートの後にノートを作成する",
"create-note-into": "アクティブなノートの子ノートを作成する",
"delete-note": "ノートを削除する",
"move-note-up": "ノートを上に移動する",
"move-note-down": "ノートを下に移動する",
"move-note-up-in-hierarchy": "ノートを上の階層に移動する",
"move-note-down-in-hierarchy": "ノートを下の階層に移動する",
"edit-note-title": "ツリーからノートの詳細にジャンプして、タイトルを編集",
"clone-notes-to": "選択したノートを複製する",
"move-notes-to": "選択したノートを移動する",
"copy-notes-to-clipboard": "選択したノートをクリップボードにコピー",
"paste-notes-from-clipboard": "クリップボードからアクティブなノートにノートを貼り付け",
"cut-notes-to-clipboard": "選択したノートをクリップボードにカット",
"select-all-notes-in-parent": "現在のノートレベルと同じノートをすべて選択する",
"add-note-above-to-the-selection": "上のノートを選択範囲に追加",
"add-note-below-to-selection": "下のノートを選択範囲に追加",
"tabs-and-windows": "タブとウィンドウ",
"open-new-tab": "新しいタブを開く",
"close-active-tab": "アクティブなタブを閉じる",
"reopen-last-tab": "最後に閉じたタブを開く",
"activate-next-tab": "右のタブをアクティブにする",
"activate-previous-tab": "左のタブをアクティブにする",
"open-new-window": "新しい空のウィンドウを開く",
"toggle-tray": "システムトレイアイコンの表示/非表示",
"first-tab": "最初のタブをアクティブにする",
"second-tab": "2番目のタブをアクティブにする",
"third-tab": "3番目のタブをアクティブにする",
"fourth-tab": "4番目のタブをアクティブにする",
"fifth-tab": "5番目のタブをアクティブにする",
"sixth-tab": "6番目のタブをアクティブにする",
"seventh-tab": "7番目のタブをアクティブにする",
"eight-tab": "8番目のタブをアクティブにする",
"ninth-tab": "9番目のタブをアクティブにする",
"last-tab": "最後のタブをアクティブにする",
"dialogs": "ダイアログ",
"show-note-source": "「ノートのソース」ダイアログを表示",
"show-options": "「オプション」ページを開く",
"show-recent-changes": "「最近の変更」ダイアログを表示",
"show-sql-console": "「SQLコンソール」ページを開く",
"show-backend-log": "「バックエンドログ」ページを開く",
"show-help": "内蔵のユーザーガイドを開く",
"show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する",
"text-note-operations": "テキストノート操作",
"add-link-to-text": "テキストにリンクを追加するダイアログを開く",
"follow-link-under-cursor": "カーソル下のリンク先へ移動",
"insert-date-and-time-to-text": "現在の日時を挿入する",
"paste-markdown-into-text": "クリップボードからMarkdownをテキストートに貼り付けます",
"cut-into-note": "現在のノートから選択範囲を切り取り、サブノートを作成",
"edit-readonly-note": "読み取り専用ノートの編集",
"ribbon-tabs": "リボンタブ",
"toggle-link-map": "リンクマップ切り替え",
"toggle-note-info": "ノート情報切り替え",
"toggle-note-paths": "ノートパス切り替え",
"toggle-similar-notes": "類似ノート切り替え",
"other": "その他",
"toggle-right-pane": "目次とハイライトを含む右ペインの表示を切り替え",
"print-active-note": "アクティブノートを印刷",
"open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く",
"render-active-note": "アクティブなノートを再描画(再レンダリング)する",
"run-active-note": "アクティブなJavaScriptフロントエンド/バックエンド)のコードノートを実行する",
"reload-frontend-app": "フロントエンドのリロード",
"open-dev-tools": "開発者ツールを開く",
"find-in-text": "検索パネルの切り替え",
"toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)",
"toggle-full-screen": "フルスクリーンの切り替え",
"note-navigation": "ノートナビゲーション",
"copy-without-formatting": "選択したテキストを書式なしでコピーする",
"export-as-pdf": "現在のートをPDFとしてエクスポートする",
"zoom-out": "ズームアウト",
"zoom-in": "ズームイン",
"reset-zoom-level": "ズームレベルのリセット",
"quick-search": "クイック検索バーを有効にする",
"sort-child-notes": "子ノートを並べ替える",
"create-note-into-inbox": "inbox定義されている場合またはデイートにートを作成する",
"note-clipboard": "ノートクリップボード",
"duplicate-subtree": "サブツリーの複製"
},
"keyboard_action_names": {
"back-in-note-history": "ノートの履歴を戻る",
"forward-in-note-history": "ノートの履歴を進む",
"command-palette": "コマンドパレット",
"scroll-to-active-note": "アクティブノートまでスクロール",
"quick-search": "クイックサーチ",
"search-in-subtree": "サブツリー内を検索",
"expand-subtree": "サブツリーを展開",
"collapse-subtree": "サブツリーを折りたたむ",
"collapse-tree": "ツリーを折りたたむ",
"sort-child-notes": "子ノートを並べ替え",
"create-note-after": "後ろにノートを作成",
"create-note-into": "子ノートを作成",
"delete-notes": "ノートを削除",
"move-note-up": "ノートを上に移動",
"move-note-down": "ノートを下に移動",
"move-note-up-in-hierarchy": "ノートの階層を上に移動",
"move-note-down-in-hierarchy": "ノートの階層を下に移動",
"edit-note-title": "ノートのタイトルを編集",
"clone-notes-to": "ノートを複製",
"move-notes-to": "ノートを移動",
"copy-notes-to-clipboard": "ノートをクリップボードにコピー",
"paste-notes-from-clipboard": "クリップボードからノートを貼り付け",
"cut-notes-to-clipboard": "ノートをクリップボードにカット",
"select-all-notes-in-parent": "親ノート内のすべてのノートを選択",
"add-note-above-to-selection": "選択範囲に上のノートを追加",
"add-note-below-to-selection": "選択範囲に下のノートを追加",
"open-new-tab": "新しいタブを開く",
"close-active-tab": "アクティブなタブを閉じる",
"reopen-last-tab": "最後のタブを開き直す",
"activate-next-tab": "次のタブに移動",
"activate-previous-tab": "前のタブに移動",
"open-new-window": "新しいウィンドウを開く",
"toggle-system-tray-icon": "システムトレイアイコンの切り替え",
"switch-to-first-tab": "最初のタブに切り替え",
"switch-to-second-tab": "2番目のタブに切り替え",
"switch-to-third-tab": "3番目のタブに切り替え",
"switch-to-fourth-tab": "4番目のタブに切り替え",
"switch-to-fifth-tab": "5番目のタブに切り替え",
"switch-to-sixth-tab": "6番目のタブに切り替え",
"switch-to-seventh-tab": "7番目のタブに切り替え",
"switch-to-eighth-tab": "8番目のタブに切り替え",
"switch-to-ninth-tab": "9番目のタブに切り替え",
"switch-to-last-tab": "最後のタブに切り替え",
"show-note-source": "ノートのソースを表示",
"show-options": "設定を表示",
"show-recent-changes": "最近の変更を表示",
"show-sql-console": "SQLコンソールを表示",
"show-backend-log": "バックエンドログを表示",
"show-help": "ヘルプを表示",
"show-cheatsheet": "チートシートを表示",
"add-link-to-text": "テキストにリンクを追加",
"follow-link-under-cursor": "カーソル下のリンクをたどる",
"insert-date-and-time-to-text": "日時を挿入",
"paste-markdown-into-text": "Markdownをテキストに貼り付け",
"cut-into-note": "ノートから切り取り",
"edit-read-only-note": "読み取り専用ノートの編集",
"toggle-right-pane": "右ペイン切り替え",
"print-active-note": "アクティブノートを印刷",
"export-active-note-as-pdf": "ートをPDFとしてエクスポート",
"open-note-externally": "外部でノートを開く",
"render-active-note": "アクティブノートを描画",
"run-active-note": "アクティブノートを実行",
"reload-frontend-app": "フロントエンドアプリのリロード",
"open-developer-tools": "開発者ツールを開く",
"find-in-text": "テキスト内検索",
"toggle-left-pane": "左ペイン切り替え",
"toggle-full-screen": "フルスクリーンの切り替え",
"copy-without-formatting": "書式なしでコピー",
"duplicate-subtree": "サブツリーの複製"
},
"login": {
"title": "ログイン",
"heading": "Trilium ログイン",
"incorrect-totp": "TOTPが正しくありません。もう一度お試しください。",
"incorrect-password": "パスワードが正しくありません。もう一度お試しください。",
"password": "パスワード",
"button": "ログイン"
},
"set_password": {
"title": "パスワードの設定",
"heading": "パスワードの設定",
"description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。",
"password": "パスワード",
"button": "パスワードの設定"
},
"javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。",
"setup": {
"heading": "Trilium Notes セットアップ",
"new-document": "私は新しいユーザーで、ートを取るために新しいTriliumドキュメントを作成したい",
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
"init-in-progress": "ドキュメントの初期化処理を実行中",
"redirecting": "まもなくアプリケーションにリダイレクトされます。"
},
"setup_sync-from-desktop": {
"heading": "デスクトップから同期",
"description": "このセットアップはデスクトップインスタンスから開始する必要があります:",
"step1": "Trilium Notes のデスクトップインスタンスを開きます。",
"step2": "Triliumメニューから、設定をクリックします。",
"step3": "同期をクリックします。",
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。"
},
"setup_sync-from-server": {
"heading": "サーバーから同期",
"instructions": "Triliumサーバーのアドレスと認証情報を下記に入力してください。これにより、Triliumドキュメント全体がサーバーからダウンロードされ、同期が設定されます。ドキュメントのサイズと接続速度によっては、時間がかかる場合があります。",
"server-host": "Triliumサーバーのアドレス",
"server-host-placeholder": "https://<hostname>:<port>",
"proxy-server": "プロキシサーバー(オプション)",
"proxy-server-placeholder": "https://<hostname>:<port>",
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
"password": "パスワード",
"password-placeholder": "パスワード",
"finish-setup": "セットアップ完了"
},
"setup_sync-in-progress": {
"heading": "同期中",
"successful": "同期が正しく設定されました。最初の同期が完了するまでしばらく時間がかかります。完了すると、ログインページにリダイレクトされます。",
"outstanding-items": "同期が未完了のアイテム:",
"outstanding-items-default": "N/A"
},
"weekdays": {
"monday": "月曜日",
"tuesday": "火曜日",
"wednesday": "水曜日",
"thursday": "木曜日",
"friday": "金曜日",
"saturday": "土曜日",
"sunday": "日曜日"
},
"months": {
"january": "1月",
"february": "2月",
"march": "3月",
"april": "4月",
"may": "5月",
"june": "6月",
"july": "7月",
"august": "8月",
"september": "9月",
"october": "10月",
"november": "11月",
"december": "12月"
},
"special_notes": {
"search_prefix": "検索:"
},
"test_sync": {
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。"
},
"hidden-subtree": {
"search-history-title": "検索履歴",
"sync-title": "同期",
"appearance-title": "外観",
"shortcuts-title": "ショートカット",
"text-notes": "テキストノート",
"code-notes-title": "コードノート",
"images-title": "画像",
"spellcheck-title": "スペルチェック",
"password-title": "パスワード",
"backup-title": "バックアップ",
"ai-llm-title": "AI/LLM",
"other": "その他",
"advanced-title": "高度",
"user-guide": "ユーザーガイド",
"localization": "言語と地域"
},
"notes": {
"new-note": "新しいノート",
"duplicate-note-suffix": "(重複)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "バックエンドのログファイル '{{ fileName }}' は(まだ)存在しません。",
"reading-log-failed": "バックエンドのログファイル '{{ fileName }}' の読み込みに失敗しました。"
},
"content_renderer": {
"note-cannot-be-displayed": "このノートタイプは表示できません。"
},
"pdf": {
"export_filter": "PDFドキュメント (*.pdf)",
"unable-to-export-message": "現在のートをPDFとしてエクスポートできませんでした。",
"unable-to-export-title": "PDFとしてエクスポートできません",
"unable-to-save-message": "選択されたファイルに書き込めませんでした。もう一度試すか、別の保存先を選択してください。"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Triliumを終了",
"recents": "最近のノート",
"bookmarks": "ブックマーク",
"today": "今日の日記を開く",
"new-note": "新しいノート",
"show-windows": "ウィンドウを表示",
"open_new_window": "新しいウィンドウを開く"
},
"migration": {
"old_version": "現在のバージョンからの直接的な移行はサポートされていません。まず最新のv0.60.4にアップグレードしてから、このバージョンにアップグレードしてください。",
"error_message": "バージョン {{version}} への移行中にエラーが発生しました: {{stack}}",
"wrong_db_version": "データベースのバージョン({{version}})は、アプリケーションが想定しているバージョン({{targetVersion}}よりも新しく、互換性のないバージョンによって作成された可能性があります。この問題を解決するには、Triliumを最新バージョンにアップグレードしてください。"
},
"modals": {
"error_title": "エラー"
},
"share_theme": {
"site-theme": "サイトのテーマ",
"search_placeholder": "検索...",
"last-updated": "最終更新日 {{- date}}",
"subpages": "サブページ:"
},
"hidden_subtree_templates": {
"text-snippet": "テキストスニペット",
"description": "説明",
"list-view": "リストビュー",
"grid-view": "グリッドビュー",
"calendar": "カレンダー",
"table": "テーブル",
"start-date": "開始日",
"end-date": "終了日",
"start-time": "開始時刻",
"end-time": "終了時間",
"board": "ボード",
"status": "ステータス",
"board_note_first": "最初のノート",
"board_note_second": "2番目のート",
"board_note_third": "3番目のート",
"board_status_progress": "進行中",
"board_status_done": "完了"
}
}

View File

@@ -267,9 +267,6 @@
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).",
"reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”."
},
"geo-map": {
"create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța."
},
"content_renderer": {
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
},

View File

@@ -0,0 +1,9 @@
{
"keyboard_actions": {
"back-in-note-history": "Pojdi na prejšnji zapisek v zgodovini",
"forward-in-note-history": "Pojdi na naslednji zapisek v zgodovini",
"open-jump-to-note-dialog": "Odpri dialog \"Skoči na zapisek\"",
"open-command-palette": "Odpri komandno ploščo",
"scroll-to-active-note": "Pomaknite se po drevesu zapiskov do aktivnega zapiska"
}
}

View File

@@ -1,143 +1,159 @@
{
"keyboard_actions": {
"open-jump-to-note-dialog": "打開「跳轉到筆記」對話",
"search-in-subtree": "在前筆記的子中搜筆記",
"expand-subtree": "展開前筆記的子",
"collapse-tree": "折疊完整的筆記樹",
"collapse-subtree": "折疊當前筆記的子",
"open-jump-to-note-dialog": "打開「跳轉到筆記」對話方塊",
"search-in-subtree": "在前筆記的子階層中搜筆記",
"expand-subtree": "展開前筆記的子階層",
"collapse-tree": "收合全部的筆記樹",
"collapse-subtree": "收合目前筆記的子階層",
"sort-child-notes": "排序子筆記",
"creating-and-moving-notes": "新增和移動筆記",
"create-note-into-inbox": "在收件匣(如果定義的話)或日記中新增筆記",
"create-note-into-inbox": "在收件匣(如果定義)或日記中新增筆記",
"delete-note": "刪除筆記",
"move-note-up": "上移筆記",
"move-note-down": "下移筆記",
"move-note-up-in-hierarchy": "上移筆記層級",
"move-note-down-in-hierarchy": "下移筆記層級",
"move-note-up-in-hierarchy": "筆記層級上移",
"move-note-down-in-hierarchy": "筆記層級下移",
"edit-note-title": "從筆記樹跳轉到筆記詳情並編輯標題",
"edit-branch-prefix": "顯示編輯分支前綴對話",
"edit-branch-prefix": "顯示編輯分支前綴對話方塊",
"note-clipboard": "筆記剪貼簿",
"copy-notes-to-clipboard": "複製選定的筆記到剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿粘貼筆記到活動筆記中",
"cut-notes-to-clipboard": "剪下選定的筆記剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中",
"cut-notes-to-clipboard": "剪下選定的筆記剪貼簿",
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
"add-note-above-to-the-selection": "上方筆記添加到選擇中",
"add-note-below-to-selection": "下方筆記添加到選擇中",
"duplicate-subtree": "複製子",
"tabs-and-windows": "標籤和窗口",
"open-new-tab": "打開新標籤",
"close-active-tab": "關閉活動標籤",
"reopen-last-tab": "重新打開最後關閉的標籤",
"activate-next-tab": "激活右側標籤",
"activate-previous-tab": "激活左側標籤",
"open-new-window": "打開新空白窗",
"toggle-tray": "顯示/隱藏應用程式的系統托盤",
"first-tab": "激活列表中的第一個標籤",
"second-tab": "激活列表中的第二個標籤",
"third-tab": "激活列表中的第三個標籤",
"fourth-tab": "激活列表中的第四個標籤",
"fifth-tab": "激活列表中的第五個標籤",
"sixth-tab": "激活列表中的第六個標籤",
"seventh-tab": "激活列表中的第七個標籤",
"eight-tab": "激活列表中的第八個標籤",
"ninth-tab": "激活列表中的第九個標籤",
"last-tab": "激活列表中的最後一個標籤",
"dialogs": "對話",
"show-note-source": "顯示筆記源對話",
"show-options": "顯示選項對話框",
"show-revisions": "顯示筆記歷史對話",
"show-recent-changes": "顯示最近更改對話",
"show-sql-console": "顯示SQL控制台對話",
"show-backend-log": "顯示後端日誌對話",
"text-note-operations": "文筆記操作",
"add-link-to-text": "打開對話框以將鏈接添加到文本",
"follow-link-under-cursor": "跟隨遊標下的鏈接",
"insert-date-and-time-to-text": "將當前日期和時間插入文本",
"paste-markdown-into-text": "將剪貼簿中的Markdown粘貼到文本筆記中",
"cut-into-note": "從前筆記剪下選擇並新增包含選定文本的子筆記",
"add-include-note-to-text": "打開對話以包含筆記",
"add-note-above-to-the-selection": "加入上方筆記選擇中",
"add-note-below-to-selection": "加入下方筆記選擇中",
"duplicate-subtree": "複製子階層",
"tabs-and-windows": "分頁和視窗",
"open-new-tab": "打開新分頁",
"close-active-tab": "關閉活動分頁",
"reopen-last-tab": "重新打開最後關閉的分頁",
"activate-next-tab": "切換至右側分頁",
"activate-previous-tab": "切換至左側分頁",
"open-new-window": "打開新空白窗",
"toggle-tray": "從系統列顯示/隱藏應用程式",
"first-tab": "切換至列表中的第一個分頁",
"second-tab": "切換至列表中的第二個分頁",
"third-tab": "切換至列表中的第三個分頁",
"fourth-tab": "切換至列表中的第四個分頁",
"fifth-tab": "切換至列表中的第五個分頁",
"sixth-tab": "切換至列表中的第六個分頁",
"seventh-tab": "切換至列表中的第七個分頁",
"eight-tab": "切換至列表中的第八個分頁",
"ninth-tab": "切換至列表中的第九個分頁",
"last-tab": "切換至列表中的最後一個分頁",
"dialogs": "對話方塊",
"show-note-source": "顯示筆記源對話方塊",
"show-options": "打開選項頁面",
"show-revisions": "顯示筆記修改歷史對話方塊",
"show-recent-changes": "顯示最近更改對話方塊",
"show-sql-console": "顯示 SQL 控制台對話方塊",
"show-backend-log": "顯示後端日誌對話方塊",
"text-note-operations": "文筆記操作",
"add-link-to-text": "打開對話方塊以插入連結",
"follow-link-under-cursor": "開啟游標處的連結",
"insert-date-and-time-to-text": "插入目前日期和時間",
"paste-markdown-into-text": "將剪貼簿中的 Markdown 文字貼上",
"cut-into-note": "從前筆記剪下選擇的部分並新增至子筆記",
"add-include-note-to-text": "打開對話方塊以包含筆記",
"edit-readonly-note": "編輯唯讀筆記",
"attributes-labels-and-relations": "屬性(標籤和關係)",
"add-new-label": "新增新標籤",
"create-new-relation": "新增新關係",
"ribbon-tabs": "功能區標籤",
"toggle-basic-properties": "切換基本屬性",
"toggle-file-properties": "切換文件屬性",
"toggle-image-properties": "切換圖像屬性",
"toggle-owned-attributes": "切換擁有的屬性",
"toggle-inherited-attributes": "切換繼承的屬性",
"toggle-promoted-attributes": "切換提升的屬性",
"toggle-link-map": "切換鏈接地圖",
"toggle-note-info": "切換筆記資訊",
"toggle-note-paths": "切換筆記路徑",
"toggle-similar-notes": "切換相似筆記",
"ribbon-tabs": "功能區分頁",
"toggle-basic-properties": "顯示基本屬性",
"toggle-file-properties": "顯示文件屬性",
"toggle-image-properties": "顯示圖像屬性",
"toggle-owned-attributes": "顯示擁有的屬性",
"toggle-inherited-attributes": "顯示繼承的屬性",
"toggle-promoted-attributes": "顯示提升的屬性",
"toggle-link-map": "顯示連結地圖",
"toggle-note-info": "顯示筆記資訊",
"toggle-note-paths": "顯示筆記路徑",
"toggle-similar-notes": "顯示相似筆記",
"other": "其他",
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
"print-active-note": "打印活動筆記",
"print-active-note": "列印目前筆記",
"open-note-externally": "以預設應用程式打開筆記文件",
"render-active-note": "渲染(重新渲染)活動筆記",
"run-active-note": "運行主動的JavaScript前端/後端)碼筆記",
"toggle-note-hoisting": "切換活動筆記的提升",
"render-active-note": "渲染(重新渲染)目前筆記",
"run-active-note": "執行目前的 JavaScript前端/後端)程式碼筆記",
"toggle-note-hoisting": "提升目前筆記",
"unhoist": "從任何地方取消提升",
"reload-frontend-app": "重新載前端應用",
"open-dev-tools": "打開開發工具",
"toggle-left-note-tree-panel": "切換左側(筆記樹)面板",
"toggle-full-screen": "切換全幕",
"reload-frontend-app": "重新載前端應用",
"open-dev-tools": "打開開發工具",
"toggle-left-note-tree-panel": "顯示左側(筆記樹)面板",
"toggle-full-screen": "切換全幕",
"zoom-out": "縮小",
"zoom-in": "放大",
"note-navigation": "筆記導航",
"reset-zoom-level": "重縮放級別",
"copy-without-formatting": "複製不帶格式的選定文",
"force-save-revision": "強制新增/保存當前筆記的歷史版本",
"show-help": "顯示內置說明/備忘單",
"toggle-book-properties": "切換書籍屬性"
"reset-zoom-level": "重縮放比例",
"copy-without-formatting": "以純文字複製選定文",
"force-save-revision": "強制新增/儲存目前筆記的版本",
"show-help": "顯示用戶指南",
"toggle-book-properties": "顯示書籍屬性",
"back-in-note-history": "跳轉至歷史記錄中的上一個筆記",
"forward-in-note-history": "跳轉至歷史記錄中的下一個筆記",
"open-command-palette": "打開命令面板",
"scroll-to-active-note": "滾動筆記樹到目前筆記",
"quick-search": "開啟快速搜尋列",
"create-note-after": "新增筆記於目前筆記之後",
"create-note-into": "新增目前筆記的子筆記",
"clone-notes-to": "複製選定筆記的複本至",
"move-notes-to": "移動選定的筆記至",
"show-cheatsheet": "顯示常用鍵盤快捷鍵",
"find-in-text": "顯示搜尋面板",
"toggle-classic-editor-toolbar": "顯示固定工具列編輯器的格式分頁",
"export-as-pdf": "匯出目前筆記為 PDF",
"toggle-zen-mode": "啟用/禁用禪模式(極簡界面以專注編輯)"
},
"login": {
"title": "登入",
"heading": "Trilium登入",
"heading": "Trilium 登入",
"incorrect-password": "密碼不正確。請再試一次。",
"password": "密碼",
"remember-me": "記住我",
"button": "登入"
"button": "登入",
"incorrect-totp": "TOTP 不正確。請再試一次。",
"sign_in_with_sso": "用 {{ ssoIssuerName }} 登入"
},
"set_password": {
"title": "設定密碼",
"heading": "設定密碼",
"description": "在您可以從Web開始使用Trilium之前您需要先設定一個密碼。然後您將使用此密碼登。",
"description": "在由網頁開始使用 Trilium 之前,您需要先設定一個密碼用此密碼登。",
"password": "密碼",
"password-confirmation": "密碼確認",
"password-confirmation": "確認密碼",
"button": "設定密碼"
},
"javascript-required": "Trilium需要啓用JavaScript。",
"javascript-required": "Trilium 需要啟用 JavaScript。",
"setup": {
"heading": "TriliumNext筆記設定",
"new-document": "我是新用戶,我想為我的筆記新增一個新的Trilium檔案",
"sync-from-desktop": "我已經有一個桌面實例,我想設定與它同步",
"sync-from-server": "我已經有一個伺服器實例,我想設定與它同步",
"heading": "Trilium 筆記設定",
"new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件",
"sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步",
"sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步",
"next": "下一步",
"init-in-progress": "檔案初始化進行中",
"redirecting": "您將很快被重定向到應用程式。",
"init-in-progress": "文件正在初始化",
"redirecting": "您即將被重新導向至應用程式。",
"title": "設定"
},
"setup_sync-from-desktop": {
"heading": "從桌面同步",
"description": "此設定需要從桌面實例啓動:",
"step1": "打開您的TriliumNext筆記桌面實例。",
"step2": "從Trilium單中,點擊選項。",
"step3": "點擊同步。",
"step4": "將伺服器實例地址更改為:{{- host}}並點擊保存。",
"step5": "點擊「測試同步」按鈕以驗證連接是否成功。",
"step6": "完成這些步驟後,點擊{{- link}}。",
"heading": "從桌面同步",
"description": "此設定需要從桌面版本啟動:",
"step1": "打開您的桌面版 TriliumNext 筆記。",
"step2": "從 Trilium單中,點擊選項。",
"step3": "點擊同步」類別。",
"step4": "將伺服器版網址更改為:{{- host}} 並點擊保存。",
"step5": "點擊「測試同步」以驗證連接是否成功。",
"step6": "完成這些步驟後,點擊 {{- link}}。",
"step6-here": "這裡"
},
"setup_sync-from-server": {
"heading": "從伺服器同步",
"instructions": "請在下輸入Trilium伺服器址和密碼。這將從伺服器下載整個Trilium數據庫檔案並設定同步。因應數據庫大小和您的連接速度,這可能需要一段時間。",
"server-host": "Trilium伺服器址",
"instructions": "請在下輸入 Trilium 伺服器址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。",
"server-host": "Trilium 伺服器址",
"server-host-placeholder": "https://<主機名稱>:<端口>",
"proxy-server": "代理伺服器(可選)",
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
"note": "注意:",
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面程式",
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面",
"password": "密碼",
"password-placeholder": "密碼",
"back": "返回",
@@ -145,7 +161,7 @@
},
"setup_sync-in-progress": {
"heading": "同步中",
"successful": "同步已正確設定。初同步完成可能需要一些時間。完成後,您將被重定向到登入頁面。",
"successful": "已正確設定同步。初同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
"outstanding-items": "未完成的同步項目:",
"outstanding-items-default": "無"
},
@@ -154,8 +170,8 @@
"heading": "未找到"
},
"share_page": {
"parent": "上級目錄",
"clipped-from": "此筆記最初剪下自 {{- url}}",
"parent": "父級",
"clipped-from": "此筆記最初自 {{- url}} 剪下",
"child-notes": "子筆記:",
"no-content": "此筆記沒有內容。"
},
@@ -186,7 +202,227 @@
"search_prefix": "搜尋:"
},
"test_sync": {
"not-configured": "未設定同步伺服器主機,請先設定同步",
"successful": "成功與同步伺服器握手,現在開始同步"
"not-configured": "未設定同步伺服器主機,請先設定同步",
"successful": "成功與同步伺服器握手,開始同步"
},
"keyboard_action_names": {
"zoom-in": "放大",
"reset-zoom-level": "重設縮放比例",
"zoom-out": "縮小",
"copy-without-formatting": "以純文字複製",
"force-save-revision": "強制儲存修改版本",
"back-in-note-history": "返回筆記歷史",
"forward-in-note-history": "前進筆記歷史",
"jump-to-note": "跳轉至…",
"scroll-to-active-note": "滾動到目前筆記",
"quick-search": "快速搜尋",
"search-in-subtree": "在子階層中搜尋",
"expand-subtree": "展開子階層",
"collapse-tree": "收合筆記樹",
"collapse-subtree": "收合子階層",
"sort-child-notes": "排序子筆記",
"create-note-after": "於後面新建筆記",
"create-note-into": "新建筆記至",
"create-note-into-inbox": "新建筆記至收件匣",
"delete-notes": "刪除筆記",
"move-note-up": "上移筆記",
"move-note-down": "下移筆記",
"move-note-up-in-hierarchy": "上移筆記階層",
"move-note-down-in-hierarchy": "下移筆記階層",
"edit-note-title": "編輯筆記標題",
"edit-branch-prefix": "編輯分支前綴",
"clone-notes-to": "複製筆記至",
"move-notes-to": "移動筆記至",
"copy-notes-to-clipboard": "複製筆記至剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記",
"cut-notes-to-clipboard": "剪下筆記至剪貼簿",
"select-all-notes-in-parent": "選擇父階層所有筆記",
"add-note-above-to-selection": "加入上方筆記至選擇中",
"add-note-below-to-selection": "加入下方筆記至選擇中",
"duplicate-subtree": "複製子階層",
"open-new-tab": "打開新分頁",
"close-active-tab": "關閉目前分頁",
"reopen-last-tab": "重新打開最後關閉的分頁",
"activate-next-tab": "切換至下一分頁",
"activate-previous-tab": "切換至上一分頁",
"open-new-window": "打開新視窗",
"toggle-system-tray-icon": "顯示/隱藏系統列圖示",
"toggle-zen-mode": "啟用/禁用禪模式",
"switch-to-first-tab": "切換至第一個分頁",
"switch-to-second-tab": "切換至第二個分頁",
"switch-to-third-tab": "切換至第三個分頁",
"switch-to-fourth-tab": "切換至第四個分頁",
"switch-to-fifth-tab": "切換至第五個分頁",
"switch-to-sixth-tab": "切換至第六個分頁",
"switch-to-seventh-tab": "切換至第七個分頁",
"switch-to-eighth-tab": "切換至第八個分頁",
"switch-to-ninth-tab": "切換至第九個分頁",
"switch-to-last-tab": "切換至第最後一個分頁",
"show-note-source": "顯示筆記原始碼",
"show-options": "顯示選項",
"show-revisions": "顯示修改歷史",
"show-recent-changes": "顯示最近更改",
"show-sql-console": "顯示 SQL 控制台",
"show-backend-log": "顯示後端日誌",
"show-help": "顯示幫助",
"show-cheatsheet": "顯示快捷鍵指南",
"add-link-to-text": "插入連結",
"follow-link-under-cursor": "開啟游標處的連結",
"insert-date-and-time-to-text": "插入日期和時間",
"paste-markdown-into-text": "貼上 Markdown 文字",
"cut-into-note": "剪下至筆記",
"add-include-note-to-text": "添加包含筆記",
"edit-read-only-note": "編輯唯讀筆記",
"add-new-label": "新增標籤",
"add-new-relation": "新增關係",
"toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器",
"toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性",
"toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性",
"toggle-ribbon-tab-file-properties": "顯示功能區分頁:文件屬性",
"toggle-ribbon-tab-image-properties": "顯示功能區分頁:圖片屬性",
"toggle-ribbon-tab-owned-attributes": "顯示功能區分頁:自有屬性",
"toggle-ribbon-tab-inherited-attributes": "顯示功能區分頁:繼承屬性",
"toggle-ribbon-tab-promoted-attributes": "顯示功能區分頁:提升屬性",
"toggle-ribbon-tab-note-map": "顯示功能區分頁:筆記地圖",
"toggle-ribbon-tab-note-info": "顯示功能區分頁:筆記資訊",
"toggle-ribbon-tab-note-paths": "顯示功能區分頁:筆記路徑",
"toggle-ribbon-tab-similar-notes": "顯示功能區分頁:相似筆記",
"toggle-right-pane": "打開右側面板",
"print-active-note": "列印目前筆記",
"export-active-note-as-pdf": "匯出目前筆記為 PDF",
"open-note-externally": "於外部打開筆記",
"render-active-note": "渲染目前筆記",
"run-active-note": "執行目前筆記",
"toggle-note-hoisting": "提升筆記",
"unhoist-note": "取消提升筆記",
"reload-frontend-app": "重新載入前端程式",
"open-developer-tools": "打開開發者工具",
"find-in-text": "在文字中尋找",
"toggle-left-pane": "打開左側面板",
"toggle-full-screen": "切換全螢幕",
"command-palette": "命令面板"
},
"weekdayNumber": "第 {weekNumber} 週",
"quarterNumber": "第 {quarterNumber} 季度",
"hidden-subtree": {
"root-title": "隱藏的筆記",
"search-history-title": "搜尋歷史",
"note-map-title": "筆記地圖",
"sql-console-history-title": "SQL 控制台歷史",
"shared-notes-title": "分享筆記",
"bulk-action-title": "批次操作",
"backend-log-title": "後端日誌",
"user-hidden-title": "隱藏的用戶",
"launch-bar-templates-title": "啟動欄模版",
"base-abstract-launcher-title": "基礎摘要啟動器",
"command-launcher-title": "命令啟動器",
"note-launcher-title": "筆記啟動器",
"script-launcher-title": "腳本啟動器",
"built-in-widget-title": "內建小工具",
"spacer-title": "空白占位",
"custom-widget-title": "自定義小工具",
"launch-bar-title": "啟動欄",
"available-launchers-title": "可用啟動器",
"go-to-previous-note-title": "跳轉到前一筆記",
"go-to-next-note-title": "跳轉到後一筆記",
"new-note-title": "新增筆記",
"search-notes-title": "搜尋筆記",
"jump-to-note-title": "跳轉至…",
"calendar-title": "日曆",
"recent-changes-title": "最近修改",
"bookmarks-title": "書籤",
"open-today-journal-note-title": "打開今日日記筆記",
"quick-search-title": "快速搜尋",
"protected-session-title": "受保護的作業階段",
"sync-status-title": "同步狀態",
"settings-title": "設定",
"llm-chat-title": "與筆記聊天",
"options-title": "選項",
"appearance-title": "外觀",
"shortcuts-title": "快捷鍵",
"text-notes": "文字筆記",
"code-notes-title": "程式碼筆記",
"images-title": "圖片",
"spellcheck-title": "拼寫檢查",
"password-title": "密碼",
"multi-factor-authentication-title": "多重身份驗證",
"etapi-title": "ETAPI",
"backup-title": "備份",
"sync-title": "同步",
"ai-llm-title": "AI/LLM",
"other": "其他",
"advanced-title": "進階",
"visible-launchers-title": "可見啟動器",
"user-guide": "使用指南",
"localization": "語言和區域",
"inbox-title": "收件匣"
},
"notes": {
"new-note": "新增筆記",
"duplicate-note-suffix": "(重複)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "後端日誌文件 '{{ fileName }}' 暫不存在。",
"reading-log-failed": "讀取後端日誌文件 '{{ fileName }}' 失敗。"
},
"content_renderer": {
"note-cannot-be-displayed": "無法顯示此類型筆記。"
},
"pdf": {
"export_filter": "PDF 文件 (*.pdf)",
"unable-to-export-message": "目前筆記無法被匯出為 PDF 。",
"unable-to-export-title": "無法匯出為 PDF",
"unable-to-save-message": "選定文件無法被寫入。請重試或選擇其他路徑。"
},
"tray": {
"tooltip": "Trilium 筆記",
"close": "退出 Trilium",
"recents": "最近筆記",
"bookmarks": "書籤",
"today": "打開今日日記筆記",
"new-note": "新增筆記",
"show-windows": "顯示視窗",
"open_new_window": "打開新視窗"
},
"migration": {
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
},
"modals": {
"error_title": "錯誤"
},
"share_theme": {
"site-theme": "網站主題",
"search_placeholder": "搜尋…",
"image_alt": "文章圖片",
"last-updated": "最近於 {{- date}} 更新",
"subpages": "子頁面:",
"on-this-page": "本頁內容",
"expand": "展開"
},
"hidden_subtree_templates": {
"text-snippet": "文字片段",
"description": "描述",
"list-view": "列表顯示",
"grid-view": "網格顯示",
"calendar": "日曆",
"table": "表格",
"geo-map": "地理地圖",
"start-date": "開始日期",
"end-date": "結束日期",
"start-time": "開始時間",
"end-time": "結束時間",
"geolocation": "地理位置",
"built-in-templates": "內建模版",
"board": "看板",
"status": "狀態",
"board_note_first": "第一個筆記",
"board_note_second": "第二個筆記",
"board_note_third": "第三個筆記",
"board_status_todo": "待辦",
"board_status_progress": "進行中",
"board_status_done": "已完成"
}
}

View File

@@ -0,0 +1,6 @@
{
"keyboard_actions": {
"delete-note": "Xoá ghi chép",
"move-notes-to": "Di chuyển ghi chép được chọn"
}
}

View File

@@ -1758,6 +1758,41 @@ class BNote extends AbstractBeccaEntity<BNote> {
return childBranches;
}
/**
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
* @param attributeId - the id of the attribute owned by this note
* @returns - the BAttribute with the given id or undefined if not found.
*/
getAttributeById(attributeId : string): BAttribute | undefined {
this.__ensureAttributeCacheIsAvailable();
if (!this.__attributeCache) {
throw new Error("Attribute cache not available.");
}
return this.__attributeCache.find((attr) => attr.attributeId === attributeId);
}
/**
* Sets an attribute's value by it's attributeId.
* @param attributeId - the id of the attribute owned by this note
* @param value - the new value to replace
*/
setAttributeValueById(attributeId : string, value? : string) {
const attributes = this.getOwnedAttributes();
const attr = attributes.find((attr) => attr.attributeId === attributeId);
value = value?.toString() || "";
if (attr) {
if (attr.value !== value) {
attr.value = value;
attr.save();
}
} else {
throw new Error(`Attribute with id ${attributeId} not found.`);
}
}
}
export default BNote;

View File

@@ -7,7 +7,7 @@ import becca from "../becca.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import sql from "../../services/sql.js";
import BAttachment from "./battachment.js";
import type { AttachmentRow, NoteType, RevisionRow } from "@triliumnext/commons";
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
import eraseService from "../../services/erase.js";
interface ContentOpts {
@@ -201,7 +201,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
utcDateModified: this.utcDateModified,
content: this.content, // used when retrieving full note revision to frontend
contentLength: this.contentLength
};
} satisfies RevisionPojo;
}
override getPojoToSave() {

View File

@@ -12,7 +12,7 @@ import ValidationError from "../../errors/validation_error.js";
import blobService from "../../services/blob.js";
import type { Request } from "express";
import type BBranch from "../../becca/entities/bbranch.js";
import type { AttributeRow } from "@triliumnext/commons";
import type { AttributeRow, DeleteNotesPreview } from "@triliumnext/commons";
/**
* @swagger
@@ -339,7 +339,7 @@ function getDeleteNotesPreview(req: Request) {
return {
noteIdsToBeDeleted: Array.from(noteIdsToBeDeleted),
brokenRelations
};
} satisfies DeleteNotesPreview;
}
function forceSaveRevision(req: Request) {

View File

@@ -93,6 +93,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"redirectBareDomain",
"showLoginInShareTheme",
"splitEditorOrientation",
"seenCallToActions",
// AI/LLM integration options
"aiEnabled",

View File

@@ -5,18 +5,7 @@ import protectedSessionService from "../../services/protected_session.js";
import noteService from "../../services/notes.js";
import becca from "../../becca/becca.js";
import type { Request } from "express";
interface RecentChangeRow {
noteId: string;
current_isDeleted: boolean;
current_deleteId: string;
current_title: string;
current_isProtected: boolean;
title: string;
utcDate: string;
date: string;
canBeUndeleted?: boolean;
}
import type { RecentChangeRow } from "@triliumnext/commons";
function getRecentChanges(req: Request) {
const { ancestorNoteId } = req.params;

View File

@@ -12,6 +12,7 @@ import type { Request, Response } from "express";
import type BRevision from "../../becca/entities/brevision.js";
import type BNote from "../../becca/entities/bnote.js";
import type { NotePojo } from "../../becca/becca-interface.js";
import { RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons";
interface NotePath {
noteId: string;
@@ -41,7 +42,7 @@ function getRevisions(req: Request) {
WHERE revisions.noteId = ?
ORDER BY revisions.utcDateCreated DESC`,
[req.params.noteId]
);
) satisfies RevisionItem[];
}
function getRevision(req: Request) {
@@ -59,7 +60,7 @@ function getRevision(req: Request) {
}
}
return revision;
return revision satisfies RevisionPojo;
}
function getRevisionFilename(revision: BRevision) {

View File

@@ -52,10 +52,15 @@ function quickSearch(req: Request) {
fuzzyAttributeSearch: false
});
const resultNoteIds = searchService.findResultsWithQuery(searchString, searchContext).map((sr) => sr.noteId);
// Use the same highlighting logic as autocomplete for consistency
const searchResults = searchService.searchNotesForAutocomplete(searchString, false);
// Extract note IDs for backward compatibility
const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[];
return {
searchResultNoteIds: resultNoteIds,
searchResults: searchResults,
error: searchContext.getError()
};
}

View File

@@ -183,7 +183,7 @@ const defaultOptions: DefaultOption[] = [
// HTML import configuration
{ name: "layoutOrientation", value: "vertical", isSynced: false },
{ name: "backgroundEffects", value: "false", isSynced: false },
{ name: "backgroundEffects", value: "true", isSynced: false },
{
name: "allowedHtmlTags",
value: JSON.stringify(DEFAULT_ALLOWED_TAGS),
@@ -206,11 +206,11 @@ const defaultOptions: DefaultOption[] = [
{ name: "ollamaEnabled", value: "false", isSynced: true },
{ name: "ollamaDefaultModel", value: "", isSynced: true },
{ name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
// Adding missing AI options
{ name: "aiTemperature", value: "0.7", isSynced: true },
{ name: "aiSystemPrompt", value: "", isSynced: true },
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
{ name: "seenCallToActions", value: "[]", isSynced: true }
];
/**

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { processMindmapContent } from "./note_content_fulltext.js";
import NoteContentFulltextExp from "./note_content_fulltext.js";
describe("processMindmapContent", () => {
it("supports empty JSON", () => {
@@ -11,3 +12,19 @@ describe("processMindmapContent", () => {
expect(processMindmapContent(`{ "node": " }`)).toEqual("");
});
});
describe("Fuzzy Search Operators", () => {
it("~= operator works with typos", () => {
// Test that the ~= operator can handle common typos
const expression = new NoteContentFulltextExp("~=", { tokens: ["hello"] });
expect(expression.tokens).toEqual(["hello"]);
expect(() => new NoteContentFulltextExp("~=", { tokens: ["he"] })).toThrow(); // Too short
});
it("~* operator works with fuzzy contains", () => {
// Test that the ~* operator handles fuzzy substring matching
const expression = new NoteContentFulltextExp("~*", { tokens: ["world"] });
expect(expression.tokens).toEqual(["world"]);
expect(() => new NoteContentFulltextExp("~*", { tokens: ["wo"] })).toThrow(); // Too short
});
});

View File

@@ -11,8 +11,19 @@ import protectedSessionService from "../../protected_session.js";
import striptags from "striptags";
import { normalize } from "../../utils.js";
import sql from "../../sql.js";
import {
normalizeSearchText,
calculateOptimizedEditDistance,
validateFuzzySearchTokens,
validateAndPreprocessContent,
fuzzyMatchWord,
FUZZY_SEARCH_CONFIG
} from "../utils/text_utils.js";
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%="]);
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
// Maximum content size for search processing (2MB)
const MAX_SEARCH_CONTENT_SIZE = 2 * 1024 * 1024;
const cachedRegexes: Record<string, RegExp> = {};
@@ -41,6 +52,16 @@ class NoteContentFulltextExp extends Expression {
constructor(operator: string, { tokens, raw, flatText }: ConstructorOpts) {
super();
if (!operator || !tokens || !Array.isArray(tokens)) {
throw new Error('Invalid parameters: operator and tokens are required');
}
// Validate fuzzy search tokens
const validation = validateFuzzySearchTokens(tokens, operator);
if (!validation.isValid) {
throw new Error(validation.error!);
}
this.operator = operator;
this.tokens = tokens;
this.raw = !!raw;
@@ -59,7 +80,9 @@ class NoteContentFulltextExp extends Expression {
for (const row of sql.iterateRows<SearchRow>(`
SELECT noteId, type, mime, content, isProtected
FROM notes JOIN blobs USING (blobId)
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap') AND isDeleted = 0`)) {
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
AND isDeleted = 0
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
this.findInText(row, inputNoteSet, resultNoteSet);
}
@@ -89,6 +112,13 @@ class NoteContentFulltextExp extends Expression {
}
content = this.preprocessContent(content, type, mime);
// Apply content size validation and preprocessing
const processedContent = validateAndPreprocessContent(content, noteId);
if (!processedContent) {
return; // Content too large or invalid
}
content = processedContent;
if (this.tokens.length === 1) {
const [token] = this.tokens;
@@ -99,21 +129,27 @@ class NoteContentFulltextExp extends Expression {
(this.operator === "*=" && content.endsWith(token)) ||
(this.operator === "=*" && content.startsWith(token)) ||
(this.operator === "*=*" && content.includes(token)) ||
(this.operator === "%=" && getRegex(token).test(content))
(this.operator === "%=" && getRegex(token).test(content)) ||
(this.operator === "~=" && this.matchesWithFuzzy(content, noteId)) ||
(this.operator === "~*" && this.fuzzyMatchToken(normalizeSearchText(token), normalizeSearchText(content)))
) {
resultNoteSet.add(becca.notes[noteId]);
}
} else {
const nonMatchingToken = this.tokens.find(
(token) =>
!content?.includes(token) &&
// in case of default fulltext search, we should consider both title, attrs and content
// so e.g. "hello world" should match when "hello" is in title and "world" in content
(!this.flatText || !becca.notes[noteId].getFlatText().includes(token))
);
// Multi-token matching with fuzzy support and phrase proximity
if (this.operator === "~=" || this.operator === "~*") {
if (this.matchesWithFuzzy(content, noteId)) {
resultNoteSet.add(becca.notes[noteId]);
}
} else {
const nonMatchingToken = this.tokens.find(
(token) =>
!this.tokenMatchesContent(token, content, noteId)
);
if (!nonMatchingToken) {
resultNoteSet.add(becca.notes[noteId]);
if (!nonMatchingToken) {
resultNoteSet.add(becca.notes[noteId]);
}
}
}
@@ -124,8 +160,8 @@ class NoteContentFulltextExp extends Expression {
content = normalize(content.toString());
if (type === "text" && mime === "text/html") {
if (!this.raw && content.length < 20000) {
// striptags is slow for very large notes
if (!this.raw) {
// Content size already filtered at DB level, safe to process
content = this.stripTags(content);
}
@@ -152,6 +188,147 @@ class NoteContentFulltextExp extends Expression {
return content.trim();
}
/**
* Checks if a token matches content with optional fuzzy matching
*/
private tokenMatchesContent(token: string, content: string, noteId: string): boolean {
const normalizedToken = normalizeSearchText(token);
const normalizedContent = normalizeSearchText(content);
if (normalizedContent.includes(normalizedToken)) {
return true;
}
// Check flat text for default fulltext search
if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) {
return false;
}
return true;
}
/**
* Performs fuzzy matching with edit distance and phrase proximity
*/
private matchesWithFuzzy(content: string, noteId: string): boolean {
try {
const normalizedContent = normalizeSearchText(content);
const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : "";
// For phrase matching, check if tokens appear within reasonable proximity
if (this.tokens.length > 1) {
return this.matchesPhrase(normalizedContent, flatText);
}
// Single token fuzzy matching
const token = normalizeSearchText(this.tokens[0]);
return this.fuzzyMatchToken(token, normalizedContent) ||
(this.flatText && this.fuzzyMatchToken(token, flatText));
} catch (error) {
log.error(`Error in fuzzy matching for note ${noteId}: ${error}`);
return false;
}
}
/**
* Checks if multiple tokens match as a phrase with proximity consideration
*/
private matchesPhrase(content: string, flatText: string): boolean {
const searchText = this.flatText ? `${content} ${flatText}` : content;
// Apply content size limits for phrase matching
const limitedText = validateAndPreprocessContent(searchText);
if (!limitedText) {
return false;
}
const words = limitedText.toLowerCase().split(/\s+/);
// Only skip phrase matching for truly extreme word counts that could crash the system
if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) {
console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`);
return false;
}
// Warn about large word counts but still attempt matching
if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) {
console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`);
}
// Find positions of each token
const tokenPositions: number[][] = this.tokens.map(token => {
const normalizedToken = normalizeSearchText(token);
const positions: number[] = [];
words.forEach((word, index) => {
if (this.fuzzyMatchSingle(normalizedToken, word)) {
positions.push(index);
}
});
return positions;
});
// Check if we found all tokens
if (tokenPositions.some(positions => positions.length === 0)) {
return false;
}
// Check for phrase proximity using configurable distance
return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY);
}
/**
* Checks if token positions indicate a phrase match within max distance
*/
private hasProximityMatch(tokenPositions: number[][], maxDistance: number): boolean {
// For 2 tokens, simple proximity check
if (tokenPositions.length === 2) {
const [pos1, pos2] = tokenPositions;
return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance));
}
// For more tokens, check if we can find a sequence where all tokens are within range
const findSequence = (remaining: number[][], currentPos: number): boolean => {
if (remaining.length === 0) return true;
const [nextPositions, ...rest] = remaining;
return nextPositions.some(pos =>
Math.abs(pos - currentPos) <= maxDistance &&
findSequence(rest, pos)
);
};
const [firstPositions, ...rest] = tokenPositions;
return firstPositions.some(startPos => findSequence(rest, startPos));
}
/**
* Performs fuzzy matching for a single token against content
*/
private fuzzyMatchToken(token: string, content: string): boolean {
if (token.length < FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH) {
// For short tokens, require exact match to avoid too many false positives
return content.includes(token);
}
const words = content.split(/\s+/);
// Only limit word processing for truly extreme cases to prevent system instability
const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT);
return limitedWords.some(word => this.fuzzyMatchSingle(token, word));
}
/**
* Fuzzy matches a single token against a single word
*/
private fuzzyMatchSingle(token: string, word: string): boolean {
// Use shared optimized fuzzy matching logic
return fuzzyMatchWord(token, word, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
}
stripTags(content: string) {
// we want to allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
// we want to insert space in place of block tags (because they imply text separation)

View File

@@ -7,6 +7,7 @@ import Expression from "./expression.js";
import NoteSet from "../note_set.js";
import becca from "../../../becca/becca.js";
import { normalize } from "../../utils.js";
import { normalizeSearchText, fuzzyMatchWord, fuzzyMatchWordWithResult } from "../utils/text_utils.js";
import beccaService from "../../../becca/becca_service.js";
class NoteFlatTextExp extends Expression {
@@ -15,7 +16,8 @@ class NoteFlatTextExp extends Expression {
constructor(tokens: string[]) {
super();
this.tokens = tokens;
// Normalize tokens using centralized normalization function
this.tokens = tokens.map(token => normalizeSearchText(token));
}
execute(inputNoteSet: NoteSet, executionContext: any, searchContext: SearchContext) {
@@ -55,14 +57,18 @@ class NoteFlatTextExp extends Expression {
const foundAttrTokens: string[] = [];
for (const token of remainingTokens) {
if (note.type.includes(token) || note.mime.includes(token)) {
// Add defensive checks for undefined properties
const typeMatches = note.type && note.type.includes(token);
const mimeMatches = note.mime && note.mime.includes(token);
if (typeMatches || mimeMatches) {
foundAttrTokens.push(token);
}
}
for (const attribute of note.getOwnedAttributes()) {
const normalizedName = normalize(attribute.name);
const normalizedValue = normalize(attribute.value);
const normalizedName = normalizeSearchText(attribute.name);
const normalizedValue = normalizeSearchText(attribute.value);
for (const token of remainingTokens) {
if (normalizedName.includes(token) || normalizedValue.includes(token)) {
@@ -72,11 +78,11 @@ class NoteFlatTextExp extends Expression {
}
for (const parentNote of note.parents) {
const title = normalize(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
const foundTokens: string[] = foundAttrTokens.slice();
for (const token of remainingTokens) {
if (title.includes(token)) {
if (this.smartMatch(title, token, searchContext)) {
foundTokens.push(token);
}
}
@@ -91,7 +97,7 @@ class NoteFlatTextExp extends Expression {
}
};
const candidateNotes = this.getCandidateNotes(inputNoteSet);
const candidateNotes = this.getCandidateNotes(inputNoteSet, searchContext);
for (const note of candidateNotes) {
// autocomplete should be able to find notes by their noteIds as well (only leafs)
@@ -103,23 +109,27 @@ class NoteFlatTextExp extends Expression {
const foundAttrTokens: string[] = [];
for (const token of this.tokens) {
if (note.type.includes(token) || note.mime.includes(token)) {
// Add defensive checks for undefined properties
const typeMatches = note.type && note.type.includes(token);
const mimeMatches = note.mime && note.mime.includes(token);
if (typeMatches || mimeMatches) {
foundAttrTokens.push(token);
}
for (const attribute of note.ownedAttributes) {
if (normalize(attribute.name).includes(token) || normalize(attribute.value).includes(token)) {
if (normalizeSearchText(attribute.name).includes(token) || normalizeSearchText(attribute.value).includes(token)) {
foundAttrTokens.push(token);
}
}
}
for (const parentNote of note.parents) {
const title = normalize(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
const foundTokens = foundAttrTokens.slice();
for (const token of this.tokens) {
if (title.includes(token)) {
if (this.smartMatch(title, token, searchContext)) {
foundTokens.push(token);
}
}
@@ -152,12 +162,13 @@ class NoteFlatTextExp extends Expression {
/**
* Returns noteIds which have at least one matching tokens
*/
getCandidateNotes(noteSet: NoteSet): BNote[] {
getCandidateNotes(noteSet: NoteSet, searchContext?: SearchContext): BNote[] {
const candidateNotes: BNote[] = [];
for (const note of noteSet.notes) {
const normalizedFlatText = normalizeSearchText(note.getFlatText());
for (const token of this.tokens) {
if (note.getFlatText().includes(token)) {
if (this.smartMatch(normalizedFlatText, token, searchContext)) {
candidateNotes.push(note);
break;
}
@@ -166,6 +177,34 @@ class NoteFlatTextExp extends Expression {
return candidateNotes;
}
/**
* Smart matching that tries exact match first, then fuzzy fallback
* @param text The text to search in
* @param token The token to search for
* @param searchContext The search context to track matched words for highlighting
* @returns True if match found (exact or fuzzy)
*/
private smartMatch(text: string, token: string, searchContext?: SearchContext): boolean {
// Exact match has priority
if (text.includes(token)) {
return true;
}
// Fuzzy fallback only if enabled and for tokens >= 4 characters
if (searchContext?.enableFuzzyMatching && token.length >= 4) {
const matchedWord = fuzzyMatchWordWithResult(token, text);
if (matchedWord) {
// Track the fuzzy matched word for highlighting
if (!searchContext.highlightedTokens.includes(matchedWord)) {
searchContext.highlightedTokens.push(matchedWord);
}
return true;
}
}
return false;
}
}
export default NoteFlatTextExp;

View File

@@ -18,6 +18,7 @@ class SearchContext {
debug?: boolean;
debugInfo: {} | null;
fuzzyAttributeSearch: boolean;
enableFuzzyMatching: boolean; // Controls whether fuzzy matching is enabled for this search phase
highlightedTokens: string[];
originalQuery: string;
fulltextQuery: string;
@@ -45,6 +46,7 @@ class SearchContext {
this.debug = params.debug;
this.debugInfo = null;
this.fuzzyAttributeSearch = !!params.fuzzyAttributeSearch;
this.enableFuzzyMatching = true; // Default to true for backward compatibility
this.highlightedTokens = [];
this.originalQuery = "";
this.fulltextQuery = ""; // complete fulltext part

View File

@@ -2,17 +2,46 @@
import beccaService from "../../becca/becca_service.js";
import becca from "../../becca/becca.js";
import {
normalizeSearchText,
calculateOptimizedEditDistance,
FUZZY_SEARCH_CONFIG
} from "./utils/text_utils.js";
// Scoring constants for better maintainability
const SCORE_WEIGHTS = {
NOTE_ID_EXACT_MATCH: 1000,
TITLE_EXACT_MATCH: 2000,
TITLE_PREFIX_MATCH: 500,
TITLE_WORD_MATCH: 300,
TOKEN_EXACT_MATCH: 4,
TOKEN_PREFIX_MATCH: 2,
TOKEN_CONTAINS_MATCH: 1,
TOKEN_FUZZY_MATCH: 0.5,
TITLE_FACTOR: 2.0,
PATH_FACTOR: 0.3,
HIDDEN_NOTE_PENALTY: 3,
// Score caps to prevent fuzzy matches from outranking exact matches
MAX_FUZZY_SCORE_PER_TOKEN: 3, // Cap fuzzy token contributions to stay below exact matches
MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER: 3, // Limit token length impact for fuzzy matches
MAX_TOTAL_FUZZY_SCORE: 200 // Total cap on fuzzy scoring per search
} as const;
class SearchResult {
notePathArray: string[];
score: number;
notePathTitle: string;
highlightedNotePathTitle?: string;
contentSnippet?: string;
highlightedContentSnippet?: string;
private fuzzyScore: number; // Track fuzzy score separately
constructor(notePathArray: string[]) {
this.notePathArray = notePathArray;
this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray);
this.score = 0;
this.fuzzyScore = 0;
}
get notePath() {
@@ -23,53 +52,117 @@ class SearchResult {
return this.notePathArray[this.notePathArray.length - 1];
}
computeScore(fulltextQuery: string, tokens: string[]) {
computeScore(fulltextQuery: string, tokens: string[], enableFuzzyMatching: boolean = true) {
this.score = 0;
this.fuzzyScore = 0; // Reset fuzzy score tracking
const note = becca.notes[this.noteId];
const normalizedQuery = fulltextQuery.toLowerCase();
const normalizedTitle = note.title.toLowerCase();
const normalizedQuery = normalizeSearchText(fulltextQuery.toLowerCase());
const normalizedTitle = normalizeSearchText(note.title.toLowerCase());
// Note ID exact match, much higher score
if (note.noteId.toLowerCase() === fulltextQuery) {
this.score += 1000;
this.score += SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH;
}
// Title matching scores, make sure to always win
// Title matching scores with fuzzy matching support
if (normalizedTitle === normalizedQuery) {
this.score += 2000; // Increased from 1000 to ensure exact matches always win
this.score += SCORE_WEIGHTS.TITLE_EXACT_MATCH;
} else if (normalizedTitle.startsWith(normalizedQuery)) {
this.score += 500; // Increased to give more weight to prefix matches
} else if (normalizedTitle.includes(` ${normalizedQuery} `) || normalizedTitle.startsWith(`${normalizedQuery} `) || normalizedTitle.endsWith(` ${normalizedQuery}`)) {
this.score += 300; // Increased to better distinguish word matches
this.score += SCORE_WEIGHTS.TITLE_PREFIX_MATCH;
} else if (this.isWordMatch(normalizedTitle, normalizedQuery)) {
this.score += SCORE_WEIGHTS.TITLE_WORD_MATCH;
} else if (enableFuzzyMatching) {
// Try fuzzy matching for typos only if enabled
const fuzzyScore = this.calculateFuzzyTitleScore(normalizedTitle, normalizedQuery);
this.score += fuzzyScore;
this.fuzzyScore += fuzzyScore; // Track fuzzy score contributions
}
// Add scores for partial matches with adjusted weights
this.addScoreForStrings(tokens, note.title, 2.0); // Increased to give more weight to title matches
this.addScoreForStrings(tokens, this.notePathTitle, 0.3); // Reduced to further de-emphasize path matches
// Add scores for token matches
this.addScoreForStrings(tokens, note.title, SCORE_WEIGHTS.TITLE_FACTOR, enableFuzzyMatching);
this.addScoreForStrings(tokens, this.notePathTitle, SCORE_WEIGHTS.PATH_FACTOR, enableFuzzyMatching);
if (note.isInHiddenSubtree()) {
this.score = this.score / 3; // Increased penalty for hidden notes
this.score = this.score / SCORE_WEIGHTS.HIDDEN_NOTE_PENALTY;
}
}
addScoreForStrings(tokens: string[], str: string, factor: number) {
const chunks = str.toLowerCase().split(" ");
addScoreForStrings(tokens: string[], str: string, factor: number, enableFuzzyMatching: boolean = true) {
const normalizedStr = normalizeSearchText(str.toLowerCase());
const chunks = normalizedStr.split(" ");
let tokenScore = 0;
for (const chunk of chunks) {
for (const token of tokens) {
if (chunk === token) {
tokenScore += 4 * token.length * factor;
} else if (chunk.startsWith(token)) {
tokenScore += 2 * token.length * factor;
} else if (chunk.includes(token)) {
tokenScore += token.length * factor;
const normalizedToken = normalizeSearchText(token.toLowerCase());
if (chunk === normalizedToken) {
tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor;
} else if (chunk.startsWith(normalizedToken)) {
tokenScore += SCORE_WEIGHTS.TOKEN_PREFIX_MATCH * token.length * factor;
} else if (chunk.includes(normalizedToken)) {
tokenScore += SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH * token.length * factor;
} else {
// Try fuzzy matching for individual tokens with caps applied
const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH &&
this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) {
const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
// Apply caps: limit token length multiplier and per-token contribution
const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER);
const fuzzyTokenScore = Math.min(
fuzzyWeight * cappedTokenLength * factor,
SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN
);
tokenScore += fuzzyTokenScore;
this.fuzzyScore += fuzzyTokenScore;
}
}
}
}
this.score += tokenScore;
}
/**
* Checks if the query matches as a complete word in the text
*/
private isWordMatch(text: string, query: string): boolean {
return text.includes(` ${query} `) ||
text.startsWith(`${query} `) ||
text.endsWith(` ${query}`);
}
/**
* Calculates fuzzy matching score for title matches with caps applied
*/
private calculateFuzzyTitleScore(title: string, query: string): number {
// Check if we've already hit the fuzzy scoring cap
if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) {
return 0;
}
const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
const maxLen = Math.max(title.length, query.length);
// Only apply fuzzy matching if the query is reasonably long and edit distance is small
if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH &&
editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
editDistance / maxLen <= 0.3) {
const similarity = 1 - (editDistance / maxLen);
const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches
// Apply cap to ensure fuzzy title matches don't exceed reasonable bounds
return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3);
}
return 0;
}
}
export default SearchResult;

View File

@@ -1,3 +1,5 @@
import { normalizeSearchText, fuzzyMatchWord, FUZZY_SEARCH_CONFIG } from "../utils/text_utils.js";
const cachedRegexes: Record<string, RegExp> = {};
function getRegex(str: string) {
@@ -20,7 +22,41 @@ const stringComparators: Record<string, Comparator<string>> = {
"*=": (comparedValue) => (val) => !!val && val.endsWith(comparedValue),
"=*": (comparedValue) => (val) => !!val && val.startsWith(comparedValue),
"*=*": (comparedValue) => (val) => !!val && val.includes(comparedValue),
"%=": (comparedValue) => (val) => !!val && !!getRegex(comparedValue).test(val)
"%=": (comparedValue) => (val) => !!val && !!getRegex(comparedValue).test(val),
"~=": (comparedValue) => (val) => {
if (!val || !comparedValue) return false;
// Validate minimum length for fuzzy search to prevent false positives
if (comparedValue.length < FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH) {
return val.includes(comparedValue);
}
const normalizedVal = normalizeSearchText(val);
const normalizedCompared = normalizeSearchText(comparedValue);
// First try exact substring match
if (normalizedVal.includes(normalizedCompared)) {
return true;
}
// Then try fuzzy word matching
const words = normalizedVal.split(/\s+/);
return words.some(word => fuzzyMatchWord(normalizedCompared, word));
},
"~*": (comparedValue) => (val) => {
if (!val || !comparedValue) return false;
// Validate minimum length for fuzzy search
if (comparedValue.length < FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH) {
return val.includes(comparedValue);
}
const normalizedVal = normalizeSearchText(val);
const normalizedCompared = normalizeSearchText(comparedValue);
// For ~* operator, use fuzzy matching across the entire content
return fuzzyMatchWord(normalizedCompared, normalizedVal);
}
};
const numericComparators: Record<string, Comparator<number>> = {

View File

@@ -40,7 +40,7 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
}
}
const OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", ">", ">=", "<", "<=", "%="]);
const OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", ">", ">=", "<", "<=", "%=", "~=", "~*"]);
function isOperator(token: TokenData) {
if (Array.isArray(token)) {

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, beforeEach } from "vitest";
import searchService from "./search.js";
import BNote from "../../../becca/entities/bnote.js";
import BBranch from "../../../becca/entities/bbranch.js";
import SearchContext from "../search_context.js";
import becca from "../../../becca/becca.js";
import { findNoteByTitle, note, NoteBuilder } from "../../../test/becca_mocking.js";
describe("Progressive Search Strategy", () => {
let rootNote: any;
beforeEach(() => {
becca.reset();
rootNote = new NoteBuilder(new BNote({ noteId: "root", title: "root", type: "text" }));
new BBranch({
branchId: "none_root",
noteId: "root",
parentNoteId: "none",
notePosition: 10
});
});
describe("Phase 1: Exact Matches Only", () => {
it("should complete search with exact matches when sufficient results found", () => {
// Create notes with exact matches
rootNote
.child(note("Document Analysis One"))
.child(note("Document Report Two"))
.child(note("Document Review Three"))
.child(note("Document Summary Four"))
.child(note("Document Overview Five"))
.child(note("Documnt Analysis Six")); // This has a typo that should require fuzzy matching
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("document", searchContext);
// Should find 5 exact matches and not need fuzzy matching
expect(searchResults.length).toEqual(5);
// Verify all results have high scores (exact matches)
const highQualityResults = searchResults.filter(result => result.score >= 10);
expect(highQualityResults.length).toEqual(5);
// The typo document should not be in results since we have enough exact matches
expect(findNoteByTitle(searchResults, "Documnt Analysis Six")).toBeFalsy();
});
it("should use exact match scoring only in Phase 1", () => {
rootNote
.child(note("Testing Exact Match"))
.child(note("Test Document"))
.child(note("Another Test"));
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test", searchContext);
// All results should have scores from exact matching only
for (const result of searchResults) {
expect(result.score).toBeGreaterThan(0);
// Scores should be from exact/prefix/contains matches, not fuzzy
expect(result.score % 0.5).not.toBe(0); // Fuzzy scores are multiples of 0.5
}
});
});
describe("Phase 2: Fuzzy Fallback", () => {
it("should trigger fuzzy matching when insufficient exact matches", () => {
// Create only a few notes, some with typos
rootNote
.child(note("Document One"))
.child(note("Report Two"))
.child(note("Anaylsis Three")) // Typo: "Analysis"
.child(note("Sumary Four")); // Typo: "Summary"
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("analysis", searchContext);
// Should find the typo through fuzzy matching
expect(searchResults.length).toBeGreaterThan(0);
expect(findNoteByTitle(searchResults, "Anaylsis Three")).toBeTruthy();
});
it("should merge exact and fuzzy results with exact matches always ranked higher", () => {
rootNote
.child(note("Analysis Report")) // Exact match
.child(note("Data Analysis")) // Exact match
.child(note("Anaylsis Doc")) // Fuzzy match
.child(note("Statistical Anlaysis")); // Fuzzy match
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("analysis", searchContext);
expect(searchResults.length).toBe(4);
// Get the note titles in result order
const resultTitles = searchResults.map(r => becca.notes[r.noteId].title);
// Find positions of exact and fuzzy matches
const exactPositions = resultTitles.map((title, index) =>
title.toLowerCase().includes("analysis") ? index : -1
).filter(pos => pos !== -1);
const fuzzyPositions = resultTitles.map((title, index) =>
(title.includes("Anaylsis") || title.includes("Anlaysis")) ? index : -1
).filter(pos => pos !== -1);
expect(exactPositions.length).toBe(2);
expect(fuzzyPositions.length).toBe(2);
// CRITICAL: All exact matches must come before all fuzzy matches
const lastExactPosition = Math.max(...exactPositions);
const firstFuzzyPosition = Math.min(...fuzzyPositions);
expect(lastExactPosition).toBeLessThan(firstFuzzyPosition);
});
it("should not duplicate results between phases", () => {
rootNote
.child(note("Test Document")) // Would match in both phases
.child(note("Tset Report")); // Only fuzzy match
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test", searchContext);
// Should only have unique results
const noteIds = searchResults.map(r => r.noteId);
const uniqueNoteIds = [...new Set(noteIds)];
expect(noteIds.length).toBe(uniqueNoteIds.length);
expect(findNoteByTitle(searchResults, "Test Document")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Tset Report")).toBeTruthy();
});
});
describe("Result Sufficiency Thresholds", () => {
it("should respect minimum result count threshold", () => {
// Create exactly 4 high-quality results (below threshold of 5)
rootNote
.child(note("Test One"))
.child(note("Test Two"))
.child(note("Test Three"))
.child(note("Test Four"))
.child(note("Tset Five")); // Typo that should be found via fuzzy
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test", searchContext);
// Should proceed to Phase 2 and include fuzzy match
expect(searchResults.length).toBe(5);
expect(findNoteByTitle(searchResults, "Tset Five")).toBeTruthy();
});
it("should respect minimum quality score threshold", () => {
// Create notes that might have low exact match scores
rootNote
.child(note("Testing Document")) // Should have decent score
.child(note("Document with test inside")) // Lower score due to position
.child(note("Another test case"))
.child(note("Test case example"))
.child(note("Tset with typo")); // Fuzzy match
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test", searchContext);
// Should include fuzzy results if exact results don't meet quality threshold
expect(searchResults.length).toBeGreaterThan(4);
});
});
describe("Fuzzy Score Management", () => {
it("should cap fuzzy token scores to prevent outranking exact matches", () => {
// Create note with exact match
rootNote.child(note("Test Document"));
// Create note that could accumulate high fuzzy scores
rootNote.child(note("Tset Documnt with many fuzzy tockens for testng")); // Multiple typos
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test document", searchContext);
expect(searchResults.length).toBe(2);
// Find the exact and fuzzy match results
const exactResult = searchResults.find(r => becca.notes[r.noteId].title === "Test Document");
const fuzzyResult = searchResults.find(r => becca.notes[r.noteId].title.includes("Tset"));
expect(exactResult).toBeTruthy();
expect(fuzzyResult).toBeTruthy();
// Exact match should always score higher than fuzzy, even with multiple fuzzy matches
expect(exactResult!.score).toBeGreaterThan(fuzzyResult!.score);
});
it("should enforce maximum total fuzzy score per search", () => {
// Create note with many potential fuzzy matches
rootNote.child(note("Tset Documnt Anaylsis Sumary Reportng")); // Many typos
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("test document analysis summary reporting", searchContext);
expect(searchResults.length).toBe(1);
// Total score should be bounded despite many fuzzy matches
expect(searchResults[0].score).toBeLessThan(500); // Should not exceed reasonable bounds due to caps
});
});
describe("SearchContext Integration", () => {
it("should respect enableFuzzyMatching flag", () => {
rootNote
.child(note("Test Document"))
.child(note("Tset Report")); // Typo
// Test with fuzzy matching disabled
const exactOnlyContext = new SearchContext();
exactOnlyContext.enableFuzzyMatching = false;
const exactResults = searchService.findResultsWithQuery("test", exactOnlyContext);
expect(exactResults.length).toBe(1);
expect(findNoteByTitle(exactResults, "Test Document")).toBeTruthy();
expect(findNoteByTitle(exactResults, "Tset Report")).toBeFalsy();
// Test with fuzzy matching enabled (default)
const fuzzyContext = new SearchContext();
const fuzzyResults = searchService.findResultsWithQuery("test", fuzzyContext);
expect(fuzzyResults.length).toBe(2);
expect(findNoteByTitle(fuzzyResults, "Tset Report")).toBeTruthy();
});
});
describe("Edge Cases", () => {
it("should handle empty search results gracefully", () => {
rootNote.child(note("Unrelated Content"));
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("nonexistent", searchContext);
expect(searchResults.length).toBe(0);
});
});
});

View File

@@ -553,6 +553,70 @@ describe("Search", () => {
expect(becca.notes[searchResults[0].noteId].title).toEqual("Reddit is bad");
});
it("search completes in reasonable time", () => {
// Create a moderate-sized dataset to test performance
const countries = ["Austria", "Belgium", "Croatia", "Denmark", "Estonia", "Finland", "Germany", "Hungary", "Ireland", "Japan"];
const europeanCountries = note("Europe");
countries.forEach(country => {
europeanCountries.child(note(country).label("type", "country").label("continent", "Europe"));
});
rootNote.child(europeanCountries);
const searchContext = new SearchContext();
const startTime = Date.now();
// Perform a search that exercises multiple features
const searchResults = searchService.findResultsWithQuery("#type=country AND continent", searchContext);
const endTime = Date.now();
const duration = endTime - startTime;
// Search should complete in under 1 second for reasonable dataset
expect(duration).toBeLessThan(1000);
expect(searchResults.length).toEqual(10);
});
it("progressive search always puts exact matches before fuzzy matches", () => {
rootNote
.child(note("Analysis Report")) // Exact match
.child(note("Data Analysis")) // Exact match
.child(note("Test Analysis")) // Exact match
.child(note("Advanced Anaylsis")) // Fuzzy match (typo)
.child(note("Quick Anlaysis")); // Fuzzy match (typo)
const searchContext = new SearchContext();
const searchResults = searchService.findResultsWithQuery("analysis", searchContext);
// With only 3 exact matches (below threshold), fuzzy should be triggered
// Should find all 5 matches but exact ones should come first
expect(searchResults.length).toEqual(5);
// Get note titles in result order
const resultTitles = searchResults.map(r => becca.notes[r.noteId].title);
// Find all exact matches (contain "analysis")
const exactMatchIndices = resultTitles.map((title, index) =>
title.toLowerCase().includes("analysis") ? index : -1
).filter(index => index !== -1);
// Find all fuzzy matches (contain typos)
const fuzzyMatchIndices = resultTitles.map((title, index) =>
(title.includes("Anaylsis") || title.includes("Anlaysis")) ? index : -1
).filter(index => index !== -1);
expect(exactMatchIndices.length).toEqual(3);
expect(fuzzyMatchIndices.length).toEqual(2);
// CRITICAL: All exact matches must appear before all fuzzy matches
const lastExactIndex = Math.max(...exactMatchIndices);
const firstFuzzyIndex = Math.min(...fuzzyMatchIndices);
expect(lastExactIndex).toBeLessThan(firstFuzzyIndex);
});
// FIXME: test what happens when we order without any filter criteria
// it("comparison between labels", () => {

View File

@@ -17,6 +17,8 @@ import type { SearchParams, TokenStructure } from "./types.js";
import type Expression from "../expressions/expression.js";
import sql from "../../sql.js";
import scriptService from "../../script.js";
import striptags from "striptags";
import protectedSessionService from "../../protected_session.js";
export interface SearchNoteResult {
searchResultNoteIds: string[];
@@ -235,6 +237,41 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
loadNeededInfoFromDatabase();
}
// If there's an explicit orderBy clause, skip progressive search
// as it would interfere with the ordering
if (searchContext.orderBy) {
// For ordered queries, don't use progressive search but respect
// the original fuzzy matching setting
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
}
// If fuzzy matching is explicitly disabled, skip progressive search
if (!searchContext.enableFuzzyMatching) {
return performSearch(expression, searchContext, false);
}
// Phase 1: Try exact matches first (without fuzzy matching)
const exactResults = performSearch(expression, searchContext, false);
// Check if we have sufficient high-quality results
const minResultThreshold = 5;
const minScoreForQuality = 10; // Minimum score to consider a result "high quality"
const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality);
// If we have enough high-quality exact matches, return them
if (highQualityResults.length >= minResultThreshold) {
return exactResults;
}
// Phase 2: Add fuzzy matching as fallback when exact matches are insufficient
const fuzzyResults = performSearch(expression, searchContext, true);
// Merge results, ensuring exact matches always rank higher than fuzzy matches
return mergeExactAndFuzzyResults(exactResults, fuzzyResults);
}
function performSearch(expression: Expression, searchContext: SearchContext, enableFuzzyMatching: boolean): SearchResult[] {
const allNoteSet = becca.getAllNoteSet();
const noteIdToNotePath: Record<string, string[]> = {};
@@ -242,6 +279,10 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
noteIdToNotePath
};
// Store original fuzzy setting and temporarily override it
const originalFuzzyMatching = searchContext.enableFuzzyMatching;
searchContext.enableFuzzyMatching = enableFuzzyMatching;
const noteSet = expression.execute(allNoteSet, executionContext, searchContext);
const searchResults = noteSet.notes.map((note) => {
@@ -255,9 +296,12 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
});
for (const res of searchResults) {
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens);
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching);
}
// Restore original fuzzy setting
searchContext.enableFuzzyMatching = originalFuzzyMatching;
if (!noteSet.sorted) {
searchResults.sort((a, b) => {
if (a.score > b.score) {
@@ -279,6 +323,49 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
return searchResults;
}
function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] {
// Create a map of exact result note IDs for deduplication
const exactNoteIds = new Set(exactResults.map(result => result.noteId));
// Add fuzzy results that aren't already in exact results
const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId));
// Sort exact results by score (best exact matches first)
exactResults.sort((a, b) => {
if (a.score > b.score) {
return -1;
} else if (a.score < b.score) {
return 1;
}
// if score does not decide then sort results by depth of the note.
if (a.notePathArray.length === b.notePathArray.length) {
return a.notePathTitle < b.notePathTitle ? -1 : 1;
}
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
});
// Sort fuzzy results by score (best fuzzy matches first)
additionalFuzzyResults.sort((a, b) => {
if (a.score > b.score) {
return -1;
} else if (a.score < b.score) {
return 1;
}
// if score does not decide then sort results by depth of the note.
if (a.notePathArray.length === b.notePathArray.length) {
return a.notePathTitle < b.notePathTitle ? -1 : 1;
}
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
});
// CRITICAL: Always put exact matches before fuzzy matches, regardless of scores
return [...exactResults, ...additionalFuzzyResults];
}
function parseQueryToExpression(query: string, searchContext: SearchContext) {
const { fulltextQuery, fulltextTokens, expressionTokens } = lex(query);
searchContext.fulltextQuery = fulltextQuery;
@@ -328,6 +415,16 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear
return [];
}
// If the query starts with '#', it's a pure expression query.
// Don't use progressive search for these as they may have complex
// ordering or other logic that shouldn't be interfered with.
const isPureExpressionQuery = query.trim().startsWith('#');
if (isPureExpressionQuery) {
// For pure expression queries, use standard search without progressive phases
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
}
return findResultsWithExpression(expression, searchContext);
}
@@ -337,6 +434,91 @@ function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BN
return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null;
}
function extractContentSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
const note = becca.notes[noteId];
if (!note) {
return "";
}
// Only extract content for text-based notes
if (!["text", "code", "mermaid", "canvas", "mindMap"].includes(note.type)) {
return "";
}
try {
let content = note.getContent();
if (!content || typeof content !== "string") {
return "";
}
// Handle protected notes
if (note.isProtected && protectedSessionService.isProtectedSessionAvailable()) {
try {
content = protectedSessionService.decryptString(content) || "";
} catch (e) {
return ""; // Can't decrypt, don't show content
}
} else if (note.isProtected) {
return ""; // Protected but no session available
}
// Strip HTML tags for text notes
if (note.type === "text") {
content = striptags(content);
}
// Normalize whitespace
content = content.replace(/\s+/g, " ").trim();
if (!content) {
return "";
}
// Try to find a snippet around the first matching token
const normalizedContent = normalizeString(content.toLowerCase());
let snippetStart = 0;
let matchFound = false;
for (const token of searchTokens) {
const normalizedToken = normalizeString(token.toLowerCase());
const matchIndex = normalizedContent.indexOf(normalizedToken);
if (matchIndex !== -1) {
// Center the snippet around the match
snippetStart = Math.max(0, matchIndex - maxLength / 2);
matchFound = true;
break;
}
}
// Extract snippet
let snippet = content.substring(snippetStart, snippetStart + maxLength);
// Try to start/end at word boundaries
if (snippetStart > 0) {
const firstSpace = snippet.indexOf(" ");
if (firstSpace > 0 && firstSpace < 20) {
snippet = snippet.substring(firstSpace + 1);
}
snippet = "..." + snippet;
}
if (snippetStart + maxLength < content.length) {
const lastSpace = snippet.lastIndexOf(" ");
if (lastSpace > snippet.length - 20) {
snippet = snippet.substring(0, lastSpace);
}
snippet = snippet + "...";
}
return snippet;
} catch (e) {
log.error(`Error extracting content snippet for note ${noteId}: ${e}`);
return "";
}
}
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const searchContext = new SearchContext({
fastSearch: fastSearch,
@@ -351,6 +533,11 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const trimmed = allSearchResults.slice(0, 200);
// Extract content snippets
for (const result of trimmed) {
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
}
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
return trimmed.map((result) => {
@@ -360,6 +547,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
noteTitle: title,
notePathTitle: result.notePathTitle,
highlightedNotePathTitle: result.highlightedNotePathTitle,
contentSnippet: result.contentSnippet,
highlightedContentSnippet: result.highlightedContentSnippet,
icon: icon ?? "bx bx-note"
};
});
@@ -381,26 +570,11 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
for (const result of searchResults) {
const note = becca.notes[result.noteId];
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
if (highlightedTokens.find((token) => note.type.includes(token))) {
result.highlightedNotePathTitle += ` "type: ${note.type}'`;
}
if (highlightedTokens.find((token) => note.mime.includes(token))) {
result.highlightedNotePathTitle += ` "mime: ${note.mime}'`;
}
for (const attr of note.getAttributes()) {
if (attr.type === "relation" && attr.name === "internalLink" && ignoreInternalAttributes) {
continue;
}
if (highlightedTokens.find((token) => normalize(attr.name).includes(token) || normalize(attr.value).includes(token))) {
result.highlightedNotePathTitle += ` "${formatAttribute(attr)}'`;
}
// Initialize highlighted content snippet
if (result.contentSnippet) {
result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, "");
}
}
@@ -419,40 +593,36 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
let match;
// Find all matches
if (!result.highlightedNotePathTitle) {
continue;
// Highlight in note path title
if (result.highlightedNotePathTitle) {
const titleRegex = new RegExp(escapeRegExp(token), "gi");
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
// 2 characters are added, so we need to adjust the index
titleRegex.lastIndex += 2;
}
}
while ((match = tokenRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
// 2 characters are added, so we need to adjust the index
tokenRegex.lastIndex += 2;
// Highlight in content snippet
if (result.highlightedContentSnippet) {
const contentRegex = new RegExp(escapeRegExp(token), "gi");
while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) {
result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}");
// 2 characters are added, so we need to adjust the index
contentRegex.lastIndex += 2;
}
}
}
}
for (const result of searchResults) {
if (!result.highlightedNotePathTitle) {
continue;
if (result.highlightedNotePathTitle) {
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
}
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/"/g, "<small>").replace(/'/g, "</small>").replace(/{/g, "<b>").replace(/}/g, "</b>");
}
}
function formatAttribute(attr: BAttribute) {
if (attr.type === "relation") {
return `~${escapeHtml(attr.name)}=…`;
} else if (attr.type === "label") {
let label = `#${escapeHtml(attr.name)}`;
if (attr.value) {
const val = /[^\w-]/.test(attr.value) ? `"${attr.value}"` : attr.value;
label += `=${escapeHtml(val)}`;
if (result.highlightedContentSnippet) {
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
}
return label;
}
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { calculateOptimizedEditDistance, validateFuzzySearchTokens, fuzzyMatchWord } from './text_utils.js';
describe('Fuzzy Search Core', () => {
describe('calculateOptimizedEditDistance', () => {
it('calculates edit distance for common typos', () => {
expect(calculateOptimizedEditDistance('hello', 'helo')).toBe(1);
expect(calculateOptimizedEditDistance('world', 'wrold')).toBe(2);
expect(calculateOptimizedEditDistance('cafe', 'café')).toBe(1);
expect(calculateOptimizedEditDistance('identical', 'identical')).toBe(0);
});
it('handles performance safety with oversized input', () => {
const longString = 'a'.repeat(2000);
const result = calculateOptimizedEditDistance(longString, 'short');
expect(result).toBeGreaterThan(2); // Should use fallback heuristic
});
});
describe('validateFuzzySearchTokens', () => {
it('validates minimum length requirements for fuzzy operators', () => {
const result1 = validateFuzzySearchTokens(['ab'], '~=');
expect(result1.isValid).toBe(false);
expect(result1.error).toContain('at least 3 characters');
const result2 = validateFuzzySearchTokens(['hello'], '~=');
expect(result2.isValid).toBe(true);
const result3 = validateFuzzySearchTokens(['ok'], '=');
expect(result3.isValid).toBe(true); // Non-fuzzy operators allow short tokens
});
it('validates token types and empty arrays', () => {
expect(validateFuzzySearchTokens([], '=')).toEqual({
isValid: false,
error: 'Invalid tokens: at least one token is required'
});
expect(validateFuzzySearchTokens([''], '=')).toEqual({
isValid: false,
error: 'Invalid tokens: empty or whitespace-only tokens are not allowed'
});
});
});
describe('fuzzyMatchWord', () => {
it('matches words with diacritics normalization', () => {
expect(fuzzyMatchWord('cafe', 'café')).toBe(true);
expect(fuzzyMatchWord('naive', 'naïve')).toBe(true);
});
it('matches with typos within distance threshold', () => {
expect(fuzzyMatchWord('hello', 'helo')).toBe(true);
expect(fuzzyMatchWord('world', 'wrold')).toBe(true);
expect(fuzzyMatchWord('test', 'tset')).toBe(true);
expect(fuzzyMatchWord('test', 'xyz')).toBe(false);
});
it('handles edge cases safely', () => {
expect(fuzzyMatchWord('', 'test')).toBe(false);
expect(fuzzyMatchWord('test', '')).toBe(false);
expect(fuzzyMatchWord('a', 'b')).toBe(false); // Very short tokens
});
});
});

View File

@@ -0,0 +1,334 @@
"use strict";
import { normalize } from "../../utils.js";
/**
* Shared text processing utilities for search functionality
*/
// Configuration constants for fuzzy matching
export const FUZZY_SEARCH_CONFIG = {
// Minimum token length for fuzzy operators to prevent false positives
MIN_FUZZY_TOKEN_LENGTH: 3,
// Maximum edit distance for fuzzy matching
MAX_EDIT_DISTANCE: 2,
// Maximum proximity distance for phrase matching (in words)
MAX_PHRASE_PROXIMITY: 10,
// Absolute hard limits for extreme cases - only to prevent system crashes
ABSOLUTE_MAX_CONTENT_SIZE: 100 * 1024 * 1024, // 100MB - extreme upper limit to prevent OOM
ABSOLUTE_MAX_WORD_COUNT: 2000000, // 2M words - extreme upper limit for word processing
// Performance warning thresholds - inform user but still attempt search
PERFORMANCE_WARNING_SIZE: 5 * 1024 * 1024, // 5MB - warn about potential performance impact
PERFORMANCE_WARNING_WORDS: 100000, // 100K words - warn about word count impact
// Progressive processing thresholds for very large content
PROGRESSIVE_PROCESSING_SIZE: 10 * 1024 * 1024, // 10MB - use progressive processing
PROGRESSIVE_PROCESSING_WORDS: 500000, // 500K words - use progressive processing
// Performance thresholds
EARLY_TERMINATION_THRESHOLD: 3,
} as const;
/**
* Normalizes text by removing diacritics and converting to lowercase.
* This is the centralized text normalization function used across all search components.
* Uses the shared normalize function from utils for consistency.
*
* Examples:
* - "café" -> "cafe"
* - "naïve" -> "naive"
* - "HELLO WORLD" -> "hello world"
*
* @param text The text to normalize
* @returns The normalized text
*/
export function normalizeSearchText(text: string): string {
if (!text || typeof text !== 'string') {
return '';
}
// Use shared normalize function for consistency across the codebase
return normalize(text);
}
/**
* Optimized edit distance calculation using single array and early termination.
* This is significantly more memory efficient than the 2D matrix approach and includes
* early termination optimizations for better performance.
*
* @param str1 First string
* @param str2 Second string
* @param maxDistance Maximum allowed distance (for early termination)
* @returns The edit distance between the strings, or maxDistance + 1 if exceeded
*/
export function calculateOptimizedEditDistance(str1: string, str2: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): number {
// Input validation
if (typeof str1 !== 'string' || typeof str2 !== 'string') {
throw new Error('Both arguments must be strings');
}
if (maxDistance < 0 || !Number.isInteger(maxDistance)) {
throw new Error('maxDistance must be a non-negative integer');
}
const len1 = str1.length;
const len2 = str2.length;
// Performance guard: if strings are too long, limit processing
const maxStringLength = 1000;
if (len1 > maxStringLength || len2 > maxStringLength) {
// For very long strings, fall back to simple length-based heuristic
return Math.abs(len1 - len2) <= maxDistance ? Math.abs(len1 - len2) : maxDistance + 1;
}
// Early termination: if length difference exceeds max distance
if (Math.abs(len1 - len2) > maxDistance) {
return maxDistance + 1;
}
// Handle edge cases
if (len1 === 0) return len2 <= maxDistance ? len2 : maxDistance + 1;
if (len2 === 0) return len1 <= maxDistance ? len1 : maxDistance + 1;
// Use single array optimization for better memory usage
let previousRow = Array.from({ length: len2 + 1 }, (_, i) => i);
let currentRow = new Array(len2 + 1);
for (let i = 1; i <= len1; i++) {
currentRow[0] = i;
let minInRow = i;
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
currentRow[j] = Math.min(
previousRow[j] + 1, // deletion
currentRow[j - 1] + 1, // insertion
previousRow[j - 1] + cost // substitution
);
// Track minimum value in current row for early termination
if (currentRow[j] < minInRow) {
minInRow = currentRow[j];
}
}
// Early termination: if minimum distance in row exceeds threshold
if (minInRow > maxDistance) {
return maxDistance + 1;
}
// Swap arrays for next iteration
[previousRow, currentRow] = [currentRow, previousRow];
}
const result = previousRow[len2];
return result <= maxDistance ? result : maxDistance + 1;
}
/**
* Validates that tokens meet minimum requirements for fuzzy operators.
*
* @param tokens Array of search tokens
* @param operator The search operator being used
* @returns Validation result with success status and error message
*/
export function validateFuzzySearchTokens(tokens: string[], operator: string): { isValid: boolean; error?: string } {
if (!operator || typeof operator !== 'string') {
return {
isValid: false,
error: 'Invalid operator: operator must be a non-empty string'
};
}
if (!Array.isArray(tokens)) {
return {
isValid: false,
error: 'Invalid tokens: tokens must be an array'
};
}
if (tokens.length === 0) {
return {
isValid: false,
error: 'Invalid tokens: at least one token is required'
};
}
// Check for null, undefined, or non-string tokens
const invalidTypeTokens = tokens.filter(token =>
token == null || typeof token !== 'string'
);
if (invalidTypeTokens.length > 0) {
return {
isValid: false,
error: 'Invalid tokens: all tokens must be non-null strings'
};
}
// Check for empty string tokens
const emptyTokens = tokens.filter(token => token.trim().length === 0);
if (emptyTokens.length > 0) {
return {
isValid: false,
error: 'Invalid tokens: empty or whitespace-only tokens are not allowed'
};
}
if (operator !== '~=' && operator !== '~*') {
return { isValid: true };
}
// Check minimum token length for fuzzy operators
const shortTokens = tokens.filter(token => token.length < FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH);
if (shortTokens.length > 0) {
return {
isValid: false,
error: `Fuzzy search operators (~=, ~*) require tokens of at least ${FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH} characters. Invalid tokens: ${shortTokens.join(', ')}`
};
}
// Check for excessively long tokens that could cause performance issues
const maxTokenLength = 100; // Reasonable limit for search tokens
const longTokens = tokens.filter(token => token.length > maxTokenLength);
if (longTokens.length > 0) {
return {
isValid: false,
error: `Tokens are too long (max ${maxTokenLength} characters). Long tokens: ${longTokens.map(t => t.substring(0, 20) + '...').join(', ')}`
};
}
return { isValid: true };
}
/**
* Validates and preprocesses content for search operations.
* Philosophy: Try to search everything! Only block truly extreme cases that could crash the system.
*
* @param content The content to validate and preprocess
* @param noteId The note ID (for logging purposes)
* @returns Processed content, only null for truly extreme cases that could cause system instability
*/
export function validateAndPreprocessContent(content: string, noteId?: string): string | null {
if (!content || typeof content !== 'string') {
return null;
}
// Only block content that could actually crash the system (100MB+)
if (content.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_CONTENT_SIZE) {
console.error(`Content size exceeds absolute system limit for note ${noteId || 'unknown'}: ${content.length} bytes - this could cause system instability`);
// Only in truly extreme cases, truncate to prevent system crash
return content.substring(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_CONTENT_SIZE);
}
// Warn about very large content but still process it
if (content.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_SIZE) {
console.info(`Large content for note ${noteId || 'unknown'}: ${content.length} bytes - processing may take time but will attempt full search`);
}
// For word count, be even more permissive - only block truly extreme cases
const wordCount = content.split(/\s+/).length;
if (wordCount > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) {
console.error(`Word count exceeds absolute system limit for note ${noteId || 'unknown'}: ${wordCount} words - this could cause system instability`);
// Only in truly extreme cases, truncate to prevent system crash
return content.split(/\s+/).slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT).join(' ');
}
// Warn about high word counts but still process them
if (wordCount > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) {
console.info(`High word count for note ${noteId || 'unknown'}: ${wordCount} words - phrase matching may take time but will attempt full search`);
}
// Progressive processing warning for very large content
if (content.length > FUZZY_SEARCH_CONFIG.PROGRESSIVE_PROCESSING_SIZE || wordCount > FUZZY_SEARCH_CONFIG.PROGRESSIVE_PROCESSING_WORDS) {
console.info(`Very large content for note ${noteId || 'unknown'} - using progressive processing to maintain responsiveness`);
}
return content;
}
/**
* Escapes special regex characters in a string for use in RegExp constructor
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Checks if a word matches a token with fuzzy matching and returns the matched word.
* Optimized for common case where distances are small.
*
* @param token The search token (should be normalized)
* @param text The text to match against (should be normalized)
* @param maxDistance Maximum allowed edit distance
* @returns The matched word if found, null otherwise
*/
export function fuzzyMatchWordWithResult(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): string | null {
// Input validation
if (typeof token !== 'string' || typeof text !== 'string') {
return null;
}
if (token.length === 0 || text.length === 0) {
return null;
}
try {
// Normalize both strings for comparison
const normalizedToken = token.toLowerCase();
const normalizedText = text.toLowerCase();
// Exact match check first (most common case)
if (normalizedText.includes(normalizedToken)) {
// Find the exact match in the original text to preserve case
const exactMatch = text.match(new RegExp(escapeRegExp(token), 'i'));
return exactMatch ? exactMatch[0] : token;
}
// For fuzzy matching, we need to check individual words in the text
// Split the text into words and check each word against the token
const words = normalizedText.split(/\s+/).filter(word => word.length > 0);
const originalWords = text.split(/\s+/).filter(word => word.length > 0);
for (let i = 0; i < words.length; i++) {
const word = words[i];
const originalWord = originalWords[i];
// Skip if word is too different in length for fuzzy matching
if (Math.abs(word.length - normalizedToken.length) > maxDistance) {
continue;
}
// For very short tokens or very different lengths, be more strict
if (normalizedToken.length < 4 || Math.abs(word.length - normalizedToken.length) > 2) {
continue;
}
// Use optimized edit distance calculation
const distance = calculateOptimizedEditDistance(normalizedToken, word, maxDistance);
if (distance <= maxDistance) {
return originalWord; // Return the original word with case preserved
}
}
return null;
} catch (error) {
// Log error and return null for safety
console.warn('Error in fuzzy word matching:', error);
return null;
}
}
/**
* Checks if a word matches a token with fuzzy matching.
* Optimized for common case where distances are small.
*
* @param token The search token (should be normalized)
* @param word The word to match against (should be normalized)
* @param maxDistance Maximum allowed edit distance
* @returns True if the word matches the token within the distance threshold
*/
export function fuzzyMatchWord(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): boolean {
return fuzzyMatchWordWithResult(token, text, maxDistance) !== null;
}