mirror of
https://github.com/zadam/trilium.git
synced 2025-11-02 03:16:11 +01:00
Merge branch 'main' into feat/snapshot-etapi-notes-too
This commit is contained in:
@@ -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 >/dev/null 2>&1 || { echo "Error: curl is required"; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "Error: jq is required"; exit 1; }
|
||||
command -v tar >/dev/null 2>&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" >/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)
|
||||
|
||||
@@ -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 <CONTAINER ID></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:<VERSION></code>.
|
||||
volume switch as follows: <code>-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/trilium:<VERSION></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>
|
||||
|
||||
@@ -27,36 +27,43 @@ class="admonition warning">
|
||||
</aside>
|
||||
<h3>TOTP</h3>
|
||||
<ol>
|
||||
<li>Go to "Menu" -> "Options" -> "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" -> "Options" -> "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://<your-trilium-domain>/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 <a class="reference-link" href="#root/_help_Gzjqa934BdH4">Configuration (config.ini or environment variables)</a> 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://<your-trilium-domain></code>.</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Restart the server</li>
|
||||
<li>Go to "Menu" -> "Options" -> "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" -> "Options" -> "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 don’t 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://<your-trilium-domain>/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,
|
||||
Google’s 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "完了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
9
apps/server/src/assets/translations/sl/server.json
Normal file
9
apps/server/src/assets/translations/sl/server.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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": "已完成"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/server/src/assets/translations/vi/server.json
Normal file
6
apps/server/src/assets/translations/vi/server.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -93,6 +93,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"redirectBareDomain",
|
||||
"showLoginInShareTheme",
|
||||
"splitEditorOrientation",
|
||||
"seenCallToActions",
|
||||
|
||||
// AI/LLM integration options
|
||||
"aiEnabled",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
apps/server/src/services/search/utils/text_utils.spec.ts
Normal file
65
apps/server/src/services/search/utils/text_utils.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
334
apps/server/src/services/search/utils/text_utils.ts
Normal file
334
apps/server/src/services/search/utils/text_utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user