offbyt 1.0.0 → 1.0.2
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/cli.js +117 -2
- package/industry-mode.json +22 -0
- package/lib/ir-integration.js +4 -4
- package/lib/modes/configBasedGenerator.js +27 -27
- package/lib/modes/connect.js +26 -25
- package/lib/modes/generateApi.js +217 -76
- package/lib/modes/interactiveSetup.js +28 -29
- package/lib/utils/cliFormatter.js +131 -0
- package/lib/utils/codeInjector.js +144 -26
- package/lib/utils/industryMode.js +419 -0
- package/lib/utils/postGenerationVerifier.js +256 -0
- package/lib/utils/resourceDetector.js +234 -169
- package/package.json +1 -1
|
@@ -1,90 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resource Detector - Detects data resources from frontend code patterns
|
|
3
|
-
* Detects from
|
|
2
|
+
* Resource Detector - Detects data resources from frontend code patterns.
|
|
3
|
+
* Detects from state, map/list rendering, forms, and existing API calls.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { globSync } from 'glob';
|
|
9
|
-
import * as parser from '@babel/parser';
|
|
10
|
-
import traverse from '@babel/traverse';
|
|
11
9
|
|
|
12
|
-
/**
|
|
13
|
-
* Scan frontend and detect resources from state variables and data patterns
|
|
14
|
-
*/
|
|
15
10
|
export function detectResourcesFromFrontend(projectPath) {
|
|
16
11
|
const resources = new Map();
|
|
17
|
-
|
|
18
|
-
// Find all frontend files
|
|
19
|
-
const patterns = [
|
|
20
|
-
'src/**/*.{js,jsx,ts,tsx}',
|
|
21
|
-
'app/**/*.{js,jsx,ts,tsx}',
|
|
22
|
-
'pages/**/*.{js,jsx,ts,tsx}',
|
|
23
|
-
'components/**/*.{js,jsx,ts,tsx}',
|
|
24
|
-
'*.{js,jsx,ts,tsx}'
|
|
25
|
-
];
|
|
12
|
+
const files = getFrontendFiles(projectPath);
|
|
26
13
|
|
|
27
|
-
|
|
28
|
-
for (const pattern of patterns) {
|
|
29
|
-
files.push(...globSync(pattern, { nodir: true, cwd: projectPath }));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
console.log(` 📁 Scanning ${files.length} files...`);
|
|
14
|
+
console.log(` Scanning ${files.length} files...`);
|
|
33
15
|
|
|
34
16
|
for (const file of files) {
|
|
35
17
|
try {
|
|
36
18
|
const fullPath = path.join(projectPath, file);
|
|
37
19
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
38
|
-
|
|
39
|
-
// Detect resources from code patterns
|
|
40
20
|
const detected = detectFromCode(content, file);
|
|
41
|
-
|
|
21
|
+
|
|
42
22
|
for (const resource of detected) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
23
|
+
const normalizedName = normalizeResourceName(resource.name);
|
|
24
|
+
if (!normalizedName) continue;
|
|
25
|
+
|
|
26
|
+
if (!resources.has(normalizedName)) {
|
|
27
|
+
resources.set(normalizedName, {
|
|
28
|
+
name: normalizedName,
|
|
29
|
+
singular: singularize(normalizedName),
|
|
30
|
+
fields: new Set(),
|
|
48
31
|
sources: []
|
|
49
32
|
});
|
|
50
33
|
}
|
|
51
|
-
|
|
52
|
-
resources.get(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (resource.fields) {
|
|
59
|
-
for (const field of resource.fields) {
|
|
60
|
-
resources.get(resource.name).fields.add(field);
|
|
34
|
+
|
|
35
|
+
const existing = resources.get(normalizedName);
|
|
36
|
+
existing.sources.push({ file, type: resource.type });
|
|
37
|
+
|
|
38
|
+
for (const field of resource.fields || []) {
|
|
39
|
+
if (!isIgnoredField(field)) {
|
|
40
|
+
existing.fields.add(field);
|
|
61
41
|
}
|
|
62
42
|
}
|
|
63
43
|
}
|
|
64
|
-
} catch
|
|
65
|
-
// Ignore
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore unreadable files.
|
|
66
46
|
}
|
|
67
47
|
}
|
|
68
48
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fields: Array.from(r.fields).filter(field =>
|
|
73
|
-
field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id'
|
|
74
|
-
)
|
|
49
|
+
return Array.from(resources.values()).map((resource) => ({
|
|
50
|
+
...resource,
|
|
51
|
+
fields: Array.from(resource.fields)
|
|
75
52
|
}));
|
|
76
53
|
}
|
|
77
54
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
55
|
+
function getFrontendFiles(projectPath) {
|
|
56
|
+
const patterns = [
|
|
57
|
+
'src/**/*.{js,jsx,ts,tsx}',
|
|
58
|
+
'app/**/*.{js,jsx,ts,tsx}',
|
|
59
|
+
'pages/**/*.{js,jsx,ts,tsx}',
|
|
60
|
+
'components/**/*.{js,jsx,ts,tsx}',
|
|
61
|
+
'*.{js,jsx,ts,tsx}'
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const ignored = ['node_modules', 'backend', 'dist', 'build', '.next', 'out', 'coverage'];
|
|
65
|
+
const files = new Set();
|
|
66
|
+
|
|
67
|
+
for (const pattern of patterns) {
|
|
68
|
+
const found = globSync(pattern, {
|
|
69
|
+
cwd: projectPath,
|
|
70
|
+
nodir: true,
|
|
71
|
+
ignore: ignored.map((dir) => `${dir}/**`)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const file of found) {
|
|
75
|
+
files.add(file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return Array.from(files);
|
|
80
|
+
}
|
|
81
|
+
|
|
81
82
|
function detectFromCode(content, fileName) {
|
|
82
83
|
const resources = [];
|
|
84
|
+
const statePattern = /const\s*\[\s*(\w+)\s*,\s*set\w+\s*\]\s*=\s*useState(?:<[^>]+>)?\s*\(/g;
|
|
85
|
+
const arrayPattern = /const\s+(\w+)\s*=\s*\[([\s\S]*?)\]\s*;?/g;
|
|
86
|
+
const mapPattern = /(\w+)\.map\s*\(\s*(?:\()?([a-zA-Z_$][\w$]*)(?:\))?\s*=>/g;
|
|
87
|
+
const inputPattern = /name=["']([a-zA-Z_$][\w$]*)["']/g;
|
|
83
88
|
|
|
84
|
-
// Pattern 1: useState hooks
|
|
85
|
-
// Example: const [products, setProducts] = useState([])
|
|
86
|
-
const statePattern = /const\s+\[(\w+),\s*set\w+\]\s*=\s*useState/g;
|
|
87
89
|
let match;
|
|
90
|
+
|
|
88
91
|
while ((match = statePattern.exec(content)) !== null) {
|
|
89
92
|
const varName = match[1];
|
|
90
93
|
if (isResourceVariable(varName)) {
|
|
@@ -92,190 +95,252 @@ function detectFromCode(content, fileName) {
|
|
|
92
95
|
name: varName,
|
|
93
96
|
singular: singularize(varName),
|
|
94
97
|
type: 'useState',
|
|
95
|
-
fields:
|
|
98
|
+
fields: extractFieldsFromListVariable(content, varName)
|
|
96
99
|
});
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
// Pattern 2: Data arrays
|
|
101
|
-
// Example: const products = []
|
|
102
|
-
const arrayPattern = /const\s+(\w+)\s*=\s*\[\]/g;
|
|
103
103
|
while ((match = arrayPattern.exec(content)) !== null) {
|
|
104
104
|
const varName = match[1];
|
|
105
|
+
const arrayLiteral = match[2] || '';
|
|
105
106
|
if (isResourceVariable(varName)) {
|
|
106
107
|
resources.push({
|
|
107
108
|
name: varName,
|
|
108
109
|
singular: singularize(varName),
|
|
109
110
|
type: 'array',
|
|
110
|
-
fields:
|
|
111
|
+
fields: [
|
|
112
|
+
...extractFieldsFromListVariable(content, varName),
|
|
113
|
+
...extractFieldsFromArrayLiteral(arrayLiteral)
|
|
114
|
+
]
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
// Pattern 3: map operations (indicates list rendering)
|
|
116
|
-
// Example: products.map(product => ...)
|
|
117
|
-
const mapPattern = /(\w+)\.map\s*\(\s*(?:\()?(\w+)(?:\))?\s*=>/g;
|
|
118
119
|
while ((match = mapPattern.exec(content)) !== null) {
|
|
119
120
|
const listName = match[1];
|
|
120
121
|
const itemName = match[2];
|
|
121
|
-
|
|
122
122
|
if (isResourceVariable(listName)) {
|
|
123
|
-
const fields = extractFieldsFromItemVariable(content, itemName);
|
|
124
123
|
resources.push({
|
|
125
124
|
name: listName,
|
|
126
|
-
singular:
|
|
125
|
+
singular: singularize(listName),
|
|
127
126
|
type: 'map',
|
|
128
|
-
fields
|
|
127
|
+
fields: extractFieldsFromItemVariable(content, itemName)
|
|
129
128
|
});
|
|
130
129
|
}
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
// Pattern 4: Form fields (input names)
|
|
134
|
-
// Example: <input name="productName" />
|
|
135
|
-
const inputPattern = /name=["'](\w+)["']/g;
|
|
136
132
|
const formFields = [];
|
|
137
133
|
while ((match = inputPattern.exec(content)) !== null) {
|
|
138
134
|
formFields.push(match[1]);
|
|
139
135
|
}
|
|
140
136
|
|
|
141
137
|
if (formFields.length > 0) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (resourceName) {
|
|
138
|
+
const inferred = inferResourceFromFields(formFields, fileName);
|
|
139
|
+
if (inferred) {
|
|
145
140
|
resources.push({
|
|
146
|
-
name:
|
|
147
|
-
singular: singularize(
|
|
141
|
+
name: inferred,
|
|
142
|
+
singular: singularize(inferred),
|
|
148
143
|
type: 'form',
|
|
149
144
|
fields: formFields
|
|
150
145
|
});
|
|
151
146
|
}
|
|
152
147
|
}
|
|
153
148
|
|
|
154
|
-
|
|
155
|
-
// Example: useEffect(() => { /* TODO: fetch products */ }, [])
|
|
156
|
-
const effectPattern = /useEffect\s*\([^)]*?(?:fetch|load|get)\s*(\w+)/gi;
|
|
157
|
-
while ((match = effectPattern.exec(content)) !== null) {
|
|
158
|
-
const varName = match[1];
|
|
159
|
-
if (isResourceVariable(varName)) {
|
|
160
|
-
resources.push({
|
|
161
|
-
name: varName,
|
|
162
|
-
singular: singularize(varName),
|
|
163
|
-
type: 'useEffect'
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
149
|
+
resources.push(...extractResourcesFromApiCalls(content));
|
|
167
150
|
|
|
168
151
|
return resources;
|
|
169
152
|
}
|
|
170
153
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (derivedPrefixes.some(prefix => name.toLowerCase().startsWith(prefix))) {
|
|
183
|
-
return false;
|
|
154
|
+
function extractResourcesFromApiCalls(content) {
|
|
155
|
+
const results = [];
|
|
156
|
+
|
|
157
|
+
const fetchPattern = /fetch\s*\(\s*([`"'])([\s\S]*?)\1/g;
|
|
158
|
+
const axiosPattern = /axios\.(get|post|put|patch|delete)\s*\(\s*([`"'])([\s\S]*?)\2/g;
|
|
159
|
+
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = fetchPattern.exec(content)) !== null) {
|
|
162
|
+
const resource = getResourceFromUrlString(match[2]);
|
|
163
|
+
if (resource) {
|
|
164
|
+
results.push({ name: resource, singular: singularize(resource), type: 'fetch', fields: [] });
|
|
184
165
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
while ((match = axiosPattern.exec(content)) !== null) {
|
|
169
|
+
const resource = getResourceFromUrlString(match[3]);
|
|
170
|
+
if (resource) {
|
|
171
|
+
results.push({ name: resource, singular: singularize(resource), type: 'axios', fields: [] });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return results;
|
|
195
176
|
}
|
|
196
177
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
178
|
+
function getResourceFromUrlString(rawUrl) {
|
|
179
|
+
if (!rawUrl) return null;
|
|
180
|
+
|
|
181
|
+
let url = rawUrl
|
|
182
|
+
.replace(/\$\{[^}]+\}/g, '')
|
|
183
|
+
.replace(/https?:\/\/[^/]+/g, '')
|
|
184
|
+
.trim();
|
|
185
|
+
|
|
186
|
+
if (!url) return null;
|
|
187
|
+
|
|
188
|
+
const apiIndex = url.indexOf('/api/');
|
|
189
|
+
if (apiIndex !== -1) {
|
|
190
|
+
url = url.slice(apiIndex + 5);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (url.startsWith('/')) {
|
|
194
|
+
url = url.slice(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
url = url.split('?')[0].split('#')[0];
|
|
198
|
+
if (!url) return null;
|
|
199
|
+
|
|
200
|
+
const firstSegment = url.split('/')[0];
|
|
201
|
+
if (!firstSegment) return null;
|
|
202
|
+
|
|
203
|
+
if (firstSegment.startsWith(':') || firstSegment === 'health') return null;
|
|
204
|
+
|
|
205
|
+
return normalizeResourceName(firstSegment);
|
|
210
206
|
}
|
|
211
207
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
208
|
+
function normalizeResourceName(name) {
|
|
209
|
+
if (!name) return null;
|
|
210
|
+
|
|
211
|
+
let normalized = name
|
|
212
|
+
.replace(/[^a-zA-Z0-9_]/g, '')
|
|
213
|
+
.replace(/^(get|fetch|load)/i, '')
|
|
214
|
+
.replace(/(List|Data)$/i, '')
|
|
215
|
+
.trim();
|
|
216
|
+
|
|
217
|
+
if (!normalized) return null;
|
|
218
|
+
|
|
219
|
+
const lowered = normalized.toLowerCase();
|
|
220
|
+
if (lowered.length < 3) return null;
|
|
221
|
+
if (!isResourceVariable(lowered)) return null;
|
|
222
|
+
|
|
223
|
+
if (!lowered.endsWith('s')) {
|
|
224
|
+
return `${lowered}s`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return lowered;
|
|
220
228
|
}
|
|
221
229
|
|
|
222
|
-
|
|
223
|
-
* Extract fields from variable usage in code
|
|
224
|
-
*/
|
|
225
|
-
function extractFieldsFromVariable(content, varName) {
|
|
230
|
+
function extractFieldsFromListVariable(content, varName) {
|
|
226
231
|
const fields = new Set();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const dotPattern = new RegExp(`${varName}\\.\\w+\\.(\\w+)`, 'g');
|
|
232
|
+
const nestedAccessPattern = new RegExp(`${varName}\\.\\w+\\.([a-zA-Z_$][\\w$]*)`, 'g');
|
|
233
|
+
|
|
230
234
|
let match;
|
|
231
|
-
while ((match =
|
|
232
|
-
|
|
235
|
+
while ((match = nestedAccessPattern.exec(content)) !== null) {
|
|
236
|
+
if (!isIgnoredField(match[1])) {
|
|
237
|
+
fields.add(match[1]);
|
|
238
|
+
}
|
|
233
239
|
}
|
|
234
|
-
|
|
235
|
-
return Array.from(fields)
|
|
236
|
-
field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id'
|
|
237
|
-
);
|
|
240
|
+
|
|
241
|
+
return Array.from(fields);
|
|
238
242
|
}
|
|
239
243
|
|
|
240
|
-
/**
|
|
241
|
-
* Extract fields from item variable in map operations
|
|
242
|
-
*/
|
|
243
244
|
function extractFieldsFromItemVariable(content, itemName) {
|
|
244
245
|
const fields = new Set();
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const fieldPattern = new RegExp(`${itemName}\\.([a-zA-Z_][a-zA-Z0-9_]*)`, 'g');
|
|
246
|
+
const fieldPattern = new RegExp(`${itemName}\\.([a-zA-Z_$][a-zA-Z0-9_$]*)`, 'g');
|
|
247
|
+
|
|
248
248
|
let match;
|
|
249
249
|
while ((match = fieldPattern.exec(content)) !== null) {
|
|
250
250
|
const field = match[1];
|
|
251
|
-
|
|
252
|
-
if (!['map', 'filter', 'reduce', 'forEach', 'length'].includes(field) &&
|
|
253
|
-
field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id') {
|
|
251
|
+
if (!['map', 'filter', 'reduce', 'forEach', 'length'].includes(field) && !isIgnoredField(field)) {
|
|
254
252
|
fields.add(field);
|
|
255
253
|
}
|
|
256
254
|
}
|
|
257
|
-
|
|
255
|
+
|
|
258
256
|
return Array.from(fields);
|
|
259
257
|
}
|
|
260
258
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
for (const [resource, keywords] of Object.entries(patterns)) {
|
|
273
|
-
if (keywords.some(kw => fields.some(f => f.toLowerCase().includes(kw.toLowerCase())))) {
|
|
274
|
-
return resource + 's';
|
|
259
|
+
function extractFieldsFromArrayLiteral(arrayLiteral) {
|
|
260
|
+
if (!arrayLiteral) return [];
|
|
261
|
+
|
|
262
|
+
const fields = new Set();
|
|
263
|
+
const keyPattern = /([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g;
|
|
264
|
+
let match;
|
|
265
|
+
|
|
266
|
+
while ((match = keyPattern.exec(arrayLiteral)) !== null) {
|
|
267
|
+
const field = match[1];
|
|
268
|
+
if (!isIgnoredField(field)) {
|
|
269
|
+
fields.add(field);
|
|
275
270
|
}
|
|
276
271
|
}
|
|
277
|
-
|
|
272
|
+
|
|
273
|
+
return Array.from(fields);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function inferResourceFromFields(fields, fileName = '') {
|
|
277
|
+
const byPrefix = {};
|
|
278
|
+
|
|
279
|
+
for (const field of fields) {
|
|
280
|
+
const clean = field.trim();
|
|
281
|
+
const camelPrefix = clean.match(/^([a-z][a-z0-9]+)[A-Z]/);
|
|
282
|
+
if (camelPrefix) {
|
|
283
|
+
const key = camelPrefix[1].toLowerCase();
|
|
284
|
+
byPrefix[key] = (byPrefix[key] || 0) + 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const candidate = Object.entries(byPrefix)
|
|
289
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
290
|
+
|
|
291
|
+
if (candidate && candidate.length > 2) {
|
|
292
|
+
return normalizeResourceName(candidate.endsWith('s') ? candidate : `${candidate}s`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fileBase = path.basename(fileName, path.extname(fileName)).toLowerCase();
|
|
296
|
+
if (isResourceVariable(fileBase)) {
|
|
297
|
+
return normalizeResourceName(fileBase.endsWith('s') ? fileBase : `${fileBase}s`);
|
|
298
|
+
}
|
|
299
|
+
|
|
278
300
|
return null;
|
|
279
301
|
}
|
|
280
302
|
|
|
303
|
+
function isResourceVariable(name) {
|
|
304
|
+
const lower = String(name || '').toLowerCase();
|
|
305
|
+
|
|
306
|
+
const excluded = new Set([
|
|
307
|
+
'data', 'loading', 'error', 'fetching', 'isloading', 'iserror', 'response', 'result',
|
|
308
|
+
'value', 'state', 'props', 'params', 'filter', 'filtered', 'sorted', 'paginated',
|
|
309
|
+
'searched', 'config', 'item', 'items', 'list', 'payload'
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
const derivedPrefixes = ['filtered', 'sorted', 'paginated', 'searched', 'visible', 'active', 'selected', 'new'];
|
|
313
|
+
if (excluded.has(lower)) return false;
|
|
314
|
+
if (derivedPrefixes.some((prefix) => lower.startsWith(prefix))) return false;
|
|
315
|
+
|
|
316
|
+
return lower.length > 2 && (
|
|
317
|
+
lower.endsWith('s') ||
|
|
318
|
+
lower.endsWith('list') ||
|
|
319
|
+
lower.endsWith('data') ||
|
|
320
|
+
isCommonResource(lower)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isCommonResource(name) {
|
|
325
|
+
const common = [
|
|
326
|
+
'user', 'product', 'order', 'cart', 'item', 'post', 'comment', 'review',
|
|
327
|
+
'category', 'tag', 'customer', 'invoice', 'payment', 'task', 'project',
|
|
328
|
+
'message', 'notification', 'ticket', 'booking'
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
return common.some((base) => name === base || name === `${base}s`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function singularize(word) {
|
|
335
|
+
if (word.endsWith('ies')) return word.slice(0, -3) + 'y';
|
|
336
|
+
if (word.endsWith('ses')) return word.slice(0, -2);
|
|
337
|
+
if (word.endsWith('s')) return word.slice(0, -1);
|
|
338
|
+
return word;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isIgnoredField(field) {
|
|
342
|
+
const lower = String(field || '').toLowerCase();
|
|
343
|
+
return lower === '_id' || lower === 'id';
|
|
344
|
+
}
|
|
345
|
+
|
|
281
346
|
export default { detectResourcesFromFrontend };
|