@cadcrawl/cad-browser 0.3.0

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.
@@ -0,0 +1,127 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import { scanProject } from './scanner.js';
5
+ import { analyzeFile } from './analyzer.js';
6
+ import { createProjectCache, readCache, writeCache } from './cache.js';
7
+ import { resolveInside } from './path-safety.js';
8
+
9
+ export class ProjectStore extends EventEmitter {
10
+ constructor(rootPath) {
11
+ super();
12
+ this.rootPath = path.resolve(rootPath);
13
+ this.cache = null;
14
+ this.cacheIndex = null;
15
+ this.snapshot = null;
16
+ this.queue = [];
17
+ this.queued = new Set();
18
+ this.active = 0;
19
+ this.concurrency = 2;
20
+ this.cacheWrite = Promise.resolve();
21
+ }
22
+
23
+ async initialize() {
24
+ this.cache = await createProjectCache(this.rootPath);
25
+ this.cacheIndex = await readCache(this.cache);
26
+ await this.rescan();
27
+ }
28
+
29
+ async rescan() {
30
+ const scanned = await scanProject(this.rootPath);
31
+ const files = scanned.files.map((file) => {
32
+ const cached = this.cacheIndex.files[file.path];
33
+ const fresh = cached?.modifiedKey === file.modifiedKey;
34
+ return {
35
+ ...file,
36
+ status: file.analyzable ? (fresh ? 'ready' : 'queued') : 'plain',
37
+ analysis: fresh ? cached.analysis : null,
38
+ };
39
+ });
40
+ this.snapshot = {
41
+ rootName: path.basename(this.rootPath),
42
+ rootPath: this.rootPath,
43
+ scannedAt: new Date().toISOString(),
44
+ tree: scanned.tree,
45
+ files,
46
+ counts: countKinds(files),
47
+ };
48
+ this.emit('snapshot', this.publicSnapshot());
49
+ for (const file of files.filter((item) => item.analyzable && item.status === 'queued')) {
50
+ this.enqueue(file.path);
51
+ }
52
+ return this.publicSnapshot();
53
+ }
54
+
55
+ publicSnapshot() {
56
+ return {
57
+ ...this.snapshot,
58
+ rootPath: this.rootPath,
59
+ cachePath: this.cache.directory,
60
+ };
61
+ }
62
+
63
+ findFile(relativePath) {
64
+ return this.snapshot.files.find((file) => file.path === relativePath) ?? null;
65
+ }
66
+
67
+ enqueue(relativePath, force = false) {
68
+ const file = this.findFile(relativePath);
69
+ if (!file?.analyzable || this.queued.has(relativePath)) return;
70
+ if (!force && file.status === 'ready') return;
71
+ file.status = 'queued';
72
+ this.queue.push({ relativePath, force });
73
+ this.queued.add(relativePath);
74
+ this.pump();
75
+ }
76
+
77
+ async pump() {
78
+ while (this.active < this.concurrency && this.queue.length > 0) {
79
+ const job = this.queue.shift();
80
+ this.active += 1;
81
+ this.process(job).finally(() => {
82
+ this.active -= 1;
83
+ this.queued.delete(job.relativePath);
84
+ this.pump();
85
+ });
86
+ }
87
+ }
88
+
89
+ async process({ relativePath }) {
90
+ const file = this.findFile(relativePath);
91
+ if (!file) return;
92
+ file.status = 'processing';
93
+ this.emit('file', file);
94
+ try {
95
+ const absolutePath = resolveInside(this.rootPath, relativePath);
96
+ const analysis = await analyzeFile(absolutePath, relativePath, this.cache, file.kind);
97
+ file.analysis = analysis;
98
+ file.status = 'ready';
99
+ file.error = null;
100
+ this.cacheIndex.files[relativePath] = {
101
+ modifiedKey: file.modifiedKey,
102
+ analysis,
103
+ };
104
+ this.cacheWrite = this.cacheWrite.then(() => writeCache(this.cache, this.cacheIndex));
105
+ await this.cacheWrite;
106
+ } catch (error) {
107
+ file.status = 'error';
108
+ file.error = error instanceof Error ? error.message : String(error);
109
+ }
110
+ this.emit('file', file);
111
+ }
112
+
113
+ async rawFile(relativePath) {
114
+ const absolutePath = resolveInside(this.rootPath, relativePath);
115
+ await fs.access(absolutePath);
116
+ return absolutePath;
117
+ }
118
+ }
119
+
120
+ function countKinds(files) {
121
+ return files.reduce((counts, file) => {
122
+ counts.total += 1;
123
+ counts[file.kind] = (counts[file.kind] ?? 0) + 1;
124
+ if (file.analyzable) counts.engineering += 1;
125
+ return counts;
126
+ }, { total: 0, engineering: 0, cad: 0, pdf: 0, image: 0, file: 0 });
127
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import open from 'open';
4
+
5
+ export function revealCommand(filePath, platform = process.platform, windowsDirectory = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows') {
6
+ if (platform === 'win32') {
7
+ return {
8
+ command: path.win32.join(windowsDirectory, 'explorer.exe'),
9
+ args: [`/select,${filePath}`],
10
+ };
11
+ }
12
+ if (platform === 'darwin') {
13
+ return { command: 'open', args: ['-R', filePath] };
14
+ }
15
+ return { command: 'xdg-open', args: [path.dirname(filePath)] };
16
+ }
17
+
18
+ export async function revealFile(filePath, platform = process.platform) {
19
+ const { command, args } = revealCommand(filePath, platform);
20
+ await new Promise((resolve, reject) => {
21
+ const child = spawn(command, args, {
22
+ detached: true,
23
+ stdio: 'ignore',
24
+ windowsHide: false,
25
+ });
26
+ child.once('error', reject);
27
+ child.once('spawn', () => {
28
+ child.unref();
29
+ resolve();
30
+ });
31
+ });
32
+ }
33
+
34
+ export async function openFile(filePath) {
35
+ await open(filePath, { wait: false });
36
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { classifyExtension, canAnalyze } from './file-types.js';
4
+ import { toProjectPath } from './path-safety.js';
5
+
6
+ const DEFAULT_IGNORES = new Set([
7
+ '.git', '.svn', '.hg', 'node_modules', '__pycache__', '.pytest_cache',
8
+ '.mypy_cache', '.ruff_cache', '.next', '.cache', 'coverage', 'dist', 'out',
9
+ ]);
10
+
11
+ export async function scanProject(rootPath) {
12
+ const files = [];
13
+ const root = createDirectoryNode('', '');
14
+ await walk(rootPath, root, files, rootPath);
15
+ root.fileCount = countFiles(root);
16
+ sortTree(root);
17
+ files.sort((left, right) => left.path.localeCompare(right.path, undefined, { numeric: true }));
18
+ return { tree: root, files };
19
+ }
20
+
21
+ async function walk(directoryPath, parentNode, files, rootPath) {
22
+ let entries = await fs.readdir(directoryPath, { withFileTypes: true });
23
+ entries = entries.filter((entry) => !entry.name.startsWith('.') || entry.name === '.github');
24
+
25
+ for (const entry of entries) {
26
+ if (DEFAULT_IGNORES.has(entry.name)) continue;
27
+ const absolutePath = path.join(directoryPath, entry.name);
28
+ const relativePath = toProjectPath(rootPath, absolutePath);
29
+
30
+ if (entry.isDirectory()) {
31
+ const node = createDirectoryNode(entry.name, relativePath);
32
+ parentNode.children.push(node);
33
+ await walk(absolutePath, node, files, rootPath);
34
+ node.fileCount = countFiles(node);
35
+ continue;
36
+ }
37
+
38
+ if (!entry.isFile()) continue;
39
+ const stat = await fs.stat(absolutePath);
40
+ const extension = path.extname(entry.name).toLowerCase();
41
+ const file = {
42
+ name: entry.name,
43
+ path: relativePath,
44
+ parent: toProjectPath(rootPath, directoryPath),
45
+ extension,
46
+ kind: classifyExtension(extension),
47
+ analyzable: canAnalyze(extension),
48
+ size: stat.size,
49
+ modifiedAt: stat.mtime.toISOString(),
50
+ modifiedKey: `${stat.size}:${stat.mtimeMs}`,
51
+ };
52
+ files.push(file);
53
+ parentNode.children.push({ type: 'file', name: file.name, path: file.path, kind: file.kind });
54
+ }
55
+ }
56
+
57
+ function createDirectoryNode(name, nodePath) {
58
+ return { type: 'directory', name, path: nodePath, children: [], fileCount: 0 };
59
+ }
60
+
61
+ function countFiles(node) {
62
+ return node.children.reduce((count, child) => (
63
+ count + (child.type === 'file' ? 1 : countFiles(child))
64
+ ), 0);
65
+ }
66
+
67
+ function sortTree(node) {
68
+ node.children.sort((left, right) => {
69
+ if (left.type !== right.type) return left.type === 'directory' ? -1 : 1;
70
+ return left.name.localeCompare(right.name, undefined, { numeric: true });
71
+ });
72
+ for (const child of node.children) {
73
+ if (child.type === 'directory') sortTree(child);
74
+ }
75
+ }
package/src/server.js ADDED
@@ -0,0 +1,103 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import fs from 'node:fs/promises';
4
+ import express from 'express';
5
+ import { resolveInside } from './path-safety.js';
6
+ import { openFile, revealFile } from './reveal-file.js';
7
+
8
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ export async function createServer(store, options = {}) {
11
+ const app = express();
12
+ const reveal = options.revealFile ?? revealFile;
13
+ const openWithDefaultApp = options.openFile ?? openFile;
14
+ app.disable('x-powered-by');
15
+ app.use(express.json({ limit: '32kb' }));
16
+
17
+ app.get('/api/project', (_request, response) => {
18
+ response.json(store.publicSnapshot());
19
+ });
20
+
21
+ app.post('/api/rescan', async (_request, response, next) => {
22
+ try {
23
+ response.json(await store.rescan());
24
+ } catch (error) {
25
+ next(error);
26
+ }
27
+ });
28
+
29
+ app.post('/api/analyze', (request, response) => {
30
+ const relativePath = String(request.body?.path ?? '');
31
+ const file = store.findFile(relativePath);
32
+ if (!file) return response.status(404).json({ error: 'File not found' });
33
+ store.enqueue(relativePath, Boolean(request.body?.force));
34
+ return response.status(202).json({ status: 'queued' });
35
+ });
36
+
37
+ app.post('/api/reveal', async (request, response, next) => {
38
+ try {
39
+ const relativePath = String(request.body?.path ?? '');
40
+ const file = store.findFile(relativePath);
41
+ if (!file) return response.status(404).json({ error: 'File not found' });
42
+ await reveal(await store.rawFile(relativePath));
43
+ return response.json({ ok: true });
44
+ } catch (error) {
45
+ return next(error);
46
+ }
47
+ });
48
+
49
+ app.post('/api/open', async (request, response, next) => {
50
+ try {
51
+ const relativePath = String(request.body?.path ?? '');
52
+ const file = store.findFile(relativePath);
53
+ if (!file) return response.status(404).json({ error: 'File not found' });
54
+ await openWithDefaultApp(await store.rawFile(relativePath));
55
+ return response.json({ ok: true });
56
+ } catch (error) {
57
+ return next(error);
58
+ }
59
+ });
60
+
61
+ app.get('/api/raw', async (request, response, next) => {
62
+ try {
63
+ response.sendFile(await store.rawFile(String(request.query.path ?? '')));
64
+ } catch (error) {
65
+ next(error);
66
+ }
67
+ });
68
+
69
+ app.get('/api/preview', async (request, response, next) => {
70
+ try {
71
+ const requestedPath = String(request.query.path ?? '');
72
+ const absolutePath = resolveInside(store.cache.rendersDirectory, requestedPath);
73
+ response.type('png').send(await fs.readFile(absolutePath));
74
+ } catch (error) {
75
+ next(error);
76
+ }
77
+ });
78
+
79
+ app.get('/api/events', (request, response) => {
80
+ response.setHeader('Content-Type', 'text/event-stream');
81
+ response.setHeader('Cache-Control', 'no-cache');
82
+ response.setHeader('Connection', 'keep-alive');
83
+ response.flushHeaders();
84
+ const sendFile = (file) => response.write(`event: file\ndata: ${JSON.stringify(file)}\n\n`);
85
+ const heartbeat = setInterval(() => response.write(': heartbeat\n\n'), 15000);
86
+ store.on('file', sendFile);
87
+ request.on('close', () => {
88
+ clearInterval(heartbeat);
89
+ store.off('file', sendFile);
90
+ });
91
+ });
92
+
93
+ const clientDirectory = options.clientDirectory
94
+ ?? path.resolve(moduleDirectory, '../dist/client');
95
+ app.use(express.static(clientDirectory, { index: false, maxAge: '1h' }));
96
+ app.get('*splat', (_request, response) => response.sendFile(path.join(clientDirectory, 'index.html')));
97
+
98
+ app.use((error, _request, response, _next) => {
99
+ response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
100
+ });
101
+
102
+ return app;
103
+ }