IncludeCPP 4.6.0__py3-none-any.whl → 4.9.3__py3-none-any.whl

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.
Files changed (35) hide show
  1. includecpp/CHANGELOG.md +241 -0
  2. includecpp/__init__.py +89 -3
  3. includecpp/__init__.pyi +2 -1
  4. includecpp/cli/commands.py +1747 -266
  5. includecpp/cli/config_parser.py +1 -1
  6. includecpp/core/build_manager.py +64 -13
  7. includecpp/core/cpp_api_extensions.pyi +43 -270
  8. includecpp/core/cssl/CSSL_DOCUMENTATION.md +1799 -1445
  9. includecpp/core/cssl/cpp/build/api.pyd +0 -0
  10. includecpp/core/cssl/cpp/build/api.pyi +274 -0
  11. includecpp/core/cssl/cpp/build/cssl_core.pyi +0 -99
  12. includecpp/core/cssl/cpp/cssl_core.cp +2 -23
  13. includecpp/core/cssl/cssl_builtins.py +2116 -171
  14. includecpp/core/cssl/cssl_builtins.pyi +1324 -104
  15. includecpp/core/cssl/cssl_compiler.py +4 -1
  16. includecpp/core/cssl/cssl_modules.py +605 -6
  17. includecpp/core/cssl/cssl_optimizer.py +12 -1
  18. includecpp/core/cssl/cssl_parser.py +1048 -52
  19. includecpp/core/cssl/cssl_runtime.py +2041 -131
  20. includecpp/core/cssl/cssl_syntax.py +405 -277
  21. includecpp/core/cssl/cssl_types.py +5891 -1655
  22. includecpp/core/cssl_bridge.py +427 -4
  23. includecpp/core/error_catalog.py +54 -10
  24. includecpp/core/homeserver.py +1037 -0
  25. includecpp/generator/parser.cpp +203 -39
  26. includecpp/generator/parser.h +15 -1
  27. includecpp/templates/cpp.proj.template +1 -1
  28. includecpp/vscode/cssl/snippets/cssl.snippets.json +163 -0
  29. includecpp/vscode/cssl/syntaxes/cssl.tmLanguage.json +87 -12
  30. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/METADATA +81 -10
  31. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/RECORD +35 -33
  32. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/WHEEL +1 -1
  33. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/entry_points.txt +0 -0
  34. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/licenses/LICENSE +0 -0
  35. {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1037 @@
1
+ """
2
+ IncludeCPP HomeServer - Local storage server for modules, projects, and files.
3
+
4
+ A lightweight background server for storing and sharing IncludeCPP content.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import sqlite3
11
+ import hashlib
12
+ import shutil
13
+ import socket
14
+ import threading
15
+ import http.server
16
+ import urllib.parse
17
+ from pathlib import Path
18
+ from datetime import datetime
19
+ from typing import Optional, Dict, List, Tuple, Any
20
+ import base64
21
+
22
+ # Server configuration
23
+ DEFAULT_PORT = 2007
24
+ MAX_PORT_ATTEMPTS = 10
25
+ SERVER_NAME = "IncludeCPP-HomeServer"
26
+
27
+ def get_server_dir() -> Path:
28
+ """Get the HomeServer installation directory."""
29
+ if sys.platform == 'win32':
30
+ base = Path(os.environ.get('APPDATA', os.path.expanduser('~')))
31
+ else:
32
+ base = Path.home() / '.config'
33
+ return base / 'IncludeCPP' / 'homeserver'
34
+
35
+
36
+ def get_db_path() -> Path:
37
+ """Get the SQLite database path."""
38
+ return get_server_dir() / 'storage.db'
39
+
40
+
41
+ def get_storage_dir() -> Path:
42
+ """Get the file storage directory."""
43
+ return get_server_dir() / 'files'
44
+
45
+
46
+ def get_config_path() -> Path:
47
+ """Get the server config file path."""
48
+ return get_server_dir() / 'config.json'
49
+
50
+
51
+ def get_pid_path() -> Path:
52
+ """Get the PID file path."""
53
+ return get_server_dir() / 'server.pid'
54
+
55
+
56
+ class HomeServerDB:
57
+ """SQLite database manager for HomeServer storage."""
58
+
59
+ def __init__(self, db_path: Optional[Path] = None):
60
+ self.db_path = db_path or get_db_path()
61
+ self._init_db()
62
+
63
+ def _init_db(self):
64
+ """Initialize the database schema."""
65
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ with sqlite3.connect(self.db_path) as conn:
68
+ conn.execute('''
69
+ CREATE TABLE IF NOT EXISTS items (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ name TEXT UNIQUE NOT NULL,
72
+ item_type TEXT NOT NULL, -- 'file' or 'project'
73
+ original_path TEXT,
74
+ storage_path TEXT NOT NULL,
75
+ size_bytes INTEGER,
76
+ file_count INTEGER DEFAULT 1,
77
+ checksum TEXT,
78
+ created_at TEXT NOT NULL,
79
+ updated_at TEXT NOT NULL,
80
+ metadata TEXT -- JSON for extra data
81
+ )
82
+ ''')
83
+ conn.execute('''
84
+ CREATE TABLE IF NOT EXISTS project_files (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ item_id INTEGER NOT NULL,
87
+ relative_path TEXT NOT NULL,
88
+ file_hash TEXT,
89
+ size_bytes INTEGER,
90
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
91
+ )
92
+ ''')
93
+ conn.execute('''
94
+ CREATE TABLE IF NOT EXISTS categories (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ name TEXT UNIQUE NOT NULL,
97
+ created_at TEXT NOT NULL
98
+ )
99
+ ''')
100
+ conn.execute('''
101
+ CREATE INDEX IF NOT EXISTS idx_items_name ON items(name)
102
+ ''')
103
+ conn.execute('''
104
+ CREATE INDEX IF NOT EXISTS idx_project_files_item ON project_files(item_id)
105
+ ''')
106
+ # Add category column if not exists (for upgrades)
107
+ try:
108
+ conn.execute('ALTER TABLE items ADD COLUMN category TEXT DEFAULT NULL')
109
+ except sqlite3.OperationalError:
110
+ pass # Column already exists
111
+ conn.execute('''
112
+ CREATE INDEX IF NOT EXISTS idx_items_category ON items(category)
113
+ ''')
114
+ conn.commit()
115
+
116
+ def add_item(self, name: str, item_type: str, storage_path: str,
117
+ original_path: str = None, size_bytes: int = 0,
118
+ file_count: int = 1, checksum: str = None,
119
+ metadata: dict = None, category: str = None) -> int:
120
+ """Add a new item to the database."""
121
+ now = datetime.now().isoformat()
122
+ with sqlite3.connect(self.db_path) as conn:
123
+ cursor = conn.execute('''
124
+ INSERT INTO items (name, item_type, original_path, storage_path,
125
+ size_bytes, file_count, checksum, created_at,
126
+ updated_at, metadata, category)
127
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
128
+ ''', (name, item_type, original_path, storage_path, size_bytes,
129
+ file_count, checksum, now, now,
130
+ json.dumps(metadata) if metadata else None, category))
131
+ conn.commit()
132
+ return cursor.lastrowid
133
+
134
+ def add_project_file(self, item_id: int, relative_path: str,
135
+ file_hash: str, size_bytes: int):
136
+ """Add a file entry for a project."""
137
+ with sqlite3.connect(self.db_path) as conn:
138
+ conn.execute('''
139
+ INSERT INTO project_files (item_id, relative_path, file_hash, size_bytes)
140
+ VALUES (?, ?, ?, ?)
141
+ ''', (item_id, relative_path, file_hash, size_bytes))
142
+ conn.commit()
143
+
144
+ def get_item(self, name: str) -> Optional[Dict]:
145
+ """Get an item by name."""
146
+ with sqlite3.connect(self.db_path) as conn:
147
+ conn.row_factory = sqlite3.Row
148
+ cursor = conn.execute(
149
+ 'SELECT * FROM items WHERE name = ?', (name,))
150
+ row = cursor.fetchone()
151
+ if row:
152
+ return dict(row)
153
+ return None
154
+
155
+ def get_all_items(self) -> List[Dict]:
156
+ """Get all items."""
157
+ with sqlite3.connect(self.db_path) as conn:
158
+ conn.row_factory = sqlite3.Row
159
+ cursor = conn.execute(
160
+ 'SELECT * FROM items ORDER BY updated_at DESC')
161
+ return [dict(row) for row in cursor.fetchall()]
162
+
163
+ def delete_item(self, name: str) -> bool:
164
+ """Delete an item by name."""
165
+ with sqlite3.connect(self.db_path) as conn:
166
+ cursor = conn.execute('DELETE FROM items WHERE name = ?', (name,))
167
+ conn.commit()
168
+ return cursor.rowcount > 0
169
+
170
+ def item_exists(self, name: str) -> bool:
171
+ """Check if an item exists."""
172
+ with sqlite3.connect(self.db_path) as conn:
173
+ cursor = conn.execute(
174
+ 'SELECT 1 FROM items WHERE name = ?', (name,))
175
+ return cursor.fetchone() is not None
176
+
177
+ def get_project_files(self, item_id: int) -> List[Dict]:
178
+ """Get all files for a project."""
179
+ with sqlite3.connect(self.db_path) as conn:
180
+ conn.row_factory = sqlite3.Row
181
+ cursor = conn.execute(
182
+ 'SELECT * FROM project_files WHERE item_id = ?', (item_id,))
183
+ return [dict(row) for row in cursor.fetchall()]
184
+
185
+ # Category management methods
186
+ def add_category(self, name: str) -> bool:
187
+ """Add a new category."""
188
+ now = datetime.now().isoformat()
189
+ try:
190
+ with sqlite3.connect(self.db_path) as conn:
191
+ conn.execute(
192
+ 'INSERT INTO categories (name, created_at) VALUES (?, ?)',
193
+ (name, now))
194
+ conn.commit()
195
+ return True
196
+ except sqlite3.IntegrityError:
197
+ return False # Category already exists
198
+
199
+ def delete_category(self, name: str) -> bool:
200
+ """Delete a category (items in it become uncategorized)."""
201
+ with sqlite3.connect(self.db_path) as conn:
202
+ # Unset category for all items in this category
203
+ conn.execute('UPDATE items SET category = NULL WHERE category = ?', (name,))
204
+ cursor = conn.execute('DELETE FROM categories WHERE name = ?', (name,))
205
+ conn.commit()
206
+ return cursor.rowcount > 0
207
+
208
+ def get_all_categories(self) -> List[str]:
209
+ """Get all category names."""
210
+ with sqlite3.connect(self.db_path) as conn:
211
+ cursor = conn.execute('SELECT name FROM categories ORDER BY name')
212
+ return [row[0] for row in cursor.fetchall()]
213
+
214
+ def category_exists(self, name: str) -> bool:
215
+ """Check if a category exists."""
216
+ with sqlite3.connect(self.db_path) as conn:
217
+ cursor = conn.execute('SELECT 1 FROM categories WHERE name = ?', (name,))
218
+ return cursor.fetchone() is not None
219
+
220
+ def set_item_category(self, item_name: str, category: str) -> bool:
221
+ """Move an item to a category (or None to uncategorize)."""
222
+ with sqlite3.connect(self.db_path) as conn:
223
+ cursor = conn.execute(
224
+ 'UPDATE items SET category = ?, updated_at = ? WHERE name = ?',
225
+ (category, datetime.now().isoformat(), item_name))
226
+ conn.commit()
227
+ return cursor.rowcount > 0
228
+
229
+ def get_items_by_category(self, category: str) -> List[Dict]:
230
+ """Get all items in a category."""
231
+ with sqlite3.connect(self.db_path) as conn:
232
+ conn.row_factory = sqlite3.Row
233
+ if category:
234
+ cursor = conn.execute(
235
+ 'SELECT * FROM items WHERE category = ? ORDER BY updated_at DESC',
236
+ (category,))
237
+ else:
238
+ cursor = conn.execute(
239
+ 'SELECT * FROM items WHERE category IS NULL ORDER BY updated_at DESC')
240
+ return [dict(row) for row in cursor.fetchall()]
241
+
242
+
243
+ class HomeServerConfig:
244
+ """Configuration manager for HomeServer."""
245
+
246
+ def __init__(self, config_path: Optional[Path] = None):
247
+ self.config_path = config_path or get_config_path()
248
+ self._config = self._load()
249
+
250
+ def _load(self) -> Dict:
251
+ """Load configuration from file."""
252
+ if self.config_path.exists():
253
+ with open(self.config_path, 'r') as f:
254
+ return json.load(f)
255
+ return {
256
+ 'port': DEFAULT_PORT,
257
+ 'auto_start': True,
258
+ 'version': '1.0.0',
259
+ 'installed_at': None,
260
+ 'last_started': None
261
+ }
262
+
263
+ def save(self):
264
+ """Save configuration to file."""
265
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
266
+ with open(self.config_path, 'w') as f:
267
+ json.dump(self._config, f, indent=2)
268
+
269
+ @property
270
+ def port(self) -> int:
271
+ return self._config.get('port', DEFAULT_PORT)
272
+
273
+ @port.setter
274
+ def port(self, value: int):
275
+ self._config['port'] = value
276
+ self.save()
277
+
278
+ @property
279
+ def auto_start(self) -> bool:
280
+ return self._config.get('auto_start', True)
281
+
282
+ @auto_start.setter
283
+ def auto_start(self, value: bool):
284
+ self._config['auto_start'] = value
285
+ self.save()
286
+
287
+ def set_installed(self):
288
+ self._config['installed_at'] = datetime.now().isoformat()
289
+ self.save()
290
+
291
+ def set_last_started(self):
292
+ self._config['last_started'] = datetime.now().isoformat()
293
+ self.save()
294
+
295
+ def is_installed(self) -> bool:
296
+ return self._config.get('installed_at') is not None
297
+
298
+
299
+ def find_available_port(start_port: int = DEFAULT_PORT) -> int:
300
+ """Find an available port starting from the given port."""
301
+ for offset in range(MAX_PORT_ATTEMPTS):
302
+ port = start_port + offset
303
+ try:
304
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
305
+ s.bind(('127.0.0.1', port))
306
+ return port
307
+ except OSError:
308
+ continue
309
+ raise RuntimeError(f"No available port found in range {start_port}-{start_port + MAX_PORT_ATTEMPTS}")
310
+
311
+
312
+ def compute_file_hash(filepath: Path) -> str:
313
+ """Compute SHA256 hash of a file."""
314
+ sha256 = hashlib.sha256()
315
+ with open(filepath, 'rb') as f:
316
+ for chunk in iter(lambda: f.read(8192), b''):
317
+ sha256.update(chunk)
318
+ return sha256.hexdigest()
319
+
320
+
321
+ def get_dir_size(path: Path) -> Tuple[int, int]:
322
+ """Get total size and file count of a directory."""
323
+ total_size = 0
324
+ file_count = 0
325
+ for item in path.rglob('*'):
326
+ if item.is_file():
327
+ total_size += item.stat().st_size
328
+ file_count += 1
329
+ return total_size, file_count
330
+
331
+
332
+ def format_size(size_bytes: int) -> str:
333
+ """Format byte size to human readable string."""
334
+ for unit in ['B', 'KB', 'MB', 'GB']:
335
+ if size_bytes < 1024:
336
+ return f"{size_bytes:.1f} {unit}"
337
+ size_bytes /= 1024
338
+ return f"{size_bytes:.1f} TB"
339
+
340
+
341
+ class HomeServerHandler(http.server.BaseHTTPRequestHandler):
342
+ """HTTP request handler for HomeServer."""
343
+
344
+ def __init__(self, *args, db: HomeServerDB = None, storage_dir: Path = None, **kwargs):
345
+ self.db = db
346
+ self.storage_dir = storage_dir
347
+ super().__init__(*args, **kwargs)
348
+
349
+ def log_message(self, format, *args):
350
+ """Suppress default logging."""
351
+ pass
352
+
353
+ def send_json(self, data: Any, status: int = 200):
354
+ """Send JSON response."""
355
+ self.send_response(status)
356
+ self.send_header('Content-Type', 'application/json')
357
+ self.end_headers()
358
+ self.wfile.write(json.dumps(data).encode())
359
+
360
+ def do_GET(self):
361
+ """Handle GET requests."""
362
+ parsed = urllib.parse.urlparse(self.path)
363
+ path = parsed.path
364
+ query = urllib.parse.parse_qs(parsed.query)
365
+
366
+ if path == '/status':
367
+ self.send_json({'status': 'running', 'server': SERVER_NAME})
368
+
369
+ elif path == '/list':
370
+ items = self.db.get_all_items()
371
+ self.send_json({'items': items})
372
+
373
+ elif path == '/get':
374
+ name = query.get('name', [None])[0]
375
+ if not name:
376
+ self.send_json({'error': 'Missing name parameter'}, 400)
377
+ return
378
+ item = self.db.get_item(name)
379
+ if item:
380
+ self.send_json({'item': item})
381
+ else:
382
+ self.send_json({'error': 'Item not found'}, 404)
383
+
384
+ elif path == '/categories':
385
+ categories = self.db.get_all_categories()
386
+ self.send_json({'categories': categories})
387
+
388
+ elif path == '/category/items':
389
+ category = query.get('category', [None])[0]
390
+ items = self.db.get_items_by_category(category)
391
+ self.send_json({'items': items, 'category': category})
392
+
393
+ elif path.startswith('/download/'):
394
+ name = urllib.parse.unquote(path[10:])
395
+ item = self.db.get_item(name)
396
+ if not item:
397
+ self.send_json({'error': 'Item not found'}, 404)
398
+ return
399
+
400
+ storage_path = Path(item['storage_path'])
401
+ if not storage_path.exists():
402
+ self.send_json({'error': 'File not found on disk'}, 404)
403
+ return
404
+
405
+ # For projects, create a zip
406
+ if item['item_type'] == 'project':
407
+ import zipfile
408
+ import io
409
+ buffer = io.BytesIO()
410
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
411
+ for file in storage_path.rglob('*'):
412
+ if file.is_file():
413
+ zf.write(file, file.relative_to(storage_path))
414
+ buffer.seek(0)
415
+ content = buffer.read()
416
+ self.send_response(200)
417
+ self.send_header('Content-Type', 'application/zip')
418
+ self.send_header('Content-Disposition', f'attachment; filename="{name}.zip"')
419
+ self.end_headers()
420
+ self.wfile.write(content)
421
+ else:
422
+ # Single file
423
+ with open(storage_path, 'rb') as f:
424
+ content = f.read()
425
+ self.send_response(200)
426
+ self.send_header('Content-Type', 'application/octet-stream')
427
+ self.send_header('Content-Disposition', f'attachment; filename="{name}"')
428
+ self.end_headers()
429
+ self.wfile.write(content)
430
+
431
+ else:
432
+ self.send_json({'error': 'Unknown endpoint'}, 404)
433
+
434
+ def do_POST(self):
435
+ """Handle POST requests."""
436
+ parsed = urllib.parse.urlparse(self.path)
437
+ path = parsed.path
438
+
439
+ content_length = int(self.headers.get('Content-Length', 0))
440
+ body = self.rfile.read(content_length)
441
+
442
+ if path == '/upload':
443
+ try:
444
+ data = json.loads(body)
445
+ name = data.get('name')
446
+ item_type = data.get('type', 'file')
447
+ content_b64 = data.get('content')
448
+ original_filename = data.get('filename', name) # Preserve original filename
449
+ category = data.get('category') # Optional category
450
+
451
+ if not name or not content_b64:
452
+ self.send_json({'error': 'Missing name or content'}, 400)
453
+ return
454
+
455
+ if self.db.item_exists(name):
456
+ self.send_json({'error': f'Item "{name}" already exists'}, 409)
457
+ return
458
+
459
+ # Auto-create category if specified and doesn't exist
460
+ if category and not self.db.category_exists(category):
461
+ self.db.add_category(category)
462
+
463
+ content = base64.b64decode(content_b64)
464
+
465
+ if item_type == 'project':
466
+ # Extract zip to storage
467
+ import zipfile
468
+ import io
469
+ storage_path = self.storage_dir / 'projects' / name
470
+ storage_path.mkdir(parents=True, exist_ok=True)
471
+
472
+ with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
473
+ zf.extractall(storage_path)
474
+
475
+ size, count = get_dir_size(storage_path)
476
+ item_id = self.db.add_item(
477
+ name=name,
478
+ item_type='project',
479
+ storage_path=str(storage_path),
480
+ size_bytes=size,
481
+ file_count=count,
482
+ category=category
483
+ )
484
+
485
+ # Add file entries
486
+ for file in storage_path.rglob('*'):
487
+ if file.is_file():
488
+ self.db.add_project_file(
489
+ item_id=item_id,
490
+ relative_path=str(file.relative_to(storage_path)),
491
+ file_hash=compute_file_hash(file),
492
+ size_bytes=file.stat().st_size
493
+ )
494
+ else:
495
+ # Single file - store with original filename
496
+ storage_path = self.storage_dir / 'files' / original_filename
497
+ storage_path.parent.mkdir(parents=True, exist_ok=True)
498
+
499
+ with open(storage_path, 'wb') as f:
500
+ f.write(content)
501
+
502
+ # Use metadata from client if provided, else build default
503
+ file_metadata = data.get('metadata', {'filename': original_filename})
504
+ if 'filename' not in file_metadata:
505
+ file_metadata['filename'] = original_filename
506
+
507
+ self.db.add_item(
508
+ name=name,
509
+ item_type='file',
510
+ storage_path=str(storage_path),
511
+ size_bytes=len(content),
512
+ checksum=hashlib.sha256(content).hexdigest(),
513
+ metadata=file_metadata,
514
+ category=category
515
+ )
516
+
517
+ self.send_json({'success': True, 'name': name, 'filename': original_filename, 'category': category})
518
+
519
+ except Exception as e:
520
+ self.send_json({'error': str(e)}, 500)
521
+
522
+ elif path == '/delete':
523
+ try:
524
+ data = json.loads(body)
525
+ name = data.get('name')
526
+
527
+ if not name:
528
+ self.send_json({'error': 'Missing name'}, 400)
529
+ return
530
+
531
+ item = self.db.get_item(name)
532
+ if not item:
533
+ self.send_json({'error': 'Item not found'}, 404)
534
+ return
535
+
536
+ # Delete from disk
537
+ storage_path = Path(item['storage_path'])
538
+ if storage_path.exists():
539
+ if storage_path.is_dir():
540
+ shutil.rmtree(storage_path)
541
+ else:
542
+ storage_path.unlink()
543
+
544
+ # Delete from database
545
+ self.db.delete_item(name)
546
+ self.send_json({'success': True})
547
+
548
+ except Exception as e:
549
+ self.send_json({'error': str(e)}, 500)
550
+
551
+ elif path == '/category/add':
552
+ try:
553
+ data = json.loads(body)
554
+ name = data.get('name')
555
+ if not name:
556
+ self.send_json({'error': 'Missing category name'}, 400)
557
+ return
558
+ if self.db.add_category(name):
559
+ self.send_json({'success': True, 'category': name})
560
+ else:
561
+ self.send_json({'error': f'Category "{name}" already exists'}, 409)
562
+ except Exception as e:
563
+ self.send_json({'error': str(e)}, 500)
564
+
565
+ elif path == '/category/delete':
566
+ try:
567
+ data = json.loads(body)
568
+ name = data.get('name')
569
+ if not name:
570
+ self.send_json({'error': 'Missing category name'}, 400)
571
+ return
572
+ if self.db.delete_category(name):
573
+ self.send_json({'success': True})
574
+ else:
575
+ self.send_json({'error': 'Category not found'}, 404)
576
+ except Exception as e:
577
+ self.send_json({'error': str(e)}, 500)
578
+
579
+ elif path == '/category/move':
580
+ try:
581
+ data = json.loads(body)
582
+ item_name = data.get('item')
583
+ category = data.get('category') # Can be None to uncategorize
584
+ if not item_name:
585
+ self.send_json({'error': 'Missing item name'}, 400)
586
+ return
587
+ # Auto-create category if specified
588
+ if category and not self.db.category_exists(category):
589
+ self.db.add_category(category)
590
+ if self.db.set_item_category(item_name, category):
591
+ self.send_json({'success': True, 'item': item_name, 'category': category})
592
+ else:
593
+ self.send_json({'error': 'Item not found'}, 404)
594
+ except Exception as e:
595
+ self.send_json({'error': str(e)}, 500)
596
+
597
+ else:
598
+ self.send_json({'error': 'Unknown endpoint'}, 404)
599
+
600
+
601
+ def create_handler(db: HomeServerDB, storage_dir: Path):
602
+ """Create a handler class with bound database and storage."""
603
+ class BoundHandler(HomeServerHandler):
604
+ def __init__(self, *args, **kwargs):
605
+ self.db = db
606
+ self.storage_dir = storage_dir
607
+ # Skip parent __init__ that causes issues
608
+ http.server.BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
609
+ return BoundHandler
610
+
611
+
612
+ def is_server_running(port: int = None) -> bool:
613
+ """Check if the HomeServer is running."""
614
+ config = HomeServerConfig()
615
+ port = port or config.port
616
+
617
+ try:
618
+ import urllib.request
619
+ with urllib.request.urlopen(f'http://127.0.0.1:{port}/status', timeout=2) as response:
620
+ data = json.loads(response.read())
621
+ return data.get('server') == SERVER_NAME
622
+ except:
623
+ return False
624
+
625
+
626
+ def _run_server_foreground(port: int):
627
+ """Internal: Run server in foreground mode (called by subprocess)."""
628
+ config = HomeServerConfig()
629
+ db = HomeServerDB()
630
+ storage_dir = get_storage_dir()
631
+ storage_dir.mkdir(parents=True, exist_ok=True)
632
+
633
+ handler = create_handler(db, storage_dir)
634
+ server = http.server.HTTPServer(('127.0.0.1', port), handler)
635
+
636
+ config.set_last_started()
637
+
638
+ # Write PID file
639
+ pid_path = get_pid_path()
640
+ with open(pid_path, 'w') as f:
641
+ f.write(str(os.getpid()))
642
+
643
+ try:
644
+ server.serve_forever()
645
+ except KeyboardInterrupt:
646
+ pass
647
+ finally:
648
+ pid_path.unlink(missing_ok=True)
649
+
650
+
651
+ def start_server(port: int = None, foreground: bool = False) -> Tuple[bool, int, str]:
652
+ """
653
+ Start the HomeServer.
654
+
655
+ Returns:
656
+ Tuple of (success, port, message)
657
+ """
658
+ # Check if already running
659
+ config = HomeServerConfig()
660
+ port = port or config.port
661
+
662
+ if is_server_running(port):
663
+ return (True, port, f"HomeServer already running on port {port}")
664
+
665
+ server_dir = get_server_dir()
666
+ server_dir.mkdir(parents=True, exist_ok=True)
667
+
668
+ # Find available port
669
+ try:
670
+ actual_port = find_available_port(port)
671
+ except RuntimeError as e:
672
+ return (False, port, str(e))
673
+
674
+ if actual_port != port:
675
+ config.port = actual_port
676
+
677
+ if foreground:
678
+ # Run directly in current process
679
+ _run_server_foreground(actual_port)
680
+ return (True, actual_port, "Server stopped")
681
+ else:
682
+ # Spawn as independent background process
683
+ import subprocess
684
+
685
+ # Use pythonw on Windows for no console window
686
+ python_exe = sys.executable
687
+ if sys.platform == 'win32':
688
+ pythonw = python_exe.replace('python.exe', 'pythonw.exe')
689
+ if os.path.exists(pythonw):
690
+ python_exe = pythonw
691
+
692
+ # Start server as subprocess
693
+ cmd = [
694
+ python_exe, '-c',
695
+ f'from includecpp.core.homeserver import _run_server_foreground; _run_server_foreground({actual_port})'
696
+ ]
697
+
698
+ if sys.platform == 'win32':
699
+ # Windows: CREATE_NO_WINDOW flag
700
+ CREATE_NO_WINDOW = 0x08000000
701
+ subprocess.Popen(cmd, creationflags=CREATE_NO_WINDOW,
702
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
703
+ else:
704
+ # Unix: double fork via nohup
705
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
706
+ start_new_session=True)
707
+
708
+ # Wait briefly and verify server started
709
+ import time
710
+ for _ in range(10):
711
+ time.sleep(0.2)
712
+ if is_server_running(actual_port):
713
+ return (True, actual_port, f"HomeServer started on port {actual_port}")
714
+
715
+ return (False, actual_port, "Server failed to start")
716
+
717
+
718
+ def stop_server() -> Tuple[bool, str]:
719
+ """
720
+ Stop the HomeServer.
721
+
722
+ Returns:
723
+ Tuple of (success, message)
724
+ """
725
+ config = HomeServerConfig()
726
+
727
+ # Check if running first
728
+ if not is_server_running(config.port):
729
+ return (False, "HomeServer is not running")
730
+
731
+ pid_path = get_pid_path()
732
+ if pid_path.exists():
733
+ try:
734
+ pid = int(pid_path.read_text().strip())
735
+ if sys.platform == 'win32':
736
+ import subprocess
737
+ subprocess.run(['taskkill', '/PID', str(pid), '/F'],
738
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
739
+ else:
740
+ os.kill(pid, 9)
741
+ pid_path.unlink(missing_ok=True)
742
+
743
+ # Verify stopped
744
+ import time
745
+ time.sleep(0.5)
746
+ if not is_server_running(config.port):
747
+ return (True, "HomeServer stopped successfully")
748
+ else:
749
+ return (False, "Failed to stop server")
750
+ except Exception as e:
751
+ return (False, f"Error stopping server: {e}")
752
+
753
+ return (False, "Server PID file not found")
754
+
755
+
756
+ # Windows auto-start helpers
757
+ def setup_windows_autostart():
758
+ """Set up Windows auto-start via registry."""
759
+ if sys.platform != 'win32':
760
+ return False
761
+
762
+ try:
763
+ import winreg
764
+ key = winreg.OpenKey(
765
+ winreg.HKEY_CURRENT_USER,
766
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
767
+ 0, winreg.KEY_SET_VALUE
768
+ )
769
+
770
+ # Path to the server starter script
771
+ server_exe = get_server_dir() / 'start_server.bat'
772
+
773
+ # Create starter batch file (use \r\n for Windows batch files)
774
+ with open(server_exe, 'w', newline='\r\n') as f:
775
+ f.write('@echo off\n')
776
+ f.write('pythonw -c "from includecpp.core.homeserver import start_server; start_server(foreground=True)" >nul 2>&1\n')
777
+
778
+ winreg.SetValueEx(key, SERVER_NAME, 0, winreg.REG_SZ, str(server_exe))
779
+ winreg.CloseKey(key)
780
+ return True
781
+ except Exception as e:
782
+ print(f"Warning: Could not set up auto-start: {e}")
783
+ return False
784
+
785
+
786
+ def remove_windows_autostart():
787
+ """Remove Windows auto-start."""
788
+ if sys.platform != 'win32':
789
+ return False
790
+
791
+ try:
792
+ import winreg
793
+ key = winreg.OpenKey(
794
+ winreg.HKEY_CURRENT_USER,
795
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
796
+ 0, winreg.KEY_SET_VALUE
797
+ )
798
+ winreg.DeleteValue(key, SERVER_NAME)
799
+ winreg.CloseKey(key)
800
+ return True
801
+ except:
802
+ return False
803
+
804
+
805
+ # Client functions for CLI commands
806
+ class HomeServerClient:
807
+ """Client for communicating with HomeServer."""
808
+
809
+ def __init__(self, host: str = '127.0.0.1', port: int = None):
810
+ self.host = host
811
+ config = HomeServerConfig()
812
+ self.port = port or config.port
813
+ self.base_url = f'http://{self.host}:{self.port}'
814
+
815
+ def _request(self, method: str, endpoint: str, data: dict = None) -> dict:
816
+ """Make HTTP request to server."""
817
+ import urllib.request
818
+ import urllib.error
819
+
820
+ url = f'{self.base_url}{endpoint}'
821
+
822
+ if method == 'GET':
823
+ if data:
824
+ url += '?' + urllib.parse.urlencode(data)
825
+ req = urllib.request.Request(url)
826
+ else:
827
+ req = urllib.request.Request(
828
+ url,
829
+ data=json.dumps(data).encode() if data else None,
830
+ headers={'Content-Type': 'application/json'},
831
+ method=method
832
+ )
833
+
834
+ try:
835
+ with urllib.request.urlopen(req, timeout=30) as response:
836
+ return json.loads(response.read())
837
+ except urllib.error.HTTPError as e:
838
+ return json.loads(e.read())
839
+ except urllib.error.URLError as e:
840
+ raise ConnectionError(f"Cannot connect to HomeServer: {e}")
841
+
842
+ def status(self) -> dict:
843
+ """Check server status."""
844
+ return self._request('GET', '/status')
845
+
846
+ def list_items(self) -> List[dict]:
847
+ """List all items."""
848
+ result = self._request('GET', '/list')
849
+ return result.get('items', [])
850
+
851
+ def get_item(self, name: str) -> Optional[dict]:
852
+ """Get item info."""
853
+ result = self._request('GET', '/get', {'name': name})
854
+ return result.get('item')
855
+
856
+ def upload_file(self, name: str, filepath: Path, category: str = None) -> dict:
857
+ """Upload a single file.
858
+
859
+ For .py files, auto-detects includecpp usage and saves the project path.
860
+ """
861
+ with open(filepath, 'rb') as f:
862
+ content = f.read()
863
+
864
+ # Build metadata with original filename
865
+ metadata = {'filename': filepath.name}
866
+
867
+ # Auto-detect project path for Python files using includecpp
868
+ if filepath.suffix.lower() == '.py':
869
+ try:
870
+ script_content = content.decode('utf-8', errors='ignore')
871
+ # Check if includecpp is imported
872
+ if 'includecpp' in script_content and ('import includecpp' in script_content or 'from includecpp' in script_content):
873
+ # Search for cpp.proj in parent directories
874
+ search_dir = filepath.parent.resolve()
875
+ for _ in range(5): # Search up to 5 levels
876
+ cpp_proj = search_dir / 'cpp.proj'
877
+ if cpp_proj.exists():
878
+ metadata['project_path'] = str(search_dir)
879
+ break
880
+ if search_dir.parent == search_dir:
881
+ break
882
+ search_dir = search_dir.parent
883
+ except:
884
+ pass
885
+
886
+ data = {
887
+ 'name': name,
888
+ 'type': 'file',
889
+ 'filename': filepath.name,
890
+ 'metadata': metadata,
891
+ 'content': base64.b64encode(content).decode()
892
+ }
893
+ if category:
894
+ data['category'] = category
895
+ return self._request('POST', '/upload', data)
896
+
897
+ def upload_project(self, name: str, project_path: Path, category: str = None) -> dict:
898
+ """Upload a project folder."""
899
+ import zipfile
900
+ import io
901
+
902
+ buffer = io.BytesIO()
903
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
904
+ for file in project_path.rglob('*'):
905
+ if file.is_file():
906
+ # Skip common non-essential files
907
+ if any(p in str(file) for p in ['__pycache__', '.git', 'node_modules', '.pyc']):
908
+ continue
909
+ zf.write(file, file.relative_to(project_path))
910
+
911
+ buffer.seek(0)
912
+ content = buffer.read()
913
+
914
+ data = {
915
+ 'name': name,
916
+ 'type': 'project',
917
+ 'content': base64.b64encode(content).decode()
918
+ }
919
+ if category:
920
+ data['category'] = category
921
+ return self._request('POST', '/upload', data)
922
+
923
+ def delete_item(self, name: str) -> dict:
924
+ """Delete an item."""
925
+ return self._request('POST', '/delete', {'name': name})
926
+
927
+ def get_filename(self, name: str) -> str:
928
+ """Get the original filename for an item."""
929
+ item = self.get_item(name)
930
+ if item and item.get('metadata'):
931
+ try:
932
+ meta = json.loads(item['metadata']) if isinstance(item['metadata'], str) else item['metadata']
933
+ return meta.get('filename', name)
934
+ except:
935
+ pass
936
+ return name
937
+
938
+ def get_project_path(self, name: str) -> Optional[str]:
939
+ """Get the saved includecpp project path for an item (if any)."""
940
+ item = self.get_item(name)
941
+ if item and item.get('metadata'):
942
+ try:
943
+ meta = json.loads(item['metadata']) if isinstance(item['metadata'], str) else item['metadata']
944
+ return meta.get('project_path')
945
+ except:
946
+ pass
947
+ return None
948
+
949
+ def download_file(self, name: str, output_path: Path, is_dir: bool = False) -> Path:
950
+ """Download a file or project.
951
+
952
+ Args:
953
+ name: Item name to download
954
+ output_path: Output path (file or directory)
955
+ is_dir: If True, treat output_path as directory and write file into it
956
+
957
+ Returns:
958
+ Path to the downloaded file/directory
959
+ """
960
+ import urllib.request
961
+
962
+ # Get original filename from metadata
963
+ original_filename = self.get_filename(name)
964
+
965
+ url = f'{self.base_url}/download/{urllib.parse.quote(name)}'
966
+
967
+ try:
968
+ with urllib.request.urlopen(url, timeout=60) as response:
969
+ content = response.read()
970
+
971
+ # Check if it's a zip (project)
972
+ content_type = response.headers.get('Content-Type', '')
973
+
974
+ if 'zip' in content_type:
975
+ # Extract project - always to a directory
976
+ import zipfile
977
+ import io
978
+ if is_dir:
979
+ # Extract into specified directory with item name as subfolder
980
+ final_path = output_path / name
981
+ else:
982
+ final_path = output_path
983
+ final_path.mkdir(parents=True, exist_ok=True)
984
+ with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
985
+ zf.extractall(final_path)
986
+ return final_path
987
+ else:
988
+ # Single file - use original filename
989
+ if is_dir or output_path.is_dir():
990
+ # Write into directory with original filename
991
+ output_path.mkdir(parents=True, exist_ok=True)
992
+ final_path = output_path / original_filename
993
+ else:
994
+ final_path = output_path
995
+ final_path.parent.mkdir(parents=True, exist_ok=True)
996
+
997
+ with open(final_path, 'wb') as f:
998
+ f.write(content)
999
+ return final_path
1000
+
1001
+ except Exception as e:
1002
+ raise RuntimeError(f"Download failed: {e}")
1003
+
1004
+ # Category management methods
1005
+ def list_categories(self) -> List[str]:
1006
+ """List all categories."""
1007
+ result = self._request('GET', '/categories')
1008
+ return result.get('categories', [])
1009
+
1010
+ def get_items_by_category(self, category: str = None) -> List[dict]:
1011
+ """Get items in a category (None for uncategorized)."""
1012
+ result = self._request('GET', '/category/items', {'category': category or ''})
1013
+ return result.get('items', [])
1014
+
1015
+ def add_category(self, name: str) -> dict:
1016
+ """Create a new category."""
1017
+ return self._request('POST', '/category/add', {'name': name})
1018
+
1019
+ def delete_category(self, name: str) -> dict:
1020
+ """Delete a category (items become uncategorized)."""
1021
+ return self._request('POST', '/category/delete', {'name': name})
1022
+
1023
+ def move_to_category(self, item_name: str, category: str = None) -> dict:
1024
+ """Move an item to a category (None to uncategorize)."""
1025
+ return self._request('POST', '/category/move', {'item': item_name, 'category': category})
1026
+
1027
+ def download_category(self, category: str, output_dir: Path) -> List[Path]:
1028
+ """Download all items in a category."""
1029
+ items = self.get_items_by_category(category)
1030
+ downloaded = []
1031
+ for item in items:
1032
+ try:
1033
+ path = self.download_file(item['name'], output_dir, is_dir=True)
1034
+ downloaded.append(path)
1035
+ except Exception:
1036
+ pass # Skip failed downloads
1037
+ return downloaded