From cbceaaf9dcea59ea29be7bb02daa4bd21b148307 Mon Sep 17 00:00:00 2001 From: jedi Date: Sat, 1 Aug 2026 01:49:49 +0200 Subject: [PATCH] offline data prototype --- backend/toolshed/api/offlinedata.py | 132 +++++++++++- backend/toolshed/offlinedata.py | 312 +++++++++++++++++++++++++++ frontend/src/views/settings/Data.vue | 48 ++++- testdata/user-a.key | 1 + testdata/user-a.zip | Bin 0 -> 5309 bytes testdata/user-b.key | 1 + testdata/user-b.zip | Bin 0 -> 4738 bytes 7 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 backend/toolshed/offlinedata.py create mode 100644 testdata/user-a.key create mode 100644 testdata/user-a.zip create mode 100644 testdata/user-b.key create mode 100644 testdata/user-b.zip diff --git a/backend/toolshed/api/offlinedata.py b/backend/toolshed/api/offlinedata.py index cb64ae1..1d25547 100644 --- a/backend/toolshed/api/offlinedata.py +++ b/backend/toolshed/api/offlinedata.py @@ -5,18 +5,40 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.signature_auth import SignatureAuthentication +from toolshed.offlinedata import ( + inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv, + import_locations, import_friends, import_inventory, +) -def user_data(): +def local_user_or_none(identity): + """Resolve the local ToolshedUser associated with an authenticated KnownIdentity, if any. + + Returns None if the identity belongs to an external/remote user with no local account. + """ + if identity is None: + return None + return identity.user.first() + + +def user_data(user): import io import zipfile zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: - for file_name, data in [('1.txt', io.BytesIO(b'111')), - ('2.txt', io.BytesIO(b'222'))]: - zip_file.writestr(file_name, data.getvalue()) + inventory_csv = b''.join(rows_to_csv(inventory_rows(user))) + zip_file.writestr('inventory.csv', inventory_csv) + + friends_csv = b''.join(rows_to_csv(friend_rows(user))) + zip_file.writestr('friends.csv', friends_csv) + + locations_csv = b''.join(rows_to_csv(location_rows(user))) + zip_file.writestr('locations.csv', locations_csv) + + for arcname, data in inventory_files(user): + zip_file.writestr(arcname, data) return zip_buffer.getvalue() @@ -30,30 +52,118 @@ def parse_user_data(data): yield file_name, zip_file.read(file_name) +def import_files(zip_file): + """Fault-tolerant extraction of the 'files/' subfolder into File objects. + + Returns a dict mapping the zip arcname to the created/existing File instance. Entries that + fail to read or save are silently skipped so a single corrupt attachment doesn't abort the import. + """ + from hashlib import sha256 + import mimetypes + + from django.core.files.base import ContentFile + from files.models import File + + result = {} + for name in zip_file.namelist(): + if not name.startswith('files/') or name == 'files/': + continue + try: + data = zip_file.read(name) + content_hash = sha256(data).hexdigest() + mime_type, _ = mimetypes.guess_type(name) + file_obj = File.objects.filter(hash=content_hash).first() + if file_obj is None: + file_obj = File.objects.create( + file=ContentFile(data, content_hash), + mime_type=mime_type or 'application/octet-stream', + hash=content_hash, + ) + result[name] = file_obj + except Exception as error: + print(f'Skipping file "{name}" during import: {error}') + return result + + + +def import_user_data(user, data): + """Fault-tolerant import of an export zip produced by `user_data()`. + + Any of 'inventory.csv', 'friends.csv', 'locations.csv' or the 'files/' subfolder may be + missing; whatever is present is imported and everything else is silently skipped. + """ + import io + import zipfile + + summary = {'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0} + + with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file: + names = set(zip_file.namelist()) + + available_files = import_files(zip_file) + summary['files'] = len(available_files) + + if 'locations.csv' in names: + summary['locations'] = import_locations(user, zip_file.read('locations.csv')) + + if 'friends.csv' in names: + summary['friends'] = import_friends(user, zip_file.read('friends.csv')) + + if 'inventory.csv' in names: + summary['inventory_items'] = import_inventory(user, zip_file.read('inventory.csv'), available_files) + + return summary + + +def _extract_zip_bytes(zip_payload): + """Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes).""" + import base64 + + if hasattr(zip_payload, 'read'): + return zip_payload.read() + if isinstance(zip_payload, str): + try: + return base64.b64decode(zip_payload, validate=True) + except Exception: + return zip_payload.encode('utf-8') + return zip_payload + + @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def import_data(request, format=None): - zip = request.data.get('zip') - if not zip: + local_user = local_user_or_none(request.user) + if local_user is None: + return Response({'detail': 'This endpoint is only available to local users'}, status=403) + zip_payload = request.data.get('zip') + if not zip_payload: return Response(status=400) - for file_name, data in parse_user_data(zip): - print(file_name, data) - return Response(status=200) + try: + zip_bytes = _extract_zip_bytes(zip_payload) + summary = import_user_data(local_user, zip_bytes) + except Exception as error: + return Response({'detail': f'Could not read zip file: {error}'}, status=400) + return Response(summary, status=200) @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def export_data(request, format=None): - return HttpResponse(user_data(), content_type='application/zip', status=200) + local_user = local_user_or_none(request.user) + if local_user is None: + return Response({'detail': 'This endpoint is only available to local users'}, status=403) + return HttpResponse(user_data(local_user), content_type='application/zip', status=200) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def delete_account(request, format=None): - pass + local_user = local_user_or_none(request.user) + if local_user is None: + return Response({'detail': 'This endpoint is only available to local users'}, status=403) urlpatterns = [ diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py new file mode 100644 index 0000000..98de35e --- /dev/null +++ b/backend/toolshed/offlinedata.py @@ -0,0 +1,312 @@ +"""Data helpers for building/importing a user's offline export. + +These functions deal with the toolshed models and CSV row shapes only - they know nothing +about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py +for the zip-building/parsing and the API views themselves. +""" + + +def inventory_rows(user): + """Generator that yields the given user's inventory items as flattened dicts, one per row.""" + import mimetypes + + from toolshed.models import InventoryItem + + items = (InventoryItem.objects + .filter(owner=user) + .select_related('category', 'storage_location') + .prefetch_related('tags', 'itemproperty_set__property', 'files')) + + for item in items: + file_paths = [] + for f in item.files.all(): + extension = mimetypes.guess_extension(f.mime_type) or '' + file_paths.append(f'files/{f.hash}{extension}') + + yield { + 'id': item.id, + 'name': item.name or '', + 'description': item.description or '', + 'category': str(item.category) if item.category else '', + 'availability_policy': item.availability_policy, + 'owned_quantity': item.owned_quantity, + 'storage_location': str(item.storage_location) if item.storage_location else '', + 'tags': ', '.join(tag.name for tag in item.tags.all()), + 'properties': ', '.join(f"{ip.property.name}={ip.value}" for ip in item.itemproperty_set.all()), + 'files': ', '.join(file_paths), + 'created_at': item.created_at.isoformat() if item.created_at else '', + } + + +def friend_rows(user): + """Generator that yields the given user's friends (known identities) as flattened dicts, one per row.""" + friends = user.friends.all() + + for friend in friends: + yield { + 'username': friend.username, + 'domain': friend.domain, + 'handle': f'{friend.username}@{friend.domain}', + 'public_key': friend.public_key, + } + + +def location_path(location): + """Recursively build the '/'-joined path of a StorageLocation, following its parent chain.""" + if location.parent: + return location_path(location.parent) + '/' + location.name + return location.name + + +def location_rows(user): + """Generator that yields the given user's storage locations as flattened dicts, one per row.""" + from toolshed.models import StorageLocation + + locations = StorageLocation.objects.filter(owner=user).select_related('category', 'parent') + + for location in locations: + yield { + 'id': location.id, + 'name': location.name, + 'description': location.description or '', + 'category': str(location.category) if location.category else '', + 'parent': str(location.parent) if location.parent else '', + 'path': location_path(location), + } + + +def inventory_files(user): + """Generator that yields (arcname, data) for each unique file attached to the user's inventory items. + + Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original + extension (guessed from mime_type) so attachments and images remain viewable once extracted. + """ + import mimetypes + + from toolshed.models import InventoryItem + + seen_hashes = set() + items = InventoryItem.objects.filter(owner=user).prefetch_related('files') + + for item in items: + for file in item.files.all(): + if file.hash in seen_hashes: + continue + seen_hashes.add(file.hash) + + extension = mimetypes.guess_extension(file.mime_type) or '' + arcname = f'files/{file.hash}{extension}' + + file.file.open('rb') + try: + data = file.file.read() + finally: + file.file.close() + + yield arcname, data + + +def rows_to_csv(rows, fieldnames=None, encoding='utf-8'): + """Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk.""" + import csv + + class _Echo: + """A file-like object whose write() just returns what was passed, for streaming csv.writer output.""" + + def write(self, value): + return value + + rows = iter(rows) + try: + first_row = next(rows) + except StopIteration: + return + + if fieldnames is None: + fieldnames = list(first_row.keys()) + + writer = csv.DictWriter(_Echo(), fieldnames=fieldnames) + yield writer.writeheader().encode(encoding) + yield writer.writerow(first_row).encode(encoding) + for row in rows: + yield writer.writerow(row).encode(encoding) + + +def _read_csv_rows(data, encoding='utf-8'): + """Decode CSV bytes and yield rows as dicts keyed by the header labels (not column offsets).""" + import csv + import io + + reader = csv.DictReader(io.StringIO(data.decode(encoding))) + for row in reader: + yield row + + +def _field_is_optional(model, field_name): + """Return True if a field may be omitted: it's a relation, has a default, or allows null/blank.""" + try: + field = model._meta.get_field(field_name) + except Exception: + return True + if getattr(field, 'many_to_many', False) or getattr(field, 'one_to_many', False): + return True + return bool(getattr(field, 'null', False) or getattr(field, 'blank', False) or field.has_default()) + + +def get_or_create_category(path): + """Resolve or create a Category from a '/'-separated path such as 'Electronics/Cables'.""" + from toolshed.models import Category + + parent = None + category = None + for part in (p for p in path.split('/') if p): + category, _ = Category.objects.get_or_create(name=part, parent=parent, defaults={'origin': 'import'}) + parent = category + return category + + +def import_locations(user, data): + """Fault-tolerant import of locations.csv into StorageLocations owned by `user`. + + Rows are read by header label. Rows missing the required 'name' column are skipped. + Optional columns ('description', 'category') are simply omitted if absent. Locations are + processed in path-depth order so a child's parent already exists by the time it's needed. + """ + from toolshed.models import StorageLocation + + rows = list(_read_csv_rows(data)) + rows.sort(key=lambda row: (row.get('path') or row.get('name') or '').count('/')) + + resolved_by_path = {} + imported = 0 + + for row in rows: + try: + name = (row.get('name') or '').strip() + if not name and not _field_is_optional(StorageLocation, 'name'): + continue # required column missing, skip this row + + path = (row.get('path') or name).strip() + parent = None + if '/' in path: + parent = resolved_by_path.get(path.rsplit('/', 1)[0]) + + category = None + category_path = row.get('category') + if category_path: + category = get_or_create_category(category_path) + + location, _ = StorageLocation.objects.update_or_create( + owner=user, name=name, parent=parent, + defaults={ + 'description': row.get('description', '') or '', + 'category': category, + }, + ) + resolved_by_path[path] = location + imported += 1 + except Exception as error: + print(f'Skipping location row {row}: {error}') + + return imported + + +def import_friends(user, data): + """Fault-tolerant import of friends.csv, adding valid entries to the user's known friends.""" + from authentication.models import KnownIdentity + + imported = 0 + for row in _read_csv_rows(data): + try: + username = row.get('username') + domain = row.get('domain') + handle = row.get('handle') + if (not username or not domain) and handle and '@' in handle: + username, domain = handle.split('@', 1) + + public_key = row.get('public_key') + if not username or not domain or not public_key: + continue # required fields missing, skip this row + + identity, _ = KnownIdentity.objects.get_or_create( + username=username, domain=domain, + defaults={'public_key': public_key}, + ) + user.public_identity.friends.add(identity) + imported += 1 + except Exception as error: + print(f'Skipping friend row {row}: {error}') + + return imported + + +def import_inventory(user, data, available_files): + """Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. + + `available_files` maps the 'files' column's paths to File instances successfully extracted + from the zip; references to missing files are ignored rather than failing the row. + """ + from toolshed.models import InventoryItem, ItemProperty, StorageLocation, Tag, Property + + imported = 0 + for row in _read_csv_rows(data): + try: + name = (row.get('name') or '').strip() + + file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()] + files = [available_files[p] for p in file_paths if p in available_files] + + if not name and not files: + continue # InventoryItem requires a name or at least one file + + category = None + category_path = row.get('category') + if category_path: + category = get_or_create_category(category_path) + + storage_location = None + location_path_value = row.get('storage_location') + if location_path_value: + storage_location = StorageLocation.objects.filter( + owner=user, name=location_path_value.rsplit('/', 1)[-1]).first() + + try: + owned_quantity = int(row.get('owned_quantity') or 1) + except (TypeError, ValueError): + owned_quantity = 1 + + item = InventoryItem.objects.create( + owner=user, + name=name or None, + description=row.get('description', '') or '', + category=category, + availability_policy=row.get('availability_policy') or 'private', + owned_quantity=owned_quantity, + storage_location=storage_location, + ) + + for tag_name in (row.get('tags') or '').split(','): + tag_name = tag_name.strip() + if tag_name: + tag, _ = Tag.objects.get_or_create(name=tag_name, category=None, + defaults={'origin': 'import'}) + item.tags.add(tag, through_defaults={}) + + for prop_entry in (row.get('properties') or '').split(','): + prop_entry = prop_entry.strip() + if '=' not in prop_entry: + continue + prop_name, value = prop_entry.split('=', 1) + prop, _ = Property.objects.get_or_create(name=prop_name.strip(), category=None, + defaults={'origin': 'import'}) + ItemProperty.objects.create(inventory_item=item, property=prop, value=value.strip()) + + for file in files: + item.files.add(file) + + imported += 1 + except Exception as error: + print(f'Skipping inventory row {row}: {error}') + + return imported + diff --git a/frontend/src/views/settings/Data.vue b/frontend/src/views/settings/Data.vue index f8e2268..2aa3365 100644 --- a/frontend/src/views/settings/Data.vue +++ b/frontend/src/views/settings/Data.vue @@ -103,20 +103,50 @@ export default { exportKey() { const key = this.userIdentityRecord; }, + fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result; + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('Could not read file')); + return; + } + const data = new Uint8Array(buffer); + const base64 = btoa(data.reduce((acc, byte) => acc + String.fromCharCode(byte), '')); + resolve(base64); + }; + reader.onerror = (error) => reject(error); + reader.readAsArrayBuffer(file); + }); + }, async importData() { if (!this.selectedFile) { alert('Please select a file to import'); return; } - alert('Data import not implemented'); - return; - - const formData = new FormData(); - formData.append('file', this.selectedFile); - console.log(formData); - const servers = await this.getHomeServers(); - const data = await servers.postRaw(this.signHashedAuth, '/api/import/', formData); - console.log(data); + try { + const base64 = await this.fileToBase64(this.selectedFile); + const servers = await this.getHomeServers(); + const summary = await servers.post(this.signAuth, '/api/import/', {zip: base64}); + if (summary && summary.detail) { + alert('Data import failed: ' + summary.detail); + return; + } + alert('Data imported successfully: ' + + `${summary.inventory_items || 0} inventory items, ` + + `${summary.friends || 0} friends, ` + + `${summary.locations || 0} locations, ` + + `${summary.files || 0} files.`); + this.selectedFile = null; + const fileInput = document.getElementById('inputFile'); + if (fileInput) { + fileInput.value = ''; + } + } catch (error) { + console.error('Data import failed', error); + alert('Data import failed: ' + error); + } }, async exportData() { const servers = await this.getHomeServers(); diff --git a/testdata/user-a.key b/testdata/user-a.key new file mode 100644 index 0000000..013ea42 --- /dev/null +++ b/testdata/user-a.key @@ -0,0 +1 @@ +f0cd0b223efb7ca24cd8ad6e39471fb1b4f69a20a3e5c6fdb41c9985b7dbf381 \ No newline at end of file diff --git a/testdata/user-a.zip b/testdata/user-a.zip new file mode 100644 index 0000000000000000000000000000000000000000..c49f5887359c04dad2c6c20295ef29b3f02edd08 GIT binary patch literal 5309 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 literal 0 HcmV?d00001