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.
- includecpp/CHANGELOG.md +241 -0
- includecpp/__init__.py +89 -3
- includecpp/__init__.pyi +2 -1
- includecpp/cli/commands.py +1747 -266
- includecpp/cli/config_parser.py +1 -1
- includecpp/core/build_manager.py +64 -13
- includecpp/core/cpp_api_extensions.pyi +43 -270
- includecpp/core/cssl/CSSL_DOCUMENTATION.md +1799 -1445
- includecpp/core/cssl/cpp/build/api.pyd +0 -0
- includecpp/core/cssl/cpp/build/api.pyi +274 -0
- includecpp/core/cssl/cpp/build/cssl_core.pyi +0 -99
- includecpp/core/cssl/cpp/cssl_core.cp +2 -23
- includecpp/core/cssl/cssl_builtins.py +2116 -171
- includecpp/core/cssl/cssl_builtins.pyi +1324 -104
- includecpp/core/cssl/cssl_compiler.py +4 -1
- includecpp/core/cssl/cssl_modules.py +605 -6
- includecpp/core/cssl/cssl_optimizer.py +12 -1
- includecpp/core/cssl/cssl_parser.py +1048 -52
- includecpp/core/cssl/cssl_runtime.py +2041 -131
- includecpp/core/cssl/cssl_syntax.py +405 -277
- includecpp/core/cssl/cssl_types.py +5891 -1655
- includecpp/core/cssl_bridge.py +427 -4
- includecpp/core/error_catalog.py +54 -10
- includecpp/core/homeserver.py +1037 -0
- includecpp/generator/parser.cpp +203 -39
- includecpp/generator/parser.h +15 -1
- includecpp/templates/cpp.proj.template +1 -1
- includecpp/vscode/cssl/snippets/cssl.snippets.json +163 -0
- includecpp/vscode/cssl/syntaxes/cssl.tmLanguage.json +87 -12
- {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/METADATA +81 -10
- {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/RECORD +35 -33
- {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/WHEEL +1 -1
- {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/entry_points.txt +0 -0
- {includecpp-4.6.0.dist-info → includecpp-4.9.3.dist-info}/licenses/LICENSE +0 -0
- {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
|