odant-work 1.0.12 → 1.0.13
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/#system/certs/russian_trusted_root_ca.pem +33 -0
- package/#system/mail.json.example +9 -0
- package/#system/package-manifest.json +986 -0
- package/#system/sandbox.json +19 -0
- package/#system/settings.json.example +3 -0
- package/package.json +1 -1
- package/run.mjs +78 -11
- package/scripts/check-ignored-code.mjs +25 -1
- package/sources/data/api.js +1071 -0
- package/sources/data/choices.js +125 -0
- package/sources/data/classes.js +412 -0
- package/sources/data/db.js +383 -0
- package/sources/data/errors.js +15 -0
- package/sources/data/expr.js +456 -0
- package/sources/data/objects.js +950 -0
- package/sources/data/posting.js +657 -0
- package/sources/data/print.js +524 -0
- package/sources/data/progress.md +526 -0
- package/sources/data/readme.md +77 -0
- package/sources/data/reports.js +1033 -0
- package/sources/data/schema.js +586 -0
- package/sources/data/scope.js +281 -0
- package/sources/data/sql-inspector.js +892 -0
- package/sources/data/time.js +67 -0
- package/sources/data/workspace.js +152 -0
- package/sources/host/auto-updater.js +52 -10
- package/.vscode/launch.json +0 -32
- package/.vscode/settings.json +0 -2
- package/.vscode/tasks.json +0 -65
- package/ORGANIZATIONS//320/221/320/270/320/267/320/275/320/265/321/201 /320/230/320/275/321/202/320/265/321/200 /320/241/320/276/321/204/321/202/orders/$structure/video/IMG_8524.MP4 +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Выбор значения ссылки (форма, ИИ): кандидаты для поля-ссылки на дату операции.
|
|
3
|
+
*
|
|
4
|
+
* Ссылка на виртуальный справочник («Клиенты», «Работающие сотрудники»):
|
|
5
|
+
* - без поиска — только состав на дату: коротко и без шума, ведомость зарплаты
|
|
6
|
+
* заполняется всеми работающими одним запросом;
|
|
7
|
+
* - с поиском — сначала подходящие участники, затем подходящие объекты
|
|
8
|
+
* реестра-источника (`member: false`): новый покупатель находится, хотя ещё не клиент;
|
|
9
|
+
* - `all` — расширить выбор всем реестром и без поиска;
|
|
10
|
+
* - ничего не нашлось — `create`: классы, где создать объект и сразу сослаться на него.
|
|
11
|
+
* Проведение состав не проверяет: участником объект делает сама операция
|
|
12
|
+
* (первая продажа делает покупателя клиентом).
|
|
13
|
+
*
|
|
14
|
+
* Ссылка на обычный справочник — его объекты (с потомками), с поиском.
|
|
15
|
+
*/
|
|
16
|
+
import { getClass, descendants, isLeaf } from './classes.js';
|
|
17
|
+
import { classSchema } from './schema.js';
|
|
18
|
+
import { queryObjects } from './objects.js';
|
|
19
|
+
import { virtualMembers } from './reports.js';
|
|
20
|
+
import { entryGrants, withoutBranches } from './scope.js';
|
|
21
|
+
import { DataError } from './errors.js';
|
|
22
|
+
|
|
23
|
+
const POOL = 500;
|
|
24
|
+
const byTitle = (a, b) => a.title.localeCompare(b.title, 'ru');
|
|
25
|
+
|
|
26
|
+
/** Целевой класс: `target` или поле класса `cid` (`field`, для табличной части — 'таблица.поле'). */
|
|
27
|
+
function resolveTarget(db, q) {
|
|
28
|
+
if (q.target)
|
|
29
|
+
return String(q.target);
|
|
30
|
+
const schema = classSchema(db, q.cid);
|
|
31
|
+
if (!schema)
|
|
32
|
+
throw new DataError(`класс ${q.cid ?? ''} не найден`);
|
|
33
|
+
const parts = String(q.field ?? '').split('.').filter(Boolean);
|
|
34
|
+
let f = null, list = schema.fields;
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
f = list?.find(x => x.id === part);
|
|
37
|
+
list = (f?.type === 'table' || f?.type === 'struct') ? f.fields : null;
|
|
38
|
+
}
|
|
39
|
+
if (!f || f.type !== 'link')
|
|
40
|
+
throw new DataError(`${schema.label}: поле «${q.field ?? ''}» — не ссылка`);
|
|
41
|
+
return f.target;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Классы-листы справочника, где можно создать объект. */
|
|
45
|
+
function creatable(db, cid) {
|
|
46
|
+
return descendants(db, cid)
|
|
47
|
+
.filter(c => isLeaf(db, c))
|
|
48
|
+
.map(c => getClass(db, c))
|
|
49
|
+
.filter(c => c.type === '$catalog' || c.type === '$registry')
|
|
50
|
+
.map(c => ({ cid: c.cid, path: c.path, label: c.label }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plain = o => ({ id: o.id, cid: o.cid, title: o.title });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Кандидаты для поля-ссылки.
|
|
57
|
+
* @param {object} q
|
|
58
|
+
* @param {string} [q.target] Класс ссылки (или `cid` + `field`)
|
|
59
|
+
* @param {string} [q.cid] Класс объекта, где поле
|
|
60
|
+
* @param {string} [q.field] Поле ('client', 'staff.employee')
|
|
61
|
+
* @param {*} [q.at] Дата операции: состав виртуального справочника на неё
|
|
62
|
+
* @param {string} [q.search] Поиск по началам слов
|
|
63
|
+
* @param {boolean} [q.all] Показать и объекты реестра вне состава (без поиска)
|
|
64
|
+
* @param {number} [q.limit=200]
|
|
65
|
+
* @param {Array<object>|null} [q.visible] Права видимости справочника (scope.js); у виртуального — проводок состава
|
|
66
|
+
* @param {Array<object>|null} [q.registryVisible] Права на объекты реестра виртуального (по умолчанию — `visible` без веток)
|
|
67
|
+
* @returns {{ target, virtual: boolean, items: Array<{ id, cid, title, member, balance?, ext? }>,
|
|
68
|
+
* total: number, others: number, create: Array<{ cid, label }> }}
|
|
69
|
+
* total — всего подходящих; others — объектов реестра вне показанного состава (кнопка «показать все»)
|
|
70
|
+
*/
|
|
71
|
+
export function linkChoices(db, q = {}) {
|
|
72
|
+
const target = resolveTarget(db, q);
|
|
73
|
+
const schema = classSchema(db, target);
|
|
74
|
+
if (!schema || schema.family !== 'catalog')
|
|
75
|
+
throw new DataError(`справочник ${target} не найден`);
|
|
76
|
+
const limit = Math.max(1, Math.min(1000, Number(q.limit) || 200));
|
|
77
|
+
const search = String(q.search ?? '').trim() || undefined;
|
|
78
|
+
|
|
79
|
+
if (schema.type !== '$virtual') {
|
|
80
|
+
const r = queryObjects(db, { cid: schema.cid, search, limit: POOL, visible: q.visible });
|
|
81
|
+
return {
|
|
82
|
+
target: schema.cid,
|
|
83
|
+
virtual: false,
|
|
84
|
+
items: r.items.map(o => ({ ...plain(o), member: true })).sort(byTitle).slice(0, limit),
|
|
85
|
+
total: r.total,
|
|
86
|
+
others: 0,
|
|
87
|
+
create: creatable(db, schema.cid),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const registry = getClass(db, schema.source?.registry);
|
|
92
|
+
const members = virtualMembers(db, {
|
|
93
|
+
cid: schema.cid, at: q.at, units: q.units, unit: q.unit, visible: entryGrants(q.visible, 'catalog'),
|
|
94
|
+
}).items;
|
|
95
|
+
const member = m => ({ id: m.id, cid: m.cid, title: m.title, member: true, balance: m.balance, ext: m.ext });
|
|
96
|
+
const create = creatable(db, registry.cid);
|
|
97
|
+
const regVisible = q.registryVisible !== undefined ? q.registryVisible : withoutBranches(q.visible);
|
|
98
|
+
const regTotal = queryObjects(db, { cid: registry.cid, limit: 1, visible: regVisible }).total;
|
|
99
|
+
|
|
100
|
+
if (!search && !q.all)
|
|
101
|
+
return {
|
|
102
|
+
target: schema.cid,
|
|
103
|
+
virtual: true,
|
|
104
|
+
items: members.slice(0, limit).map(member),
|
|
105
|
+
total: members.length,
|
|
106
|
+
others: Math.max(0, regTotal - members.length),
|
|
107
|
+
create,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const pool = queryObjects(db, { cid: registry.cid, search, limit: POOL, visible: regVisible });
|
|
111
|
+
const found = new Set(pool.items.map(o => o.id));
|
|
112
|
+
const ids = new Set(members.map(m => m.id));
|
|
113
|
+
const items = [
|
|
114
|
+
...members.filter(m => !search || found.has(m.id)).map(member),
|
|
115
|
+
...pool.items.filter(o => !ids.has(o.id)).map(o => ({ ...plain(o), member: false })).sort(byTitle),
|
|
116
|
+
];
|
|
117
|
+
return {
|
|
118
|
+
target: schema.cid,
|
|
119
|
+
virtual: true,
|
|
120
|
+
items: items.slice(0, limit),
|
|
121
|
+
total: pool.total,
|
|
122
|
+
others: 0,
|
|
123
|
+
create,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Кэш дерева классов в БД данных: таблицы `classes` и `class_tree`.
|
|
3
|
+
*
|
|
4
|
+
* Источник истины — файлы дерева: класс = папка с метапапкой `$тип`, собственный
|
|
5
|
+
* слой — `<класс>/$тип/class.js`. Здесь дерево сканируется и складывается в БД,
|
|
6
|
+
* чтобы иерархию (итоги по счёту со всеми субсчетами, объекты промежуточного
|
|
7
|
+
* класса операций, подразделение со всеми вложенными) отвечал один SQL-запрос
|
|
8
|
+
* по замыканию `class_tree`.
|
|
9
|
+
*
|
|
10
|
+
* Идентификатор класса `cid` всегда равен его пути в дереве WORK (`path`).
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fsp from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
import { tx } from './db.js';
|
|
17
|
+
|
|
18
|
+
/** Типы прикладных классов данных. */
|
|
19
|
+
export const DATA_TYPES = Object.freeze(['$catalog', '$virtual', '$struct', '$operation', '$account', '$register']);
|
|
20
|
+
|
|
21
|
+
/** Типы классов орг-структуры (подразделения), доменов и модулей. */
|
|
22
|
+
export const STRUCTURE_TYPES = Object.freeze(['$structure', '$base']);
|
|
23
|
+
export const MODULE_TYPES = Object.freeze(['$module']);
|
|
24
|
+
export const DOMAIN_TYPES = Object.freeze(['$domain']);
|
|
25
|
+
|
|
26
|
+
/** Корни дерева, которые сканируются по умолчанию. */
|
|
27
|
+
export const DEFAULT_ROOTS = Object.freeze(['MODULES', 'ORGANIZATIONS', 'DATA', 'BASE']);
|
|
28
|
+
|
|
29
|
+
/** Папки, которые не бывают классами. */
|
|
30
|
+
const SKIP = new Set(['node_modules', 'history', 'logs']);
|
|
31
|
+
|
|
32
|
+
/** Приоритет при нескольких метапапках в одной папке: известные типы раньше прочих. */
|
|
33
|
+
const KNOWN = [...DATA_TYPES, '$registry', ...STRUCTURE_TYPES, ...MODULE_TYPES, ...DOMAIN_TYPES];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Копия значения без функций, геттеров и символов (только данные class.js).
|
|
37
|
+
* Геттеры не вызываются: в class.js они могут обращаться к ядру.
|
|
38
|
+
*/
|
|
39
|
+
export function plainData(value, depth = 0) {
|
|
40
|
+
if (depth > 32 || typeof value === 'function' || typeof value === 'symbol')
|
|
41
|
+
return undefined;
|
|
42
|
+
if (value === null || typeof value !== 'object')
|
|
43
|
+
return value;
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return value.map(v => plainData(v, depth + 1) ?? null);
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [key, d] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
48
|
+
if (!('value' in d))
|
|
49
|
+
continue;
|
|
50
|
+
const v = plainData(d.value, depth + 1);
|
|
51
|
+
if (v !== undefined)
|
|
52
|
+
out[key] = v;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ownFileCache = new Map();
|
|
58
|
+
const rootScanCache = new Map();
|
|
59
|
+
|
|
60
|
+
export function clearScanCache() {
|
|
61
|
+
rootScanCache.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Собственный слой class.js как данные (быстрый разбор литерала с фолбэком на ESM-импорт). */
|
|
65
|
+
async function readOwn(file) {
|
|
66
|
+
let stat;
|
|
67
|
+
try {
|
|
68
|
+
stat = await fsp.stat(file);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.code === 'ENOENT')
|
|
72
|
+
return {};
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
const mtime = Math.trunc(stat.mtimeMs);
|
|
76
|
+
const cached = ownFileCache.get(file);
|
|
77
|
+
if (cached && cached.mtime === mtime)
|
|
78
|
+
return cached.data;
|
|
79
|
+
let rawMod;
|
|
80
|
+
try {
|
|
81
|
+
const src = await fsp.readFile(file, 'utf-8');
|
|
82
|
+
const trimmed = src.replace(/^\uFEFF/, '').trim();
|
|
83
|
+
if (trimmed.startsWith('export default') && !/\bimport\b/.test(trimmed)) {
|
|
84
|
+
const expr = trimmed.replace(/^export\s+default\s+/, '').replace(/;\s*$/, '');
|
|
85
|
+
rawMod = new Function(`"use strict"; return (${expr});`)();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
rawMod = undefined;
|
|
90
|
+
}
|
|
91
|
+
if (rawMod === undefined) {
|
|
92
|
+
const mod = await import(pathToFileURL(file).href + '?mtime=' + mtime);
|
|
93
|
+
rawMod = mod.default ?? {};
|
|
94
|
+
}
|
|
95
|
+
const data = plainData(rawMod ?? {}) || {};
|
|
96
|
+
ownFileCache.set(file, { mtime, data });
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Найти классы в дереве.
|
|
102
|
+
* Идентификатор класса `cid` равен его пути `path`.
|
|
103
|
+
* @param {string} rootDir Корень WORK на диске
|
|
104
|
+
* @param {readonly string[]} [roots] Корневые папки сканирования (отсутствующие пропускаются)
|
|
105
|
+
* @returns {Promise<{ classes: Array<{cid, path, type, label, own}>, problems: string[] }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function scanClasses(rootDir, roots = DEFAULT_ROOTS) {
|
|
108
|
+
const classes = [];
|
|
109
|
+
const problems = [];
|
|
110
|
+
|
|
111
|
+
async function visit(rel, probList = problems) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await fsp.readdir(path.join(rootDir, rel), { withFileTypes: true });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR')
|
|
118
|
+
return;
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
122
|
+
const metas = dirs.filter(n => n.startsWith('$'));
|
|
123
|
+
let selfItem = null;
|
|
124
|
+
if (metas.length) {
|
|
125
|
+
const ranked = [...metas].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
126
|
+
const type = ranked[0];
|
|
127
|
+
const clsPath = '/' + rel.split(path.sep).join('/');
|
|
128
|
+
if (metas.length > 1)
|
|
129
|
+
probList.push(`${clsPath}: несколько метапапок (${metas.join(', ')}), выбран ${type}`);
|
|
130
|
+
let own = {};
|
|
131
|
+
try {
|
|
132
|
+
own = await readOwn(path.join(rootDir, rel, type, 'class.js'));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
probList.push(`${clsPath}: class.js не загружен — ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
const label = typeof own.label === 'string' && own.label ? own.label : path.basename(rel);
|
|
138
|
+
selfItem = { cid: clsPath, path: clsPath, type, label, own };
|
|
139
|
+
}
|
|
140
|
+
const childDirs = dirs.filter(name => !name.startsWith('$') && !name.startsWith('.') && !name.startsWith('#') && !SKIP.has(name));
|
|
141
|
+
const subLists = await Promise.all(childDirs.map(name => visit(path.join(rel, name), probList)));
|
|
142
|
+
const out = selfItem ? [selfItem] : [];
|
|
143
|
+
for (const sub of subLists) {
|
|
144
|
+
if (sub?.length)
|
|
145
|
+
out.push(...sub);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const absRoot = path.resolve(rootDir);
|
|
152
|
+
const isMainRepo = absRoot === path.resolve(process.cwd());
|
|
153
|
+
const rootLists = await Promise.all(roots.map(async root => {
|
|
154
|
+
const cacheKey = `${absRoot}::${root}`;
|
|
155
|
+
if (isMainRepo && root === 'MODULES') {
|
|
156
|
+
const hit = rootScanCache.get(cacheKey);
|
|
157
|
+
if (hit && now - hit.ts < 5000) {
|
|
158
|
+
const res = await hit.promise;
|
|
159
|
+
if (res.problems.length)
|
|
160
|
+
problems.push(...res.problems);
|
|
161
|
+
return res.items;
|
|
162
|
+
}
|
|
163
|
+
const promise = (async () => {
|
|
164
|
+
const localProblems = [];
|
|
165
|
+
const items = await visit(root, localProblems);
|
|
166
|
+
return { items: items || [], problems: localProblems };
|
|
167
|
+
})();
|
|
168
|
+
rootScanCache.set(cacheKey, { ts: now, promise });
|
|
169
|
+
const res = await promise;
|
|
170
|
+
if (res.problems.length)
|
|
171
|
+
problems.push(...res.problems);
|
|
172
|
+
return res.items;
|
|
173
|
+
}
|
|
174
|
+
return visit(root, problems);
|
|
175
|
+
}));
|
|
176
|
+
for (const list of rootLists) {
|
|
177
|
+
if (list?.length)
|
|
178
|
+
classes.push(...list);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { classes, problems };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function rank(meta) {
|
|
185
|
+
const i = KNOWN.indexOf(meta);
|
|
186
|
+
return i < 0 ? KNOWN.length : i;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const generations = new WeakMap();
|
|
190
|
+
|
|
191
|
+
/** Поколение кэша классов БД: растёт при каждом изменении (сброс производных кэшей — схем). */
|
|
192
|
+
export function classesGeneration(db) {
|
|
193
|
+
return generations.get(db) || 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hashOf(own) {
|
|
197
|
+
return crypto.createHash('sha1').update(JSON.stringify(own)).digest('hex');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Записать найденные классы в БД: upsert, удаление исчезнувших, пересборка замыкания.
|
|
202
|
+
* Родитель класса — ближайший найденный класс-предок по пути.
|
|
203
|
+
* @returns {{ total: number, changed: number, removed: number }}
|
|
204
|
+
*/
|
|
205
|
+
export function syncClasses(db, classes) {
|
|
206
|
+
const byPath = new Map(classes.map(c => [c.path, c]));
|
|
207
|
+
const parentOf = c => {
|
|
208
|
+
for (let p = path.posix.dirname(c.path); p !== '/' && p !== '.'; p = path.posix.dirname(p)) {
|
|
209
|
+
const hit = byPath.get(p);
|
|
210
|
+
if (hit)
|
|
211
|
+
return hit.cid;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
};
|
|
215
|
+
return tx(db, () => {
|
|
216
|
+
const old = new Map(db.prepare('SELECT cid, path, parent, type, label, hash FROM classes').all().map(r => [r.cid, r]));
|
|
217
|
+
const upsert = db.prepare(`
|
|
218
|
+
INSERT INTO classes (cid, path, parent, type, label, own, hash) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
219
|
+
ON CONFLICT(cid) DO UPDATE SET path = excluded.path, parent = excluded.parent, type = excluded.type,
|
|
220
|
+
label = excluded.label, own = excluded.own, hash = excluded.hash`);
|
|
221
|
+
let changed = 0;
|
|
222
|
+
const del = db.prepare('DELETE FROM classes WHERE cid = ?');
|
|
223
|
+
const keep = new Set(classes.map(c => c.cid));
|
|
224
|
+
let removed = 0;
|
|
225
|
+
for (const cid of old.keys()) {
|
|
226
|
+
if (!keep.has(cid)) {
|
|
227
|
+
del.run(cid);
|
|
228
|
+
removed++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const c of classes) {
|
|
232
|
+
const parent = parentOf(c);
|
|
233
|
+
const hash = hashOf(c.own);
|
|
234
|
+
const prev = old.get(c.cid);
|
|
235
|
+
if (prev && prev.path === c.path && prev.parent === parent && prev.type === c.type && prev.label === c.label && prev.hash === hash)
|
|
236
|
+
continue;
|
|
237
|
+
if (prev?.path !== c.path)
|
|
238
|
+
db.prepare('DELETE FROM classes WHERE path = ? AND cid <> ?').run(c.path, c.cid);
|
|
239
|
+
upsert.run(c.cid, c.path, parent, c.type, c.label, JSON.stringify(c.own), hash);
|
|
240
|
+
changed++;
|
|
241
|
+
}
|
|
242
|
+
if (changed || removed) {
|
|
243
|
+
rebuildTree(db);
|
|
244
|
+
generations.set(db, classesGeneration(db) + 1);
|
|
245
|
+
}
|
|
246
|
+
return { total: classes.length, changed, removed };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Пересобрать замыкание иерархии из `classes.parent`. */
|
|
251
|
+
export function rebuildTree(db) {
|
|
252
|
+
tx(db, () => {
|
|
253
|
+
db.exec('DELETE FROM class_tree');
|
|
254
|
+
const parent = new Map(db.prepare('SELECT cid, parent FROM classes').all().map(r => [r.cid, r.parent]));
|
|
255
|
+
const ins = db.prepare('INSERT INTO class_tree (ancestor, descendant, depth) VALUES (?, ?, ?)');
|
|
256
|
+
for (const cid of parent.keys()) {
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
for (let a = cid, d = 0; a && parent.has(a) && !seen.has(a); a = parent.get(a), d++) {
|
|
259
|
+
seen.add(a);
|
|
260
|
+
ins.run(a, cid, d);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
generations.set(db, classesGeneration(db) + 1);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Просканировать дерево и обновить кэш классов.
|
|
269
|
+
* @returns {Promise<{ total: number, changed: number, removed: number, problems: string[] }>}
|
|
270
|
+
*/
|
|
271
|
+
export async function refreshClasses(db, rootDir, roots = DEFAULT_ROOTS) {
|
|
272
|
+
const { classes, problems } = await scanClasses(rootDir, roots);
|
|
273
|
+
return { ...syncClasses(db, classes), problems };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const classIndexCache = new WeakMap();
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Построить или вернуть из памяти индекс всех классов и замыкания иерархии БД.
|
|
280
|
+
* Сбрасывается автоматически при изменении `classesGeneration(db)`.
|
|
281
|
+
*/
|
|
282
|
+
function getClassIndex(db) {
|
|
283
|
+
const gen = classesGeneration(db);
|
|
284
|
+
let idx = classIndexCache.get(db);
|
|
285
|
+
if (idx && idx.gen === gen)
|
|
286
|
+
return idx;
|
|
287
|
+
|
|
288
|
+
const byCid = new Map();
|
|
289
|
+
const byPath = new Map();
|
|
290
|
+
const childrenByParent = new Map();
|
|
291
|
+
const nonVirtualChildParents = new Set();
|
|
292
|
+
const allSortedByPath = [];
|
|
293
|
+
|
|
294
|
+
const rows = db.prepare('SELECT cid, path, parent, type, label, own FROM classes ORDER BY path').all();
|
|
295
|
+
for (const r of rows) {
|
|
296
|
+
const item = {
|
|
297
|
+
cid: r.cid,
|
|
298
|
+
path: r.path,
|
|
299
|
+
parent: r.parent,
|
|
300
|
+
type: r.type,
|
|
301
|
+
label: r.label,
|
|
302
|
+
own: r.own ? JSON.parse(r.own) : {},
|
|
303
|
+
};
|
|
304
|
+
byCid.set(item.cid, item);
|
|
305
|
+
byPath.set(item.path, item);
|
|
306
|
+
allSortedByPath.push(item);
|
|
307
|
+
if (item.parent) {
|
|
308
|
+
let list = childrenByParent.get(item.parent);
|
|
309
|
+
if (!list)
|
|
310
|
+
childrenByParent.set(item.parent, list = []);
|
|
311
|
+
list.push(item);
|
|
312
|
+
if (item.type !== '$virtual')
|
|
313
|
+
nonVirtualChildParents.add(item.parent);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const list of childrenByParent.values())
|
|
317
|
+
list.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
|
|
318
|
+
|
|
319
|
+
const descByAncestor = new Map();
|
|
320
|
+
const treeRows = db.prepare('SELECT ancestor, descendant, depth FROM class_tree ORDER BY ancestor, depth, descendant').all();
|
|
321
|
+
for (const tr of treeRows) {
|
|
322
|
+
let dList = descByAncestor.get(tr.ancestor);
|
|
323
|
+
if (!dList)
|
|
324
|
+
descByAncestor.set(tr.ancestor, dList = []);
|
|
325
|
+
dList.push(tr.descendant);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ancByDescendant = new Map();
|
|
329
|
+
for (const item of allSortedByPath) {
|
|
330
|
+
const chain = [];
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
for (let cur = item; cur && !seen.has(cur.cid); cur = cur.parent ? byCid.get(cur.parent) : null) {
|
|
333
|
+
seen.add(cur.cid);
|
|
334
|
+
chain.unshift(cur);
|
|
335
|
+
}
|
|
336
|
+
ancByDescendant.set(item.cid, chain);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
idx = {
|
|
340
|
+
gen,
|
|
341
|
+
byCid,
|
|
342
|
+
byPath,
|
|
343
|
+
childrenByParent,
|
|
344
|
+
nonVirtualChildParents,
|
|
345
|
+
descByAncestor,
|
|
346
|
+
ancByDescendant,
|
|
347
|
+
allSortedByPath,
|
|
348
|
+
};
|
|
349
|
+
classIndexCache.set(db, idx);
|
|
350
|
+
return idx;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function cloneClassRow(r) {
|
|
354
|
+
return r ? { ...r } : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Класс по пути (null — нет). */
|
|
358
|
+
export function getClass(db, ref) {
|
|
359
|
+
const key = String(ref ?? '');
|
|
360
|
+
if (!key)
|
|
361
|
+
return null;
|
|
362
|
+
const idx = getClassIndex(db);
|
|
363
|
+
return cloneClassRow(idx.byCid.get(key) || idx.byPath.get(key));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Пути потомков класса.
|
|
368
|
+
* @param {object} [opts] { self = true — включая сам класс }
|
|
369
|
+
* @returns {string[]}
|
|
370
|
+
*/
|
|
371
|
+
export function descendants(db, cid, { self = true } = {}) {
|
|
372
|
+
const list = getClassIndex(db).descByAncestor.get(String(cid ?? ''));
|
|
373
|
+
if (!list?.length)
|
|
374
|
+
return [];
|
|
375
|
+
return self ? list.slice() : list.slice(1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Цепочка предков от корня к классу.
|
|
380
|
+
* @param {object} [opts] { self = true — включая сам класс }
|
|
381
|
+
* @returns {Array<{cid, path, parent, type, label, own}>}
|
|
382
|
+
*/
|
|
383
|
+
export function ancestors(db, cid, { self = true } = {}) {
|
|
384
|
+
const chain = getClassIndex(db).ancByDescendant.get(String(cid ?? ''));
|
|
385
|
+
if (!chain?.length)
|
|
386
|
+
return [];
|
|
387
|
+
const slice = self ? chain : chain.slice(0, -1);
|
|
388
|
+
return slice.map(cloneClassRow);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Прямые дочерние классы. */
|
|
392
|
+
export function children(db, cid) {
|
|
393
|
+
const list = getClassIndex(db).childrenByParent.get(String(cid ?? ''));
|
|
394
|
+
return list ? list.map(cloneClassRow) : [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Лист — класс без дочерних классов своего реального типа (виртуальные справочники $virtual всегда листья для своих расширений и не превращают родительский $catalog в не-лист). */
|
|
398
|
+
export function isLeaf(db, cid) {
|
|
399
|
+
const key = String(cid ?? '');
|
|
400
|
+
const idx = getClassIndex(db);
|
|
401
|
+
const cls = idx.byCid.get(key) || idx.byPath.get(key);
|
|
402
|
+
if (cls?.type === '$virtual')
|
|
403
|
+
return true;
|
|
404
|
+
const targetCid = cls?.cid || key;
|
|
405
|
+
return !idx.nonVirtualChildParents.has(targetCid);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Все классы типа (или типов). */
|
|
409
|
+
export function classesOfType(db, types) {
|
|
410
|
+
const set = new Set([].concat(types).map(String));
|
|
411
|
+
return getClassIndex(db).allSortedByPath.filter(c => set.has(c.type)).map(cloneClassRow);
|
|
412
|
+
}
|