2026-10-30 12:28:48 +01:00
|
|
|
// Generated, do not edit. sha256:ac3a8e4322658b62e93903e91a12ebe573e29b3520917e4d09d4d77210b67abb
|
2026-10-29 11:57:29 +01:00
|
|
|
import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs';
|
|
|
|
|
import { join, resolve, dirname, sep } from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
import { fail } from '../shared/app-error.js';
|
2026-10-30 12:28:48 +01:00
|
|
|
import { CONFIG } from '../shared/config.js';
|
2026-10-29 11:57:29 +01:00
|
|
|
|
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
|
|
|
|
|
|
const POINTER_NAME = 'active.json';
|
|
|
|
|
const LEGACY_POINTER_NAME = 'actief.json';
|
|
|
|
|
const POINTER_FIELD = 'portfolio';
|
|
|
|
|
const LEGACY_POINTER_FIELD = 'portefeuille';
|
|
|
|
|
|
|
|
|
|
export const RESERVED_DIRS = Object.freeze(['tls', 'Archive']);
|
|
|
|
|
|
|
|
|
|
const ARCHIVE_NAME = 'Archive';
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_NAME = 'MyAssets';
|
|
|
|
|
|
|
|
|
|
export const MIGRATION_NAME = 'Test';
|
|
|
|
|
|
|
|
|
|
const PORTFOLIO_FILE_PREFIXES = ['db.sqlite', 'secrets.json'];
|
|
|
|
|
|
|
|
|
|
export function installDir() {
|
|
|
|
|
return process.env.ASSETAPP_DATA_DIR || join(ROOT, 'data');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function pointerFile() {
|
|
|
|
|
return join(installDir(), POINTER_NAME);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function archiveDir() {
|
|
|
|
|
return join(installDir(), ARCHIVE_NAME);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function portfolioDir(name) {
|
|
|
|
|
assertValidName(name);
|
|
|
|
|
const basis = resolve(installDir());
|
|
|
|
|
const doel = resolve(join(installDir(), name));
|
|
|
|
|
if (!doel.startsWith(basis + sep)) {
|
|
|
|
|
throw new Error(`Portfolio '${name}' would land outside the data directory.`);
|
|
|
|
|
}
|
|
|
|
|
return doel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FORBIDDEN_CHARS = /[<>:"/\\|?*]/;
|
|
|
|
|
|
|
|
|
|
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
|
|
|
|
|
|
|
|
const MAX_LENGTH = 64;
|
|
|
|
|
|
|
|
|
|
const hasControlChar = (name) => [...name].some(teken => teken.codePointAt(0) < 0x20);
|
|
|
|
|
|
|
|
|
|
export function assertValidName(name) {
|
|
|
|
|
if (typeof name !== 'string' || name.length === 0) {
|
|
|
|
|
fail('portfolios.nameRequired', null, 'A portfolio needs a name.');
|
|
|
|
|
}
|
|
|
|
|
if (name.length > MAX_LENGTH) {
|
|
|
|
|
fail('portfolios.nameTooLong', { max: MAX_LENGTH }, `A portfolio name is at most ${MAX_LENGTH} characters long.`);
|
|
|
|
|
}
|
|
|
|
|
if (name === '.' || name === '..') {
|
|
|
|
|
fail('portfolios.nameIsDirRef', { naam: name }, `'${name}' is not a name but a reference to a directory.`);
|
|
|
|
|
}
|
|
|
|
|
if (FORBIDDEN_CHARS.test(name) || hasControlChar(name)) {
|
|
|
|
|
fail('portfolios.nameForbiddenChars', null, 'A portfolio name may not contain any of these characters: < > : " / \\ | ? *');
|
|
|
|
|
}
|
|
|
|
|
if (/[ .]$/.test(name)) {
|
|
|
|
|
fail('portfolios.nameTrailing', null, 'A portfolio name may not end in a space or a dot.');
|
|
|
|
|
}
|
|
|
|
|
if (/^ /.test(name)) {
|
|
|
|
|
fail('portfolios.nameLeadingSpace', null, 'A portfolio name may not start with a space.');
|
|
|
|
|
}
|
|
|
|
|
if (RESERVED_NAMES.test(name.split('.')[0])) {
|
|
|
|
|
fail('portfolios.nameReserved', { naam: name }, `'${name}' is a reserved name on Windows and cannot become a directory.`);
|
|
|
|
|
}
|
|
|
|
|
if (RESERVED_DIRS.some(gereserveerd => normalizeName(gereserveerd) === normalizeName(name))) {
|
|
|
|
|
fail('portfolios.nameTaken', { naam: name }, `'${name}' is a name the app uses itself.`);
|
|
|
|
|
}
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeName(name) {
|
|
|
|
|
if (typeof name !== 'string') {
|
|
|
|
|
throw new TypeError(`Een portefeuillenaam is tekst; gekregen: ${typeof name}.`);
|
|
|
|
|
}
|
|
|
|
|
return name.normalize('NFC').toLocaleLowerCase('nl');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sameName(a, b) {
|
|
|
|
|
if (a == null || b == null) return false;
|
|
|
|
|
return normalizeName(a) === normalizeName(b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readActiveName() {
|
|
|
|
|
let rauw;
|
|
|
|
|
try { rauw = readFileSync(pointerFile(), 'utf-8'); }
|
|
|
|
|
catch (e) {
|
|
|
|
|
if (e.code !== 'ENOENT') throw e;
|
|
|
|
|
try { rauw = readFileSync(join(installDir(), LEGACY_POINTER_NAME), 'utf-8'); }
|
|
|
|
|
catch (e2) { if (e2.code === 'ENOENT') return null; throw e2; }
|
|
|
|
|
}
|
|
|
|
|
let data;
|
|
|
|
|
try { data = JSON.parse(rauw); }
|
|
|
|
|
catch { throw new Error(`${pointerFile()} is not valid JSON; repair or remove the file.`); }
|
|
|
|
|
const naam = data && (data[POINTER_FIELD] ?? data[LEGACY_POINTER_FIELD]);
|
|
|
|
|
if (typeof naam !== 'string' || naam.length === 0) {
|
|
|
|
|
throw new Error(`${pointerFile()} names no portfolio (field '${POINTER_FIELD}').`);
|
|
|
|
|
}
|
|
|
|
|
return assertValidName(naam);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listPortfolios() {
|
|
|
|
|
let entries;
|
|
|
|
|
try { entries = readdirSync(installDir(), { withFileTypes: true }); }
|
|
|
|
|
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
|
|
|
const actief = readActiveName();
|
|
|
|
|
return entries
|
|
|
|
|
.filter(e => e.isDirectory())
|
|
|
|
|
.map(e => e.name)
|
|
|
|
|
.filter(naam => !RESERVED_DIRS.some(g => normalizeName(g) === normalizeName(naam)))
|
|
|
|
|
.map(naam => {
|
|
|
|
|
const map = join(installDir(), naam);
|
|
|
|
|
const db = join(map, 'db.sqlite');
|
|
|
|
|
return {
|
|
|
|
|
naam,
|
|
|
|
|
actief: actief !== null && sameName(naam, actief),
|
2026-10-30 12:28:48 +01:00
|
|
|
demo: sameName(naam, CONFIG.PORTFOLIO.DEMO_NAME),
|
2026-10-29 11:57:29 +01:00
|
|
|
heeftDatabase: bestaat(db),
|
|
|
|
|
heeftGeheimen: bestaat(join(map, 'secrets.json')),
|
|
|
|
|
laatstGebruikt: mtimeOf(db),
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => a.naam.localeCompare(b.naam, 'nl'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function writeActiveName(name) {
|
|
|
|
|
assertValidName(name);
|
|
|
|
|
mkdirSync(installDir(), { recursive: true });
|
|
|
|
|
writeFileSync(pointerFile(), `${JSON.stringify({ [POINTER_FIELD]: name }, null, 2)}\n`, 'utf-8');
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function activeName() {
|
|
|
|
|
return readActiveName() || DEFAULT_NAME;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function activeDir() {
|
|
|
|
|
return portfolioDir(activeName());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bestaat = (pad) => { try { statSync(pad); return true; } catch { return false; } };
|
|
|
|
|
|
|
|
|
|
const mtimeOf = (pad) => { try { return statSync(pad).mtime.toISOString(); } catch { return null; } };
|
|
|
|
|
|
|
|
|
|
function looseFiles() {
|
|
|
|
|
let entries;
|
|
|
|
|
try { entries = readdirSync(installDir(), { withFileTypes: true }); }
|
|
|
|
|
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
|
|
|
return entries
|
|
|
|
|
.filter(e => e.isFile())
|
|
|
|
|
.map(e => e.name)
|
|
|
|
|
.filter(naam => PORTFOLIO_FILE_PREFIXES.some(prefix => naam.startsWith(prefix)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function migrateLooseLayout() {
|
|
|
|
|
const losse = looseFiles();
|
|
|
|
|
if (!losse.length) return null;
|
|
|
|
|
|
|
|
|
|
const doelMap = portfolioDir(MIGRATION_NAME);
|
|
|
|
|
if (bestaat(join(doelMap, 'db.sqlite'))) {
|
|
|
|
|
const losseNamen = losse.join(', ');
|
|
|
|
|
throw new Error(
|
|
|
|
|
`There are loose database files in ${installDir()} (${losseNamen}) and there is already a database `
|
|
|
|
|
+ `in ${doelMap}. The app does not guess which one is the right one.\n`
|
|
|
|
|
+ 'Almost always a process from before this layout is still running: an MCP server or an old app '
|
|
|
|
|
+ 'server then recreates the loose file every time. Close those processes (and restart your AI '
|
|
|
|
|
+ 'client), check which file is the largest, and remove or rename the loose one so it no longer '
|
|
|
|
|
+ 'starts with "db.sqlite" or "secrets.json".');
|
|
|
|
|
}
|
|
|
|
|
mkdirSync(doelMap, { recursive: true });
|
|
|
|
|
for (const naam of losse) renameSync(join(installDir(), naam), join(doelMap, naam));
|
|
|
|
|
return MIGRATION_NAME;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hernoemOudeWijzer() {
|
|
|
|
|
const oud = join(installDir(), LEGACY_POINTER_NAME);
|
|
|
|
|
if (!bestaat(oud) || bestaat(pointerFile())) return;
|
|
|
|
|
const naam = readActiveName();
|
|
|
|
|
if (naam) writeActiveName(naam);
|
|
|
|
|
rmSync(oud, { force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function prepareLayout() {
|
|
|
|
|
mkdirSync(installDir(), { recursive: true });
|
|
|
|
|
hernoemOudeWijzer();
|
|
|
|
|
const migrated = migrateLooseLayout();
|
|
|
|
|
let naam = readActiveName();
|
2026-10-30 12:28:48 +01:00
|
|
|
const fresh = !naam && !migrated && listPortfolios().length === 0;
|
2026-10-29 11:57:29 +01:00
|
|
|
let herkomst = `uit ${POINTER_NAME}`;
|
|
|
|
|
if (!naam) {
|
|
|
|
|
naam = writeActiveName(migrated || DEFAULT_NAME);
|
|
|
|
|
herkomst = migrated ? `losse bestanden verhuisd naar '${migrated}'` : 'nieuw aangemaakt';
|
|
|
|
|
} else if (migrated) {
|
|
|
|
|
herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`;
|
|
|
|
|
}
|
|
|
|
|
mkdirSync(portfolioDir(naam), { recursive: true });
|
2026-10-30 12:28:48 +01:00
|
|
|
return { name: naam, migrated, fresh, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` };
|
2026-10-29 11:57:29 +01:00
|
|
|
}
|