links-client 1.0.0__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.
@@ -0,0 +1,22 @@
1
+ """
2
+ Links Client - Python client library for link-cli (Links Theory database)
3
+
4
+ This package provides a Python interface to the link-cli (clink) tool for
5
+ working with Links Theory databases.
6
+ """
7
+
8
+ from links_client.services.link_db_service import LinkDBService
9
+ from links_client.services.menu_storage_service import MenuStorageService
10
+ from links_client.services.auth_storage_service import AuthStorageService
11
+ from links_client.api.ilinks import ILinks, LinkConstants
12
+ from links_client.api.recursive_links import RecursiveLinks
13
+
14
+ __version__ = "1.0.0"
15
+ __all__ = [
16
+ "LinkDBService",
17
+ "MenuStorageService",
18
+ "AuthStorageService",
19
+ "ILinks",
20
+ "LinkConstants",
21
+ "RecursiveLinks"
22
+ ]
@@ -0,0 +1,7 @@
1
+ """Services for interacting with link-cli database"""
2
+
3
+ from links_client.services.link_db_service import LinkDBService
4
+ from links_client.services.menu_storage_service import MenuStorageService
5
+ from links_client.services.auth_storage_service import AuthStorageService
6
+
7
+ __all__ = ["LinkDBService", "MenuStorageService", "AuthStorageService"]
@@ -0,0 +1,561 @@
1
+ """AuthStorageService - Service for storing authentication data using link-cli"""
2
+
3
+ import json
4
+ import hashlib
5
+ import time
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import List, Dict, Optional, Any
9
+ from links_client.services.link_db_service import LinkDBService
10
+ from links_client.utils.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ # Data directories for storing authentication data
15
+ DATA_DIR = Path(__file__).parent.parent.parent.parent / "data" / "auth-data"
16
+ USERS_DIR = DATA_DIR / "users"
17
+ TOKENS_DIR = DATA_DIR / "tokens"
18
+ PASSWORDS_DIR = DATA_DIR / "passwords"
19
+ AUTH_DB_FILE = DATA_DIR / "auth.links"
20
+
21
+ # Type identifiers for links
22
+ USER_TYPE_ID = 2000 # Links of type (userId 2000) represent users
23
+ TOKEN_LINK_PARENT = 3000 # Parent for token links (tokenId userId)
24
+ PASSWORD_LINK_PARENT = 4000 # Parent for password links (passwordId userId)
25
+
26
+
27
+ class AuthStorageService:
28
+ """
29
+ AuthStorageService - Store authentication data using link-cli
30
+
31
+ Architecture:
32
+ - Link-cli stores relationships between entities
33
+ - File system stores the actual data (JSON files)
34
+ - Links represent entity types and relationships
35
+
36
+ Link schemas:
37
+ 1. Users: (userId 2000) - represents a user entity
38
+ 2. Tokens: (tokenId userId) - token belongs to user
39
+ 3. Passwords: (passwordId userId) - password belongs to user
40
+
41
+ File storage:
42
+ - data/auth-data/users/{userId}.json - user profile data
43
+ - data/auth-data/tokens/{tokenId}.json - token data
44
+ - data/auth-data/passwords/{passwordId}.json - hashed password data
45
+ """
46
+
47
+ def __init__(self):
48
+ """Initialize AuthStorageService"""
49
+ self.link_db = LinkDBService(str(AUTH_DB_FILE))
50
+ self._ensure_data_directories()
51
+
52
+ def _ensure_data_directories(self):
53
+ """Ensure all data directories exist"""
54
+ for directory in [DATA_DIR, USERS_DIR, TOKENS_DIR, PASSWORDS_DIR]:
55
+ directory.mkdir(parents=True, exist_ok=True)
56
+ logger.info(f"Ensured auth data directory exists: {directory}")
57
+
58
+ def generate_id(self, content: Dict[str, Any], prefix: str = '') -> str:
59
+ """
60
+ Generate a stable ID from content
61
+
62
+ Args:
63
+ content: Content object
64
+ prefix: ID prefix (e.g., 'user', 'token', 'pwd')
65
+
66
+ Returns:
67
+ Unique ID
68
+ """
69
+ content_str = json.dumps(content, sort_keys=True) + str(time.time())
70
+ hash_obj = hashlib.sha256(content_str.encode())
71
+ hash_hex = hash_obj.hexdigest()
72
+ numeric_id = int(hash_hex[:12], 16)
73
+ return f"{prefix}_{numeric_id}" if prefix else str(numeric_id)
74
+
75
+ def id_to_number(self, id_str: str) -> int:
76
+ """
77
+ Convert string ID to numeric for link storage
78
+
79
+ Args:
80
+ id_str: String ID
81
+
82
+ Returns:
83
+ Numeric ID
84
+ """
85
+ hash_obj = hashlib.sha256(id_str.encode())
86
+ hash_hex = hash_obj.hexdigest()
87
+ return int(hash_hex[:8], 16) % 1000000000
88
+
89
+ def save_data(self, directory: Path, item_id: str, data: Dict[str, Any]):
90
+ """
91
+ Save data to file
92
+
93
+ Args:
94
+ directory: Directory path
95
+ item_id: Item ID
96
+ data: Data to save
97
+ """
98
+ file_path = directory / f"{item_id}.json"
99
+ with open(file_path, 'w', encoding='utf-8') as f:
100
+ json.dump(data, f, indent=2)
101
+
102
+ def load_data(self, directory: Path, item_id: str) -> Optional[Dict[str, Any]]:
103
+ """
104
+ Load data from file
105
+
106
+ Args:
107
+ directory: Directory path
108
+ item_id: Item ID
109
+
110
+ Returns:
111
+ Data or None if not found
112
+ """
113
+ file_path = directory / f"{item_id}.json"
114
+ try:
115
+ with open(file_path, 'r', encoding='utf-8') as f:
116
+ return json.load(f)
117
+ except FileNotFoundError:
118
+ return None
119
+
120
+ # ==================== USER OPERATIONS ====================
121
+
122
+ def create_user(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
123
+ """
124
+ Create a new user
125
+
126
+ Args:
127
+ user_data: User data (username, email, profile, etc.)
128
+
129
+ Returns:
130
+ Created user with ID
131
+ """
132
+ user_id = self.generate_id(user_data, 'user')
133
+ user_id_numeric = self.id_to_number(user_id)
134
+
135
+ # Save user data to file
136
+ user_data_with_id = {
137
+ **user_data,
138
+ 'userId': user_id,
139
+ 'createdAt': datetime.utcnow().isoformat()
140
+ }
141
+ self.save_data(USERS_DIR, user_id, user_data_with_id)
142
+
143
+ # Create link: (userId, USER_TYPE_ID)
144
+ try:
145
+ self.link_db.create_link(user_id_numeric, USER_TYPE_ID)
146
+ logger.info(f"User created in link database: {user_id}")
147
+ except Exception as error:
148
+ logger.error(f"Failed to create user link: {user_id}, error: {error}")
149
+ raise
150
+
151
+ return user_data_with_id
152
+
153
+ def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
154
+ """
155
+ Get user by ID
156
+
157
+ Args:
158
+ user_id: User ID
159
+
160
+ Returns:
161
+ User data or None
162
+ """
163
+ return self.load_data(USERS_DIR, user_id)
164
+
165
+ def get_all_users(self) -> List[Dict[str, Any]]:
166
+ """
167
+ Get all users
168
+
169
+ Returns:
170
+ List of users
171
+ """
172
+ users = []
173
+ try:
174
+ for file_path in USERS_DIR.glob("*.json"):
175
+ user_data = self.load_data(USERS_DIR, file_path.stem)
176
+ if user_data:
177
+ users.append(user_data)
178
+ except Exception as error:
179
+ logger.warning(f"Error reading users directory: {error}")
180
+
181
+ return users
182
+
183
+ def update_user(self, user_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
184
+ """
185
+ Update user data
186
+
187
+ Args:
188
+ user_id: User ID
189
+ updates: Data to update
190
+
191
+ Returns:
192
+ Updated user data
193
+ """
194
+ existing_user = self.get_user(user_id)
195
+ if not existing_user:
196
+ raise ValueError(f"User {user_id} not found")
197
+
198
+ updated_user = {
199
+ **existing_user,
200
+ **updates,
201
+ 'userId': user_id, # Preserve userId
202
+ 'updatedAt': datetime.utcnow().isoformat()
203
+ }
204
+
205
+ self.save_data(USERS_DIR, user_id, updated_user)
206
+ logger.info(f"User updated: {user_id}")
207
+
208
+ return updated_user
209
+
210
+ def delete_user(self, user_id: str) -> bool:
211
+ """
212
+ Delete user and all associated data (tokens, passwords)
213
+
214
+ Args:
215
+ user_id: User ID
216
+
217
+ Returns:
218
+ Success status
219
+ """
220
+ user_id_numeric = self.id_to_number(user_id)
221
+
222
+ # Delete user's tokens
223
+ user_tokens = self.get_user_tokens(user_id)
224
+ for token in user_tokens:
225
+ self.delete_token(token['tokenId'])
226
+
227
+ # Delete user's passwords
228
+ user_passwords = self.get_user_passwords(user_id)
229
+ for pwd in user_passwords:
230
+ self.delete_password(pwd['passwordId'])
231
+
232
+ # Delete user link
233
+ all_links = self.link_db.read_all_links()
234
+ user_link = next(
235
+ (link for link in all_links
236
+ if link['source'] == user_id_numeric and link['target'] == USER_TYPE_ID),
237
+ None
238
+ )
239
+
240
+ if user_link:
241
+ self.link_db.delete_link(user_link['id'])
242
+
243
+ # Delete user data file
244
+ try:
245
+ file_path = USERS_DIR / f"{user_id}.json"
246
+ file_path.unlink()
247
+ logger.info(f"User deleted: {user_id}")
248
+ except Exception as error:
249
+ logger.warning(f"Failed to delete user data file: {user_id}, error: {error}")
250
+
251
+ return True
252
+
253
+ # ==================== TOKEN OPERATIONS ====================
254
+
255
+ def create_token(self, user_id: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
256
+ """
257
+ Create a new token for a user
258
+
259
+ Args:
260
+ user_id: User ID
261
+ token_data: Token data (apiKey, permissions, expiresAt, etc.)
262
+
263
+ Returns:
264
+ Created token with ID
265
+ """
266
+ token_id = self.generate_id(token_data, 'token')
267
+ token_id_numeric = self.id_to_number(token_id)
268
+ user_id_numeric = self.id_to_number(user_id)
269
+
270
+ # Save token data to file
271
+ token_data_with_id = {
272
+ **token_data,
273
+ 'tokenId': token_id,
274
+ 'userId': user_id,
275
+ 'createdAt': datetime.utcnow().isoformat()
276
+ }
277
+ self.save_data(TOKENS_DIR, token_id, token_data_with_id)
278
+
279
+ # Create link: (tokenId, userId)
280
+ try:
281
+ self.link_db.create_link(token_id_numeric, user_id_numeric)
282
+ logger.info(f"Token created in link database: tokenId={token_id}, userId={user_id}")
283
+ except Exception as error:
284
+ logger.error(f"Failed to create token link: tokenId={token_id}, userId={user_id}, error: {error}")
285
+ raise
286
+
287
+ return token_data_with_id
288
+
289
+ def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
290
+ """
291
+ Get token by ID
292
+
293
+ Args:
294
+ token_id: Token ID
295
+
296
+ Returns:
297
+ Token data or None
298
+ """
299
+ return self.load_data(TOKENS_DIR, token_id)
300
+
301
+ def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
302
+ """
303
+ Get all tokens for a user
304
+
305
+ Args:
306
+ user_id: User ID
307
+
308
+ Returns:
309
+ List of tokens
310
+ """
311
+ tokens = []
312
+ try:
313
+ for file_path in TOKENS_DIR.glob("*.json"):
314
+ token_data = self.load_data(TOKENS_DIR, file_path.stem)
315
+ if token_data and token_data.get('userId') == user_id:
316
+ tokens.append(token_data)
317
+ except Exception as error:
318
+ logger.warning(f"Error reading tokens directory: {error}")
319
+
320
+ return tokens
321
+
322
+ def delete_token(self, token_id: str) -> bool:
323
+ """
324
+ Delete a token
325
+
326
+ Args:
327
+ token_id: Token ID
328
+
329
+ Returns:
330
+ Success status
331
+ """
332
+ token_id_numeric = self.id_to_number(token_id)
333
+
334
+ # Delete token link
335
+ all_links = self.link_db.read_all_links()
336
+ token_link = next(
337
+ (link for link in all_links if link['source'] == token_id_numeric),
338
+ None
339
+ )
340
+
341
+ if token_link:
342
+ self.link_db.delete_link(token_link['id'])
343
+
344
+ # Delete token data file
345
+ try:
346
+ file_path = TOKENS_DIR / f"{token_id}.json"
347
+ file_path.unlink()
348
+ logger.info(f"Token deleted: {token_id}")
349
+ except Exception as error:
350
+ logger.warning(f"Failed to delete token data file: {token_id}, error: {error}")
351
+
352
+ return True
353
+
354
+ # ==================== PASSWORD OPERATIONS ====================
355
+
356
+ def set_password(self, user_id: str, password_data: Dict[str, Any]) -> Dict[str, Any]:
357
+ """
358
+ Create/update password for a user
359
+
360
+ Args:
361
+ user_id: User ID
362
+ password_data: Password data (hash, salt, algorithm)
363
+
364
+ Returns:
365
+ Created/updated password with ID
366
+ """
367
+ # First, delete existing passwords for this user
368
+ existing_passwords = self.get_user_passwords(user_id)
369
+ for pwd in existing_passwords:
370
+ self.delete_password(pwd['passwordId'])
371
+
372
+ # Create new password
373
+ password_id = self.generate_id(password_data, 'pwd')
374
+ password_id_numeric = self.id_to_number(password_id)
375
+ user_id_numeric = self.id_to_number(user_id)
376
+
377
+ # Save password data to file
378
+ password_data_with_id = {
379
+ **password_data,
380
+ 'passwordId': password_id,
381
+ 'userId': user_id,
382
+ 'createdAt': datetime.utcnow().isoformat()
383
+ }
384
+ self.save_data(PASSWORDS_DIR, password_id, password_data_with_id)
385
+
386
+ # Create link: (passwordId, userId)
387
+ try:
388
+ self.link_db.create_link(password_id_numeric, user_id_numeric)
389
+ logger.info(f"Password created in link database: passwordId={password_id}, userId={user_id}")
390
+ except Exception as error:
391
+ logger.error(f"Failed to create password link: passwordId={password_id}, userId={user_id}, error: {error}")
392
+ raise
393
+
394
+ return password_data_with_id
395
+
396
+ def get_user_password(self, user_id: str) -> Optional[Dict[str, Any]]:
397
+ """
398
+ Get password for a user
399
+
400
+ Args:
401
+ user_id: User ID
402
+
403
+ Returns:
404
+ Password data or None
405
+ """
406
+ passwords = self.get_user_passwords(user_id)
407
+ # Return the most recent password (should only be one)
408
+ return passwords[0] if passwords else None
409
+
410
+ def get_user_passwords(self, user_id: str) -> List[Dict[str, Any]]:
411
+ """
412
+ Get all passwords for a user (for migration/history)
413
+
414
+ Args:
415
+ user_id: User ID
416
+
417
+ Returns:
418
+ List of passwords
419
+ """
420
+ passwords = []
421
+ try:
422
+ for file_path in PASSWORDS_DIR.glob("*.json"):
423
+ password_data = self.load_data(PASSWORDS_DIR, file_path.stem)
424
+ if password_data and password_data.get('userId') == user_id:
425
+ passwords.append(password_data)
426
+ except Exception as error:
427
+ logger.warning(f"Error reading passwords directory: {error}")
428
+
429
+ return passwords
430
+
431
+ def delete_password(self, password_id: str) -> bool:
432
+ """
433
+ Delete a password
434
+
435
+ Args:
436
+ password_id: Password ID
437
+
438
+ Returns:
439
+ Success status
440
+ """
441
+ password_id_numeric = self.id_to_number(password_id)
442
+
443
+ # Delete password link
444
+ all_links = self.link_db.read_all_links()
445
+ password_link = next(
446
+ (link for link in all_links if link['source'] == password_id_numeric),
447
+ None
448
+ )
449
+
450
+ if password_link:
451
+ self.link_db.delete_link(password_link['id'])
452
+
453
+ # Delete password data file
454
+ try:
455
+ file_path = PASSWORDS_DIR / f"{password_id}.json"
456
+ file_path.unlink()
457
+ logger.info(f"Password deleted: {password_id}")
458
+ except Exception as error:
459
+ logger.warning(f"Failed to delete password data file: {password_id}, error: {error}")
460
+
461
+ return True
462
+
463
+ # ==================== STATISTICS & UTILITIES ====================
464
+
465
+ def get_statistics(self) -> Dict[str, Any]:
466
+ """
467
+ Get statistics about stored authentication data
468
+
469
+ Returns:
470
+ Statistics dictionary
471
+ """
472
+ all_links = self.link_db.read_all_links()
473
+
474
+ user_files = list(USERS_DIR.glob("*.json"))
475
+ token_files = list(TOKENS_DIR.glob("*.json"))
476
+ password_files = list(PASSWORDS_DIR.glob("*.json"))
477
+
478
+ return {
479
+ 'totalLinks': len(all_links),
480
+ 'users': {
481
+ 'links': len([link for link in all_links if link['target'] == USER_TYPE_ID]),
482
+ 'files': len(user_files)
483
+ },
484
+ 'tokens': {
485
+ 'links': len([link for link in all_links
486
+ if link['target'] != USER_TYPE_ID and link['target'] < 1000000000]),
487
+ 'files': len(token_files)
488
+ },
489
+ 'passwords': {
490
+ 'files': len(password_files)
491
+ }
492
+ }
493
+
494
+ def clear_all_auth_data(self) -> bool:
495
+ """
496
+ Clear all authentication data (DANGEROUS - use with caution)
497
+
498
+ Returns:
499
+ Success status
500
+ """
501
+ logger.warning("Clearing ALL authentication data - this is irreversible!")
502
+
503
+ # Clear link database
504
+ self.link_db.clear_database()
505
+
506
+ # Clear all data files
507
+ for directory in [USERS_DIR, TOKENS_DIR, PASSWORDS_DIR]:
508
+ try:
509
+ for file_path in directory.glob("*.json"):
510
+ file_path.unlink()
511
+ logger.info(f"Cleared auth data directory: {directory}")
512
+ except Exception as error:
513
+ logger.warning(f"Failed to clear auth data directory: {directory}, error: {error}")
514
+
515
+ return True
516
+
517
+ def find_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
518
+ """
519
+ Find user by username (requires scanning all users)
520
+
521
+ Args:
522
+ username: Username to search for
523
+
524
+ Returns:
525
+ User data or None
526
+ """
527
+ all_users = self.get_all_users()
528
+ return next((user for user in all_users if user.get('username') == username), None)
529
+
530
+ def find_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
531
+ """
532
+ Find user by email (requires scanning all users)
533
+
534
+ Args:
535
+ email: Email to search for
536
+
537
+ Returns:
538
+ User data or None
539
+ """
540
+ all_users = self.get_all_users()
541
+ return next((user for user in all_users if user.get('email') == email), None)
542
+
543
+ def find_token_by_api_key(self, api_key: str) -> Optional[Dict[str, Any]]:
544
+ """
545
+ Find token by API key (requires scanning all tokens)
546
+
547
+ Args:
548
+ api_key: API key to search for
549
+
550
+ Returns:
551
+ Token data or None
552
+ """
553
+ try:
554
+ for file_path in TOKENS_DIR.glob("*.json"):
555
+ token_data = self.load_data(TOKENS_DIR, file_path.stem)
556
+ if token_data and token_data.get('apiKey') == api_key:
557
+ return token_data
558
+ except Exception as error:
559
+ logger.warning(f"Error searching for token by API key: {error}")
560
+
561
+ return None
@@ -0,0 +1,286 @@
1
+ """LinkDBService - Service wrapper for link-cli (clink) database operations"""
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import List, Dict, Optional, Any
8
+ from links_client.utils.logger import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ # Default database path
13
+ DEFAULT_DB_DIR = Path(__file__).parent.parent.parent.parent / "data"
14
+ DEFAULT_DB_FILE = DEFAULT_DB_DIR / "linkdb.links"
15
+
16
+
17
+ class LinkDBService:
18
+ """
19
+ LinkDBService - Wrapper for link-cli database operations
20
+ Uses the clink tool (link-cli) for associative link-based data storage
21
+ """
22
+
23
+ def __init__(self, db_path: Optional[str] = None):
24
+ """
25
+ Initialize LinkDBService
26
+
27
+ Args:
28
+ db_path: Path to database file (defaults to DEFAULT_DB_FILE)
29
+ """
30
+ self.db_path = db_path or str(DEFAULT_DB_FILE)
31
+ self.next_id = 1 # Track next available ID for menu items
32
+
33
+ def execute_query(
34
+ self,
35
+ query: str,
36
+ before: bool = False,
37
+ changes: bool = False,
38
+ after: bool = False,
39
+ trace: bool = False
40
+ ) -> str:
41
+ """
42
+ Execute a clink command
43
+
44
+ Args:
45
+ query: LiNo query string
46
+ before: Show state before query
47
+ changes: Show changes
48
+ after: Show state after query
49
+ trace: Show trace information
50
+
51
+ Returns:
52
+ Command output
53
+
54
+ Raises:
55
+ RuntimeError: If clink is not available or query fails
56
+ """
57
+ flags = []
58
+ if before:
59
+ flags.append('--before')
60
+ if changes:
61
+ flags.append('--changes')
62
+ if after:
63
+ flags.append('--after')
64
+ if trace:
65
+ flags.append('--trace')
66
+
67
+ command = ['clink', query, '--db', self.db_path] + flags
68
+
69
+ try:
70
+ logger.debug(f"Executing clink command: {' '.join(command)}")
71
+
72
+ # Set PATH to include .dotnet/tools directory where clink is installed
73
+ env = os.environ.copy()
74
+ home = os.path.expanduser('~')
75
+ dotnet_tools = os.path.join(home, '.dotnet', 'tools')
76
+ env['PATH'] = f"{dotnet_tools}:{env.get('PATH', '')}"
77
+
78
+ result = subprocess.run(
79
+ command,
80
+ capture_output=True,
81
+ text=True,
82
+ env=env,
83
+ check=True
84
+ )
85
+
86
+ if result.stderr:
87
+ logger.warning(f"clink command produced stderr: {result.stderr}")
88
+
89
+ return result.stdout.strip()
90
+
91
+ except FileNotFoundError:
92
+ logger.error("clink command not found - link-cli may not be installed")
93
+ raise RuntimeError(
94
+ "LinkDB not available: clink command not found. "
95
+ "Please install link-cli."
96
+ )
97
+ except subprocess.CalledProcessError as error:
98
+ logger.error(f"Failed to execute clink command: {error}")
99
+ raise RuntimeError(f"LinkDB query failed: {error}")
100
+
101
+ def parse_links(self, output: str) -> List[Dict[str, int]]:
102
+ """
103
+ Parse clink output to extract links
104
+ Format: (id: source target)
105
+
106
+ Args:
107
+ output: Raw clink output
108
+
109
+ Returns:
110
+ List of parsed links
111
+ """
112
+ if not output or not output.strip():
113
+ return []
114
+
115
+ lines = [line.strip() for line in output.split('\n') if line.strip()]
116
+ links = []
117
+
118
+ for line in lines:
119
+ # Match pattern: (id: source target)
120
+ match = re.match(r'\((\d+):\s+(\d+)\s+(\d+)\)', line)
121
+ if match:
122
+ links.append({
123
+ 'id': int(match.group(1)),
124
+ 'source': int(match.group(2)),
125
+ 'target': int(match.group(3))
126
+ })
127
+
128
+ return links
129
+
130
+ def create_link(self, source: int, target: int) -> Dict[str, int]:
131
+ """
132
+ Create a new link
133
+
134
+ Args:
135
+ source: Source link ID
136
+ target: Target link ID
137
+
138
+ Returns:
139
+ Created link
140
+
141
+ Raises:
142
+ RuntimeError: If link creation fails
143
+ """
144
+ query = f"() (({source} {target}))"
145
+ output = self.execute_query(query, changes=True)
146
+
147
+ # Parse the created link from output
148
+ match = re.search(r'\((\d+):\s+(\d+)\s+(\d+)\)', output)
149
+ if match:
150
+ return {
151
+ 'id': int(match.group(1)),
152
+ 'source': int(match.group(2)),
153
+ 'target': int(match.group(3))
154
+ }
155
+
156
+ raise RuntimeError("Failed to parse created link")
157
+
158
+ def read_all_links(self) -> List[Dict[str, int]]:
159
+ """
160
+ Read all links from database
161
+
162
+ Returns:
163
+ List of all links
164
+ """
165
+ query = "((($i: $s $t)) (($i: $s $t)))"
166
+ output = self.execute_query(query, after=True)
167
+ return self.parse_links(output)
168
+
169
+ def read_link(self, link_id: int) -> Optional[Dict[str, int]]:
170
+ """
171
+ Read a specific link by ID
172
+
173
+ Args:
174
+ link_id: Link ID
175
+
176
+ Returns:
177
+ Link or None if not found
178
+ """
179
+ query = f"((({link_id}: $s $t)) (({link_id}: $s $t)))"
180
+ output = self.execute_query(query, after=True)
181
+ links = self.parse_links(output)
182
+ return links[0] if links else None
183
+
184
+ def update_link(self, link_id: int, new_source: int, new_target: int) -> Dict[str, int]:
185
+ """
186
+ Update a link
187
+
188
+ Args:
189
+ link_id: Link ID
190
+ new_source: New source value
191
+ new_target: New target value
192
+
193
+ Returns:
194
+ Updated link
195
+ """
196
+ query = f"((({link_id}: $s $t)) (({link_id}: {new_source} {new_target})))"
197
+ self.execute_query(query, changes=True)
198
+
199
+ return {
200
+ 'id': link_id,
201
+ 'source': new_source,
202
+ 'target': new_target
203
+ }
204
+
205
+ def delete_link(self, link_id: int) -> bool:
206
+ """
207
+ Delete a link
208
+
209
+ Args:
210
+ link_id: Link ID
211
+
212
+ Returns:
213
+ Success status
214
+ """
215
+ query = f"((({link_id}: $s $t)) ())"
216
+ self.execute_query(query, changes=True)
217
+ return True
218
+
219
+ def store_menu_item(self, menu_item: Dict[str, Any]) -> int:
220
+ """
221
+ Store menu item data as a link with encoded JSON
222
+ This is a higher-level abstraction for storing menu items
223
+
224
+ Args:
225
+ menu_item: Menu item object
226
+
227
+ Returns:
228
+ Link ID
229
+ """
230
+ # For now, we'll store the menu item as a link where:
231
+ # - source = incrementing ID
232
+ # - target = menu type identifier
233
+
234
+ menu_type_id = 1000 # Identifier for menu items
235
+ item_id = self.next_id
236
+ self.next_id += 1
237
+
238
+ link = self.create_link(item_id, menu_type_id)
239
+
240
+ # Store the actual JSON data separately (we'll need to implement a string storage mechanism)
241
+ # For now, return the link ID
242
+ return link['id']
243
+
244
+ def get_all_menu_items(self) -> List[Dict[str, int]]:
245
+ """
246
+ Get all menu items
247
+
248
+ Returns:
249
+ List of menu items
250
+ """
251
+ # Read all links with source pointing to menu type
252
+ all_links = self.read_all_links()
253
+
254
+ # Filter links that represent menu items (target = 1000)
255
+ menu_links = [link for link in all_links if link['target'] == 1000]
256
+
257
+ return menu_links
258
+
259
+ def delete_menu_item(self, link_id: int) -> bool:
260
+ """
261
+ Delete menu item
262
+
263
+ Args:
264
+ link_id: Link ID
265
+
266
+ Returns:
267
+ Success status
268
+ """
269
+ return self.delete_link(link_id)
270
+
271
+ def clear_database(self) -> bool:
272
+ """
273
+ Clear all data from database
274
+
275
+ Returns:
276
+ Success status
277
+ """
278
+ try:
279
+ # Delete all links one by one
280
+ all_links = self.read_all_links()
281
+ for link in all_links:
282
+ self.delete_link(link['id'])
283
+ return True
284
+ except Exception as error:
285
+ logger.error(f"Failed to clear database: {error}")
286
+ raise
@@ -0,0 +1,271 @@
1
+ """MenuStorageService - Service for storing menu configuration using link-cli"""
2
+
3
+ import json
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import List, Dict, Optional, Any
7
+ from links_client.services.link_db_service import LinkDBService
8
+ from links_client.utils.logger import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ # Data directory for storing menu item content
13
+ DATA_DIR = Path(__file__).parent.parent.parent.parent / "data" / "menu-items"
14
+
15
+
16
+ class MenuStorageService:
17
+ """
18
+ MenuStorageService - Store menu configurations using link-cli
19
+
20
+ Architecture:
21
+ - Link-cli stores the relationships and structure (menu hierarchy)
22
+ - File system stores the actual menu item data (JSON files)
23
+ - Links represent parent-child relationships in menu structure
24
+
25
+ Link schema:
26
+ - Link (menuItemId, parentId) represents a menu item under a parent
27
+ - Special parentId = 0 means root-level menu item
28
+ - Link IDs are derived from content hashes for consistency
29
+ """
30
+
31
+ def __init__(self):
32
+ """Initialize MenuStorageService"""
33
+ self.link_db = LinkDBService()
34
+ self._ensure_data_directory()
35
+
36
+ def _ensure_data_directory(self):
37
+ """Ensure data directory exists"""
38
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
39
+ logger.info(f"Ensured menu items data directory exists: {DATA_DIR}")
40
+
41
+ def generate_item_id(self, item: Dict[str, Any]) -> int:
42
+ """
43
+ Generate a stable ID from menu item content
44
+
45
+ Args:
46
+ item: Menu item
47
+
48
+ Returns:
49
+ Numeric ID
50
+ """
51
+ content = json.dumps(item, sort_keys=True)
52
+ hash_obj = hashlib.sha256(content.encode())
53
+ hash_hex = hash_obj.hexdigest()
54
+ # Take first 8 characters of hash and convert to number
55
+ return int(hash_hex[:8], 16) % 1000000 # Keep it reasonable
56
+
57
+ def save_item_data(self, item_id: int, item: Dict[str, Any]):
58
+ """
59
+ Save menu item data to file
60
+
61
+ Args:
62
+ item_id: Item ID
63
+ item: Menu item data
64
+ """
65
+ file_path = DATA_DIR / f"{item_id}.json"
66
+ with open(file_path, 'w', encoding='utf-8') as f:
67
+ json.dump(item, f, indent=2)
68
+
69
+ def load_item_data(self, item_id: int) -> Optional[Dict[str, Any]]:
70
+ """
71
+ Load menu item data from file
72
+
73
+ Args:
74
+ item_id: Item ID
75
+
76
+ Returns:
77
+ Menu item or None if not found
78
+ """
79
+ file_path = DATA_DIR / f"{item_id}.json"
80
+ try:
81
+ with open(file_path, 'r', encoding='utf-8') as f:
82
+ return json.load(f)
83
+ except FileNotFoundError:
84
+ return None
85
+
86
+ def store_menu_item(self, item: Dict[str, Any], parent_id: int = 0) -> int:
87
+ """
88
+ Store a menu item with its parent relationship
89
+
90
+ Args:
91
+ item: Menu item (must have label, icon, to/items)
92
+ parent_id: Parent item ID (0 for root)
93
+
94
+ Returns:
95
+ Item ID
96
+ """
97
+ item_id = self.generate_item_id(item)
98
+
99
+ # Save item data to file
100
+ self.save_item_data(item_id, item)
101
+
102
+ # Create link in database: (itemId, parentId)
103
+ try:
104
+ self.link_db.create_link(item_id, parent_id)
105
+ logger.info(f"Menu item stored in link database: itemId={item_id}, parentId={parent_id}")
106
+ except Exception as error:
107
+ # Link might already exist, that's okay
108
+ logger.debug(f"Link already exists or failed to create: itemId={item_id}, parentId={parent_id}")
109
+
110
+ return item_id
111
+
112
+ def store_menu_structure(self, menu_items: List[Dict[str, Any]], parent_id: int = 0) -> List[int]:
113
+ """
114
+ Store a complete menu structure recursively
115
+
116
+ Args:
117
+ menu_items: Menu items array
118
+ parent_id: Parent ID
119
+
120
+ Returns:
121
+ List of created item IDs
122
+ """
123
+ item_ids = []
124
+
125
+ for item in menu_items:
126
+ # Create a copy without nested items for the link
127
+ item_without_children = {k: v for k, v in item.items() if k != 'items'}
128
+ children = item.get('items', [])
129
+
130
+ # Store the item
131
+ item_id = self.store_menu_item(item_without_children, parent_id)
132
+ item_ids.append(item_id)
133
+
134
+ # Recursively store children
135
+ if children and isinstance(children, list) and len(children) > 0:
136
+ self.store_menu_structure(children, item_id)
137
+
138
+ return item_ids
139
+
140
+ def get_menu_structure(self, parent_id: int = 0) -> List[Dict[str, Any]]:
141
+ """
142
+ Retrieve menu structure by building from links
143
+
144
+ Args:
145
+ parent_id: Parent ID (0 for root)
146
+
147
+ Returns:
148
+ List of menu items
149
+ """
150
+ # Get all links from database
151
+ all_links = self.link_db.read_all_links()
152
+
153
+ # Filter links that have this parent
154
+ child_links = [link for link in all_links if link['target'] == parent_id]
155
+
156
+ # Build menu items
157
+ menu_items = []
158
+
159
+ for link in child_links:
160
+ item_id = link['source']
161
+
162
+ # Load item data
163
+ item_data = self.load_item_data(item_id)
164
+
165
+ if item_data:
166
+ # Recursively get children
167
+ children = self.get_menu_structure(item_id)
168
+
169
+ menu_item = {
170
+ **item_data,
171
+ '_linkId': link['id'],
172
+ '_itemId': item_id
173
+ }
174
+
175
+ # Add children if any
176
+ if children:
177
+ menu_item['items'] = children
178
+
179
+ menu_items.append(menu_item)
180
+
181
+ return menu_items
182
+
183
+ def get_all_menu_items(self) -> List[Dict[str, Any]]:
184
+ """
185
+ Get all menu items (flat list)
186
+
187
+ Returns:
188
+ List of all menu items
189
+ """
190
+ all_links = self.link_db.read_all_links()
191
+ items = []
192
+
193
+ for link in all_links:
194
+ item_data = self.load_item_data(link['source'])
195
+ if item_data:
196
+ items.append({
197
+ **item_data,
198
+ '_linkId': link['id'],
199
+ '_itemId': link['source'],
200
+ '_parentId': link['target']
201
+ })
202
+
203
+ return items
204
+
205
+ def delete_menu_item(self, item_id: int) -> bool:
206
+ """
207
+ Delete a menu item and its children
208
+
209
+ Args:
210
+ item_id: Item ID
211
+
212
+ Returns:
213
+ Success status
214
+ """
215
+ # Get all child items
216
+ children = self.get_menu_structure(item_id)
217
+
218
+ # Recursively delete children
219
+ for child in children:
220
+ self.delete_menu_item(child['_itemId'])
221
+
222
+ # Delete links where this item is the source
223
+ all_links = self.link_db.read_all_links()
224
+ item_links = [link for link in all_links if link['source'] == item_id]
225
+
226
+ for link in item_links:
227
+ self.link_db.delete_link(link['id'])
228
+
229
+ # Delete item data file
230
+ try:
231
+ file_path = DATA_DIR / f"{item_id}.json"
232
+ file_path.unlink()
233
+ except Exception as error:
234
+ logger.warning(f"Failed to delete item data file: itemId={item_id}, error={error}")
235
+
236
+ return True
237
+
238
+ def clear_all_menus(self) -> bool:
239
+ """
240
+ Clear all menu data
241
+
242
+ Returns:
243
+ Success status
244
+ """
245
+ # Clear link database
246
+ self.link_db.clear_database()
247
+
248
+ # Clear all menu item files
249
+ try:
250
+ for file_path in DATA_DIR.glob("*.json"):
251
+ file_path.unlink()
252
+ except Exception as error:
253
+ logger.warning(f"Failed to clear menu data files: {error}")
254
+
255
+ return True
256
+
257
+ def get_statistics(self) -> Dict[str, int]:
258
+ """
259
+ Get statistics about stored menu data
260
+
261
+ Returns:
262
+ Statistics dictionary
263
+ """
264
+ all_links = self.link_db.read_all_links()
265
+ json_files = list(DATA_DIR.glob("*.json"))
266
+
267
+ return {
268
+ 'totalLinks': len(all_links),
269
+ 'totalFiles': len(json_files),
270
+ 'rootItems': len([link for link in all_links if link['target'] == 0])
271
+ }
@@ -0,0 +1,5 @@
1
+ """Utility modules"""
2
+
3
+ from links_client.utils.logger import get_logger
4
+
5
+ __all__ = ["get_logger"]
@@ -0,0 +1,41 @@
1
+ """Logging utility for links-client"""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Optional
6
+
7
+
8
+ def get_logger(name: str = "links_client", level: Optional[int] = None) -> logging.Logger:
9
+ """
10
+ Get or create a logger instance
11
+
12
+ Args:
13
+ name: Logger name
14
+ level: Logging level (defaults to WARNING)
15
+
16
+ Returns:
17
+ Logger instance
18
+ """
19
+ logger = logging.getLogger(name)
20
+
21
+ # Only configure if not already configured
22
+ if not logger.handlers:
23
+ if level is None:
24
+ level = logging.WARNING
25
+
26
+ logger.setLevel(level)
27
+
28
+ # Create console handler
29
+ handler = logging.StreamHandler(sys.stdout)
30
+ handler.setLevel(level)
31
+
32
+ # Create formatter
33
+ formatter = logging.Formatter(
34
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
35
+ datefmt='%Y-%m-%d %H:%M:%S'
36
+ )
37
+ handler.setFormatter(formatter)
38
+
39
+ logger.addHandler(handler)
40
+
41
+ return logger
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: links-client
3
+ Version: 1.0.0
4
+ Summary: Python client library for link-cli (Links Theory database)
5
+ Author: DronDoc Team
6
+ License: Unlicense
7
+ Project-URL: Homepage, https://github.com/link-foundation/links-client
8
+ Project-URL: Repository, https://github.com/link-foundation/links-client
9
+ Project-URL: Issues, https://github.com/link-foundation/links-client/issues
10
+ Keywords: link-cli,links-theory,database,associative,clink
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Public Domain
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
25
+
26
+ # Links Client - Python Implementation
27
+
28
+ Python client library for [link-cli](https://github.com/link-foundation/link-cli) — implementation of Links Theory database.
29
+
30
+ ## Installation
31
+
32
+ ### Prerequisites
33
+
34
+ Install link-cli globally:
35
+ ```bash
36
+ dotnet tool install --global clink
37
+ ```
38
+
39
+ ### Package Installation
40
+
41
+ ```bash
42
+ pip install -e .
43
+ ```
44
+
45
+ Or install from the repository root:
46
+
47
+ ```bash
48
+ cd python
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Basic Usage
55
+
56
+ ```python
57
+ from links_client import LinkDBService
58
+
59
+ db = LinkDBService('/path/to/database.links')
60
+
61
+ # Create a link
62
+ link = db.create_link(100, 200)
63
+ print(link) # {'id': 1, 'source': 100, 'target': 200}
64
+
65
+ # Read all links
66
+ links = db.read_all_links()
67
+
68
+ # Delete a link
69
+ db.delete_link(1)
70
+ ```
71
+
72
+ ### Menu Storage
73
+
74
+ ```python
75
+ from links_client import MenuStorageService
76
+
77
+ menu_storage = MenuStorageService()
78
+
79
+ menu = [
80
+ {
81
+ "label": "Dashboard",
82
+ "icon": "pi pi-home",
83
+ "to": "/dashboard",
84
+ "items": [
85
+ {"label": "Analytics", "to": "/dashboard/analytics"}
86
+ ]
87
+ }
88
+ ]
89
+
90
+ menu_storage.store_menu_structure(menu, 0)
91
+ retrieved_menu = menu_storage.get_menu_structure(0)
92
+ ```
93
+
94
+ ### Authentication Storage
95
+
96
+ ```python
97
+ from links_client import AuthStorageService
98
+
99
+ auth_storage = AuthStorageService()
100
+
101
+ # Create user
102
+ user = auth_storage.create_user({
103
+ "username": "alice",
104
+ "email": "alice@example.com"
105
+ })
106
+
107
+ # Set password
108
+ auth_storage.set_password(user['userId'], {
109
+ "hash": "hashed_password",
110
+ "salt": "random_salt"
111
+ })
112
+ ```
113
+
114
+ ## API
115
+
116
+ For detailed API documentation, see the docs/ directory.
117
+
118
+ ## Examples
119
+
120
+ See examples in the examples/ directory:
121
+
122
+ - `basic_usage.py` - Basic clink integration tests
123
+ - `menu_storage_example.py` - Menu storage demonstration
124
+ - `auth_storage_example.py` - Authentication storage demonstration
125
+
126
+ ## Testing
127
+
128
+ Run tests with pytest:
129
+
130
+ ```bash
131
+ pytest tests/
132
+ ```
133
+
134
+ ## License
135
+
136
+ The Unlicense
137
+
138
+ ## Links
139
+
140
+ - Source repository: https://github.com/link-foundation/links-client
141
+ - link-cli: https://github.com/link-foundation/link-cli
@@ -0,0 +1,11 @@
1
+ links_client/__init__.py,sha256=P9NUBwfms4dnNbrlQLSe9H1hDnJqLJU_XqcF20ID8xc,691
2
+ links_client/services/__init__.py,sha256=Obq56i8AjqtMcbFRJdjvRiPZBWmQjtmwIXl5uKU2-wo,340
3
+ links_client/services/auth_storage_service.py,sha256=zpRSI9hNubY1Ra8ECH8bR8uNKj-aKUmRV_w2YFZiqEs,17538
4
+ links_client/services/link_db_service.py,sha256=aqqSlLscZ96BVXgNXICR9CGByY4s39I-OciAwCZbhmY,8108
5
+ links_client/services/menu_storage_service.py,sha256=JnGvYL5tEVJhtPHC9aFLHTaIhiVRMtFSdd9Jf_Mzhn0,8150
6
+ links_client/utils/__init__.py,sha256=hAGOfxhB2d-8GFk6Gn4-lTGDDk26DzyhwEMx0_dO5-I,98
7
+ links_client/utils/logger.py,sha256=W_fa5Z4Y5RiUUOFlZFBn79mN_KWB967k67KwfqyF-lI,970
8
+ links_client-1.0.0.dist-info/METADATA,sha256=uQ4-x9oK0LVttIdHpU37q8PjD3ENhLv_5378sTHL3bY,3069
9
+ links_client-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ links_client-1.0.0.dist-info/top_level.txt,sha256=DBKi60S1rGFP8QU5lgEzUIEpz_G4gq0AVfJlNC_0V7U,13
11
+ links_client-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ links_client