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.
- links_client/__init__.py +22 -0
- links_client/services/__init__.py +7 -0
- links_client/services/auth_storage_service.py +561 -0
- links_client/services/link_db_service.py +286 -0
- links_client/services/menu_storage_service.py +271 -0
- links_client/utils/__init__.py +5 -0
- links_client/utils/logger.py +41 -0
- links_client-1.0.0.dist-info/METADATA +141 -0
- links_client-1.0.0.dist-info/RECORD +11 -0
- links_client-1.0.0.dist-info/WHEEL +5 -0
- links_client-1.0.0.dist-info/top_level.txt +1 -0
links_client/__init__.py
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
links_client
|