@batalabs/virlow-mcp-core 3.18.0 → 3.68.0
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/LICENSE +8 -18
- package/README.md +1 -1
- package/dist/api.d.ts +32 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +38 -5
- package/dist/api.js.map +1 -1
- package/dist/attrs-extract.d.ts +48 -0
- package/dist/attrs-extract.d.ts.map +1 -0
- package/dist/attrs-extract.js +153 -0
- package/dist/attrs-extract.js.map +1 -0
- package/dist/attrs.d.ts +445 -0
- package/dist/attrs.d.ts.map +1 -0
- package/dist/attrs.js +604 -0
- package/dist/attrs.js.map +1 -0
- package/dist/cjs/api.d.ts +32 -0
- package/dist/cjs/api.js +38 -5
- package/dist/cjs/attrs-extract.d.ts +47 -0
- package/dist/cjs/attrs-extract.js +159 -0
- package/dist/cjs/attrs.d.ts +444 -0
- package/dist/cjs/attrs.js +635 -0
- package/dist/cjs/index.d.ts +5 -1
- package/dist/cjs/index.js +22 -1
- package/dist/cjs/memories.d.ts +79 -1
- package/dist/cjs/memories.js +249 -15
- package/dist/cjs/memory-graph.d.ts +105 -0
- package/dist/cjs/memory-graph.js +270 -0
- package/dist/cjs/memory-note.d.ts +57 -0
- package/dist/cjs/memory-note.js +174 -10
- package/dist/cjs/memory-secrets.d.ts +19 -0
- package/dist/cjs/memory-secrets.js +108 -0
- package/dist/cjs/notes.d.ts +208 -2
- package/dist/cjs/notes.js +721 -12
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +3 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/local-origin.d.ts +61 -0
- package/dist/http/local-origin.d.ts.map +1 -0
- package/dist/http/local-origin.js +92 -0
- package/dist/http/local-origin.js.map +1 -0
- package/dist/http/server.d.ts +39 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +131 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/memories.d.ts +79 -1
- package/dist/memories.d.ts.map +1 -1
- package/dist/memories.js +249 -15
- package/dist/memories.js.map +1 -1
- package/dist/memory-graph.d.ts +106 -0
- package/dist/memory-graph.d.ts.map +1 -0
- package/dist/memory-graph.js +258 -0
- package/dist/memory-graph.js.map +1 -0
- package/dist/memory-note.d.ts +57 -0
- package/dist/memory-note.d.ts.map +1 -1
- package/dist/memory-note.js +170 -10
- package/dist/memory-note.js.map +1 -1
- package/dist/memory-secrets.d.ts +20 -0
- package/dist/memory-secrets.d.ts.map +1 -0
- package/dist/memory-secrets.js +103 -0
- package/dist/memory-secrets.js.map +1 -0
- package/dist/notes.d.ts +208 -2
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +722 -13
- package/dist/notes.js.map +1 -1
- package/dist/tools.d.ts +8 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +465 -10
- package/dist/tools.js.map +1 -1
- package/package.json +21 -5
package/dist/cjs/api.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface AuthTokens {
|
|
|
6
6
|
accessToken: string;
|
|
7
7
|
refreshToken: string;
|
|
8
8
|
}
|
|
9
|
+
/** What `/api/auth/refresh` answers, minus the refresh token this client keeps. */
|
|
10
|
+
export interface RefreshBody {
|
|
11
|
+
user: {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
};
|
|
15
|
+
accessToken: string;
|
|
16
|
+
}
|
|
9
17
|
export type LoginResult = {
|
|
10
18
|
mfaRequired: true;
|
|
11
19
|
mfaToken: string;
|
|
@@ -38,6 +46,8 @@ export interface ApiNote {
|
|
|
38
46
|
hidden: boolean | null;
|
|
39
47
|
createdAt: string;
|
|
40
48
|
updatedAt: string;
|
|
49
|
+
/** Encrypted v2 blob of the note's structured attrs (props, links, mentions). */
|
|
50
|
+
encryptedAttrs: string | null;
|
|
41
51
|
}
|
|
42
52
|
export interface NoteWrite {
|
|
43
53
|
title: string;
|
|
@@ -52,6 +62,7 @@ export interface NoteWrite {
|
|
|
52
62
|
starred?: boolean;
|
|
53
63
|
archived?: boolean;
|
|
54
64
|
deleted?: boolean;
|
|
65
|
+
encryptedAttrs?: string | null;
|
|
55
66
|
}
|
|
56
67
|
export interface ApiFolder {
|
|
57
68
|
id: string;
|
|
@@ -63,14 +74,26 @@ export interface ApiFolder {
|
|
|
63
74
|
/** The user's "Expose to MCP" switch. False by default — nothing in this
|
|
64
75
|
* folder is shown to AI tools until they turn it on. */
|
|
65
76
|
mcpEnabled: boolean;
|
|
77
|
+
/** Encrypted v2 blob of the folder's property and view definitions. */
|
|
78
|
+
encryptedSchema: string | null;
|
|
66
79
|
}
|
|
67
80
|
export declare class VirlowApi {
|
|
68
81
|
private readonly baseUrl;
|
|
69
82
|
private tokens;
|
|
70
83
|
private refreshInFlight;
|
|
71
84
|
onTokensRotated?: (tokens: AuthTokens) => void;
|
|
85
|
+
/** Fired when a refresh is refused outright and the session is gone. */
|
|
86
|
+
onTokensCleared?: () => void;
|
|
72
87
|
constructor(baseUrl: string);
|
|
73
88
|
setTokens(tokens: AuthTokens | null): void;
|
|
89
|
+
getTokens(): AuthTokens | null;
|
|
90
|
+
/**
|
|
91
|
+
* Rotate the refresh token exactly once no matter how many callers arrive
|
|
92
|
+
* while a rotation is pending. The API revokes the whole token family when a
|
|
93
|
+
* replaced refresh token is presented again, so two concurrent refreshes are
|
|
94
|
+
* not a race, they are a forced sign-out.
|
|
95
|
+
*/
|
|
96
|
+
refreshSession(): Promise<RefreshBody>;
|
|
74
97
|
private request;
|
|
75
98
|
private refresh;
|
|
76
99
|
private doRefresh;
|
|
@@ -135,4 +158,13 @@ export declare class VirlowApi {
|
|
|
135
158
|
folders: ApiFolder[];
|
|
136
159
|
}>;
|
|
137
160
|
createFolder(name: string, parentId?: string | null, kind?: 'memories'): Promise<ApiFolder>;
|
|
161
|
+
/** Partial update of a folder. Only `encryptedSchema` is used from here so
|
|
162
|
+
* far, and it is forwarded as an already-encrypted blob: nothing in this
|
|
163
|
+
* client encrypts, and nothing on the server can read it. */
|
|
164
|
+
updateFolder(id: string, body: {
|
|
165
|
+
name?: string;
|
|
166
|
+
parentId?: string | null;
|
|
167
|
+
mcpEnabled?: boolean;
|
|
168
|
+
encryptedSchema?: string | null;
|
|
169
|
+
}): Promise<ApiFolder>;
|
|
138
170
|
}
|
package/dist/cjs/api.js
CHANGED
|
@@ -60,12 +60,29 @@ class VirlowApi {
|
|
|
60
60
|
tokens = null;
|
|
61
61
|
refreshInFlight = null;
|
|
62
62
|
onTokensRotated;
|
|
63
|
+
/** Fired when a refresh is refused outright and the session is gone. */
|
|
64
|
+
onTokensCleared;
|
|
63
65
|
constructor(baseUrl) {
|
|
64
66
|
this.baseUrl = baseUrl;
|
|
65
67
|
}
|
|
66
68
|
setTokens(tokens) {
|
|
67
69
|
this.tokens = tokens;
|
|
68
70
|
}
|
|
71
|
+
getTokens() {
|
|
72
|
+
return this.tokens;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rotate the refresh token exactly once no matter how many callers arrive
|
|
76
|
+
* while a rotation is pending. The API revokes the whole token family when a
|
|
77
|
+
* replaced refresh token is presented again, so two concurrent refreshes are
|
|
78
|
+
* not a race, they are a forced sign-out.
|
|
79
|
+
*/
|
|
80
|
+
refreshSession() {
|
|
81
|
+
this.refreshInFlight ??= this.doRefresh().finally(() => {
|
|
82
|
+
this.refreshInFlight = null;
|
|
83
|
+
});
|
|
84
|
+
return this.refreshInFlight;
|
|
85
|
+
}
|
|
69
86
|
async request(method, path, body, opts = {}) {
|
|
70
87
|
const { auth = true, retryOn401 = true } = opts;
|
|
71
88
|
const headers = { 'content-type': 'application/json' };
|
|
@@ -89,17 +106,27 @@ class VirlowApi {
|
|
|
89
106
|
return data;
|
|
90
107
|
}
|
|
91
108
|
refresh() {
|
|
92
|
-
|
|
93
|
-
this.refreshInFlight = null;
|
|
94
|
-
});
|
|
95
|
-
return this.refreshInFlight;
|
|
109
|
+
return this.refreshSession().then(() => undefined);
|
|
96
110
|
}
|
|
97
111
|
async doRefresh() {
|
|
98
112
|
if (!this.tokens)
|
|
99
113
|
throw new ApiError(401, 'Not authenticated');
|
|
100
|
-
|
|
114
|
+
let rotated;
|
|
115
|
+
try {
|
|
116
|
+
rotated = await this.request('POST', '/api/auth/refresh', { refreshToken: this.tokens.refreshToken }, { auth: false, retryOn401: false });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Only a definitive refusal ends the session. A network failure or a 5xx
|
|
120
|
+
// while the API restarts must leave the tokens in place for a retry.
|
|
121
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
122
|
+
this.tokens = null;
|
|
123
|
+
this.onTokensCleared?.();
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
101
127
|
this.tokens = { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken };
|
|
102
128
|
this.onTokensRotated?.(this.tokens);
|
|
129
|
+
return { user: rotated.user, accessToken: rotated.accessToken };
|
|
103
130
|
}
|
|
104
131
|
login(email, password) {
|
|
105
132
|
return this.request('POST', '/api/auth/login', { email, password }, { auth: false });
|
|
@@ -189,5 +216,11 @@ class VirlowApi {
|
|
|
189
216
|
body.kind = kind;
|
|
190
217
|
return this.request('POST', '/api/folders', body);
|
|
191
218
|
}
|
|
219
|
+
/** Partial update of a folder. Only `encryptedSchema` is used from here so
|
|
220
|
+
* far, and it is forwarded as an already-encrypted blob: nothing in this
|
|
221
|
+
* client encrypts, and nothing on the server can read it. */
|
|
222
|
+
updateFolder(id, body) {
|
|
223
|
+
return this.request('PUT', `/api/folders/${id}`, body);
|
|
224
|
+
}
|
|
192
225
|
}
|
|
193
226
|
exports.VirlowApi = VirlowApi;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-side twins of the web's src/lib/attrs/extract.ts. Kept in this shared
|
|
3
|
+
* package (not copied into mcp-core in a form the web could not later reuse)
|
|
4
|
+
* so the two can eventually converge on one implementation.
|
|
5
|
+
*
|
|
6
|
+
* The web module prefers DOMParser and falls back to this regex/tokenizer
|
|
7
|
+
* path only when DOMParser is unavailable. mcp-core runs in Node, which has
|
|
8
|
+
* no DOMParser, so it always takes that fallback path; the logic here is
|
|
9
|
+
* meant to produce the same answers as the web's fallback for the same
|
|
10
|
+
* input, not to reimplement its DOMParser branch.
|
|
11
|
+
*/
|
|
12
|
+
/** Ids of every `<span data-type="noteLink" data-note-id="...">` in `html`,
|
|
13
|
+
* unique, in document order. Attribute order within the tag does not matter. */
|
|
14
|
+
export declare function extractLinkIds(html: string): string[];
|
|
15
|
+
/** Minimal entity decoding for the no-DOMParser text extraction below,
|
|
16
|
+
* covering the common cases the editor actually emits. */
|
|
17
|
+
export declare function decodeEntities(text: string): string;
|
|
18
|
+
/** Flattens note HTML to plain text for mention matching: block-level
|
|
19
|
+
* closing tags and `<br>` become a space (so words either side of a block
|
|
20
|
+
* boundary do not run together), remaining tags are stripped, and entities
|
|
21
|
+
* are decoded. */
|
|
22
|
+
export declare function htmlToText(html: string): string;
|
|
23
|
+
export interface TitleEntry {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TitleMatcher {
|
|
28
|
+
find(text: string, excludeIds?: Set<string>): string[];
|
|
29
|
+
}
|
|
30
|
+
/** Split into word tokens (letters, digits, underscore); punctuation and
|
|
31
|
+
* spaces separate. Titles are tokenized with the same tokenizer as body text
|
|
32
|
+
* so punctuation like "Follow-up", "Node.js" or "(draft)" indexes the same
|
|
33
|
+
* way it appears in prose. */
|
|
34
|
+
export declare function tokenize(text: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Titles are indexed by their first lowercase token. At each token position
|
|
37
|
+
* in the text we look up only the titles that start with that token. Before
|
|
38
|
+
* doing the O(title length) positional check, a token-set pre-filter built
|
|
39
|
+
* once per `find` call rejects, in O(1) per word, any candidate whose words
|
|
40
|
+
* do not all occur somewhere in the text; a candidate that fails this
|
|
41
|
+
* pre-filter is rejected without walking its words. This keeps `find` linear
|
|
42
|
+
* in text tokens for the pre-filter, with positional work spent only on
|
|
43
|
+
* candidates that could plausibly match.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildTitleMatcher(entries: TitleEntry[], opts?: {
|
|
46
|
+
minLength?: number;
|
|
47
|
+
}): TitleMatcher;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Node-side twins of the web's src/lib/attrs/extract.ts. Kept in this shared
|
|
4
|
+
* package (not copied into mcp-core in a form the web could not later reuse)
|
|
5
|
+
* so the two can eventually converge on one implementation.
|
|
6
|
+
*
|
|
7
|
+
* The web module prefers DOMParser and falls back to this regex/tokenizer
|
|
8
|
+
* path only when DOMParser is unavailable. mcp-core runs in Node, which has
|
|
9
|
+
* no DOMParser, so it always takes that fallback path; the logic here is
|
|
10
|
+
* meant to produce the same answers as the web's fallback for the same
|
|
11
|
+
* input, not to reimplement its DOMParser branch.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.extractLinkIds = extractLinkIds;
|
|
15
|
+
exports.decodeEntities = decodeEntities;
|
|
16
|
+
exports.htmlToText = htmlToText;
|
|
17
|
+
exports.tokenize = tokenize;
|
|
18
|
+
exports.buildTitleMatcher = buildTitleMatcher;
|
|
19
|
+
/**
|
|
20
|
+
* Matches one opening `<span ...>` tag. A naive `[^>]*` up to the first `>`
|
|
21
|
+
* would stop inside a quoted attribute value that happens to contain a
|
|
22
|
+
* literal `>` (for example `data-note-title="A > B"`), truncating the tag
|
|
23
|
+
* and losing whatever attribute came after it, such as data-note-id. This
|
|
24
|
+
* alternates "not a quote or '>'", a double-quoted run, or a single-quoted
|
|
25
|
+
* run, so only the real closing '>' outside any quotes ends the match.
|
|
26
|
+
*/
|
|
27
|
+
const SPAN_TAG = /<span\b((?:[^"'>]|"[^"]*"|'[^']*')*)>/g;
|
|
28
|
+
const ATTR = /([a-zA-Z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
29
|
+
/** Attribute name/value pairs from inside one tag's attribute list (the
|
|
30
|
+
* captured group of SPAN_TAG), independent of the order they appear in. */
|
|
31
|
+
function parseAttrs(attrString) {
|
|
32
|
+
const attrs = {};
|
|
33
|
+
for (const m of attrString.matchAll(ATTR)) {
|
|
34
|
+
attrs[m[1]] = m[2] ?? m[3] ?? '';
|
|
35
|
+
}
|
|
36
|
+
return attrs;
|
|
37
|
+
}
|
|
38
|
+
/** Ids of every `<span data-type="noteLink" data-note-id="...">` in `html`,
|
|
39
|
+
* unique, in document order. Attribute order within the tag does not matter. */
|
|
40
|
+
function extractLinkIds(html) {
|
|
41
|
+
const ids = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const m of html.matchAll(SPAN_TAG)) {
|
|
44
|
+
const attrs = parseAttrs(m[1] ?? '');
|
|
45
|
+
if (attrs['data-type'] !== 'noteLink')
|
|
46
|
+
continue;
|
|
47
|
+
const id = attrs['data-note-id'] ?? '';
|
|
48
|
+
if (id && !seen.has(id)) {
|
|
49
|
+
seen.add(id);
|
|
50
|
+
ids.push(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
const BLOCK_END = /<\/(p|div|h[1-6]|li|blockquote|pre|td|th|tr|section|article)>|<br\s*\/?>/gi;
|
|
56
|
+
/** Minimal entity decoding for the no-DOMParser text extraction below,
|
|
57
|
+
* covering the common cases the editor actually emits. */
|
|
58
|
+
function decodeEntities(text) {
|
|
59
|
+
return text
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, "'")
|
|
64
|
+
.replace(/ /g, ' ')
|
|
65
|
+
.replace(/&/g, '&');
|
|
66
|
+
}
|
|
67
|
+
/** Flattens note HTML to plain text for mention matching: block-level
|
|
68
|
+
* closing tags and `<br>` become a space (so words either side of a block
|
|
69
|
+
* boundary do not run together), remaining tags are stripped, and entities
|
|
70
|
+
* are decoded. */
|
|
71
|
+
function htmlToText(html) {
|
|
72
|
+
const spaced = html.replace(BLOCK_END, ' ');
|
|
73
|
+
return decodeEntities(spaced.replace(/<[^>]+>/g, '')).trim();
|
|
74
|
+
}
|
|
75
|
+
const WORD = /[\p{L}\p{N}_]/u;
|
|
76
|
+
/** Split into word tokens (letters, digits, underscore); punctuation and
|
|
77
|
+
* spaces separate. Titles are tokenized with the same tokenizer as body text
|
|
78
|
+
* so punctuation like "Follow-up", "Node.js" or "(draft)" indexes the same
|
|
79
|
+
* way it appears in prose. */
|
|
80
|
+
function tokenize(text) {
|
|
81
|
+
const tokens = [];
|
|
82
|
+
let current = '';
|
|
83
|
+
for (const ch of text) {
|
|
84
|
+
if (WORD.test(ch))
|
|
85
|
+
current += ch;
|
|
86
|
+
else if (current) {
|
|
87
|
+
tokens.push(current);
|
|
88
|
+
current = '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (current)
|
|
92
|
+
tokens.push(current);
|
|
93
|
+
return tokens;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Titles are indexed by their first lowercase token. At each token position
|
|
97
|
+
* in the text we look up only the titles that start with that token. Before
|
|
98
|
+
* doing the O(title length) positional check, a token-set pre-filter built
|
|
99
|
+
* once per `find` call rejects, in O(1) per word, any candidate whose words
|
|
100
|
+
* do not all occur somewhere in the text; a candidate that fails this
|
|
101
|
+
* pre-filter is rejected without walking its words. This keeps `find` linear
|
|
102
|
+
* in text tokens for the pre-filter, with positional work spent only on
|
|
103
|
+
* candidates that could plausibly match.
|
|
104
|
+
*/
|
|
105
|
+
function buildTitleMatcher(entries, opts = {}) {
|
|
106
|
+
const minLength = opts.minLength ?? 3;
|
|
107
|
+
const byFirstWord = new Map();
|
|
108
|
+
for (const { id, title } of entries) {
|
|
109
|
+
const words = tokenize(title.trim().toLowerCase());
|
|
110
|
+
if (words.length === 0)
|
|
111
|
+
continue;
|
|
112
|
+
const joinedLength = words.reduce((sum, word) => sum + word.length, 0);
|
|
113
|
+
if (joinedLength < minLength)
|
|
114
|
+
continue;
|
|
115
|
+
const list = byFirstWord.get(words[0]) ?? [];
|
|
116
|
+
list.push({ id, words });
|
|
117
|
+
byFirstWord.set(words[0], list);
|
|
118
|
+
}
|
|
119
|
+
for (const list of byFirstWord.values())
|
|
120
|
+
list.sort((a, b) => b.words.length - a.words.length);
|
|
121
|
+
return {
|
|
122
|
+
find(text, excludeIds) {
|
|
123
|
+
const tokens = tokenize(text.toLowerCase());
|
|
124
|
+
const tokenSet = new Set(tokens);
|
|
125
|
+
const found = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
128
|
+
const candidates = byFirstWord.get(tokens[i]);
|
|
129
|
+
if (!candidates)
|
|
130
|
+
continue;
|
|
131
|
+
for (const { id, words } of candidates) {
|
|
132
|
+
if (seen.has(id) || excludeIds?.has(id))
|
|
133
|
+
continue;
|
|
134
|
+
let allWordsPresent = true;
|
|
135
|
+
for (let w = 1; w < words.length; w++) {
|
|
136
|
+
if (!tokenSet.has(words[w])) {
|
|
137
|
+
allWordsPresent = false;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!allWordsPresent)
|
|
142
|
+
continue;
|
|
143
|
+
let ok = true;
|
|
144
|
+
for (let k = 1; k < words.length; k++) {
|
|
145
|
+
if (tokens[i + k] !== words[k]) {
|
|
146
|
+
ok = false;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (ok) {
|
|
151
|
+
seen.add(id);
|
|
152
|
+
found.push(id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|