mirror of
https://github.com/zadam/trilium.git
synced 2026-03-12 07:00:24 +01:00
Compare commits
35 Commits
autocomple
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77733ce205 | ||
|
|
585b6ccd3e | ||
|
|
caa428c1a2 | ||
|
|
517c721664 | ||
|
|
a8cdaa69f7 | ||
|
|
53d221ef34 | ||
|
|
5450fde472 | ||
|
|
808446cef5 | ||
|
|
921c663199 | ||
|
|
1b8a75b615 | ||
|
|
f78ced5bc3 | ||
|
|
81bf5f4f3b | ||
|
|
aaed368670 | ||
|
|
5e8de14721 | ||
|
|
634ab5b5c0 | ||
|
|
906889a035 | ||
|
|
ab9d50b905 | ||
|
|
e61b7c7cfc | ||
|
|
1c628fba4c | ||
|
|
f8b4c6cb15 | ||
|
|
3edd8f6c5a | ||
|
|
7777f72893 | ||
|
|
9af85b767b | ||
|
|
73260b91eb | ||
|
|
2858f63873 | ||
|
|
15ca328727 | ||
|
|
5b3fbecc0f | ||
|
|
365d0f0aac | ||
|
|
e86d84c463 | ||
|
|
6b974c2ac7 | ||
|
|
d2afcbb98d | ||
|
|
68a122fcf5 | ||
|
|
92f0144b48 | ||
|
|
a5a345728c | ||
|
|
23890e64e9 |
@@ -803,12 +803,13 @@
|
||||
"web-view": "عرض الويب",
|
||||
"mind-map": "خريطة ذهنية",
|
||||
"geo-map": "خريطة جغرافية",
|
||||
"task-list": "قائمة المهام"
|
||||
"task-list": "قائمة المهام",
|
||||
"spreadsheet": "جدول البيانات"
|
||||
},
|
||||
"shared_switch": {
|
||||
"shared": "مشترك",
|
||||
"toggle-on-title": "مشاركة الملاحظة",
|
||||
"toggle-off-title": "الغاء مشاركة الملاحظة"
|
||||
"toggle-off-title": "إلغاء مشاركة الملاحظة"
|
||||
},
|
||||
"template_switch": {
|
||||
"template": "قالب"
|
||||
@@ -1286,8 +1287,10 @@
|
||||
"search-for": "بحث ل \"{{term}}\""
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-off": "ازالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة"
|
||||
"toggle-off": "إزالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة",
|
||||
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
|
||||
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
|
||||
},
|
||||
"open-help-page": "فتح صفحة المساعدة",
|
||||
"empty": {
|
||||
|
||||
@@ -1036,7 +1036,7 @@
|
||||
"file_preview_not_available": "File preview is not available for this file format.",
|
||||
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
|
||||
},
|
||||
"video": {
|
||||
"media": {
|
||||
"play": "Play (Space)",
|
||||
"pause": "Pause (Space)",
|
||||
"back-10s": "Back 10s (Left arrow key)",
|
||||
@@ -1051,7 +1051,7 @@
|
||||
"exit-picture-in-picture": "Exit picture-in-picture",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"exit-fullscreen": "Exit fullscreen",
|
||||
"unsupported-format": "Video preview is not available for this file format.",
|
||||
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
|
||||
"zoom-to-fit": "Zoom to fill",
|
||||
"zoom-reset": "Reset zoom to fill"
|
||||
},
|
||||
|
||||
@@ -1780,7 +1780,8 @@
|
||||
"ai-chat": "Czat AI",
|
||||
"task-list": "Lista zadań",
|
||||
"new-feature": "Nowość",
|
||||
"collections": "Kolekcje"
|
||||
"collections": "Kolekcje",
|
||||
"spreadsheet": "Arkusz"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Chroń notatkę",
|
||||
|
||||
@@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
|
||||
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
color: var(--muted-text-color);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 4em;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import "./File.css";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import { getUrlForDownload } from "../../services/open";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteBlob } from "../react/hooks";
|
||||
import AudioPreview from "./file/Audio";
|
||||
import PdfPreview from "./file/Pdf";
|
||||
import VideoPreview from "./file/Video";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
@@ -43,16 +42,6 @@ function TextPreview({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AudioPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<audio
|
||||
class="audio-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPreview() {
|
||||
return (
|
||||
<Alert className="file-preview-not-available" type="info">
|
||||
|
||||
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { getUrlForDownload } from "../../../services/open";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoItems from "../../react/NoItems";
|
||||
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||
|
||||
export default function AudioPreview({ note }: { note: FNote }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const togglePlayback = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, []);
|
||||
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
|
||||
|
||||
useEffect(() => setError(false), [note.noteId]);
|
||||
const onError = useCallback(() => setError(true), []);
|
||||
|
||||
if (error) {
|
||||
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<audio
|
||||
class="audio-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
ref={audioRef}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={onError}
|
||||
/>
|
||||
<div className="audio-preview-icon-wrapper">
|
||||
<Icon icon="bx bx-music" className="audio-preview-icon" />
|
||||
</div>
|
||||
<div className="media-preview-controls">
|
||||
<SeekBar mediaRef={audioRef} />
|
||||
|
||||
<div class="media-buttons-row">
|
||||
<div className="left">
|
||||
<PlaybackSpeed mediaRef={audioRef} />
|
||||
</div>
|
||||
|
||||
<div className="center">
|
||||
<div className="spacer" />
|
||||
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||
<LoopButton mediaRef={audioRef} />
|
||||
</div>
|
||||
|
||||
<div className="right">
|
||||
<VolumeControl mediaRef={audioRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
|
||||
return useCallback((e: KeyboardEvent) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayback();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
audio.muted = !audio.muted;
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
audio.volume = Math.min(1, audio.volume + 0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
audio.volume = Math.max(0, audio.volume - 0.05);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
audio.currentTime = 0;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
audio.currentTime = audio.duration;
|
||||
break;
|
||||
}
|
||||
}, [ audioRef, togglePlayback ]);
|
||||
}
|
||||
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.media-preview-controls {
|
||||
padding: 1.25em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
.media-buttons-row {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: var(--icon-button-size, 32px);
|
||||
height: var(--icon-button-size, 32px);
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
--icon-button-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-seekbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
.media-time {
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-trackbar {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.media-volume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
|
||||
.media-volume-slider {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.speed-dropdown {
|
||||
position: relative;
|
||||
|
||||
.tn-icon {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
|
||||
.media-speed-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(15%);
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-preview-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.audio-preview-icon-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import "./MediaPlayer.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Dropdown from "../../react/Dropdown";
|
||||
import Icon from "../../react/Icon";
|
||||
|
||||
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(media.currentTime);
|
||||
const onDurationChange = () => setDuration(media.duration);
|
||||
|
||||
media.addEventListener("timeupdate", onTimeUpdate);
|
||||
media.addEventListener("durationchange", onDurationChange);
|
||||
return () => {
|
||||
media.removeEventListener("timeupdate", onTimeUpdate);
|
||||
media.removeEventListener("durationchange", onDurationChange);
|
||||
};
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const onSeek = (e: Event) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="media-seekbar-row">
|
||||
<span class="media-time">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="media-trackbar"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onInput={onSeek}
|
||||
/>
|
||||
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PlayPauseButton({ playing, togglePlayback }: {
|
||||
playing: boolean,
|
||||
togglePlayback: () => void
|
||||
}) {
|
||||
return (
|
||||
<ActionButton
|
||||
className="play-button"
|
||||
icon={playing ? "bx bx-pause" : "bx bx-play"}
|
||||
text={playing ? t("media.pause") : t("media.play")}
|
||||
onClick={togglePlayback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
|
||||
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
|
||||
|
||||
// Sync state when the media element changes volume externally.
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
setVolume(media.volume);
|
||||
setMuted(media.muted);
|
||||
|
||||
const onVolumeChange = () => {
|
||||
setVolume(media.volume);
|
||||
setMuted(media.muted);
|
||||
};
|
||||
media.addEventListener("volumechange", onVolumeChange);
|
||||
return () => media.removeEventListener("volumechange", onVolumeChange);
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const onVolumeChange = (e: Event) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
media.volume = val;
|
||||
setVolume(val);
|
||||
if (val > 0 && media.muted) {
|
||||
media.muted = false;
|
||||
setMuted(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.muted = !media.muted;
|
||||
setMuted(media.muted);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="media-volume-row">
|
||||
<ActionButton
|
||||
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
|
||||
text={muted ? t("media.unmute") : t("media.mute")}
|
||||
onClick={toggleMute}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
class="media-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onInput={onVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
|
||||
const skip = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton icon={icon} text={text} onClick={skip} />
|
||||
);
|
||||
}
|
||||
|
||||
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
setLoop(media.loop);
|
||||
|
||||
const observer = new MutationObserver(() => setLoop(media.loop));
|
||||
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
|
||||
return () => observer.disconnect();
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const toggle = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.loop = !media.loop;
|
||||
setLoop(media.loop);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={loop ? "active" : ""}
|
||||
icon="bx bx-repeat"
|
||||
text={loop ? t("media.disable-loop") : t("media.loop")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
|
||||
|
||||
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
setSpeed(media.playbackRate);
|
||||
|
||||
const onRateChange = () => setSpeed(media.playbackRate);
|
||||
media.addEventListener("ratechange", onRateChange);
|
||||
return () => media.removeEventListener("ratechange", onRateChange);
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const selectSpeed = (rate: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.playbackRate = rate;
|
||||
setSpeed(rate);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
iconAction
|
||||
hideToggleArrow
|
||||
buttonClassName="speed-dropdown"
|
||||
text={<>
|
||||
<Icon icon="bx bx-tachometer" />
|
||||
<span class="media-speed-label">{speed}x</span>
|
||||
</>}
|
||||
title={t("media.playback-speed")}
|
||||
>
|
||||
{PLAYBACK_SPEEDS.map((rate) => (
|
||||
<li key={rate}>
|
||||
<button
|
||||
class={`dropdown-item ${rate === speed ? "active" : ""}`}
|
||||
onClick={() => selectSpeed(rate)}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: black;
|
||||
background-color: black;
|
||||
|
||||
.video-preview {
|
||||
background-color: black;
|
||||
@@ -13,102 +13,23 @@
|
||||
&.controls-hidden {
|
||||
cursor: pointer;
|
||||
|
||||
.video-preview-controls {
|
||||
.media-preview-controls {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.video-preview-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1.25em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
.media-preview-controls {
|
||||
--icon-button-hover-color: white;
|
||||
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ease;
|
||||
|
||||
.video-buttons-row {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: var(--icon-button-size, 32px);
|
||||
height: var(--icon-button-size, 32px);
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
--icon-button-size: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-seekbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.video-trackbar {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-time {
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-volume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.video-volume-slider {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.speed-dropdown {
|
||||
position: relative;
|
||||
|
||||
.tn-icon {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
|
||||
.video-speed-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(15%);
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import "./Video.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { getUrlForDownload } from "../../../services/open";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Dropdown from "../../react/Dropdown";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoItems from "../../react/NoItems";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||
|
||||
const AUTO_HIDE_DELAY = 3000;
|
||||
|
||||
@@ -40,11 +33,56 @@ export default function VideoPreview({ note }: { note: FNote }) {
|
||||
}, []);
|
||||
|
||||
const onVideoClick = useCallback((e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
|
||||
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
|
||||
togglePlayback();
|
||||
}, [togglePlayback]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
|
||||
|
||||
if (error) {
|
||||
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
class="video-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
datatype={note?.mime}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<div className="media-preview-controls">
|
||||
<SeekBar mediaRef={videoRef} />
|
||||
<div class="media-buttons-row">
|
||||
<div className="left">
|
||||
<PlaybackSpeed mediaRef={videoRef} />
|
||||
<RotateButton videoRef={videoRef} />
|
||||
</div>
|
||||
<div className="center">
|
||||
<div className="spacer" />
|
||||
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||
<LoopButton mediaRef={videoRef} />
|
||||
</div>
|
||||
<div className="right">
|
||||
<VolumeControl mediaRef={videoRef} />
|
||||
<ZoomToFitButton videoRef={videoRef} />
|
||||
<PictureInPictureButton videoRef={videoRef} />
|
||||
<FullscreenButton targetRef={wrapperRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
|
||||
return useCallback((e: KeyboardEvent) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
@@ -100,48 +138,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
|
||||
flashControls();
|
||||
break;
|
||||
}
|
||||
}, [togglePlayback, flashControls]);
|
||||
|
||||
if (error) {
|
||||
return <NoItems icon="bx bx-video-off" text={t("video.unsupported-format")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
class="video-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
datatype={note?.mime}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<div className="video-preview-controls">
|
||||
<SeekBar videoRef={videoRef} />
|
||||
<div class="video-buttons-row">
|
||||
<div className="left">
|
||||
<PlaybackSpeed videoRef={videoRef} />
|
||||
<RotateButton videoRef={videoRef} />
|
||||
</div>
|
||||
<div className="center">
|
||||
<div className="spacer" />
|
||||
<SkipButton videoRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("video.back-10s")} />
|
||||
<PlayPauseButton videoRef={videoRef} playing={playing} />
|
||||
<SkipButton videoRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("video.forward-30s")} />
|
||||
<LoopButton videoRef={videoRef} />
|
||||
</div>
|
||||
<div className="right">
|
||||
<VolumeControl videoRef={videoRef} />
|
||||
<ZoomToFitButton videoRef={videoRef} />
|
||||
<PictureInPictureButton videoRef={videoRef} />
|
||||
<FullscreenButton targetRef={wrapperRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
|
||||
}
|
||||
|
||||
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
|
||||
@@ -153,7 +150,7 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
|
||||
}
|
||||
}, []);
|
||||
}, [ videoRef]);
|
||||
|
||||
const onMouseMove = useCallback(() => {
|
||||
setVisible(true);
|
||||
@@ -174,219 +171,6 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
|
||||
return { visible, onMouseMove, flash: onMouseMove };
|
||||
}
|
||||
|
||||
function PlayPauseButton({ videoRef, playing }: { videoRef: RefObject<HTMLVideoElement>, playing: boolean }) {
|
||||
const togglePlayback = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className="play-button"
|
||||
icon={playing ? "bx bx-pause" : "bx bx-play"}
|
||||
text={playing ? t("video.pause") : t("video.play")}
|
||||
onClick={togglePlayback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipButton({ videoRef, seconds, icon, text }: { videoRef: RefObject<HTMLVideoElement>, seconds: number, icon: string, text: string }) {
|
||||
const skip = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton icon={icon} text={text} onClick={skip} />
|
||||
);
|
||||
}
|
||||
|
||||
function SeekBar({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(video.currentTime);
|
||||
const onDurationChange = () => setDuration(video.duration);
|
||||
|
||||
video.addEventListener("timeupdate", onTimeUpdate);
|
||||
video.addEventListener("durationchange", onDurationChange);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
||||
video.removeEventListener("durationchange", onDurationChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSeek = (e: Event) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="video-seekbar-row">
|
||||
<span class="video-time">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="video-trackbar"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onInput={onSeek}
|
||||
/>
|
||||
<span class="video-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VolumeControl({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [volume, setVolume] = useState(() => videoRef.current?.volume ?? 1);
|
||||
const [muted, setMuted] = useState(() => videoRef.current?.muted ?? false);
|
||||
|
||||
// Sync state when the video element changes volume externally.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
setVolume(video.volume);
|
||||
setMuted(video.muted);
|
||||
|
||||
const onVolumeChange = () => {
|
||||
setVolume(video.volume);
|
||||
setMuted(video.muted);
|
||||
};
|
||||
video.addEventListener("volumechange", onVolumeChange);
|
||||
return () => video.removeEventListener("volumechange", onVolumeChange);
|
||||
}, []);
|
||||
|
||||
const onVolumeChange = (e: Event) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
video.volume = val;
|
||||
setVolume(val);
|
||||
if (val > 0 && video.muted) {
|
||||
video.muted = false;
|
||||
setMuted(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
setMuted(video.muted);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="video-volume-row">
|
||||
<ActionButton
|
||||
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
|
||||
text={muted ? t("video.unmute") : t("video.mute")}
|
||||
onClick={toggleMute}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
class="video-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onInput={onVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
|
||||
|
||||
function PlaybackSpeed({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
setSpeed(video.playbackRate);
|
||||
|
||||
const onRateChange = () => setSpeed(video.playbackRate);
|
||||
video.addEventListener("ratechange", onRateChange);
|
||||
return () => video.removeEventListener("ratechange", onRateChange);
|
||||
}, []);
|
||||
|
||||
const selectSpeed = (rate: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.playbackRate = rate;
|
||||
setSpeed(rate);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
iconAction
|
||||
hideToggleArrow
|
||||
buttonClassName="speed-dropdown"
|
||||
text={<>
|
||||
<Icon icon="bx bx-tachometer" />
|
||||
<span class="video-speed-label">{speed}x</span>
|
||||
</>}
|
||||
title={t("video.playback-speed")}
|
||||
>
|
||||
{PLAYBACK_SPEEDS.map((rate) => (
|
||||
<li key={rate}>
|
||||
<button
|
||||
class={`dropdown-item ${rate === speed ? "active" : ""}`}
|
||||
onClick={() => selectSpeed(rate)}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function LoopButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setLoop(video.loop);
|
||||
|
||||
const observer = new MutationObserver(() => setLoop(video.loop));
|
||||
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.loop = !video.loop;
|
||||
setLoop(video.loop);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={loop ? "active" : ""}
|
||||
icon="bx bx-repeat"
|
||||
text={loop ? t("video.disable-loop") : t("video.loop")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
@@ -414,7 +198,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
return (
|
||||
<ActionButton
|
||||
icon="bx bx-rotate-right"
|
||||
text={t("video.rotate")}
|
||||
text={t("media.rotate")}
|
||||
onClick={rotate}
|
||||
/>
|
||||
);
|
||||
@@ -435,7 +219,7 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
|
||||
<ActionButton
|
||||
className={fitted ? "active" : ""}
|
||||
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
|
||||
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
|
||||
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
@@ -460,7 +244,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
|
||||
video.removeEventListener("enterpictureinpicture", onEnter);
|
||||
video.removeEventListener("leavepictureinpicture", onLeave);
|
||||
};
|
||||
}, [supported]);
|
||||
}, [ videoRef, supported ]);
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
@@ -478,7 +262,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
|
||||
return (
|
||||
<ActionButton
|
||||
icon={active ? "bx bx-exit" : "bx bx-window-open"}
|
||||
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
|
||||
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
@@ -507,7 +291,7 @@ function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> })
|
||||
return (
|
||||
<ActionButton
|
||||
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
|
||||
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
|
||||
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
);
|
||||
|
||||
306
apps/server/spec/search_profiling.spec.ts
Normal file
306
apps/server/spec/search_profiling.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Integration-level search profiling test.
|
||||
*
|
||||
* Uses the real SQLite database (spec/db/document.db loaded in-memory),
|
||||
* real sql module, real becca cache, and the full app stack.
|
||||
*
|
||||
* Profiles search at large scale (50K+ notes) to match real-world
|
||||
* performance reports from users with 240K+ notes.
|
||||
*/
|
||||
import { Application } from "express";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import config from "../src/services/config.js";
|
||||
|
||||
let app: Application;
|
||||
|
||||
function timed<T>(fn: () => T): [T, number] {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
return [result, performance.now() - start];
|
||||
}
|
||||
|
||||
function randomId(len = 12): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < len; i++) id += chars[Math.floor(Math.random() * chars.length)];
|
||||
return id;
|
||||
}
|
||||
|
||||
function randomWord(len = 8): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||
let w = "";
|
||||
for (let i = 0; i < len; i++) w += chars[Math.floor(Math.random() * chars.length)];
|
||||
return w;
|
||||
}
|
||||
|
||||
function generateContent(wordCount: number, keyword?: string): string {
|
||||
const paragraphs: string[] = [];
|
||||
let remaining = wordCount;
|
||||
let injected = false;
|
||||
while (remaining > 0) {
|
||||
const n = Math.min(remaining, 30 + Math.floor(Math.random() * 30));
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < n; i++) words.push(randomWord(3 + Math.floor(Math.random() * 10)));
|
||||
if (keyword && !injected && remaining < wordCount / 2) {
|
||||
words[Math.floor(words.length / 2)] = keyword;
|
||||
injected = true;
|
||||
}
|
||||
paragraphs.push(`<p>${words.join(" ")}</p>`);
|
||||
remaining -= n;
|
||||
}
|
||||
return paragraphs.join("\n");
|
||||
}
|
||||
|
||||
describe("Search profiling (integration)", () => {
|
||||
beforeAll(async () => {
|
||||
config.General.noAuthentication = true;
|
||||
const buildApp = (await import("../src/app.js")).default;
|
||||
app = await buildApp();
|
||||
});
|
||||
|
||||
it("large-scale profiling (50K notes)", async () => {
|
||||
const sql = (await import("../src/services/sql.js")).default;
|
||||
const becca = (await import("../src/becca/becca.js")).default;
|
||||
const beccaLoader = (await import("../src/becca/becca_loader.js")).default;
|
||||
const cls = (await import("../src/services/cls.js")).default;
|
||||
const searchService = (await import("../src/services/search/services/search.js")).default;
|
||||
const SearchContext = (await import("../src/services/search/search_context.js")).default;
|
||||
const beccaService = (await import("../src/becca/becca_service.js")).default;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
cls.init(() => {
|
||||
const initialNoteCount = Object.keys(becca.notes).length;
|
||||
console.log(`\n Initial becca notes: ${initialNoteCount}`);
|
||||
|
||||
// ── Seed 50K notes with hierarchy ──
|
||||
// Some folders (depth), some with common keyword "test" in title
|
||||
const TOTAL_NOTES = 50000;
|
||||
const FOLDER_COUNT = 500; // 500 folders
|
||||
const NOTES_PER_FOLDER = (TOTAL_NOTES - FOLDER_COUNT) / FOLDER_COUNT; // ~99 notes per folder
|
||||
const MATCH_FRACTION = 0.10; // 10% match "test" — ~5000 notes
|
||||
const CONTENT_WORDS = 500;
|
||||
|
||||
const now = new Date().toISOString().replace("T", " ").replace("Z", "+0000");
|
||||
console.log(` Seeding ${TOTAL_NOTES} notes (${FOLDER_COUNT} folders, ~${NOTES_PER_FOLDER.toFixed(0)} per folder)...`);
|
||||
|
||||
const [, seedMs] = timed(() => {
|
||||
sql.transactional(() => {
|
||||
const folderIds: string[] = [];
|
||||
|
||||
// Create folders under root
|
||||
for (let f = 0; f < FOLDER_COUNT; f++) {
|
||||
const noteId = `seed${randomId(8)}`;
|
||||
const branchId = `seed${randomId(8)}`;
|
||||
const blobId = `seed${randomId(16)}`;
|
||||
folderIds.push(noteId);
|
||||
|
||||
sql.execute(
|
||||
`INSERT INTO blobs (blobId, content, dateModified, utcDateModified) VALUES (?, ?, ?, ?)`,
|
||||
[blobId, `<p>Folder ${f}</p>`, now, now]
|
||||
);
|
||||
sql.execute(
|
||||
`INSERT INTO notes (noteId, title, type, mime, blobId, isProtected, isDeleted,
|
||||
dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, 'text', 'text/html', ?, 0, 0, ?, ?, ?, ?)`,
|
||||
[noteId, `Folder ${f} ${randomWord(5)}`, blobId, now, now, now, now]
|
||||
);
|
||||
sql.execute(
|
||||
`INSERT INTO branches (branchId, noteId, parentNoteId, notePosition, isDeleted, isExpanded, utcDateModified)
|
||||
VALUES (?, ?, 'root', ?, 0, 0, ?)`,
|
||||
[branchId, noteId, f * 10, now]
|
||||
);
|
||||
}
|
||||
|
||||
// Create notes under folders
|
||||
let noteIdx = 0;
|
||||
for (let f = 0; f < FOLDER_COUNT; f++) {
|
||||
const parentId = folderIds[f];
|
||||
for (let n = 0; n < NOTES_PER_FOLDER; n++) {
|
||||
const isMatch = noteIdx < TOTAL_NOTES * MATCH_FRACTION;
|
||||
const noteId = `seed${randomId(8)}`;
|
||||
const branchId = `seed${randomId(8)}`;
|
||||
const blobId = `seed${randomId(16)}`;
|
||||
const title = isMatch
|
||||
? `Test Document ${noteIdx} ${randomWord(6)}`
|
||||
: `Note ${noteIdx} ${randomWord(6)} ${randomWord(5)}`;
|
||||
const content = generateContent(CONTENT_WORDS, isMatch ? "test" : undefined);
|
||||
|
||||
sql.execute(
|
||||
`INSERT INTO blobs (blobId, content, dateModified, utcDateModified) VALUES (?, ?, ?, ?)`,
|
||||
[blobId, content, now, now]
|
||||
);
|
||||
sql.execute(
|
||||
`INSERT INTO notes (noteId, title, type, mime, blobId, isProtected, isDeleted,
|
||||
dateCreated, dateModified, utcDateCreated, utcDateModified)
|
||||
VALUES (?, ?, 'text', 'text/html', ?, 0, 0, ?, ?, ?, ?)`,
|
||||
[noteId, title, blobId, now, now, now, now]
|
||||
);
|
||||
sql.execute(
|
||||
`INSERT INTO branches (branchId, noteId, parentNoteId, notePosition, isDeleted, isExpanded, utcDateModified)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?)`,
|
||||
[branchId, noteId, parentId, n * 10, now]
|
||||
);
|
||||
noteIdx++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
console.log(` SQL seeding: ${seedMs.toFixed(0)}ms`);
|
||||
|
||||
const [, reloadMs] = timed(() => beccaLoader.load());
|
||||
const totalNotes = Object.keys(becca.notes).length;
|
||||
console.log(` Becca reload: ${reloadMs.toFixed(0)}ms Total notes: ${totalNotes}`);
|
||||
|
||||
// ── Warm caches ──
|
||||
searchService.searchNotesForAutocomplete("test", true);
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// PROFILING AT SCALE
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
console.log(`\n ════ PROFILING (${totalNotes} notes) ════\n`);
|
||||
|
||||
// 1. getCandidateNotes cost (the full-scan bottleneck)
|
||||
const allNotes = Object.values(becca.notes);
|
||||
const [, flatScanMs] = timed(() => {
|
||||
let count = 0;
|
||||
for (const note of allNotes) {
|
||||
const ft = note.getFlatText();
|
||||
if (ft.includes("test")) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
console.log(` getFlatText + includes scan (${allNotes.length} notes): ${flatScanMs.toFixed(1)}ms`);
|
||||
|
||||
// 2. Full findResultsWithQuery (includes candidate scan + parent walk + scoring)
|
||||
const findTimes: number[] = [];
|
||||
let findResultCount = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [r, ms] = timed(() =>
|
||||
searchService.findResultsWithQuery("test", new SearchContext({ fastSearch: true }))
|
||||
);
|
||||
findTimes.push(ms);
|
||||
findResultCount = r.length;
|
||||
}
|
||||
const findAvg = findTimes.reduce((a, b) => a + b, 0) / findTimes.length;
|
||||
console.log(` findResultsWithQuery (fast): avg ${findAvg.toFixed(1)}ms (${findResultCount} results)`);
|
||||
|
||||
// 3. Exact-only (no fuzzy)
|
||||
const exactTimes: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [, ms] = timed(() =>
|
||||
searchService.findResultsWithQuery("test", new SearchContext({ fastSearch: true, enableFuzzyMatching: false }))
|
||||
);
|
||||
exactTimes.push(ms);
|
||||
}
|
||||
const exactAvg = exactTimes.reduce((a, b) => a + b, 0) / exactTimes.length;
|
||||
console.log(` findResultsWithQuery (exact): avg ${exactAvg.toFixed(1)}ms`);
|
||||
console.log(` Fuzzy overhead: ${(findAvg - exactAvg).toFixed(1)}ms`);
|
||||
|
||||
// 4. SearchResult construction + computeScore cost (isolated)
|
||||
const results = searchService.findResultsWithQuery("test", new SearchContext({ fastSearch: true }));
|
||||
console.log(` Total results before trim: ${results.length}`);
|
||||
|
||||
const [, scoreAllMs] = timed(() => {
|
||||
for (const r of results) r.computeScore("test", ["test"], true);
|
||||
});
|
||||
console.log(` computeScore × ${results.length}: ${scoreAllMs.toFixed(1)}ms (${(scoreAllMs / results.length).toFixed(3)}ms/result)`);
|
||||
|
||||
// 5. getNoteTitleForPath for all results
|
||||
const [, pathTitleMs] = timed(() => {
|
||||
for (const r of results) beccaService.getNoteTitleForPath(r.notePathArray);
|
||||
});
|
||||
console.log(` getNoteTitleForPath × ${results.length}: ${pathTitleMs.toFixed(1)}ms`);
|
||||
|
||||
// 6. Content snippet extraction (only 200)
|
||||
const trimmed = results.slice(0, 200);
|
||||
const [, snippetMs] = timed(() => {
|
||||
for (const r of trimmed) {
|
||||
r.contentSnippet = searchService.extractContentSnippet(r.noteId, ["test"]);
|
||||
}
|
||||
});
|
||||
console.log(` extractContentSnippet × 200: ${snippetMs.toFixed(1)}ms`);
|
||||
|
||||
// 7. Highlighting (only 200)
|
||||
const [, hlMs] = timed(() => {
|
||||
searchService.highlightSearchResults(trimmed, ["test"]);
|
||||
});
|
||||
console.log(` highlightSearchResults × 200: ${hlMs.toFixed(1)}ms`);
|
||||
|
||||
// 7b. getBestNotePath cost (used by fast path)
|
||||
const sampleNotes = Object.values(becca.notes).filter(n => n.title.startsWith("Test Document")).slice(0, 1000);
|
||||
const [, bestPathMs] = timed(() => {
|
||||
for (const n of sampleNotes) n.getBestNotePath();
|
||||
});
|
||||
console.log(` getBestNotePath × ${sampleNotes.length}: ${bestPathMs.toFixed(1)}ms (${(bestPathMs/sampleNotes.length).toFixed(3)}ms/note)`);
|
||||
|
||||
// 8. Full autocomplete end-to-end
|
||||
const autoTimes: number[] = [];
|
||||
let autoCount = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [r, ms] = timed(() =>
|
||||
searchService.searchNotesForAutocomplete("test", true)
|
||||
);
|
||||
autoTimes.push(ms);
|
||||
autoCount = r.length;
|
||||
}
|
||||
const autoAvg = autoTimes.reduce((a, b) => a + b, 0) / autoTimes.length;
|
||||
const autoMin = Math.min(...autoTimes);
|
||||
console.log(`\n ★ FULL AUTOCOMPLETE: avg ${autoAvg.toFixed(1)}ms min ${autoMin.toFixed(1)}ms (${autoCount} results)`);
|
||||
|
||||
// 9. With a less common search term (fewer matches)
|
||||
const rareTimes: number[] = [];
|
||||
let rareCount = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [r, ms] = timed(() =>
|
||||
searchService.searchNotesForAutocomplete("leitfaden", true)
|
||||
);
|
||||
rareTimes.push(ms);
|
||||
rareCount = r.length;
|
||||
}
|
||||
const rareAvg = rareTimes.reduce((a, b) => a + b, 0) / rareTimes.length;
|
||||
console.log(` Autocomplete "leitfaden": avg ${rareAvg.toFixed(1)}ms (${rareCount} results)`);
|
||||
|
||||
// 10. Full search (fastSearch=false) — the 2.7s bottleneck
|
||||
console.log(`\n ── Full search (fastSearch=false) ──`);
|
||||
const fullTimes: number[] = [];
|
||||
let fullCount = 0;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const [r, ms] = timed(() =>
|
||||
searchService.findResultsWithQuery("test", new SearchContext({ fastSearch: false }))
|
||||
);
|
||||
fullTimes.push(ms);
|
||||
fullCount = r.length;
|
||||
}
|
||||
const fullAvg = fullTimes.reduce((a, b) => a + b, 0) / fullTimes.length;
|
||||
console.log(` Full search (flat + SQL): avg ${fullAvg.toFixed(1)}ms (${fullCount} results)`);
|
||||
|
||||
// 11. SQL content scan alone
|
||||
const [scanCount, scanMs] = timed(() => {
|
||||
let count = 0;
|
||||
for (const row of sql.iterateRows<{ content: Buffer | string }>(`
|
||||
SELECT noteId, type, mime, content, isProtected
|
||||
FROM notes JOIN blobs USING (blobId)
|
||||
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND isDeleted = 0
|
||||
AND LENGTH(content) < 2097152`)) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
console.log(` Raw SQL scan (${scanCount} rows): ${scanMs.toFixed(1)}ms`);
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n ════ SUMMARY ════`);
|
||||
console.log(` Notes: ${totalNotes} | Matches: ${findResultCount} | Hierarchy depth: 3 (root → folder → note)`);
|
||||
console.log(` ──────────────────────────────────`);
|
||||
console.log(` Autocomplete (fast): ${autoAvg.toFixed(1)}ms`);
|
||||
console.log(` findResults: ${findAvg.toFixed(1)}ms (${((findAvg/autoAvg)*100).toFixed(0)}%)`);
|
||||
console.log(` snippets+highlight: ${(snippetMs + hlMs).toFixed(1)}ms (${(((snippetMs+hlMs)/autoAvg)*100).toFixed(0)}%)`);
|
||||
console.log(` Full search: ${fullAvg.toFixed(1)}ms`);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -31,9 +31,17 @@ export default class Becca {
|
||||
|
||||
allNoteSetCache: NoteSet | null;
|
||||
|
||||
/**
|
||||
* Pre-built parallel arrays for fast flat text scanning in search.
|
||||
* Avoids per-note property access overhead when iterating 50K+ notes.
|
||||
* Dirtied when notes change (along with allNoteSetCache).
|
||||
*/
|
||||
flatTextIndex: { notes: BNote[], flatTexts: string[] } | null;
|
||||
|
||||
constructor() {
|
||||
this.reset();
|
||||
this.allNoteSetCache = null;
|
||||
this.flatTextIndex = null;
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -239,6 +247,28 @@ export default class Becca {
|
||||
/** Should be called when the set of all non-skeleton notes changes (added/removed) */
|
||||
dirtyNoteSetCache() {
|
||||
this.allNoteSetCache = null;
|
||||
this.flatTextIndex = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns pre-built parallel arrays of notes and their flat texts for fast scanning.
|
||||
* The flat texts are already normalized (lowercase, diacritics removed).
|
||||
*/
|
||||
getFlatTextIndex(): { notes: BNote[], flatTexts: string[] } {
|
||||
if (!this.flatTextIndex) {
|
||||
const allNoteSet = this.getAllNoteSet();
|
||||
const notes: BNote[] = [];
|
||||
const flatTexts: string[] = [];
|
||||
|
||||
for (const note of allNoteSet.notes) {
|
||||
notes.push(note);
|
||||
flatTexts.push(note.getFlatText());
|
||||
}
|
||||
|
||||
this.flatTextIndex = { notes, flatTexts };
|
||||
}
|
||||
|
||||
return this.flatTextIndex;
|
||||
}
|
||||
|
||||
getAllNoteSet() {
|
||||
|
||||
@@ -790,6 +790,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
this.__attributeCache = null;
|
||||
this.__inheritableAttributeCache = null;
|
||||
this.__ancestorCache = null;
|
||||
|
||||
// Dirty the becca-level flat text index since this note's flat text may have changed
|
||||
this.becca.flatTextIndex = null;
|
||||
}
|
||||
|
||||
invalidateSubTree(path: string[] = []) {
|
||||
|
||||
@@ -99,6 +99,22 @@ class NoteFlatTextExp extends Expression {
|
||||
|
||||
const candidateNotes = this.getCandidateNotes(inputNoteSet, searchContext);
|
||||
|
||||
// Fast path for single-token searches with a limit (e.g. autocomplete):
|
||||
// Skip the expensive recursive parent walk and just use getBestNotePath().
|
||||
// The flat text already matched, so we know the token is present.
|
||||
if (this.tokens.length === 1 && searchContext.limit) {
|
||||
for (const note of candidateNotes) {
|
||||
if (!resultNoteSet.hasNoteId(note.noteId)) {
|
||||
const notePath = note.getBestNotePath();
|
||||
if (notePath) {
|
||||
executionContext.noteIdToNotePath[note.noteId] = notePath;
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
for (const note of candidateNotes) {
|
||||
// autocomplete should be able to find notes by their noteIds as well (only leafs)
|
||||
if (this.tokens.length === 1 && note.noteId.toLowerCase() === this.tokens[0]) {
|
||||
@@ -112,7 +128,7 @@ class NoteFlatTextExp extends Expression {
|
||||
// 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);
|
||||
}
|
||||
@@ -165,14 +181,38 @@ class NoteFlatTextExp extends Expression {
|
||||
getCandidateNotes(noteSet: NoteSet, searchContext?: SearchContext): BNote[] {
|
||||
const candidateNotes: BNote[] = [];
|
||||
|
||||
for (const note of noteSet.notes) {
|
||||
const normalizedFlatText = normalizeSearchText(note.getFlatText());
|
||||
// For limited searches (e.g. autocomplete), cap candidates to avoid
|
||||
// processing thousands of matches when only a few hundred are needed.
|
||||
// Use 5x the limit to ensure enough quality candidates for scoring.
|
||||
const maxCandidates = searchContext?.limit ? searchContext.limit * 5 : Infinity;
|
||||
|
||||
// Use the pre-built flat text index for fast scanning.
|
||||
// This provides pre-computed flat texts in a parallel array, avoiding
|
||||
// per-note property access overhead at large scale (50K+ notes).
|
||||
const { notes: indexNotes, flatTexts } = becca.getFlatTextIndex();
|
||||
|
||||
// Build a set for quick membership check when noteSet isn't the full set
|
||||
const isFullSet = noteSet.notes.length === indexNotes.length;
|
||||
|
||||
for (let i = 0; i < indexNotes.length; i++) {
|
||||
const note = indexNotes[i];
|
||||
|
||||
// Skip notes not in the input set (only check when not using the full set)
|
||||
if (!isFullSet && !noteSet.hasNoteId(note.noteId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flatText = flatTexts[i];
|
||||
for (const token of this.tokens) {
|
||||
if (this.smartMatch(normalizedFlatText, token, searchContext)) {
|
||||
if (this.smartMatch(flatText, token, searchContext)) {
|
||||
candidateNotes.push(note);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateNotes.length >= maxCandidates) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return candidateNotes;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use strict";
|
||||
|
||||
import normalizeString from "normalize-strings";
|
||||
import lex from "./lex.js";
|
||||
import handleParens from "./handle_parens.js";
|
||||
import parse from "./parse.js";
|
||||
@@ -8,7 +7,7 @@ import SearchResult from "../search_result.js";
|
||||
import SearchContext from "../search_context.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
import beccaService from "../../../becca/becca_service.js";
|
||||
import { normalize, escapeHtml, escapeRegExp } from "../../utils.js";
|
||||
import { normalize, removeDiacritic, escapeHtml, escapeRegExp } from "../../utils.js";
|
||||
import log from "../../log.js";
|
||||
import hoistedNoteService from "../../hoisted_note.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
@@ -17,7 +16,6 @@ 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 {
|
||||
@@ -250,23 +248,30 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
|
||||
return performSearch(expression, searchContext, false);
|
||||
}
|
||||
|
||||
// For limited searches (e.g. autocomplete), skip the expensive two-phase
|
||||
// fuzzy fallback. The user is typing and will refine their query — exact
|
||||
// matching is sufficient and avoids a second full scan of all notes.
|
||||
if (searchContext.limit) {
|
||||
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);
|
||||
}
|
||||
@@ -448,7 +453,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
|
||||
|
||||
try {
|
||||
let content = note.getContent();
|
||||
|
||||
|
||||
if (!content || typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
@@ -464,77 +469,66 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
|
||||
return ""; // Protected but no session available
|
||||
}
|
||||
|
||||
// Strip HTML tags for text notes
|
||||
// Strip HTML tags for text notes — use fast regex for snippet extraction
|
||||
// (striptags library is ~18x slower and not needed for search snippets)
|
||||
if (note.type === "text") {
|
||||
content = striptags(content);
|
||||
content = content.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
// Normalize whitespace while preserving paragraph breaks
|
||||
// First, normalize multiple newlines to double newlines (paragraph breaks)
|
||||
content = content.replace(/\n\s*\n/g, "\n\n");
|
||||
// Then normalize spaces within lines
|
||||
content = content.split('\n').map(line => line.replace(/\s+/g, " ").trim()).join('\n');
|
||||
// Finally trim the whole content
|
||||
content = content.trim();
|
||||
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try to find a snippet around the first matching token
|
||||
const normalizedContent = normalizeString(content.toLowerCase());
|
||||
// Find match position using normalize on the raw stripped content.
|
||||
// We use a single normalize() pass — no need for expensive whitespace
|
||||
// normalization just to find the match index.
|
||||
const normalizedContent = normalize(content);
|
||||
const normalizedTokens = searchTokens.map(token => normalize(token));
|
||||
let snippetStart = 0;
|
||||
let matchFound = false;
|
||||
|
||||
for (const token of searchTokens) {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
for (const normalizedToken of normalizedTokens) {
|
||||
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);
|
||||
// Extract a snippet region from the raw content, then clean only that
|
||||
const snippetRegion = content.substring(snippetStart, snippetStart + maxLength + 100);
|
||||
|
||||
// If snippet contains linebreaks, limit to max 4 lines and override character limit
|
||||
// Normalize whitespace only on the small snippet region
|
||||
let snippet = snippetRegion
|
||||
.replace(/\n\s*\n/g, "\n\n")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim()
|
||||
.substring(0, maxLength);
|
||||
|
||||
// If snippet contains linebreaks, limit to max 4 lines
|
||||
const lines = snippet.split('\n');
|
||||
if (lines.length > 4) {
|
||||
// Find which lines contain the search tokens to ensure they're included
|
||||
const normalizedLines = lines.map(line => normalizeString(line.toLowerCase()));
|
||||
const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase()));
|
||||
|
||||
// Find the first line that contains a search token
|
||||
let firstMatchLine = -1;
|
||||
for (let i = 0; i < normalizedLines.length; i++) {
|
||||
if (normalizedTokens.some(token => normalizedLines[i].includes(token))) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const normalizedLine = normalize(lines[i]);
|
||||
if (normalizedTokens.some(token => normalizedLine.includes(token))) {
|
||||
firstMatchLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstMatchLine !== -1) {
|
||||
// Center the 4-line window around the first match
|
||||
// Try to show 1 line before and 2 lines after the match
|
||||
const startLine = Math.max(0, firstMatchLine - 1);
|
||||
const endLine = Math.min(lines.length, startLine + 4);
|
||||
snippet = lines.slice(startLine, endLine).join('\n');
|
||||
} else {
|
||||
// No match found in lines (shouldn't happen), just take first 4
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
}
|
||||
// Add ellipsis if we truncated lines
|
||||
snippet = snippet + "...";
|
||||
} else if (lines.length > 1) {
|
||||
// For multi-line snippets that are 4 or fewer lines, keep them as-is
|
||||
// No need to truncate
|
||||
} else {
|
||||
// Single line content - apply original word boundary logic
|
||||
// Try to start/end at word boundaries
|
||||
} else if (lines.length <= 1) {
|
||||
// Single line content - apply word boundary logic
|
||||
if (snippetStart > 0) {
|
||||
const firstSpace = snippet.search(/\s/);
|
||||
if (firstSpace > 0 && firstSpace < 20) {
|
||||
@@ -542,7 +536,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
|
||||
}
|
||||
snippet = "..." + snippet;
|
||||
}
|
||||
|
||||
|
||||
if (snippetStart + maxLength < content.length) {
|
||||
const lastSpace = snippet.search(/\s[^\s]*$/);
|
||||
if (lastSpace > snippet.length - 20 && lastSpace > 0) {
|
||||
@@ -582,7 +576,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
|
||||
|
||||
// Check if any search token matches the attribute name or value
|
||||
const hasMatch = searchTokens.some(token => {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
const normalizedToken = normalize(token);
|
||||
return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken);
|
||||
});
|
||||
|
||||
@@ -650,7 +644,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||
includeHiddenNotes: true,
|
||||
fuzzyAttributeSearch: true,
|
||||
ignoreInternalAttributes: true,
|
||||
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId()
|
||||
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId(),
|
||||
limit: 200
|
||||
});
|
||||
|
||||
const allSearchResults = findResultsWithQuery(query, searchContext);
|
||||
@@ -734,7 +729,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
// Highlight in note path title
|
||||
if (result.highlightedNotePathTitle) {
|
||||
const titleRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
|
||||
while ((match = titleRegex.exec(removeDiacritic(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;
|
||||
@@ -744,7 +739,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
// Highlight in content snippet
|
||||
if (result.highlightedContentSnippet) {
|
||||
const contentRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) {
|
||||
while ((match = contentRegex.exec(removeDiacritic(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;
|
||||
@@ -754,7 +749,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
// Highlight in attribute snippet
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
const attributeRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) {
|
||||
while ((match = attributeRegex.exec(removeDiacritic(result.highlightedAttributeSnippet))) !== null) {
|
||||
result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
attributeRegex.lastIndex += 2;
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Search performance profiling tests.
|
||||
*
|
||||
* These tests measure where time is spent in the search pipeline.
|
||||
* We monkeypatch note.getContent() to return synthetic HTML content
|
||||
* since unit tests don't have a real SQLite database.
|
||||
*
|
||||
* KNOWN GAPS vs production:
|
||||
* - note.getContent() is instant (monkeypatched) vs ~2ms SQL fetch
|
||||
* - NoteContentFulltextExp.execute() is skipped (no sql.iterateRows)
|
||||
* because fastSearch=true uses only NoteFlatTextExp
|
||||
* - These tests focus on the in-memory/CPU-bound parts of the pipeline
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } 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 beccaService from "../../../becca/becca_service.js";
|
||||
import { NoteBuilder, note, id } from "../../../test/becca_mocking.js";
|
||||
import SearchResult from "../search_result.js";
|
||||
import { normalizeSearchText } from "../utils/text_utils.js";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function randomWord(len = 6): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||
let word = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
word += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
function generateHtmlContent(wordCount: number, includeTarget = false): string {
|
||||
const paragraphs: string[] = [];
|
||||
let wordsRemaining = wordCount;
|
||||
|
||||
while (wordsRemaining > 0) {
|
||||
const paraWords = Math.min(wordsRemaining, 20 + Math.floor(Math.random() * 40));
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < paraWords; i++) {
|
||||
words.push(randomWord(3 + Math.floor(Math.random() * 10)));
|
||||
}
|
||||
if (includeTarget && paragraphs.length === 2) {
|
||||
words[Math.floor(words.length / 2)] = "target";
|
||||
}
|
||||
paragraphs.push(`<p>${words.join(" ")}</p>`);
|
||||
wordsRemaining -= paraWords;
|
||||
}
|
||||
|
||||
return `<html><body>${paragraphs.join("\n")}</body></html>`;
|
||||
}
|
||||
|
||||
function timed<T>(fn: () => T): [T, number] {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
return [result, performance.now() - start];
|
||||
}
|
||||
|
||||
interface TimingEntry { label: string; ms: number; }
|
||||
|
||||
function reportTimings(title: string, timings: TimingEntry[]) {
|
||||
const total = timings.reduce((s, t) => s + t.ms, 0);
|
||||
console.log(`\n=== ${title} (total: ${total.toFixed(1)}ms) ===`);
|
||||
for (const { label, ms } of timings) {
|
||||
const pct = total > 0 ? ((ms / total) * 100).toFixed(0) : "0";
|
||||
const bar = "#".repeat(Math.max(1, Math.round(ms / total * 40)));
|
||||
console.log(` ${label.padEnd(55)} ${ms.toFixed(1).padStart(8)}ms ${pct.padStart(3)}% ${bar}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── dataset builder ──────────────────────────────────────────────────
|
||||
|
||||
const syntheticContent: Record<string, string> = {};
|
||||
|
||||
function buildDataset(noteCount: number, opts: {
|
||||
matchFraction?: number;
|
||||
labelsPerNote?: number;
|
||||
depth?: number;
|
||||
contentWordCount?: number;
|
||||
} = {}) {
|
||||
const {
|
||||
matchFraction = 0.1,
|
||||
labelsPerNote = 3,
|
||||
depth = 3,
|
||||
contentWordCount = 200,
|
||||
} = opts;
|
||||
|
||||
becca.reset();
|
||||
for (const key of Object.keys(syntheticContent)) {
|
||||
delete syntheticContent[key];
|
||||
}
|
||||
|
||||
const rootNote = new NoteBuilder(new BNote({ noteId: "root", title: "root", type: "text" }));
|
||||
new BBranch({
|
||||
branchId: "none_root",
|
||||
noteId: "root",
|
||||
parentNoteId: "none",
|
||||
notePosition: 10
|
||||
});
|
||||
|
||||
const containers: NoteBuilder[] = [];
|
||||
let parent = rootNote;
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const container = note(`Container_${d}_${randomWord(4)}`);
|
||||
parent.child(container);
|
||||
containers.push(container);
|
||||
parent = container;
|
||||
}
|
||||
|
||||
const matchCount = Math.floor(noteCount * matchFraction);
|
||||
|
||||
for (let i = 0; i < noteCount; i++) {
|
||||
const isMatch = i < matchCount;
|
||||
const title = isMatch
|
||||
? `${randomWord(5)} target ${randomWord(5)} Document ${i}`
|
||||
: `${randomWord(5)} ${randomWord(6)} ${randomWord(4)} Note ${i}`;
|
||||
|
||||
const n = note(title);
|
||||
|
||||
for (let l = 0; l < labelsPerNote; l++) {
|
||||
const labelName = isMatch && l === 0 ? "category" : `label_${randomWord(4)}`;
|
||||
const labelValue = isMatch && l === 0 ? "important target" : randomWord(8);
|
||||
n.label(labelName, labelValue);
|
||||
}
|
||||
|
||||
syntheticContent[n.note.noteId] = generateHtmlContent(contentWordCount, isMatch);
|
||||
|
||||
const containerIndex = i % containers.length;
|
||||
containers[containerIndex].child(n);
|
||||
}
|
||||
|
||||
// Monkeypatch getContent()
|
||||
for (const noteObj of Object.values(becca.notes)) {
|
||||
const noteId = noteObj.noteId;
|
||||
if (syntheticContent[noteId]) {
|
||||
(noteObj as any).getContent = () => syntheticContent[noteId];
|
||||
} else {
|
||||
(noteObj as any).getContent = () => "";
|
||||
}
|
||||
}
|
||||
|
||||
return { rootNote, matchCount };
|
||||
}
|
||||
|
||||
// ── profiling tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("Search Profiling", () => {
|
||||
|
||||
afterEach(() => {
|
||||
becca.reset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Break down the autocomplete pipeline into every individual stage,
|
||||
* including previously unmeasured operations like getBestNotePath,
|
||||
* SearchResult construction, and getNoteTitleForPath.
|
||||
*/
|
||||
describe("Granular autocomplete pipeline", () => {
|
||||
|
||||
for (const noteCount of [500, 2000, 5000, 10000]) {
|
||||
it(`granular breakdown with ${noteCount} notes`, () => {
|
||||
const timings: TimingEntry[] = [];
|
||||
|
||||
const [, buildMs] = timed(() => buildDataset(noteCount, {
|
||||
matchFraction: 0.2,
|
||||
contentWordCount: 300,
|
||||
depth: 5
|
||||
}));
|
||||
timings.push({ label: `Dataset build (${noteCount} notes)`, ms: buildMs });
|
||||
|
||||
// === NoteFlatTextExp: getCandidateNotes ===
|
||||
// This calls getFlatText() + normalizeSearchText() for EVERY note
|
||||
const allNotes = Object.values(becca.notes);
|
||||
for (const n of allNotes) n.invalidateThisCache();
|
||||
|
||||
const [, candidateMs] = timed(() => {
|
||||
const token = normalizeSearchText("target");
|
||||
let count = 0;
|
||||
for (const n of allNotes) {
|
||||
const flatText = normalizeSearchText(n.getFlatText());
|
||||
if (flatText.includes(token)) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
timings.push({ label: `getCandidateNotes simulation (cold caches)`, ms: candidateMs });
|
||||
|
||||
// Warm cache version
|
||||
const [candidateCount, candidateWarmMs] = timed(() => {
|
||||
const token = normalizeSearchText("target");
|
||||
let count = 0;
|
||||
for (const n of allNotes) {
|
||||
const flatText = normalizeSearchText(n.getFlatText());
|
||||
if (flatText.includes(token)) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
timings.push({ label: `getCandidateNotes simulation (warm caches)`, ms: candidateWarmMs });
|
||||
|
||||
// === getBestNotePath for each candidate ===
|
||||
const candidates = allNotes.filter(n => {
|
||||
const flatText = normalizeSearchText(n.getFlatText());
|
||||
return flatText.includes("target");
|
||||
});
|
||||
|
||||
const [, pathMs] = timed(() => {
|
||||
for (const n of candidates) {
|
||||
n.getBestNotePath();
|
||||
}
|
||||
});
|
||||
timings.push({ label: `getBestNotePath (${candidates.length} notes)`, ms: pathMs });
|
||||
|
||||
// === SearchResult construction (includes getNoteTitleForPath) ===
|
||||
const paths = candidates.map(n => n.getBestNotePath()).filter(Boolean);
|
||||
|
||||
const [searchResults, srMs] = timed(() => {
|
||||
return paths.map(p => new SearchResult(p));
|
||||
});
|
||||
timings.push({ label: `SearchResult construction (${paths.length} results)`, ms: srMs });
|
||||
|
||||
// === computeScore ===
|
||||
const [, scoreMs] = timed(() => {
|
||||
for (const r of searchResults) {
|
||||
r.computeScore("target", ["target"], true);
|
||||
}
|
||||
});
|
||||
timings.push({ label: `computeScore with fuzzy (${searchResults.length} results)`, ms: scoreMs });
|
||||
|
||||
const [, scoreNoFuzzyMs] = timed(() => {
|
||||
for (const r of searchResults) {
|
||||
r.computeScore("target", ["target"], false);
|
||||
}
|
||||
});
|
||||
timings.push({ label: `computeScore no-fuzzy`, ms: scoreNoFuzzyMs });
|
||||
|
||||
// === Sorting ===
|
||||
const [, sortMs] = timed(() => {
|
||||
searchResults.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
return a.notePathArray.length - b.notePathArray.length;
|
||||
});
|
||||
});
|
||||
timings.push({ label: `Sort results`, ms: sortMs });
|
||||
|
||||
// === Trim + content snippet extraction ===
|
||||
const trimmed = searchResults.slice(0, 200);
|
||||
|
||||
const [, snippetMs] = timed(() => {
|
||||
for (const r of trimmed) {
|
||||
r.contentSnippet = searchService.extractContentSnippet(
|
||||
r.noteId, ["target"]
|
||||
);
|
||||
}
|
||||
});
|
||||
timings.push({ label: `Content snippet extraction (${trimmed.length} results)`, ms: snippetMs });
|
||||
|
||||
const [, attrMs] = timed(() => {
|
||||
for (const r of trimmed) {
|
||||
r.attributeSnippet = searchService.extractAttributeSnippet(
|
||||
r.noteId, ["target"]
|
||||
);
|
||||
}
|
||||
});
|
||||
timings.push({ label: `Attribute snippet extraction`, ms: attrMs });
|
||||
|
||||
// === Highlighting ===
|
||||
const [, hlMs] = timed(() => {
|
||||
searchService.highlightSearchResults(trimmed, ["target"]);
|
||||
});
|
||||
timings.push({ label: `Highlighting`, ms: hlMs });
|
||||
|
||||
// === Final mapping (getNoteTitleAndIcon) ===
|
||||
const [, mapMs] = timed(() => {
|
||||
for (const r of trimmed) {
|
||||
beccaService.getNoteTitleAndIcon(r.noteId);
|
||||
}
|
||||
});
|
||||
timings.push({ label: `getNoteTitleAndIcon (${trimmed.length} results)`, ms: mapMs });
|
||||
|
||||
// === Full autocomplete for comparison ===
|
||||
const [autoResults, autoMs] = timed(() => {
|
||||
return searchService.searchNotesForAutocomplete("target", true);
|
||||
});
|
||||
timings.push({ label: `Full autocomplete call (end-to-end)`, ms: autoMs });
|
||||
|
||||
reportTimings(`Granular Autocomplete — ${noteCount} notes`, timings);
|
||||
expect(autoResults.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test the specific cost of normalizeSearchText which is called
|
||||
* pervasively throughout the pipeline.
|
||||
*/
|
||||
describe("normalizeSearchText cost", () => {
|
||||
|
||||
it("profile normalizeSearchText at scale", () => {
|
||||
buildDataset(5000, { matchFraction: 0.2, contentWordCount: 100 });
|
||||
|
||||
// Generate various text lengths to profile
|
||||
const shortTexts = Array.from({ length: 5000 }, () => randomWord(10));
|
||||
const mediumTexts = Array.from({ length: 5000 }, () =>
|
||||
Array.from({ length: 20 }, () => randomWord(6)).join(" ")
|
||||
);
|
||||
const longTexts = Object.values(becca.notes).map(n => n.getFlatText());
|
||||
|
||||
console.log("\n=== normalizeSearchText cost ===");
|
||||
|
||||
const [, shortMs] = timed(() => {
|
||||
for (const t of shortTexts) normalizeSearchText(t);
|
||||
});
|
||||
console.log(` 5000 short texts (10 chars): ${shortMs.toFixed(1)}ms (${(shortMs/5000*1000).toFixed(1)}µs/call)`);
|
||||
|
||||
const [, medMs] = timed(() => {
|
||||
for (const t of mediumTexts) normalizeSearchText(t);
|
||||
});
|
||||
console.log(` 5000 medium texts (120 chars): ${medMs.toFixed(1)}ms (${(medMs/5000*1000).toFixed(1)}µs/call)`);
|
||||
|
||||
const [, longMs] = timed(() => {
|
||||
for (const t of longTexts) normalizeSearchText(t);
|
||||
});
|
||||
console.log(` ${longTexts.length} flat texts (varying): ${longMs.toFixed(1)}ms (${(longMs/longTexts.length*1000).toFixed(1)}µs/call)`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test the searchPathTowardsRoot recursive walk which runs
|
||||
* for every candidate note in NoteFlatTextExp.
|
||||
*/
|
||||
describe("searchPathTowardsRoot cost", () => {
|
||||
|
||||
it("profile recursive walk with varying hierarchy depth", () => {
|
||||
console.log("\n=== Search path walk vs hierarchy depth ===");
|
||||
|
||||
for (const depth of [3, 5, 8, 12]) {
|
||||
buildDataset(2000, {
|
||||
matchFraction: 0.15,
|
||||
depth,
|
||||
contentWordCount: 50
|
||||
});
|
||||
|
||||
const [results, ms] = timed(() => {
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
return searchService.findResultsWithQuery("target", ctx);
|
||||
});
|
||||
console.log(` depth=${depth}: ${ms.toFixed(1)}ms (${results.length} results)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Content snippet extraction scaling — the operation that calls
|
||||
* note.getContent() for each result.
|
||||
*/
|
||||
describe("Content snippet extraction", () => {
|
||||
|
||||
it("profile snippet extraction with varying content sizes", () => {
|
||||
console.log("\n=== Content snippet extraction vs content size ===");
|
||||
|
||||
for (const wordCount of [50, 200, 500, 1000, 2000, 5000]) {
|
||||
buildDataset(500, {
|
||||
matchFraction: 0.5,
|
||||
contentWordCount: wordCount
|
||||
});
|
||||
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
const results = searchService.findResultsWithQuery("target", ctx);
|
||||
const trimmed = results.slice(0, 200);
|
||||
|
||||
const [, ms] = timed(() => {
|
||||
for (const r of trimmed) {
|
||||
r.contentSnippet = searchService.extractContentSnippet(
|
||||
r.noteId, ["target"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const avgContentLen = Object.values(syntheticContent)
|
||||
.slice(0, 100)
|
||||
.reduce((s, c) => s + c.length, 0) / 100;
|
||||
|
||||
console.log(` ${String(wordCount).padStart(5)} words/note (avg ${Math.round(avgContentLen)} chars) × ${trimmed.length} results: ${ms.toFixed(1)}ms (${(ms / trimmed.length).toFixed(3)}ms/note)`);
|
||||
}
|
||||
});
|
||||
|
||||
it("profile snippet extraction with varying result counts", () => {
|
||||
console.log("\n=== Content snippet extraction vs result count ===");
|
||||
|
||||
buildDataset(2000, {
|
||||
matchFraction: 0.5,
|
||||
contentWordCount: 500
|
||||
});
|
||||
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
const allResults = searchService.findResultsWithQuery("target", ctx);
|
||||
|
||||
for (const count of [5, 10, 20, 50, 100, 200]) {
|
||||
const subset = allResults.slice(0, count);
|
||||
|
||||
const [, ms] = timed(() => {
|
||||
for (const r of subset) {
|
||||
r.contentSnippet = searchService.extractContentSnippet(
|
||||
r.noteId, ["target"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(` ${String(count).padStart(3)} results: ${ms.toFixed(1)}ms (${(ms / count).toFixed(3)}ms/note)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two-phase exact/fuzzy search cost.
|
||||
*/
|
||||
describe("Two-phase search cost", () => {
|
||||
|
||||
for (const noteCount of [1000, 5000, 10000]) {
|
||||
it(`exact vs progressive with ${noteCount} notes`, () => {
|
||||
const timings: TimingEntry[] = [];
|
||||
|
||||
buildDataset(noteCount, { matchFraction: 0.005, contentWordCount: 50 });
|
||||
|
||||
const [exactR, exactMs] = timed(() => {
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
ctx.enableFuzzyMatching = false;
|
||||
return searchService.findResultsWithQuery("target", ctx);
|
||||
});
|
||||
timings.push({ label: `Exact-only (${exactR.length} results)`, ms: exactMs });
|
||||
|
||||
const [progR, progMs] = timed(() => {
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
return searchService.findResultsWithQuery("target", ctx);
|
||||
});
|
||||
timings.push({ label: `Progressive exact→fuzzy (${progR.length} results)`, ms: progMs });
|
||||
|
||||
const overhead = progMs - exactMs;
|
||||
timings.push({ label: `Fuzzy phase overhead`, ms: Math.max(0, overhead) });
|
||||
|
||||
reportTimings(`Two-phase — ${noteCount} notes`, timings);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* End-to-end scaling to give the full picture.
|
||||
*/
|
||||
describe("End-to-end scaling", () => {
|
||||
|
||||
it("autocomplete at different scales", () => {
|
||||
console.log("\n=== End-to-end autocomplete scaling ===");
|
||||
console.log(" (fastSearch=true, monkeypatched getContent, no real SQL)");
|
||||
|
||||
for (const noteCount of [100, 500, 1000, 2000, 5000, 10000, 20000]) {
|
||||
buildDataset(noteCount, {
|
||||
matchFraction: 0.2,
|
||||
contentWordCount: 300,
|
||||
depth: 4
|
||||
});
|
||||
|
||||
// Warm up
|
||||
searchService.searchNotesForAutocomplete("target", true);
|
||||
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [, ms] = timed(() => searchService.searchNotesForAutocomplete("target", true));
|
||||
times.push(ms);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const min = Math.min(...times);
|
||||
|
||||
console.log(
|
||||
` ${String(noteCount).padStart(6)} notes: avg ${avg.toFixed(1)}ms ` +
|
||||
`min ${min.toFixed(1)}ms`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("compare fast vs non-fast search", () => {
|
||||
console.log("\n=== Fast vs non-fast search (no real SQL for content) ===");
|
||||
|
||||
for (const noteCount of [500, 2000, 5000]) {
|
||||
buildDataset(noteCount, {
|
||||
matchFraction: 0.2,
|
||||
contentWordCount: 200,
|
||||
depth: 4
|
||||
});
|
||||
|
||||
const [, fastMs] = timed(() => {
|
||||
const ctx = new SearchContext({ fastSearch: true });
|
||||
return searchService.findResultsWithQuery("target", ctx);
|
||||
});
|
||||
|
||||
// Non-fast search tries NoteContentFulltextExp which uses sql.iterateRows
|
||||
// This will likely fail/return empty since there's no real DB, but we
|
||||
// can still measure the overhead of attempting it
|
||||
let nonFastMs: number;
|
||||
let nonFastCount: number;
|
||||
try {
|
||||
const [results, ms] = timed(() => {
|
||||
const ctx = new SearchContext({ fastSearch: false });
|
||||
return searchService.findResultsWithQuery("target", ctx);
|
||||
});
|
||||
nonFastMs = ms;
|
||||
nonFastCount = results.length;
|
||||
} catch {
|
||||
nonFastMs = -1;
|
||||
nonFastCount = -1;
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${String(noteCount).padStart(5)} notes: fast=${fastMs.toFixed(1)}ms ` +
|
||||
`non-fast=${nonFastMs >= 0 ? nonFastMs.toFixed(1) + 'ms' : 'FAILED (no real DB)'} ` +
|
||||
`(non-fast results: ${nonFastCount})`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,5 +197,12 @@
|
||||
"description": "Trilium Notes는 간편한 접근 및 관리를 위해 유료 서비스인 PikaPods에서 호스팅할 수 있습니다. Trilium 팀과 직접 제휴되어있지는 않습니다.",
|
||||
"download_pikapod": "PikaPods에서 설치하기",
|
||||
"download_triliumcc": "또는 trilium.cc를 참조하세요"
|
||||
},
|
||||
"resources": {
|
||||
"title": "리소스",
|
||||
"icon_packs": "아이콘 팩",
|
||||
"icon_packs_intro": "아이콘 팩을 사용하여 노트에 사용할 수 있는 아이콘 종류를 늘려보세요. 아이콘 팩에 대한 자세한 내용은 <DocumentationLink>공식 문서</DocumentationLink>를 참조하세요.",
|
||||
"download": "다운로드",
|
||||
"website": "웹사이트"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"canvas_description": "Розташовуйте фігури, зображення та текст на нескінченному полотні, використовуючи ту саму технологію, що й excalidraw.com. Ідеально підходить для діаграм, ескізів та візуального планування.",
|
||||
"mermaid_description": "Створюйте діаграми, такі як блок-схеми, діаграми класів та послідовностей, діаграми Ганта та багато іншого, використовуючи синтаксис Mermaid.",
|
||||
"others_list": "та інші: <0>карта нотаток</0>, <1>карта зв'язків</1>, <2>збережені пошуки</2>, <3>візуалізація нотаток</3> та <4>веб-перегляди</4>.",
|
||||
"mermaid_title": "Mermaid діаграми"
|
||||
"mermaid_title": "Mermaid діаграми",
|
||||
"mindmap_title": "Карта думок",
|
||||
"mindmap_description": "Візуально упорядкуйте свої думки або проведіть мозковий штурм."
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"title": "Спільне використання та розширюваність",
|
||||
@@ -59,7 +61,9 @@
|
||||
"share_title": "Діліться нотатками в Інтернеті",
|
||||
"share_description": "Якщо у Вас є сервер, Ви можете використати його, щоб поділитися частиною своїх нотаток з іншими людьми.",
|
||||
"api_title": "REST API",
|
||||
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API."
|
||||
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API.",
|
||||
"scripting_title": "Розширений скриптинг",
|
||||
"scripting_description": "Створюйте власні інтеграції в Trilium за допомогою користувацьких віджетів або серверної логіки."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Колекції",
|
||||
@@ -108,7 +112,8 @@
|
||||
"header": {
|
||||
"get-started": "Почати",
|
||||
"documentation": "Документація",
|
||||
"support-us": "Підтримайте нас"
|
||||
"support-us": "Підтримайте нас",
|
||||
"resources": "Ресурси"
|
||||
},
|
||||
"footer": {
|
||||
"copyright_and_the": " і ",
|
||||
@@ -148,7 +153,8 @@
|
||||
"description_arm64": "Сумісний з пристроями ARM (наприклад, з Qualcomm Snapdragon).",
|
||||
"quick_start": "Щоб встановити через Winget:",
|
||||
"download_exe": "Завантажити інсталятор (.exe)",
|
||||
"download_zip": "Портативний (.zip)"
|
||||
"download_zip": "Портативний (.zip)",
|
||||
"download_scoop": "Scoop"
|
||||
},
|
||||
"download_helper_desktop_linux": {
|
||||
"title_x64": "Linux 64-bit",
|
||||
@@ -159,23 +165,44 @@
|
||||
"download_deb": ".deb",
|
||||
"download_rpm": ".rpm",
|
||||
"download_flatpak": ".flatpak",
|
||||
"download_nixpkgs": "nixpkgs"
|
||||
"download_nixpkgs": "nixpkgs",
|
||||
"download_zip": "Portable (.zip)",
|
||||
"download_aur": "AUR"
|
||||
},
|
||||
"download_helper_desktop_macos": {
|
||||
"title_x64": "macOS для Intel",
|
||||
"title_arm64": "macOS для Apple Silicon",
|
||||
"quick_start": "Для того, щоб встановити за допомогою Homebrew:",
|
||||
"download_homebrew_cask": "Homebrew Cask"
|
||||
"download_homebrew_cask": "Homebrew Cask",
|
||||
"description_x64": "Для комп’ютерів Mac на базі Intel з macOS Monterey або пізнішої версії.",
|
||||
"description_arm64": "Для комп'ютерів Apple Silicon Mac, таких як ті, що мають чіпи M1 та M2.",
|
||||
"download_dmg": "Завантажити інсталятор (.dmg)",
|
||||
"download_zip": "Portable (.zip)"
|
||||
},
|
||||
"download_helper_server_docker": {
|
||||
"download_dockerhub": "Docker Hub",
|
||||
"download_ghcr": "ghcr.io"
|
||||
"download_ghcr": "ghcr.io",
|
||||
"title": "Self-hosted using Docker",
|
||||
"description": "Легке розгортання на Windows, Linux або macOS за допомогою контейнера Docker."
|
||||
},
|
||||
"download_helper_server_linux": {
|
||||
"download_tar_x64": "x64 (.tar.xz)",
|
||||
"download_tar_arm64": "ARM (.tar.xz)"
|
||||
"download_tar_arm64": "ARM (.tar.xz)",
|
||||
"title": "Self-hosted on Linux",
|
||||
"description": "Розгорніть Trilium Notes на власному сервері або VPS, сумісному з більшістю дистрибутивів.",
|
||||
"download_nixos": "NixOS module"
|
||||
},
|
||||
"download_helper_server_hosted": {
|
||||
"title": "Платний хостинг"
|
||||
"title": "Платний хостинг",
|
||||
"description": "Нотатки Trilium розміщені на PikaPods, платному сервісі для легкого доступу та керування. Не пов'язаний безпосередньо з командою Trilium.",
|
||||
"download_pikapod": "Налаштування на PikaPods",
|
||||
"download_triliumcc": "Або див. trilium.cc"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ресурси",
|
||||
"icon_packs": "Пакети піктограм",
|
||||
"icon_packs_intro": "Розширте вибір доступних піктограм для ваших нотаток за допомогою пакету піктограм. Щоб отримати докладнішу інформацію про пакети піктограм, див. <DocumentationLink>офіційну документацію</DocumentationLink>.",
|
||||
"download": "Завантажити",
|
||||
"website": "Вебсайт"
|
||||
}
|
||||
}
|
||||
|
||||
28
docs/README-ko.md
vendored
28
docs/README-ko.md
vendored
@@ -263,23 +263,19 @@ docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/De
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
|
||||
기능을 제공해주셔서 감사합니다.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
|
||||
whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
|
||||
mind map functionality.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
|
||||
maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
|
||||
table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
|
||||
without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
|
||||
Used in [relation
|
||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
||||
[link
|
||||
maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - Canvas 노트에서 사용되는 무한
|
||||
화이트보드입니다.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - 마인드맵 기능을 제공합니다.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - 지리 지도를 렌더링 합니다.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - 컬렉션에서 사용되는 인터랙티브
|
||||
테이블입니다.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - 독보적으로 기능이 풍부한 트리 라이브러리입니다.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 시각적 연결 라이브러리입니다. [관계
|
||||
맵](https://docs.triliumnotes.org/user-guide/note-types/relation-map) 과 [링크
|
||||
맵](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)에
|
||||
사용됩니다
|
||||
|
||||
## 🤝 Support
|
||||
## 🤝 후원
|
||||
|
||||
Trilium is built and maintained with [hundreds of hours of
|
||||
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
|
||||
|
||||
46
docs/README-uk.md
vendored
46
docs/README-uk.md
vendored
@@ -95,8 +95,8 @@ Trilium Notes — це безкоштовний кросплатформний
|
||||
безпечнішого входу
|
||||
* [Синхронізація](https://docs.triliumnotes.org/user-guide/setup/synchronization)
|
||||
із власним сервером синхронізації
|
||||
* there are [3rd party services for hosting synchronisation
|
||||
server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||
* існують [сторонні сервіси для розміщення сервера
|
||||
синхронізації](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||
* [Спільне
|
||||
використання](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)
|
||||
(публікація) нотаток у загальнодоступному інтернеті
|
||||
@@ -105,10 +105,11 @@ Trilium Notes — це безкоштовний кросплатформний
|
||||
з деталізацією для кожної нотатки
|
||||
* Створення ескізних схем на основі [Excalidraw](https://excalidraw.com/) (тип
|
||||
нотатки "полотно")
|
||||
* [Relation
|
||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
||||
[note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
||||
for visualizing notes and their relations
|
||||
* [Карти
|
||||
зв'язків](https://docs.triliumnotes.org/user-guide/note-types/relation-map) та
|
||||
[карти
|
||||
нотаток/посилань](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
||||
для візуалізації нотаток та їх зв'язків
|
||||
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
|
||||
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
|
||||
географічними позначками та GPX-треками
|
||||
@@ -148,19 +149,18 @@ TriliumNext:
|
||||
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
|
||||
https://github.com/TriliumNext
|
||||
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
### ⬆️Переходите із Zadam/Trilium?
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to
|
||||
a TriliumNext/Trilium instance. Simply [install
|
||||
TriliumNext/Trilium](#-installation) as usual and it will use your existing
|
||||
database.
|
||||
Немає жодних спеціальних кроків для міграції з екземпляра zadam/Trilium до
|
||||
екземпляра TriliumNext/Trilium. Просто [встановіть
|
||||
TriliumNext/Trilium](#-installation) як завжди, і він використовуватиме вашу
|
||||
існуючу базу даних.
|
||||
|
||||
Versions up to and including
|
||||
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
|
||||
compatible with the latest zadam/trilium version of
|
||||
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
|
||||
versions of TriliumNext/Trilium have their sync versions incremented which
|
||||
prevents direct migration.
|
||||
Версії до [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4)
|
||||
включно сумісні з останньою версією zadam/trilium
|
||||
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Будь-які
|
||||
пізніші версії TriliumNext/Trilium мають збільшені версії синхронізації, що
|
||||
запобігає прямій міграції.
|
||||
|
||||
## Обговоріть це з нами
|
||||
|
||||
@@ -189,8 +189,8 @@ prevents direct migration.
|
||||
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
|
||||
дистрибутива.
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
|
||||
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
|
||||
@@ -281,10 +281,10 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
||||
|
||||
### Документація розробника
|
||||
|
||||
Please view the [documentation
|
||||
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
||||
for details. If you have more questions, feel free to reach out via the links
|
||||
described in the "Discuss with us" section above.
|
||||
Будь ласка, перегляньте
|
||||
[документацію](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
||||
для отримання детальної інформації. Якщо у вас виникнуть додаткові запитання,
|
||||
звертайтеся до нас за посиланнями, описаними в розділі «Обговоріть з нами» вище.
|
||||
|
||||
## 👏 Привітання
|
||||
|
||||
|
||||
Reference in New Issue
Block a user