@mongoosejs/studio 0.3.11 → 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/README.md +9 -0
- package/backend/actions/ChatThread/createChatMessage.js +15 -3
- package/backend/actions/ChatThread/createChatThread.js +8 -1
- package/backend/actions/ChatThread/index.js +1 -0
- package/backend/actions/ChatThread/streamChatMessage.js +49 -14
- package/backend/actions/ChatThread/toggleAgentMode.js +37 -0
- package/backend/actions/Model/getDocuments.js +7 -4
- package/backend/actions/Model/getDocumentsStream.js +7 -4
- package/backend/actions/Model/streamChatMessage.js +15 -4
- package/backend/authorize.js +1 -0
- package/backend/chatAgent/agentSystemPrompt.js +45 -0
- package/backend/chatAgent/agentToolMetadata.js +20 -0
- package/backend/chatAgent/getAgentTools.js +140 -0
- package/backend/db/chatMessageSchema.js +5 -0
- package/backend/db/chatThreadSchema.js +4 -0
- package/backend/helpers/isBindIPConnection.js +8 -0
- package/backend/helpers/isLocalhostConnection.js +8 -0
- package/backend/helpers/normalizeBindIPOption.js +16 -0
- package/backend/helpers/parseProjectionParam.js +180 -0
- package/backend/integrations/callLLM.js +4 -2
- package/backend/integrations/streamLLM.js +12 -4
- package/eslint.config.js +2 -1
- package/express.js +16 -0
- package/frontend/public/app.js +913 -110
- package/frontend/public/tw.css +34 -8
- package/frontend/src/api.js +6 -0
- package/frontend/src/chat/chat-message/chat-message.html +12 -1
- package/frontend/src/chat/chat-message/chat-message.js +8 -0
- package/frontend/src/chat/chat-message-script/chat-message-script.html +1 -1
- package/frontend/src/chat/chat.html +91 -44
- package/frontend/src/chat/chat.js +119 -19
- package/frontend/src/create-document/create-document.js +3 -0
- package/frontend/src/detail-objectid/detail-objectid.html +1 -0
- package/frontend/src/detail-objectid/detail-objectid.js +67 -0
- package/frontend/src/document-details/document-property/document-property.html +8 -3
- package/frontend/src/document-details/document-property/document-property.js +7 -0
- package/frontend/src/document-details/objectid-view-mode-picker/objectid-view-mode-picker.html +12 -0
- package/frontend/src/document-details/objectid-view-mode-picker/objectid-view-mode-picker.js +19 -0
- package/frontend/src/models/document-search/document-search.js +6 -0
- package/frontend/src/models/models.html +6 -8
- package/frontend/src/models/models.js +257 -85
- package/frontend/src/models/projection-search/projection-search.html +27 -0
- package/frontend/src/models/projection-search/projection-search.js +226 -0
- package/index.d.ts +1 -1
- package/package.json +3 -2
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<span class="ml-2 text-sm text-content-tertiary">({{(path.instance || 'unknown').toLowerCase()}})</span>
|
|
21
21
|
<div v-if="isGeoJsonGeometry" class="ml-3 inline-flex items-center gap-2">
|
|
22
22
|
<div class="inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
|
|
23
|
-
|
|
23
|
+
<button
|
|
24
24
|
type="button"
|
|
25
25
|
class="rounded-full px-2.5 py-0.5 transition"
|
|
26
26
|
:class="detailViewMode === 'text' ? 'bg-blue-600 text-white shadow' : 'text-content-secondary hover:text-content'"
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
:path="path"
|
|
77
77
|
@update:viewMode="dateViewMode = $event"
|
|
78
78
|
></date-view-mode-picker>
|
|
79
|
+
<objectid-view-mode-picker
|
|
80
|
+
v-if="isObjectIdPath"
|
|
81
|
+
:viewMode="objectIdViewMode"
|
|
82
|
+
@update:viewMode="objectIdViewMode = $event"
|
|
83
|
+
></objectid-view-mode-picker>
|
|
79
84
|
<button
|
|
80
85
|
type="button"
|
|
81
86
|
class="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-edge-strong bg-surface"
|
|
@@ -202,7 +207,7 @@
|
|
|
202
207
|
<component
|
|
203
208
|
:is="getComponentForPath(path)"
|
|
204
209
|
:value="rawValue"
|
|
205
|
-
:viewMode="isDatePath ? dateViewMode : detailViewMode"
|
|
210
|
+
:viewMode="isDatePath ? dateViewMode : (isObjectIdPath ? objectIdViewMode : detailViewMode)"
|
|
206
211
|
@updated="renderedValue = $event"></component>
|
|
207
212
|
<button
|
|
208
213
|
@click="toggleValueExpansion"
|
|
@@ -219,7 +224,7 @@
|
|
|
219
224
|
<component
|
|
220
225
|
:is="getComponentForPath(path)"
|
|
221
226
|
:value="rawValue"
|
|
222
|
-
:viewMode="isDatePath ? dateViewMode : detailViewMode"
|
|
227
|
+
:viewMode="isDatePath ? dateViewMode : (isObjectIdPath ? objectIdViewMode : detailViewMode)"
|
|
223
228
|
@updated="renderedValue = $event"></component>
|
|
224
229
|
</div>
|
|
225
230
|
</div>
|
|
@@ -18,6 +18,7 @@ module.exports = app => app.component('document-property', {
|
|
|
18
18
|
return {
|
|
19
19
|
dateType: 'picker', // picker, iso
|
|
20
20
|
dateViewMode: 'utc_iso',
|
|
21
|
+
objectIdViewMode: 'hex',
|
|
21
22
|
renderedValue: UNSET,
|
|
22
23
|
isCollapsed: false, // Start uncollapsed by default
|
|
23
24
|
isValueExpanded: false, // Track if the value is expanded
|
|
@@ -38,6 +39,9 @@ module.exports = app => app.component('document-property', {
|
|
|
38
39
|
isDatePath() {
|
|
39
40
|
return this.path?.instance === 'Date';
|
|
40
41
|
},
|
|
42
|
+
isObjectIdPath() {
|
|
43
|
+
return this.path?.instance === 'ObjectID' || this.path?.instance === 'ObjectId';
|
|
44
|
+
},
|
|
41
45
|
rawValue() {
|
|
42
46
|
return this.getValueForPath(this.path.path);
|
|
43
47
|
},
|
|
@@ -177,6 +181,9 @@ module.exports = app => app.component('document-property', {
|
|
|
177
181
|
if (schemaPath.instance === 'Date') {
|
|
178
182
|
return 'detail-date';
|
|
179
183
|
}
|
|
184
|
+
if (schemaPath.instance === 'ObjectID' || schemaPath.instance === 'ObjectId') {
|
|
185
|
+
return 'detail-objectid';
|
|
186
|
+
}
|
|
180
187
|
return 'detail-default';
|
|
181
188
|
},
|
|
182
189
|
getEditComponentForPath(path) {
|
package/frontend/src/document-details/objectid-view-mode-picker/objectid-view-mode-picker.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div class="flex items-center gap-2" @click.stop>
|
|
2
|
+
<select
|
|
3
|
+
:value="format"
|
|
4
|
+
@input="onFormatChange($event.target.value)"
|
|
5
|
+
class="text-xs border border-edge rounded-md bg-surface px-2 py-1"
|
|
6
|
+
>
|
|
7
|
+
<option value="hex">Hex string</option>
|
|
8
|
+
<option value="object_call">ObjectId('...')</option>
|
|
9
|
+
<option value="unix_seconds">Unix timestamp</option>
|
|
10
|
+
<option value="date">Date</option>
|
|
11
|
+
</select>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const template = require('./objectid-view-mode-picker.html');
|
|
4
|
+
|
|
5
|
+
module.exports = app => app.component('objectid-view-mode-picker', {
|
|
6
|
+
template,
|
|
7
|
+
props: ['viewMode'],
|
|
8
|
+
emits: ['update:viewMode'],
|
|
9
|
+
computed: {
|
|
10
|
+
format() {
|
|
11
|
+
return this.viewMode || 'hex';
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
methods: {
|
|
15
|
+
onFormatChange(newFormat) {
|
|
16
|
+
this.$emit('update:viewMode', newFormat);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -45,6 +45,12 @@ module.exports = app => app.component('document-search', {
|
|
|
45
45
|
this.$refs.searchInput.focus();
|
|
46
46
|
},
|
|
47
47
|
methods: {
|
|
48
|
+
focusInput() {
|
|
49
|
+
const input = this.$refs.searchInput;
|
|
50
|
+
if (input && typeof input.focus === 'function') {
|
|
51
|
+
input.focus();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
48
54
|
emitSearch() {
|
|
49
55
|
this.$emit('input', this.searchText);
|
|
50
56
|
this.$emit('search', this.searchText);
|
|
@@ -263,14 +263,12 @@
|
|
|
263
263
|
</span>
|
|
264
264
|
</div>
|
|
265
265
|
<div v-if="isProjectionMenuSelected && (outputType === 'table' || outputType === 'json')" class="flex items-center gap-2 w-full mt-2 flex-shrink-0">
|
|
266
|
-
<
|
|
267
|
-
ref="
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
@focus="initProjection($event)"
|
|
273
|
-
@keydown.enter="applyProjectionFromInput()"
|
|
266
|
+
<projection-search
|
|
267
|
+
ref="projectionSearch"
|
|
268
|
+
:value="projectionText"
|
|
269
|
+
:schema-paths="schemaPaths"
|
|
270
|
+
@projection-input="projectionText = $event"
|
|
271
|
+
@apply="applyProjectionFromInput"
|
|
274
272
|
/>
|
|
275
273
|
</div>
|
|
276
274
|
</div>
|
|
@@ -16,58 +16,126 @@ const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
|
|
|
16
16
|
const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
|
|
17
17
|
const SHOW_ROW_NUMBERS_STORAGE_KEY = 'studio:model-show-row-numbers';
|
|
18
18
|
const PROJECTION_MODE_QUERY_KEY = 'projectionMode';
|
|
19
|
+
const PROJECTION_INPUT_QUERY_KEY = 'projectionInput';
|
|
19
20
|
const RECENTLY_VIEWED_MODELS_KEY = 'studio:recently-viewed-models';
|
|
20
21
|
const MAX_RECENT_MODELS = 4;
|
|
22
|
+
const FOCUS_AFTER_MODELS_REMOUNT_KEY = '__studioModelsFocusAfterRemount';
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
const s = typeof fields === 'string' ? fields : String(fields);
|
|
28
|
-
const trimmed = s.trim();
|
|
29
|
-
if (!trimmed) {
|
|
30
|
-
return [];
|
|
24
|
+
function setModelsRemountFocusIntent(target) {
|
|
25
|
+
if (typeof window === 'undefined') {
|
|
26
|
+
return;
|
|
31
27
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (Array.isArray(parsed)) {
|
|
39
|
-
return parsed.map(x => String(x).trim()).filter(Boolean);
|
|
28
|
+
window[FOCUS_AFTER_MODELS_REMOUNT_KEY] = target;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function takeModelsRemountFocusIntent() {
|
|
32
|
+
if (typeof window === 'undefined') {
|
|
33
|
+
return null;
|
|
40
34
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
const v = window[FOCUS_AFTER_MODELS_REMOUNT_KEY] || null;
|
|
36
|
+
delete window[FOCUS_AFTER_MODELS_REMOUNT_KEY];
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split projection list tokens on commas/whitespace, merging a lone `-` or `+` with the next
|
|
42
|
+
* chunk so `- password` and `-password` both exclude `password`.
|
|
43
|
+
* @returns {string[]|null} null when a dangling `+`/`-` has no following field name.
|
|
44
|
+
*/
|
|
45
|
+
function normalizeProjectionTokens(trimmed) {
|
|
46
|
+
const rawTokens = trimmed.split(/[,\s]+/).filter(Boolean);
|
|
47
|
+
const tokens = [];
|
|
48
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
49
|
+
const t = rawTokens[i];
|
|
50
|
+
if (t === '-' || t === '+') {
|
|
51
|
+
if (i + 1 >= rawTokens.length) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
tokens.push(t + rawTokens[++i]);
|
|
55
|
+
} else {
|
|
56
|
+
tokens.push(t);
|
|
57
|
+
}
|
|
45
58
|
}
|
|
46
|
-
return
|
|
59
|
+
return tokens;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Parse projection object syntax like:
|
|
64
|
+
* { deletedAt: 1 }
|
|
65
|
+
* { "deletedAt": true, email: 1 }
|
|
66
|
+
* { password: 0 }
|
|
67
|
+
*
|
|
68
|
+
* Supported values are 1/0/true/false (quoted or unquoted).
|
|
69
|
+
* Returns include paths, exclude-derived include paths, or null when invalid.
|
|
70
|
+
*/
|
|
71
|
+
function parseProjectionObjectNotation(trimmed, schemaPaths) {
|
|
72
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
52
73
|
return null;
|
|
53
74
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return null;
|
|
75
|
+
const body = trimmed.slice(1, -1).trim();
|
|
76
|
+
if (!body) {
|
|
77
|
+
return [];
|
|
58
78
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
|
|
80
|
+
const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
|
|
81
|
+
const includeKeys = [];
|
|
82
|
+
const excludeKeys = [];
|
|
83
|
+
let matchedChars = '';
|
|
84
|
+
let match;
|
|
85
|
+
|
|
86
|
+
while ((match = pairRe.exec(body)) !== null) {
|
|
87
|
+
matchedChars += match[0];
|
|
88
|
+
const key = (match[1] || match[2] || match[3] || '').trim();
|
|
89
|
+
const rawValue = (match[4] || '').trim();
|
|
90
|
+
if (!key || !rawValue) {
|
|
91
|
+
return null;
|
|
63
92
|
}
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
|
|
94
|
+
const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
|
|
95
|
+
const isInclude = valueLower === '1' || valueLower === 'true';
|
|
96
|
+
const isExclude = valueLower === '0' || valueLower === 'false';
|
|
97
|
+
if (!isInclude && !isExclude) {
|
|
98
|
+
return null;
|
|
66
99
|
}
|
|
67
|
-
|
|
100
|
+
if (isInclude) {
|
|
101
|
+
includeKeys.push(key);
|
|
102
|
+
} else {
|
|
103
|
+
excludeKeys.push(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reject malformed object strings where regex skipped segments.
|
|
108
|
+
const normalizedBody = body.replace(/\s+/g, '');
|
|
109
|
+
const normalizedMatched = matchedChars.replace(/\s+/g, '').replace(/^,/, '');
|
|
110
|
+
if (!normalizedMatched || normalizedMatched !== normalizedBody) {
|
|
68
111
|
return null;
|
|
69
112
|
}
|
|
70
|
-
|
|
113
|
+
|
|
114
|
+
const normalizeKey = (key) => String(key).trim();
|
|
115
|
+
if (includeKeys.length > 0 && excludeKeys.length > 0) {
|
|
116
|
+
const includeSet = new Set(includeKeys.map(normalizeKey));
|
|
117
|
+
for (const path of excludeKeys) {
|
|
118
|
+
const ex = normalizeKey(path);
|
|
119
|
+
for (const k of Array.from(includeSet)) {
|
|
120
|
+
if (k.toLowerCase() === ex.toLowerCase()) {
|
|
121
|
+
includeSet.delete(k);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (includeSet.size === 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return Array.from(includeSet);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (excludeKeys.length > 0) {
|
|
132
|
+
const excludeNorm = excludeKeys.map(normalizeKey);
|
|
133
|
+
return schemaPaths
|
|
134
|
+
.map(p => p.path)
|
|
135
|
+
.filter(p => !excludeNorm.some(ex => ex === p || p.toLowerCase() === ex.toLowerCase()));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return includeKeys.map(normalizeKey);
|
|
71
139
|
}
|
|
72
140
|
|
|
73
141
|
module.exports = app => app.component('models', {
|
|
@@ -212,10 +280,19 @@ module.exports = app => app.component('models', {
|
|
|
212
280
|
this.setOutputType('json');
|
|
213
281
|
}
|
|
214
282
|
this.$nextTick(() => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
283
|
+
const focusIntent = takeModelsRemountFocusIntent();
|
|
284
|
+
if (focusIntent === 'documentSearch') {
|
|
285
|
+
const ds = this.$refs.documentSearch;
|
|
286
|
+
if (ds && typeof ds.focusInput === 'function') {
|
|
287
|
+
ds.focusInput();
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (focusIntent === 'projection' && this.isProjectionMenuSelected) {
|
|
292
|
+
const projectionSearch = this.$refs.projectionSearch;
|
|
293
|
+
if (projectionSearch && typeof projectionSearch.focusInput === 'function') {
|
|
294
|
+
projectionSearch.focusInput();
|
|
295
|
+
}
|
|
219
296
|
}
|
|
220
297
|
});
|
|
221
298
|
},
|
|
@@ -622,21 +699,21 @@ module.exports = app => app.component('models', {
|
|
|
622
699
|
params.searchText = this.searchText;
|
|
623
700
|
}
|
|
624
701
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
702
|
+
const projectionInput = typeof this.query?.[PROJECTION_INPUT_QUERY_KEY] === 'string' ?
|
|
703
|
+
this.query[PROJECTION_INPUT_QUERY_KEY].trim() :
|
|
704
|
+
'';
|
|
705
|
+
if (projectionInput) {
|
|
706
|
+
params.projectionInput = projectionInput;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!projectionInput && this.isProjectionMenuSelected === true) {
|
|
630
710
|
const fieldPaths = this.filteredPaths && this.filteredPaths.length > 0
|
|
631
711
|
? this.filteredPaths.map(p => p.path).filter(Boolean)
|
|
632
712
|
: null;
|
|
633
713
|
if (fieldPaths && fieldPaths.length > 0) {
|
|
634
|
-
|
|
714
|
+
params.projectionInput = fieldPaths.join(' ');
|
|
635
715
|
}
|
|
636
716
|
}
|
|
637
|
-
if (fieldsParam) {
|
|
638
|
-
params.fields = fieldsParam;
|
|
639
|
-
}
|
|
640
717
|
|
|
641
718
|
return params;
|
|
642
719
|
},
|
|
@@ -671,13 +748,16 @@ module.exports = app => app.component('models', {
|
|
|
671
748
|
if (this.currentModel != null) {
|
|
672
749
|
await this.getDocuments();
|
|
673
750
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
this.
|
|
680
|
-
|
|
751
|
+
const routeProjectionInput = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
|
|
752
|
+
if (typeof routeProjectionInput === 'string' && routeProjectionInput.trim().length > 0) {
|
|
753
|
+
const urlPaths = this.normalizeProjectionPathsForDisplay(routeProjectionInput);
|
|
754
|
+
if (urlPaths !== null) {
|
|
755
|
+
const nextFiltered = urlPaths
|
|
756
|
+
.map(path => this.schemaPaths.find(p => p.path === path))
|
|
757
|
+
.filter(Boolean);
|
|
758
|
+
this.filteredPaths = nextFiltered;
|
|
759
|
+
this.selectedPaths = [...this.filteredPaths];
|
|
760
|
+
this.syncProjectionInputFromQueryOrPaths();
|
|
681
761
|
}
|
|
682
762
|
}
|
|
683
763
|
this.status = 'loaded';
|
|
@@ -786,6 +866,7 @@ module.exports = app => app.component('models', {
|
|
|
786
866
|
} else {
|
|
787
867
|
delete this.query.search;
|
|
788
868
|
}
|
|
869
|
+
setModelsRemountFocusIntent('documentSearch');
|
|
789
870
|
const query = this.query;
|
|
790
871
|
this.$router.push({ query });
|
|
791
872
|
this.documents = [];
|
|
@@ -820,11 +901,13 @@ module.exports = app => app.component('models', {
|
|
|
820
901
|
// Persist projection UI state in the URL so Reset/Suggest don't turn the mode off.
|
|
821
902
|
if (next) {
|
|
822
903
|
this.query[PROJECTION_MODE_QUERY_KEY] = '1';
|
|
904
|
+
setModelsRemountFocusIntent('projection');
|
|
823
905
|
if (this.outputType === 'map') {
|
|
824
906
|
this.setOutputType('json');
|
|
825
907
|
}
|
|
826
908
|
} else {
|
|
827
909
|
delete this.query[PROJECTION_MODE_QUERY_KEY];
|
|
910
|
+
delete this.query[PROJECTION_INPUT_QUERY_KEY];
|
|
828
911
|
delete this.query.fields;
|
|
829
912
|
this.filteredPaths = [];
|
|
830
913
|
this.selectedPaths = [];
|
|
@@ -970,14 +1053,25 @@ module.exports = app => app.component('models', {
|
|
|
970
1053
|
}
|
|
971
1054
|
const isProjectionModeOn = this.isProjectionMenuSelected === true;
|
|
972
1055
|
if (isProjectionModeOn) {
|
|
973
|
-
this.
|
|
1056
|
+
const routeProjectionInput = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
|
|
1057
|
+
if (typeof routeProjectionInput === 'string' && routeProjectionInput.trim().length > 0) {
|
|
1058
|
+
const projectionPaths = this.normalizeProjectionPathsForDisplay(routeProjectionInput);
|
|
1059
|
+
const fromProjectionInput = Array.isArray(projectionPaths) ?
|
|
1060
|
+
projectionPaths
|
|
1061
|
+
.map(path => this.schemaPaths.find(p => p.path === path))
|
|
1062
|
+
.filter(Boolean) :
|
|
1063
|
+
[];
|
|
1064
|
+
this.filteredPaths = fromProjectionInput;
|
|
1065
|
+
} else {
|
|
1066
|
+
this.filteredPaths = [];
|
|
1067
|
+
}
|
|
974
1068
|
} else {
|
|
975
1069
|
this.filteredPaths = [];
|
|
976
1070
|
this.selectedPaths = [];
|
|
977
1071
|
this.projectionText = '';
|
|
978
1072
|
}
|
|
979
1073
|
this.selectedPaths = [...this.filteredPaths];
|
|
980
|
-
this.
|
|
1074
|
+
this.syncProjectionInputFromQueryOrPaths();
|
|
981
1075
|
schemaPathsReceived = true;
|
|
982
1076
|
}
|
|
983
1077
|
if (event.numDocs !== undefined) {
|
|
@@ -1058,6 +1152,10 @@ module.exports = app => app.component('models', {
|
|
|
1058
1152
|
// Keep current filter input in sync with the URL so projection reset
|
|
1059
1153
|
// does not unintentionally wipe the filter on remount.
|
|
1060
1154
|
this.syncFilterToQuery();
|
|
1155
|
+
if (this.$route.query?.fields) {
|
|
1156
|
+
delete this.query.fields;
|
|
1157
|
+
}
|
|
1158
|
+
delete this.query[PROJECTION_INPUT_QUERY_KEY];
|
|
1061
1159
|
this.filteredPaths = [];
|
|
1062
1160
|
this.selectedPaths = [];
|
|
1063
1161
|
this.projectionText = '';
|
|
@@ -1080,18 +1178,9 @@ module.exports = app => app.component('models', {
|
|
|
1080
1178
|
this.applyDefaultProjection(pathNames.slice(0, DEFAULT_FIRST_N_FIELDS));
|
|
1081
1179
|
this.selectedPaths = [...this.filteredPaths];
|
|
1082
1180
|
this.syncProjectionFromPaths();
|
|
1181
|
+
setModelsRemountFocusIntent('projection');
|
|
1083
1182
|
this.updateProjectionQuery();
|
|
1084
1183
|
},
|
|
1085
|
-
initProjection(ev) {
|
|
1086
|
-
if (!this.projectionText || !this.projectionText.trim()) {
|
|
1087
|
-
this.projectionText = '';
|
|
1088
|
-
this.$nextTick(() => {
|
|
1089
|
-
if (ev && ev.target) {
|
|
1090
|
-
ev.target.setSelectionRange(0, 0);
|
|
1091
|
-
}
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
},
|
|
1095
1184
|
syncProjectionFromPaths() {
|
|
1096
1185
|
if (this.filteredPaths.length === 0) {
|
|
1097
1186
|
this.projectionText = '';
|
|
@@ -1101,6 +1190,29 @@ module.exports = app => app.component('models', {
|
|
|
1101
1190
|
// Since `filteredPaths` represents the final include set, we serialize as space-separated fields.
|
|
1102
1191
|
this.projectionText = this.filteredPaths.map(p => p.path).join(' ');
|
|
1103
1192
|
},
|
|
1193
|
+
syncProjectionInputFromQueryOrPaths() {
|
|
1194
|
+
const fromQuery = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
|
|
1195
|
+
if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
|
|
1196
|
+
this.projectionText = fromQuery;
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
this.syncProjectionFromPaths();
|
|
1200
|
+
},
|
|
1201
|
+
normalizeProjectionPathsForDisplay(text) {
|
|
1202
|
+
const paths = this.parseProjectionInput(text);
|
|
1203
|
+
if (paths == null) {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
const excludesId = this.projectionExplicitlyExcludesId(text);
|
|
1207
|
+
const hasIdSchemaPath = this.schemaPaths.some(p => p.path === '_id');
|
|
1208
|
+
if (hasIdSchemaPath && !excludesId) {
|
|
1209
|
+
const hasIdAlready = paths.some(p => String(p).toLowerCase() === '_id');
|
|
1210
|
+
if (!hasIdAlready) {
|
|
1211
|
+
paths.unshift('_id');
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return paths;
|
|
1215
|
+
},
|
|
1104
1216
|
parseProjectionInput(text) {
|
|
1105
1217
|
if (!text || typeof text !== 'string') {
|
|
1106
1218
|
return [];
|
|
@@ -1111,17 +1223,21 @@ module.exports = app => app.component('models', {
|
|
|
1111
1223
|
}
|
|
1112
1224
|
const normalizeKey = (key) => String(key).trim();
|
|
1113
1225
|
|
|
1114
|
-
// String
|
|
1226
|
+
// String projection syntax:
|
|
1115
1227
|
// name email
|
|
1116
1228
|
// -password (exclusion-only)
|
|
1117
1229
|
// +email (inclusion-only)
|
|
1230
|
+
// { deletedAt: 1 } (object notation)
|
|
1118
1231
|
//
|
|
1119
|
-
//
|
|
1120
|
-
if (trimmed.startsWith('{')
|
|
1121
|
-
return
|
|
1232
|
+
// For object notation, return include paths based on 1/0/true/false values.
|
|
1233
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
1234
|
+
return parseProjectionObjectNotation(trimmed, this.schemaPaths);
|
|
1122
1235
|
}
|
|
1123
1236
|
|
|
1124
|
-
const tokens = trimmed
|
|
1237
|
+
const tokens = normalizeProjectionTokens(trimmed);
|
|
1238
|
+
if (tokens === null) {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1125
1241
|
if (tokens.length === 0) return [];
|
|
1126
1242
|
|
|
1127
1243
|
const includeKeys = [];
|
|
@@ -1150,22 +1266,78 @@ module.exports = app => app.component('models', {
|
|
|
1150
1266
|
// `name email createdAt -email` -> `name createdAt`.
|
|
1151
1267
|
const includeSet = new Set(includeKeys.map(normalizeKey));
|
|
1152
1268
|
for (const path of excludeKeys) {
|
|
1153
|
-
|
|
1269
|
+
const ex = normalizeKey(path);
|
|
1270
|
+
for (const k of Array.from(includeSet)) {
|
|
1271
|
+
if (k.toLowerCase() === ex.toLowerCase()) {
|
|
1272
|
+
includeSet.delete(k);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (includeSet.size === 0) {
|
|
1277
|
+
return null;
|
|
1154
1278
|
}
|
|
1155
1279
|
return Array.from(includeSet);
|
|
1156
1280
|
}
|
|
1157
1281
|
|
|
1158
1282
|
if (excludeKeys.length > 0) {
|
|
1159
|
-
const
|
|
1160
|
-
return this.schemaPaths
|
|
1283
|
+
const excludeNorm = excludeKeys.map(normalizeKey);
|
|
1284
|
+
return this.schemaPaths
|
|
1285
|
+
.map(p => p.path)
|
|
1286
|
+
.filter(p => !excludeNorm.some(ex => ex === p || p.toLowerCase() === ex.toLowerCase()));
|
|
1161
1287
|
}
|
|
1162
1288
|
|
|
1163
1289
|
return includeKeys.map(normalizeKey);
|
|
1164
1290
|
},
|
|
1291
|
+
projectionExplicitlyExcludesId(text) {
|
|
1292
|
+
if (!text || typeof text !== 'string') {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
const trimmed = text.trim();
|
|
1296
|
+
if (!trimmed) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
1301
|
+
const body = trimmed.slice(1, -1).trim();
|
|
1302
|
+
if (!body) {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
|
|
1306
|
+
let match;
|
|
1307
|
+
while ((match = pairRe.exec(body)) !== null) {
|
|
1308
|
+
const key = (match[1] || match[2] || match[3] || '').trim();
|
|
1309
|
+
if (!key || key.toLowerCase() !== '_id') {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const rawValue = (match[4] || '').trim();
|
|
1313
|
+
const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
|
|
1314
|
+
if (valueLower === '0' || valueLower === 'false') {
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const tokens = normalizeProjectionTokens(trimmed);
|
|
1322
|
+
if (!tokens) {
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
return tokens.some((rawToken) => {
|
|
1326
|
+
const token = rawToken.trim();
|
|
1327
|
+
return token.toLowerCase() === '-_id';
|
|
1328
|
+
});
|
|
1329
|
+
},
|
|
1165
1330
|
applyProjectionFromInput() {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
this.
|
|
1331
|
+
if (typeof this.projectionText === 'string' && this.projectionText.trim().length === 0) {
|
|
1332
|
+
this.filteredPaths = [];
|
|
1333
|
+
this.selectedPaths = [];
|
|
1334
|
+
setModelsRemountFocusIntent('projection');
|
|
1335
|
+
this.updateProjectionQuery();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const paths = this.normalizeProjectionPathsForDisplay(this.projectionText);
|
|
1340
|
+
if (paths == null) {
|
|
1169
1341
|
return;
|
|
1170
1342
|
}
|
|
1171
1343
|
if (paths.length === 0) {
|
|
@@ -1187,16 +1359,16 @@ module.exports = app => app.component('models', {
|
|
|
1187
1359
|
}
|
|
1188
1360
|
}
|
|
1189
1361
|
this.selectedPaths = [...this.filteredPaths];
|
|
1190
|
-
|
|
1362
|
+
setModelsRemountFocusIntent('projection');
|
|
1191
1363
|
this.updateProjectionQuery();
|
|
1192
1364
|
},
|
|
1193
1365
|
updateProjectionQuery() {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
this.query.fields = JSON.stringify(paths);
|
|
1366
|
+
if (typeof this.projectionText === 'string' && this.projectionText.trim().length > 0) {
|
|
1367
|
+
this.query[PROJECTION_INPUT_QUERY_KEY] = this.projectionText;
|
|
1197
1368
|
} else {
|
|
1198
|
-
delete this.query
|
|
1369
|
+
delete this.query[PROJECTION_INPUT_QUERY_KEY];
|
|
1199
1370
|
}
|
|
1371
|
+
delete this.query.fields;
|
|
1200
1372
|
this.$router.push({ query: this.query });
|
|
1201
1373
|
},
|
|
1202
1374
|
removeField(schemaPath) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<div class="relative flex-grow m-0">
|
|
2
|
+
<input
|
|
3
|
+
ref="projectionInput"
|
|
4
|
+
class="w-full min-w-0 rounded border border-edge bg-surface px-2 py-1.5 text-sm font-mono placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
5
|
+
type="text"
|
|
6
|
+
placeholder="Projection: name email, or -password"
|
|
7
|
+
v-model="projectionText"
|
|
8
|
+
@focus="initProjection"
|
|
9
|
+
@input="handleInput"
|
|
10
|
+
@keydown="handleKeyDown"
|
|
11
|
+
@blur="hideAutocomplete"
|
|
12
|
+
/>
|
|
13
|
+
<ul
|
|
14
|
+
v-if="autocompleteSuggestions.length"
|
|
15
|
+
class="absolute left-0 right-0 top-full z-[9999] mt-1 max-h-40 overflow-y-auto rounded border border-edge bg-surface shadow"
|
|
16
|
+
>
|
|
17
|
+
<li
|
|
18
|
+
v-for="(suggestion, index) in autocompleteSuggestions"
|
|
19
|
+
:key="suggestion"
|
|
20
|
+
class="cursor-pointer px-2 py-1 text-sm font-mono"
|
|
21
|
+
:class="{ 'bg-primary-subtle': index === autocompleteIndex }"
|
|
22
|
+
@mousedown.prevent="applySuggestion(index)"
|
|
23
|
+
>
|
|
24
|
+
{{ suggestion }}
|
|
25
|
+
</li>
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|