Files

209 lines
7.5 KiB
JavaScript
Raw Permalink Normal View History

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
}