opencode-manager 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROJECT-SUMMARY.md +104 -24
- package/README.md +403 -14
- package/bun.lock +22 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +7 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +336 -0
- package/src/cli/commands/projects.ts +238 -0
- package/src/cli/commands/sessions.ts +520 -0
- package/src/cli/commands/tokens.ts +180 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +209 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +274 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data-provider.ts +685 -0
- package/src/lib/opencode-data-sqlite.ts +1973 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
- package/tsconfig.json +1 -1
package/src/lib/opencode-data.ts
CHANGED
|
@@ -27,6 +27,39 @@ export type AggregateTokenSummary = {
|
|
|
27
27
|
unknownSessions?: number
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// ========================
|
|
31
|
+
// Chat History Types
|
|
32
|
+
// ========================
|
|
33
|
+
|
|
34
|
+
export type PartType = "text" | "subtask" | "tool" | "unknown"
|
|
35
|
+
|
|
36
|
+
export interface ChatPart {
|
|
37
|
+
partId: string
|
|
38
|
+
messageId: string
|
|
39
|
+
type: PartType
|
|
40
|
+
text: string // extracted human-readable content
|
|
41
|
+
toolName?: string // for tool parts
|
|
42
|
+
toolStatus?: string // "running" | "completed" | "error"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ChatRole = "user" | "assistant" | "unknown"
|
|
46
|
+
|
|
47
|
+
export interface ChatMessage {
|
|
48
|
+
sessionId: string
|
|
49
|
+
messageId: string
|
|
50
|
+
role: ChatRole
|
|
51
|
+
createdAt: Date | null
|
|
52
|
+
parentId?: string // for threading (assistant → user)
|
|
53
|
+
tokens?: TokenBreakdown // only on assistant messages
|
|
54
|
+
|
|
55
|
+
// Parts are loaded lazily for performance.
|
|
56
|
+
parts: ChatPart[] | null
|
|
57
|
+
|
|
58
|
+
// Computed for display
|
|
59
|
+
previewText: string // placeholder until parts load; then first N chars of combined parts
|
|
60
|
+
totalChars: number | null // null until parts load
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
export interface ProjectRecord {
|
|
31
64
|
index: number
|
|
32
65
|
bucket: ProjectBucket
|
|
@@ -566,7 +599,7 @@ function parseMessageTokens(tokens: MessageTokens | null | undefined): TokenBrea
|
|
|
566
599
|
return breakdown
|
|
567
600
|
}
|
|
568
601
|
|
|
569
|
-
async function loadSessionMessagePaths(sessionId: string, root: string): Promise<string[] | null> {
|
|
602
|
+
export async function loadSessionMessagePaths(sessionId: string, root: string): Promise<string[] | null> {
|
|
570
603
|
// Primary path: storage/message/<sessionId>
|
|
571
604
|
const primaryPath = join(root, 'storage', 'message', sessionId)
|
|
572
605
|
if (await pathExists(primaryPath)) {
|
|
@@ -732,3 +765,349 @@ async function computeAggregateTokenSummary(
|
|
|
732
765
|
unknownSessions,
|
|
733
766
|
}
|
|
734
767
|
}
|
|
768
|
+
|
|
769
|
+
// ========================
|
|
770
|
+
// Chat History Loading
|
|
771
|
+
// ========================
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Load paths for part files associated with a message.
|
|
775
|
+
* Tries primary storage first, falls back to legacy layout.
|
|
776
|
+
*
|
|
777
|
+
* @returns Array of full paths to part JSON files, or null if neither directory exists.
|
|
778
|
+
*/
|
|
779
|
+
export async function loadMessagePartPaths(messageId: string, root: string): Promise<string[] | null> {
|
|
780
|
+
// Primary path: storage/part/<messageId>
|
|
781
|
+
const primaryPath = join(root, 'storage', 'part', messageId)
|
|
782
|
+
if (await pathExists(primaryPath)) {
|
|
783
|
+
try {
|
|
784
|
+
const entries = await fs.readdir(primaryPath)
|
|
785
|
+
return entries
|
|
786
|
+
.filter((e) => e.endsWith('.json'))
|
|
787
|
+
.map((e) => join(primaryPath, e))
|
|
788
|
+
} catch {
|
|
789
|
+
return null
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Legacy fallback: storage/session/part/<messageId>
|
|
794
|
+
const legacyPath = join(root, 'storage', 'session', 'part', messageId)
|
|
795
|
+
if (await pathExists(legacyPath)) {
|
|
796
|
+
try {
|
|
797
|
+
const entries = await fs.readdir(legacyPath)
|
|
798
|
+
return entries
|
|
799
|
+
.filter((e) => e.endsWith('.json'))
|
|
800
|
+
.map((e) => join(legacyPath, e))
|
|
801
|
+
} catch {
|
|
802
|
+
return null
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return null
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Safely convert a value to display text with optional truncation.
|
|
811
|
+
*/
|
|
812
|
+
function toDisplayText(value: unknown, maxChars = 10_000): string {
|
|
813
|
+
let full = ""
|
|
814
|
+
if (value == null) {
|
|
815
|
+
full = ""
|
|
816
|
+
} else if (typeof value === "string") {
|
|
817
|
+
full = value
|
|
818
|
+
} else {
|
|
819
|
+
try {
|
|
820
|
+
full = JSON.stringify(value, null, 2)
|
|
821
|
+
} catch {
|
|
822
|
+
full = String(value)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (full.length <= maxChars) {
|
|
827
|
+
return full
|
|
828
|
+
}
|
|
829
|
+
return `${full.slice(0, maxChars)}\n[... truncated, ${full.length} chars total]`
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Extract human-readable content from a part object.
|
|
834
|
+
*/
|
|
835
|
+
function extractPartContent(part: unknown): { text: string; toolName?: string; toolStatus?: string } {
|
|
836
|
+
const p = part as Record<string, unknown>
|
|
837
|
+
const type = typeof p.type === "string" ? p.type : "unknown"
|
|
838
|
+
|
|
839
|
+
switch (type) {
|
|
840
|
+
case "text":
|
|
841
|
+
return { text: toDisplayText(p.text) }
|
|
842
|
+
|
|
843
|
+
case "subtask":
|
|
844
|
+
return { text: toDisplayText(p.prompt ?? p.description ?? "") }
|
|
845
|
+
|
|
846
|
+
case "tool": {
|
|
847
|
+
const state = (p.state ?? {}) as Record<string, unknown>
|
|
848
|
+
const toolName = typeof p.tool === "string" ? p.tool : "unknown"
|
|
849
|
+
const status = typeof state.status === "string" ? state.status : "unknown"
|
|
850
|
+
|
|
851
|
+
// Prefer output when present; otherwise show a prompt-like input summary.
|
|
852
|
+
if ("output" in state) {
|
|
853
|
+
return { text: toDisplayText(state.output), toolName, toolStatus: status }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const input = (state.input ?? {}) as Record<string, unknown>
|
|
857
|
+
const prompt = input.prompt ?? `[tool:${toolName}]`
|
|
858
|
+
return { text: toDisplayText(prompt), toolName, toolStatus: status }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
default:
|
|
862
|
+
// Unknown part type: attempt a safe JSON preview, then fall back to a label.
|
|
863
|
+
return { text: toDisplayText(part) || `[${type} part]` }
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
interface RawMessagePayload {
|
|
868
|
+
id?: string
|
|
869
|
+
sessionID?: string
|
|
870
|
+
role?: string
|
|
871
|
+
time?: { created?: number }
|
|
872
|
+
parentID?: string
|
|
873
|
+
tokens?: MessageTokens | null
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Load chat message index for a session (metadata only, no parts).
|
|
878
|
+
* Returns an array of ChatMessage stubs with parts set to null.
|
|
879
|
+
*/
|
|
880
|
+
export async function loadSessionChatIndex(
|
|
881
|
+
sessionId: string,
|
|
882
|
+
root: string = DEFAULT_ROOT
|
|
883
|
+
): Promise<ChatMessage[]> {
|
|
884
|
+
const normalizedRoot = resolve(root)
|
|
885
|
+
const messagePaths = await loadSessionMessagePaths(sessionId, normalizedRoot)
|
|
886
|
+
|
|
887
|
+
if (messagePaths === null || messagePaths.length === 0) {
|
|
888
|
+
return []
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const messages: ChatMessage[] = []
|
|
892
|
+
|
|
893
|
+
for (const msgPath of messagePaths) {
|
|
894
|
+
const payload = await readJsonFile<RawMessagePayload>(msgPath)
|
|
895
|
+
if (!payload || !payload.id) {
|
|
896
|
+
// Skip malformed entries
|
|
897
|
+
continue
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const role: ChatRole =
|
|
901
|
+
payload.role === "user" ? "user" :
|
|
902
|
+
payload.role === "assistant" ? "assistant" :
|
|
903
|
+
"unknown"
|
|
904
|
+
|
|
905
|
+
const createdAt = msToDate(payload.time?.created)
|
|
906
|
+
|
|
907
|
+
// Parse tokens for assistant messages
|
|
908
|
+
let tokens: TokenBreakdown | undefined
|
|
909
|
+
if (role === "assistant" && payload.tokens) {
|
|
910
|
+
const parsed = parseMessageTokens(payload.tokens)
|
|
911
|
+
if (parsed) {
|
|
912
|
+
tokens = parsed
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
messages.push({
|
|
917
|
+
sessionId,
|
|
918
|
+
messageId: payload.id,
|
|
919
|
+
role,
|
|
920
|
+
createdAt,
|
|
921
|
+
parentId: payload.parentID,
|
|
922
|
+
tokens,
|
|
923
|
+
parts: null,
|
|
924
|
+
previewText: "[loading...]",
|
|
925
|
+
totalChars: null,
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Sort by createdAt, with stable fallback on messageId for ties/missing timestamps
|
|
930
|
+
messages.sort((a, b) => {
|
|
931
|
+
const aTime = a.createdAt?.getTime() ?? 0
|
|
932
|
+
const bTime = b.createdAt?.getTime() ?? 0
|
|
933
|
+
if (aTime !== bTime) {
|
|
934
|
+
return aTime - bTime // ascending (oldest first)
|
|
935
|
+
}
|
|
936
|
+
return a.messageId.localeCompare(b.messageId)
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
return messages
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Load all parts for a message and extract readable content.
|
|
944
|
+
*/
|
|
945
|
+
export async function loadMessageParts(
|
|
946
|
+
messageId: string,
|
|
947
|
+
root: string = DEFAULT_ROOT
|
|
948
|
+
): Promise<ChatPart[]> {
|
|
949
|
+
const normalizedRoot = resolve(root)
|
|
950
|
+
const partPaths = await loadMessagePartPaths(messageId, normalizedRoot)
|
|
951
|
+
|
|
952
|
+
if (partPaths === null || partPaths.length === 0) {
|
|
953
|
+
return []
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const parts: ChatPart[] = []
|
|
957
|
+
|
|
958
|
+
// Sort part paths by filename for deterministic order
|
|
959
|
+
const sortedPaths = [...partPaths].sort((a, b) => {
|
|
960
|
+
const aName = a.split('/').pop() ?? ''
|
|
961
|
+
const bName = b.split('/').pop() ?? ''
|
|
962
|
+
return aName.localeCompare(bName)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
for (const partPath of sortedPaths) {
|
|
966
|
+
try {
|
|
967
|
+
const raw = await readJsonFile<Record<string, unknown>>(partPath)
|
|
968
|
+
if (!raw || !raw.id) {
|
|
969
|
+
// Skip malformed part files
|
|
970
|
+
continue
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const partId = typeof raw.id === "string" ? raw.id : String(raw.id)
|
|
974
|
+
const typeRaw = typeof raw.type === "string" ? raw.type : "unknown"
|
|
975
|
+
const type: PartType =
|
|
976
|
+
typeRaw === "text" ? "text" :
|
|
977
|
+
typeRaw === "subtask" ? "subtask" :
|
|
978
|
+
typeRaw === "tool" ? "tool" :
|
|
979
|
+
"unknown"
|
|
980
|
+
|
|
981
|
+
const extracted = extractPartContent(raw)
|
|
982
|
+
|
|
983
|
+
parts.push({
|
|
984
|
+
partId,
|
|
985
|
+
messageId,
|
|
986
|
+
type,
|
|
987
|
+
text: extracted.text,
|
|
988
|
+
toolName: extracted.toolName,
|
|
989
|
+
toolStatus: extracted.toolStatus,
|
|
990
|
+
})
|
|
991
|
+
} catch {
|
|
992
|
+
// Skip files that fail to parse
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return parts
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const PREVIEW_CHARS = 200
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Hydrate a ChatMessage with its parts, computing previewText and totalChars.
|
|
1004
|
+
*/
|
|
1005
|
+
export async function hydrateChatMessageParts(
|
|
1006
|
+
message: ChatMessage,
|
|
1007
|
+
root: string = DEFAULT_ROOT
|
|
1008
|
+
): Promise<ChatMessage> {
|
|
1009
|
+
const parts = await loadMessageParts(message.messageId, root)
|
|
1010
|
+
|
|
1011
|
+
// Combine all part texts for total chars and preview
|
|
1012
|
+
const combinedText = parts.map(p => p.text).join('\n\n')
|
|
1013
|
+
const totalChars = combinedText.length
|
|
1014
|
+
|
|
1015
|
+
let previewText: string
|
|
1016
|
+
if (combinedText.length === 0) {
|
|
1017
|
+
previewText = "[no content]"
|
|
1018
|
+
} else if (combinedText.length <= PREVIEW_CHARS) {
|
|
1019
|
+
previewText = combinedText.replace(/\n/g, ' ').trim()
|
|
1020
|
+
} else {
|
|
1021
|
+
previewText = combinedText.slice(0, PREVIEW_CHARS).replace(/\n/g, ' ').trim() + "..."
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
...message,
|
|
1026
|
+
parts,
|
|
1027
|
+
previewText,
|
|
1028
|
+
totalChars,
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ========================
|
|
1033
|
+
// Cross-Session Chat Search
|
|
1034
|
+
// ========================
|
|
1035
|
+
|
|
1036
|
+
export interface ChatSearchResult {
|
|
1037
|
+
sessionId: string
|
|
1038
|
+
sessionTitle: string
|
|
1039
|
+
projectId: string
|
|
1040
|
+
messageId: string
|
|
1041
|
+
role: ChatRole
|
|
1042
|
+
matchedText: string // snippet around the match
|
|
1043
|
+
fullText: string // full part text for display
|
|
1044
|
+
partType: PartType
|
|
1045
|
+
createdAt: Date | null
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Search across all chat content in specified sessions.
|
|
1050
|
+
* Returns matching messages with context snippets.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function searchSessionsChat(
|
|
1053
|
+
sessions: SessionRecord[],
|
|
1054
|
+
query: string,
|
|
1055
|
+
root: string = DEFAULT_ROOT,
|
|
1056
|
+
options: { maxResults?: number } = {}
|
|
1057
|
+
): Promise<ChatSearchResult[]> {
|
|
1058
|
+
const normalizedRoot = resolve(root)
|
|
1059
|
+
const queryLower = query.toLowerCase().trim()
|
|
1060
|
+
const maxResults = options.maxResults ?? 100
|
|
1061
|
+
const results: ChatSearchResult[] = []
|
|
1062
|
+
|
|
1063
|
+
if (!queryLower) {
|
|
1064
|
+
return results
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
for (const session of sessions) {
|
|
1068
|
+
if (results.length >= maxResults) break
|
|
1069
|
+
|
|
1070
|
+
// Load messages for this session
|
|
1071
|
+
const messages = await loadSessionChatIndex(session.sessionId, normalizedRoot)
|
|
1072
|
+
|
|
1073
|
+
for (const message of messages) {
|
|
1074
|
+
if (results.length >= maxResults) break
|
|
1075
|
+
|
|
1076
|
+
// Load parts to search content
|
|
1077
|
+
const parts = await loadMessageParts(message.messageId, normalizedRoot)
|
|
1078
|
+
|
|
1079
|
+
for (const part of parts) {
|
|
1080
|
+
if (results.length >= maxResults) break
|
|
1081
|
+
|
|
1082
|
+
const textLower = part.text.toLowerCase()
|
|
1083
|
+
const matchIndex = textLower.indexOf(queryLower)
|
|
1084
|
+
|
|
1085
|
+
if (matchIndex !== -1) {
|
|
1086
|
+
// Create a snippet around the match
|
|
1087
|
+
const snippetStart = Math.max(0, matchIndex - 50)
|
|
1088
|
+
const snippetEnd = Math.min(part.text.length, matchIndex + query.length + 50)
|
|
1089
|
+
let snippet = part.text.slice(snippetStart, snippetEnd)
|
|
1090
|
+
if (snippetStart > 0) snippet = "..." + snippet
|
|
1091
|
+
if (snippetEnd < part.text.length) snippet = snippet + "..."
|
|
1092
|
+
|
|
1093
|
+
results.push({
|
|
1094
|
+
sessionId: session.sessionId,
|
|
1095
|
+
sessionTitle: session.title || session.sessionId,
|
|
1096
|
+
projectId: session.projectId,
|
|
1097
|
+
messageId: message.messageId,
|
|
1098
|
+
role: message.role,
|
|
1099
|
+
matchedText: snippet.replace(/\n/g, ' '),
|
|
1100
|
+
fullText: part.text,
|
|
1101
|
+
partType: part.type,
|
|
1102
|
+
createdAt: message.createdAt,
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
// Only one result per message to avoid duplicates
|
|
1106
|
+
break
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return results
|
|
1113
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy search utilities using fast-fuzzy.
|
|
3
|
+
* Extracted from TUI for reuse in CLI.
|
|
4
|
+
*/
|
|
5
|
+
import { Searcher, MatchData, FullOptions } from "fast-fuzzy"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A search candidate with an item and searchable text.
|
|
9
|
+
*/
|
|
10
|
+
export type SearchCandidate<T> = {
|
|
11
|
+
item: T
|
|
12
|
+
searchText: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A search result with the matched item and score.
|
|
17
|
+
*/
|
|
18
|
+
export type SearchResult<T> = {
|
|
19
|
+
item: T
|
|
20
|
+
score: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for fuzzy search.
|
|
25
|
+
*/
|
|
26
|
+
export type FuzzySearchOptions = {
|
|
27
|
+
/** Maximum number of results to return (default: 200) */
|
|
28
|
+
limit?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Type for the searcher options with keySelector
|
|
32
|
+
type SearcherOptions<T> = FullOptions<SearchCandidate<T>> & {
|
|
33
|
+
keySelector: (c: SearchCandidate<T>) => string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a fuzzy searcher for items with searchable text.
|
|
38
|
+
*
|
|
39
|
+
* @param candidates - Array of search candidates with items and search text
|
|
40
|
+
* @returns A Searcher instance configured for the candidates
|
|
41
|
+
*/
|
|
42
|
+
export function createSearcher<T>(
|
|
43
|
+
candidates: SearchCandidate<T>[]
|
|
44
|
+
): Searcher<SearchCandidate<T>, SearcherOptions<T>> {
|
|
45
|
+
return new Searcher(candidates, {
|
|
46
|
+
keySelector: (c: SearchCandidate<T>) => c.searchText,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Performs a fuzzy search on the given candidates.
|
|
52
|
+
*
|
|
53
|
+
* @param candidates - Array of search candidates
|
|
54
|
+
* @param query - The search query string
|
|
55
|
+
* @param options - Optional search options
|
|
56
|
+
* @returns Array of search results sorted by score (descending)
|
|
57
|
+
*/
|
|
58
|
+
export function fuzzySearch<T>(
|
|
59
|
+
candidates: SearchCandidate<T>[],
|
|
60
|
+
query: string,
|
|
61
|
+
options?: FuzzySearchOptions
|
|
62
|
+
): SearchResult<T>[] {
|
|
63
|
+
const q = query.trim()
|
|
64
|
+
if (!q) {
|
|
65
|
+
// No query - return all items with score 1
|
|
66
|
+
return candidates.map((c) => ({ item: c.item, score: 1 }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const searcher = createSearcher(candidates)
|
|
70
|
+
const results = searcher.search(q, { returnMatchData: true }) as MatchData<SearchCandidate<T>>[]
|
|
71
|
+
|
|
72
|
+
const mapped: SearchResult<T>[] = results.map((match) => ({
|
|
73
|
+
item: match.item.item,
|
|
74
|
+
score: match.score,
|
|
75
|
+
}))
|
|
76
|
+
|
|
77
|
+
// Sort by score descending
|
|
78
|
+
mapped.sort((a, b) => b.score - a.score)
|
|
79
|
+
|
|
80
|
+
// Apply limit
|
|
81
|
+
const limit = options?.limit ?? 200
|
|
82
|
+
if (mapped.length > limit) {
|
|
83
|
+
return mapped.slice(0, limit)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return mapped
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Performs fuzzy search and returns only the matched items (not scores).
|
|
91
|
+
*
|
|
92
|
+
* @param candidates - Array of search candidates
|
|
93
|
+
* @param query - The search query string
|
|
94
|
+
* @param options - Optional search options
|
|
95
|
+
* @returns Array of matched items sorted by score (descending)
|
|
96
|
+
*/
|
|
97
|
+
export function fuzzySearchItems<T>(
|
|
98
|
+
candidates: SearchCandidate<T>[],
|
|
99
|
+
query: string,
|
|
100
|
+
options?: FuzzySearchOptions
|
|
101
|
+
): T[] {
|
|
102
|
+
return fuzzySearch(candidates, query, options).map((r) => r.item)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds a search text string from multiple fields.
|
|
107
|
+
* Joins all fields with spaces and normalizes whitespace.
|
|
108
|
+
*
|
|
109
|
+
* @param fields - Array of string fields to combine
|
|
110
|
+
* @returns A normalized search text string
|
|
111
|
+
*/
|
|
112
|
+
export function buildSearchText(...fields: (string | null | undefined)[]): string {
|
|
113
|
+
return fields
|
|
114
|
+
.filter((f): f is string => f != null && f !== "")
|
|
115
|
+
.join(" ")
|
|
116
|
+
.replace(/\s+/g, " ")
|
|
117
|
+
.trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Options for tokenized search.
|
|
122
|
+
*/
|
|
123
|
+
export type TokenizedSearchOptions = {
|
|
124
|
+
/** Maximum number of results to return (default: 200) */
|
|
125
|
+
limit?: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Performs tokenized substring search on items.
|
|
130
|
+
* Matches TUI project search semantics:
|
|
131
|
+
* - Query is split on whitespace into tokens
|
|
132
|
+
* - Each token must be found in at least one of the searchable fields
|
|
133
|
+
* - Matching is case-insensitive substring matching
|
|
134
|
+
*
|
|
135
|
+
* @param items - Array of items to search
|
|
136
|
+
* @param query - The search query string
|
|
137
|
+
* @param getFields - Function to extract searchable fields from an item
|
|
138
|
+
* @param options - Optional search options
|
|
139
|
+
* @returns Array of items that match all tokens
|
|
140
|
+
*/
|
|
141
|
+
export function tokenizedSearch<T>(
|
|
142
|
+
items: T[],
|
|
143
|
+
query: string,
|
|
144
|
+
getFields: (item: T) => (string | null | undefined)[],
|
|
145
|
+
options?: TokenizedSearchOptions
|
|
146
|
+
): T[] {
|
|
147
|
+
const q = query?.trim().toLowerCase() ?? ""
|
|
148
|
+
if (!q) {
|
|
149
|
+
const limit = options?.limit ?? 200
|
|
150
|
+
return items.slice(0, limit)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tokens = q.split(/\s+/).filter(Boolean)
|
|
154
|
+
if (tokens.length === 0) {
|
|
155
|
+
const limit = options?.limit ?? 200
|
|
156
|
+
return items.slice(0, limit)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const matched = items.filter((item) => {
|
|
160
|
+
const fields = getFields(item).map((f) => (f || "").toLowerCase())
|
|
161
|
+
return tokens.every((tok) => fields.some((field) => field.includes(tok)))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const limit = options?.limit ?? 200
|
|
165
|
+
if (matched.length > limit) {
|
|
166
|
+
return matched.slice(0, limit)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return matched
|
|
170
|
+
}
|