@iola_adm/iola-cli 0.1.14 → 0.1.15
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 +59 -0
- package/package.json +1 -1
- package/src/cli.js +540 -14
package/README.md
CHANGED
|
@@ -99,6 +99,20 @@ iola features enable api-cache
|
|
|
99
99
|
iola mcp status
|
|
100
100
|
iola mcp list
|
|
101
101
|
iola mcp install codex
|
|
102
|
+
iola cache status
|
|
103
|
+
iola cache warm
|
|
104
|
+
iola cache clear
|
|
105
|
+
iola sync
|
|
106
|
+
iola search "Петрова" --local
|
|
107
|
+
iola data schools --where address=Петрова --save schools-petrova
|
|
108
|
+
iola views
|
|
109
|
+
iola view schools-petrova --format csv --output schools-petrova.csv
|
|
110
|
+
iola views delete schools-petrova
|
|
111
|
+
iola report missing-phones
|
|
112
|
+
iola privacy
|
|
113
|
+
iola backup create
|
|
114
|
+
iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
|
|
115
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
102
116
|
iola config get
|
|
103
117
|
iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
|
|
104
118
|
iola config reset
|
|
@@ -107,6 +121,7 @@ iola version --check
|
|
|
107
121
|
iola ask "Найди школу 29"
|
|
108
122
|
iola ask "Найди школу 29" --profile codex --events --output answer.txt
|
|
109
123
|
iola ask "Найди школу 29" --schema json --no-history
|
|
124
|
+
iola data schools --format csv --output schools.csv
|
|
110
125
|
iola data schools --limit 10
|
|
111
126
|
iola data kindergartens --search "29"
|
|
112
127
|
iola data schools --where address=Петрова --columns name,address,phone
|
|
@@ -350,6 +365,50 @@ iola ask "Найди школу 29" --schema json
|
|
|
350
365
|
iola ask "Найди школу 29" --output answer.txt
|
|
351
366
|
```
|
|
352
367
|
|
|
368
|
+
## Кеш, локальный поиск и выборки
|
|
369
|
+
|
|
370
|
+
API-ответы можно кешировать локально:
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
iola cache status
|
|
374
|
+
iola cache warm
|
|
375
|
+
iola cache clear
|
|
376
|
+
iola data schools --cache
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Локальная синхронизация сохраняет открытые слои в SQLite и позволяет искать без
|
|
380
|
+
повторного обращения к API:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
iola sync
|
|
384
|
+
iola search "Петрова" --local
|
|
385
|
+
iola data schools --local --search "лицей"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Сохраненные выборки:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
iola data schools --where address=Петрова --columns name,address,phone --save schools-petrova
|
|
392
|
+
iola views
|
|
393
|
+
iola view schools-petrova
|
|
394
|
+
iola view schools-petrova --format csv --output schools-petrova.csv
|
|
395
|
+
iola views delete schools-petrova
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Отчеты, backup и алиасы:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
iola report schools-summary
|
|
402
|
+
iola report education-contacts
|
|
403
|
+
iola report missing-phones
|
|
404
|
+
iola report licenses
|
|
405
|
+
iola privacy
|
|
406
|
+
iola backup create
|
|
407
|
+
iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
|
|
408
|
+
iola petrova
|
|
409
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
410
|
+
```
|
|
411
|
+
|
|
353
412
|
## Переменные окружения
|
|
354
413
|
|
|
355
414
|
```bash
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -95,6 +95,15 @@ const COMMANDS = new Map([
|
|
|
95
95
|
["fork", forkSession],
|
|
96
96
|
["features", handleFeatures],
|
|
97
97
|
["mcp", handleMcp],
|
|
98
|
+
["cache", handleCache],
|
|
99
|
+
["sync", handleSync],
|
|
100
|
+
["views", handleViews],
|
|
101
|
+
["view", handleView],
|
|
102
|
+
["report", handleReport],
|
|
103
|
+
["privacy", handlePrivacy],
|
|
104
|
+
["backup", handleBackup],
|
|
105
|
+
["alias", handleAlias],
|
|
106
|
+
["run", runNaturalLanguage],
|
|
98
107
|
["config", handleConfig],
|
|
99
108
|
["banner", showBanner],
|
|
100
109
|
["agent", startAgent],
|
|
@@ -122,6 +131,11 @@ export async function main(argv) {
|
|
|
122
131
|
const handler = COMMANDS.get(command);
|
|
123
132
|
|
|
124
133
|
if (!handler) {
|
|
134
|
+
const alias = getAlias(command);
|
|
135
|
+
if (alias) {
|
|
136
|
+
await main([...splitCommandLine(alias.command), ...args]);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
125
139
|
throw new Error(`Unknown command: ${command}\nRun "iola help" to see available commands.`);
|
|
126
140
|
}
|
|
127
141
|
|
|
@@ -147,6 +161,15 @@ Usage:
|
|
|
147
161
|
iola fork SESSION_ID [TEXT]
|
|
148
162
|
iola features list|enable|disable
|
|
149
163
|
iola mcp list|status|install|remove
|
|
164
|
+
iola cache status|warm|clear
|
|
165
|
+
iola sync [--dataset schools|kindergartens]
|
|
166
|
+
iola views
|
|
167
|
+
iola view NAME [--format table|json|csv] [--output FILE]
|
|
168
|
+
iola report schools-summary|education-contacts|missing-phones|licenses
|
|
169
|
+
iola privacy
|
|
170
|
+
iola backup create
|
|
171
|
+
iola alias add NAME COMMAND
|
|
172
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
150
173
|
iola config get
|
|
151
174
|
iola config set api.baseUrl URL
|
|
152
175
|
iola config set api.mcpBaseUrl URL
|
|
@@ -291,6 +314,21 @@ async function handleAgentLine(line, state) {
|
|
|
291
314
|
return false;
|
|
292
315
|
}
|
|
293
316
|
|
|
317
|
+
if (command === "cache") {
|
|
318
|
+
await handleCache(args);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (command === "sync") {
|
|
323
|
+
await handleSync(args);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (command === "views" || command === "view" || command === "report" || command === "privacy" || command === "backup" || command === "alias" || command === "run") {
|
|
328
|
+
await COMMANDS.get(command)(args);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
294
332
|
if (command === "config") {
|
|
295
333
|
await handleConfig(args.length > 0 ? args : ["get"]);
|
|
296
334
|
return false;
|
|
@@ -376,6 +414,8 @@ async function handleAgentLine(line, state) {
|
|
|
376
414
|
fork: ["fork", args],
|
|
377
415
|
features: ["features", args],
|
|
378
416
|
mcp: ["mcp", args],
|
|
417
|
+
cache: ["cache", args],
|
|
418
|
+
sync: ["sync", args],
|
|
379
419
|
config: ["config", args],
|
|
380
420
|
layers: ["layers", args],
|
|
381
421
|
data: ["data", args],
|
|
@@ -407,6 +447,9 @@ function printAgentHelp() {
|
|
|
407
447
|
/resume SESSION_ID
|
|
408
448
|
/features list
|
|
409
449
|
/mcp status
|
|
450
|
+
/cache status
|
|
451
|
+
/sync
|
|
452
|
+
/views
|
|
410
453
|
/config get
|
|
411
454
|
/config set api.baseUrl URL
|
|
412
455
|
/layers
|
|
@@ -1046,6 +1089,148 @@ async function handleMcp(args) {
|
|
|
1046
1089
|
throw new Error("Команды mcp: status, list, install codex, remove codex.");
|
|
1047
1090
|
}
|
|
1048
1091
|
|
|
1092
|
+
async function handleCache(args) {
|
|
1093
|
+
const [action = "status"] = args;
|
|
1094
|
+
if (action === "status") {
|
|
1095
|
+
printKeyValue(getCacheStatus());
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (action === "clear") {
|
|
1099
|
+
clearCache();
|
|
1100
|
+
console.log("Кеш очищен.");
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (action === "warm") {
|
|
1104
|
+
const result = await warmCache();
|
|
1105
|
+
printKeyValue(result);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
throw new Error("Команды cache: status, warm, clear.");
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function handleSync(args) {
|
|
1112
|
+
const options = parseOptions(args);
|
|
1113
|
+
const datasets = options.dataset ? [options.dataset] : Object.keys(DATASETS);
|
|
1114
|
+
const rows = [];
|
|
1115
|
+
for (const dataset of datasets) {
|
|
1116
|
+
rows.push(await syncDataset(dataset));
|
|
1117
|
+
}
|
|
1118
|
+
printTable(rows, [
|
|
1119
|
+
["dataset", "Слой"],
|
|
1120
|
+
["records", "Записей"],
|
|
1121
|
+
["status", "Статус"],
|
|
1122
|
+
["message", "Сообщение"],
|
|
1123
|
+
]);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function handleViews(args) {
|
|
1127
|
+
const [action, name] = args;
|
|
1128
|
+
if (action === "delete" || action === "remove") {
|
|
1129
|
+
deleteSavedView(name);
|
|
1130
|
+
console.log(`View удален: ${name}`);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const rows = listSavedViews();
|
|
1134
|
+
printTable(rows, [
|
|
1135
|
+
["name", "Имя"],
|
|
1136
|
+
["dataset", "Слой"],
|
|
1137
|
+
["created_at", "Создано"],
|
|
1138
|
+
]);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function handleView(args) {
|
|
1142
|
+
const [name, ...rest] = args;
|
|
1143
|
+
if (!name) {
|
|
1144
|
+
throw new Error("Имя view обязательно.");
|
|
1145
|
+
}
|
|
1146
|
+
const view = getSavedView(name);
|
|
1147
|
+
const query = JSON.parse(view.query_json);
|
|
1148
|
+
await listDataset(view.dataset, [...(query.args || []), ...rest]);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function handleReport(args) {
|
|
1152
|
+
const [name] = args;
|
|
1153
|
+
await ensureLocalData();
|
|
1154
|
+
if (name === "schools-summary") {
|
|
1155
|
+
printTable(getLocalSummaryRows("schools"), [["metric", "Показатель"], ["value", "Значение"]]);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (name === "education-contacts") {
|
|
1159
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,address,phone,email,website");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (name === "missing-phones") {
|
|
1163
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }).filter((item) => !item.phone || item.phone === "-"));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (name === "licenses") {
|
|
1167
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,license_number,license_status");
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
throw new Error("Отчеты: schools-summary, education-contacts, missing-phones, licenses.");
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function handlePrivacy() {
|
|
1174
|
+
printKeyValue({
|
|
1175
|
+
config: CONFIG_FILE,
|
|
1176
|
+
secrets: SECRETS_FILE,
|
|
1177
|
+
sqlite: DB_FILE,
|
|
1178
|
+
api: await getApiBaseUrl(),
|
|
1179
|
+
mcp: await getMcpBaseUrl(),
|
|
1180
|
+
keys_in_sqlite: "no",
|
|
1181
|
+
history_clear: "iola history clear",
|
|
1182
|
+
db_reset: "iola db reset",
|
|
1183
|
+
delete_openai_key: "iola ai key delete openai",
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function handleBackup(args) {
|
|
1188
|
+
const [action = "create", fileArg] = args;
|
|
1189
|
+
if (action !== "create") {
|
|
1190
|
+
throw new Error("Пока доступно: iola backup create [FILE]");
|
|
1191
|
+
}
|
|
1192
|
+
const file = fileArg || path.join(CONFIG_DIR, `iola-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
|
1193
|
+
const payload = {
|
|
1194
|
+
created_at: new Date().toISOString(),
|
|
1195
|
+
config: await loadConfig(),
|
|
1196
|
+
db: exportDbSnapshot(),
|
|
1197
|
+
};
|
|
1198
|
+
await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1199
|
+
console.log(`Backup создан: ${file}`);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function handleAlias(args) {
|
|
1203
|
+
const [action, name, ...commandParts] = args;
|
|
1204
|
+
if (action === "list" || !action) {
|
|
1205
|
+
printTable(listAliases(), [["name", "Алиас"], ["command", "Команда"]]);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (action === "add") {
|
|
1209
|
+
if (!name || commandParts.length === 0) {
|
|
1210
|
+
throw new Error('Пример: iola alias add petrova "data schools --where address=Петрова"');
|
|
1211
|
+
}
|
|
1212
|
+
saveAlias(name, commandParts.join(" "));
|
|
1213
|
+
console.log(`Алиас сохранен: ${name}`);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (action === "delete" || action === "remove") {
|
|
1217
|
+
deleteAlias(name);
|
|
1218
|
+
console.log(`Алиас удален: ${name}`);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
throw new Error("Команды alias: list, add NAME COMMAND, delete NAME.");
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function runNaturalLanguage(args) {
|
|
1225
|
+
const text = args.join(" ").trim();
|
|
1226
|
+
if (!text) {
|
|
1227
|
+
throw new Error('Пример: iola run "выгрузи школы на Петрова в csv"');
|
|
1228
|
+
}
|
|
1229
|
+
const command = inferCommandFromText(text);
|
|
1230
|
+
console.log(`> iola ${command.join(" ")}`);
|
|
1231
|
+
await main(command);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1049
1234
|
async function aiDoctor(args) {
|
|
1050
1235
|
const options = parseOptions(args);
|
|
1051
1236
|
const diagnostics = await getLocalDiagnostics();
|
|
@@ -1600,6 +1785,29 @@ function initDatabase() {
|
|
|
1600
1785
|
query_json TEXT NOT NULL,
|
|
1601
1786
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1602
1787
|
);
|
|
1788
|
+
CREATE TABLE IF NOT EXISTS local_records (
|
|
1789
|
+
dataset TEXT NOT NULL,
|
|
1790
|
+
record_key TEXT NOT NULL,
|
|
1791
|
+
record_json TEXT NOT NULL,
|
|
1792
|
+
searchable_text TEXT NOT NULL,
|
|
1793
|
+
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1794
|
+
PRIMARY KEY(dataset, record_key)
|
|
1795
|
+
);
|
|
1796
|
+
CREATE INDEX IF NOT EXISTS idx_local_records_dataset ON local_records(dataset);
|
|
1797
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS local_records_fts USING fts5(dataset, record_key, searchable_text);
|
|
1798
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
1799
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1800
|
+
dataset TEXT NOT NULL,
|
|
1801
|
+
records INTEGER NOT NULL,
|
|
1802
|
+
status TEXT NOT NULL,
|
|
1803
|
+
message TEXT,
|
|
1804
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1805
|
+
);
|
|
1806
|
+
CREATE TABLE IF NOT EXISTS aliases (
|
|
1807
|
+
name TEXT PRIMARY KEY,
|
|
1808
|
+
command TEXT NOT NULL,
|
|
1809
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1810
|
+
);
|
|
1603
1811
|
`);
|
|
1604
1812
|
db.prepare(`
|
|
1605
1813
|
INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
@@ -1626,12 +1834,16 @@ function getDbStatus() {
|
|
|
1626
1834
|
const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
1627
1835
|
const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
|
|
1628
1836
|
const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
|
|
1837
|
+
const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
|
|
1838
|
+
const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
|
|
1629
1839
|
return {
|
|
1630
1840
|
status: "ok",
|
|
1631
1841
|
file: DB_FILE,
|
|
1632
1842
|
schema: schema?.value || "-",
|
|
1633
1843
|
history: history?.count ?? 0,
|
|
1634
1844
|
sessions: sessions?.count ?? 0,
|
|
1845
|
+
local_records: local?.count ?? 0,
|
|
1846
|
+
cache: cache?.count ?? 0,
|
|
1635
1847
|
};
|
|
1636
1848
|
} finally {
|
|
1637
1849
|
db.close();
|
|
@@ -1862,6 +2074,309 @@ function setFeatureEnabled(name, enabled) {
|
|
|
1862
2074
|
}
|
|
1863
2075
|
}
|
|
1864
2076
|
|
|
2077
|
+
async function fetchJsonMaybeCached(url, options = {}) {
|
|
2078
|
+
if (!options.cache && !isFeatureEnabled("api-cache")) {
|
|
2079
|
+
return fetchJson(url);
|
|
2080
|
+
}
|
|
2081
|
+
const cached = getCachedResponse(url);
|
|
2082
|
+
if (cached) {
|
|
2083
|
+
return cached;
|
|
2084
|
+
}
|
|
2085
|
+
const payload = await fetchJson(url);
|
|
2086
|
+
setCachedResponse(url, payload, 3600);
|
|
2087
|
+
return payload;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function getCachedResponse(url) {
|
|
2091
|
+
initDatabase();
|
|
2092
|
+
const db = openDatabase();
|
|
2093
|
+
try {
|
|
2094
|
+
const row = db.prepare("SELECT response_json, expires_at FROM api_cache WHERE key = ?").get(cacheKey(url));
|
|
2095
|
+
if (!row) return null;
|
|
2096
|
+
if (row.expires_at && new Date(row.expires_at) < new Date()) return null;
|
|
2097
|
+
return JSON.parse(row.response_json);
|
|
2098
|
+
} finally {
|
|
2099
|
+
db.close();
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function setCachedResponse(url, payload, ttlSeconds) {
|
|
2104
|
+
initDatabase();
|
|
2105
|
+
const db = openDatabase();
|
|
2106
|
+
try {
|
|
2107
|
+
const expires = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
2108
|
+
db.prepare(`
|
|
2109
|
+
INSERT INTO api_cache(key, url, response_json, expires_at, created_at)
|
|
2110
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
2111
|
+
ON CONFLICT(key) DO UPDATE SET response_json = excluded.response_json, expires_at = excluded.expires_at, created_at = excluded.created_at
|
|
2112
|
+
`).run(cacheKey(url), url, JSON.stringify(payload), expires);
|
|
2113
|
+
} finally {
|
|
2114
|
+
db.close();
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function cacheKey(url) {
|
|
2119
|
+
return Buffer.from(url).toString("base64url");
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function getCacheStatus() {
|
|
2123
|
+
initDatabase();
|
|
2124
|
+
const db = openDatabase();
|
|
2125
|
+
try {
|
|
2126
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
|
|
2127
|
+
return { status: "ok", entries: row?.count ?? 0 };
|
|
2128
|
+
} finally {
|
|
2129
|
+
db.close();
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function clearCache() {
|
|
2134
|
+
initDatabase();
|
|
2135
|
+
const db = openDatabase();
|
|
2136
|
+
try {
|
|
2137
|
+
db.exec("DELETE FROM api_cache");
|
|
2138
|
+
} finally {
|
|
2139
|
+
db.close();
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function warmCache() {
|
|
2144
|
+
const urls = [
|
|
2145
|
+
`${await getMcpBaseUrl()}/mcp-version`,
|
|
2146
|
+
`${await getMcpBaseUrl()}/mcp-health`,
|
|
2147
|
+
`${await getApiBaseUrl()}/schools?limit=100&offset=0`,
|
|
2148
|
+
`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`,
|
|
2149
|
+
];
|
|
2150
|
+
for (const url of urls) {
|
|
2151
|
+
setCachedResponse(url, await fetchJson(url), 3600);
|
|
2152
|
+
}
|
|
2153
|
+
return { status: "ok", entries: urls.length };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
async function syncDataset(dataset) {
|
|
2157
|
+
if (!DATASETS[dataset]) {
|
|
2158
|
+
throw new Error(`Неизвестный слой: ${dataset}`);
|
|
2159
|
+
}
|
|
2160
|
+
try {
|
|
2161
|
+
const payload = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
|
|
2162
|
+
const items = normalizeItems(payload);
|
|
2163
|
+
saveLocalRecords(dataset, items);
|
|
2164
|
+
recordSyncRun(dataset, items.length, "ok", "");
|
|
2165
|
+
return { dataset, records: items.length, status: "ok", message: "" };
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2168
|
+
recordSyncRun(dataset, 0, "error", message);
|
|
2169
|
+
return { dataset, records: 0, status: "error", message };
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function saveLocalRecords(dataset, items) {
|
|
2174
|
+
initDatabase();
|
|
2175
|
+
const db = openDatabase();
|
|
2176
|
+
try {
|
|
2177
|
+
db.prepare("DELETE FROM local_records WHERE dataset = ?").run(dataset);
|
|
2178
|
+
db.prepare("DELETE FROM local_records_fts WHERE dataset = ?").run(dataset);
|
|
2179
|
+
const insert = db.prepare("INSERT INTO local_records(dataset, record_key, record_json, searchable_text, synced_at) VALUES (?, ?, ?, ?, datetime('now'))");
|
|
2180
|
+
const insertFts = db.prepare("INSERT INTO local_records_fts(dataset, record_key, searchable_text) VALUES (?, ?, ?)");
|
|
2181
|
+
for (const item of items) {
|
|
2182
|
+
const summary = selectPublicSummary(item);
|
|
2183
|
+
const key = String(summary.inn || item.id || `${dataset}-${Math.random()}`);
|
|
2184
|
+
const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
|
|
2185
|
+
insert.run(dataset, key, JSON.stringify(item), text);
|
|
2186
|
+
insertFts.run(dataset, key, text);
|
|
2187
|
+
}
|
|
2188
|
+
} finally {
|
|
2189
|
+
db.close();
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function recordSyncRun(dataset, records, status, message) {
|
|
2194
|
+
initDatabase();
|
|
2195
|
+
const db = openDatabase();
|
|
2196
|
+
try {
|
|
2197
|
+
db.prepare("INSERT INTO sync_runs(dataset, records, status, message) VALUES (?, ?, ?, ?)").run(dataset, records, status, message);
|
|
2198
|
+
} finally {
|
|
2199
|
+
db.close();
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
async function ensureLocalData() {
|
|
2204
|
+
const status = getDbStatus();
|
|
2205
|
+
if (Number(status.local_records || 0) === 0) {
|
|
2206
|
+
await handleSync([]);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function searchLocalRecords(query, options = {}) {
|
|
2211
|
+
initDatabase();
|
|
2212
|
+
const db = openDatabase();
|
|
2213
|
+
const dataset = options.dataset || "all";
|
|
2214
|
+
const limit = Number(options.limit || 20);
|
|
2215
|
+
try {
|
|
2216
|
+
const params = [];
|
|
2217
|
+
let sql = "SELECT dataset, record_json FROM local_records";
|
|
2218
|
+
const where = [];
|
|
2219
|
+
if (dataset !== "all") {
|
|
2220
|
+
where.push("dataset = ?");
|
|
2221
|
+
params.push(dataset);
|
|
2222
|
+
}
|
|
2223
|
+
if (query) {
|
|
2224
|
+
where.push("searchable_text LIKE ?");
|
|
2225
|
+
params.push(`%${query.toLocaleLowerCase("ru-RU")}%`);
|
|
2226
|
+
}
|
|
2227
|
+
if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
|
|
2228
|
+
sql += " ORDER BY dataset, record_key LIMIT ?";
|
|
2229
|
+
params.push(limit);
|
|
2230
|
+
return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
|
|
2231
|
+
} finally {
|
|
2232
|
+
db.close();
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function saveView(name, dataset, args) {
|
|
2237
|
+
initDatabase();
|
|
2238
|
+
const db = openDatabase();
|
|
2239
|
+
try {
|
|
2240
|
+
db.prepare(`
|
|
2241
|
+
INSERT INTO saved_views(name, dataset, query_json) VALUES (?, ?, ?)
|
|
2242
|
+
ON CONFLICT(name) DO UPDATE SET dataset = excluded.dataset, query_json = excluded.query_json
|
|
2243
|
+
`).run(name, dataset, JSON.stringify({ args }));
|
|
2244
|
+
} finally {
|
|
2245
|
+
db.close();
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function listSavedViews() {
|
|
2250
|
+
initDatabase();
|
|
2251
|
+
const db = openDatabase();
|
|
2252
|
+
try {
|
|
2253
|
+
return db.prepare("SELECT name, dataset, created_at FROM saved_views ORDER BY name").all();
|
|
2254
|
+
} finally {
|
|
2255
|
+
db.close();
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function getSavedView(name) {
|
|
2260
|
+
initDatabase();
|
|
2261
|
+
const db = openDatabase();
|
|
2262
|
+
try {
|
|
2263
|
+
const row = db.prepare("SELECT * FROM saved_views WHERE name = ?").get(name);
|
|
2264
|
+
if (!row) throw new Error(`View не найден: ${name}`);
|
|
2265
|
+
return row;
|
|
2266
|
+
} finally {
|
|
2267
|
+
db.close();
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function deleteSavedView(name) {
|
|
2272
|
+
if (!name) {
|
|
2273
|
+
throw new Error("Имя view обязательно.");
|
|
2274
|
+
}
|
|
2275
|
+
initDatabase();
|
|
2276
|
+
const db = openDatabase();
|
|
2277
|
+
try {
|
|
2278
|
+
db.prepare("DELETE FROM saved_views WHERE name = ?").run(name);
|
|
2279
|
+
} finally {
|
|
2280
|
+
db.close();
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function getLocalSummaryRows(dataset) {
|
|
2285
|
+
const rows = searchLocalRecords("", { dataset, limit: 1000 });
|
|
2286
|
+
return [
|
|
2287
|
+
{ metric: "records", value: rows.length },
|
|
2288
|
+
{ metric: "with_phone", value: rows.filter((row) => row.phone && row.phone !== "-").length },
|
|
2289
|
+
{ metric: "with_email", value: rows.filter((row) => row.email).length },
|
|
2290
|
+
{ metric: "with_website", value: rows.filter((row) => row.website).length },
|
|
2291
|
+
];
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function exportDbSnapshot() {
|
|
2295
|
+
initDatabase();
|
|
2296
|
+
return {
|
|
2297
|
+
db: getDbStatus(),
|
|
2298
|
+
views: listSavedViews(),
|
|
2299
|
+
aliases: listAliases(),
|
|
2300
|
+
features: listFeatures(),
|
|
2301
|
+
sessions: listSessions(100),
|
|
2302
|
+
history: listHistory(100),
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function listAliases() {
|
|
2307
|
+
initDatabase();
|
|
2308
|
+
const db = openDatabase();
|
|
2309
|
+
try {
|
|
2310
|
+
return db.prepare("SELECT name, command FROM aliases ORDER BY name").all();
|
|
2311
|
+
} finally {
|
|
2312
|
+
db.close();
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function getAlias(name) {
|
|
2317
|
+
try {
|
|
2318
|
+
initDatabase();
|
|
2319
|
+
const db = openDatabase();
|
|
2320
|
+
try {
|
|
2321
|
+
return db.prepare("SELECT name, command FROM aliases WHERE name = ?").get(name);
|
|
2322
|
+
} finally {
|
|
2323
|
+
db.close();
|
|
2324
|
+
}
|
|
2325
|
+
} catch {
|
|
2326
|
+
return null;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function saveAlias(name, command) {
|
|
2331
|
+
initDatabase();
|
|
2332
|
+
const db = openDatabase();
|
|
2333
|
+
try {
|
|
2334
|
+
db.prepare("INSERT INTO aliases(name, command) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET command = excluded.command").run(name, command);
|
|
2335
|
+
} finally {
|
|
2336
|
+
db.close();
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function deleteAlias(name) {
|
|
2341
|
+
initDatabase();
|
|
2342
|
+
const db = openDatabase();
|
|
2343
|
+
try {
|
|
2344
|
+
db.prepare("DELETE FROM aliases WHERE name = ?").run(name);
|
|
2345
|
+
} finally {
|
|
2346
|
+
db.close();
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function inferCommandFromText(text) {
|
|
2351
|
+
const normalized = text.toLocaleLowerCase("ru-RU");
|
|
2352
|
+
const dataset = normalized.includes("сад") ? "kindergartens" : "schools";
|
|
2353
|
+
const command = ["data", dataset];
|
|
2354
|
+
const street = normalized.match(/(?:на|по|улица|ул\.?)\s+([а-яёa-z-]+)/iu)?.[1];
|
|
2355
|
+
if (street) command.push("--where", `address=${street}`);
|
|
2356
|
+
if (normalized.includes("csv")) command.push("--format", "csv");
|
|
2357
|
+
if (normalized.includes("json")) command.push("--format", "json");
|
|
2358
|
+
const output = text.match(/(?:в файл|файл)\s+([^\s]+)/iu)?.[1];
|
|
2359
|
+
if (output) command.push("--output", output);
|
|
2360
|
+
return command;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
async function outputData(value, options, format) {
|
|
2364
|
+
const text = format === "csv" ? toCsv(value) : `${JSON.stringify(value, null, 2)}\n`;
|
|
2365
|
+
if (options.output) {
|
|
2366
|
+
await writeFile(options.output, text, "utf8");
|
|
2367
|
+
console.log(`Файл сохранен: ${options.output}`);
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
process.stdout.write(text);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function toCsv(rows) {
|
|
2374
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
2375
|
+
if (list.length === 0) return "";
|
|
2376
|
+
const columns = [...new Set(list.flatMap((row) => Object.keys(row)))];
|
|
2377
|
+
return `${columns.map(csvCell).join(",")}\n${list.map((row) => columns.map((column) => csvCell(row[column])).join(",")).join("\n")}\n`;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1865
2380
|
function assertKeyProvider(provider) {
|
|
1866
2381
|
if (provider !== "openai" && provider !== "openrouter") {
|
|
1867
2382
|
throw new Error("Провайдер должен быть openai или openrouter.");
|
|
@@ -2396,20 +2911,26 @@ async function listDataset(dataset, args) {
|
|
|
2396
2911
|
params.set("limit", options.limit || "20");
|
|
2397
2912
|
params.set("offset", options.offset || "0");
|
|
2398
2913
|
|
|
2399
|
-
const data =
|
|
2400
|
-
|
|
2914
|
+
const data = options.local
|
|
2915
|
+
? searchLocalRecords(options.search || "", { dataset, limit: Number(options.limit || 20) })
|
|
2916
|
+
: normalizeItems(await fetchJsonMaybeCached(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`, options));
|
|
2917
|
+
const items = data;
|
|
2401
2918
|
const filtered = applyDatasetFilters(items, options);
|
|
2402
2919
|
const limited = filtered.slice(0, Number(options.limit || 20));
|
|
2403
2920
|
const summarized = limited.map(selectPublicSummary);
|
|
2404
2921
|
const projected = projectColumns(summarized, options.columns);
|
|
2922
|
+
if (options.save) {
|
|
2923
|
+
saveView(options.save, dataset, args.filter((arg) => arg !== "--save" && arg !== options.save));
|
|
2924
|
+
console.log(`View сохранен: ${options.save}`);
|
|
2925
|
+
}
|
|
2405
2926
|
|
|
2406
2927
|
if (options.json || options.format === "json") {
|
|
2407
|
-
|
|
2928
|
+
await outputData(projected, options, "json");
|
|
2408
2929
|
return;
|
|
2409
2930
|
}
|
|
2410
2931
|
|
|
2411
2932
|
if (options.format === "csv") {
|
|
2412
|
-
|
|
2933
|
+
await outputData(projected, options, "csv");
|
|
2413
2934
|
return;
|
|
2414
2935
|
}
|
|
2415
2936
|
|
|
@@ -2444,27 +2965,32 @@ async function searchAll(args) {
|
|
|
2444
2965
|
throw new Error('Search text is required. Example: iola search "лицей"');
|
|
2445
2966
|
}
|
|
2446
2967
|
|
|
2447
|
-
const [schools, kindergartens] = await Promise.all([
|
|
2448
|
-
fetchJson(`${await getApiBaseUrl()}/schools?limit=100&offset=0`),
|
|
2449
|
-
fetchJson(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`),
|
|
2450
|
-
]);
|
|
2451
|
-
|
|
2452
2968
|
const limit = Number(options.limit || 5);
|
|
2969
|
+
const [schools, kindergartens] = options.local
|
|
2970
|
+
? [
|
|
2971
|
+
searchLocalRecords(query, { dataset: "schools", limit }),
|
|
2972
|
+
searchLocalRecords(query, { dataset: "kindergartens", limit }),
|
|
2973
|
+
]
|
|
2974
|
+
: await Promise.all([
|
|
2975
|
+
fetchJsonMaybeCached(`${await getApiBaseUrl()}/schools?limit=100&offset=0`, options),
|
|
2976
|
+
fetchJsonMaybeCached(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`, options),
|
|
2977
|
+
]);
|
|
2978
|
+
|
|
2453
2979
|
const result = {
|
|
2454
2980
|
schools: projectColumns(filterItems(normalizeItems(schools), query).slice(0, limit).map(selectPublicSummary), options.columns),
|
|
2455
2981
|
kindergartens: projectColumns(filterItems(normalizeItems(kindergartens), query).slice(0, limit).map(selectPublicSummary), options.columns),
|
|
2456
2982
|
};
|
|
2457
2983
|
|
|
2458
2984
|
if (options.json || options.format === "json") {
|
|
2459
|
-
|
|
2985
|
+
await outputData(result, options, "json");
|
|
2460
2986
|
return;
|
|
2461
2987
|
}
|
|
2462
2988
|
|
|
2463
2989
|
if (options.format === "csv") {
|
|
2464
|
-
|
|
2990
|
+
await outputData([
|
|
2465
2991
|
...result.schools.map((item) => ({ layer: "schools", ...item })),
|
|
2466
2992
|
...result.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
|
|
2467
|
-
]);
|
|
2993
|
+
], options, "csv");
|
|
2468
2994
|
return;
|
|
2469
2995
|
}
|
|
2470
2996
|
|
|
@@ -2492,12 +3018,12 @@ function parseOptions(args) {
|
|
|
2492
3018
|
|
|
2493
3019
|
for (let index = 0; index < args.length; index += 1) {
|
|
2494
3020
|
const arg = args[index];
|
|
2495
|
-
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all") {
|
|
3021
|
+
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache") {
|
|
2496
3022
|
result[arg.slice(2)] = true;
|
|
2497
3023
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
2498
3024
|
result.check = true;
|
|
2499
3025
|
result[arg.slice(2)] = true;
|
|
2500
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config") {
|
|
3026
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save") {
|
|
2501
3027
|
result[arg.slice(2)] = args[index + 1];
|
|
2502
3028
|
index += 1;
|
|
2503
3029
|
} else {
|