a.name.localeCompare(b.name))) {
+ const node = nodesById[location.id]
+ // Falls back to root if the parent isn't in this owner's own location list.
+ const parent = location.parent != null ? nodesById[location.parent] : null
+ ;(parent ? parent.children : roots).push(node)
+ }
+ return roots
+ },
addLocationRoute() {
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
return {name: 'locations-new', params}
@@ -240,6 +294,29 @@ export default {
font-size: 0.8rem;
}
+.tree-location-row {
+ display: grid;
+ /* Category/Visibility match the table's 20% th widths; actions is max-content so it never wraps. */
+ grid-template-columns: minmax(0, 1fr) 20% 20% auto;
+ align-items: center;
+ column-gap: 0.5rem;
+ width: 100%;
+}
+
+.tree-location-name {
+ min-width: 0;
+}
+
+.tree-location-actions {
+ display: flex;
+ align-items: center;
+}
+
+/* The th widths above leave Actions only the leftover 5% - without this it wraps mid-icon. */
+.table-action {
+ white-space: nowrap;
+}
+
.btn-group.mt-auto {
width: 100%;
}
diff --git a/testdata/avatars/avatar-2.png b/testdata/avatars/avatar-2.png
new file mode 100644
index 0000000..9eb3ec9
Binary files /dev/null and b/testdata/avatars/avatar-2.png differ
diff --git a/testdata/avatars/avatar-3.png b/testdata/avatars/avatar-3.png
new file mode 100644
index 0000000..a0dbc96
Binary files /dev/null and b/testdata/avatars/avatar-3.png differ
diff --git a/testdata/avatars/avatar-4.png b/testdata/avatars/avatar-4.png
new file mode 100644
index 0000000..de75ef6
Binary files /dev/null and b/testdata/avatars/avatar-4.png differ
diff --git a/testdata/avatars/avatar-5.png b/testdata/avatars/avatar-5.png
new file mode 100644
index 0000000..40d595e
Binary files /dev/null and b/testdata/avatars/avatar-5.png differ
diff --git a/testdata/avatars/avatar-6.png b/testdata/avatars/avatar-6.png
new file mode 100644
index 0000000..bc8577c
Binary files /dev/null and b/testdata/avatars/avatar-6.png differ
diff --git a/testdata/avatars/avatar.png b/testdata/avatars/avatar.png
new file mode 100644
index 0000000..894246c
Binary files /dev/null and b/testdata/avatars/avatar.png differ
diff --git a/testdata/generate_testdata.py b/testdata/generate_testdata.py
index 721f64b..1191a26 100644
--- a/testdata/generate_testdata.py
+++ b/testdata/generate_testdata.py
@@ -17,6 +17,7 @@ from nacl.signing import SigningKey
from nacl.encoding import HexEncoder
IMGS_DIR = os.path.join(os.path.dirname(__file__), 'imgs')
+AVATARS_DIR = os.path.join(os.path.dirname(__file__), 'avatars')
IMAGE_ATTACHMENT_PROBABILITY = 0.9
@@ -254,11 +255,11 @@ def load_shared_data():
return all_tags, all_categories, all_properties
-def load_available_images():
- """Read every file in imgs/ and return a list of (data, mime_type) tuples."""
+def _load_images_from_dir(directory):
+ """Read every image file in the given directory and return a list of (data, mime_type) tuples."""
images = []
- for filename in sorted(os.listdir(IMGS_DIR)):
- filepath = os.path.join(IMGS_DIR, filename)
+ for filename in sorted(os.listdir(directory)):
+ filepath = os.path.join(directory, filename)
if not os.path.isfile(filepath):
continue
mime_type, _ = mimetypes.guess_type(filename)
@@ -269,6 +270,16 @@ def load_available_images():
return images
+def load_available_images():
+ """Read every file in imgs/ and return a list of (data, mime_type) tuples."""
+ return _load_images_from_dir(IMGS_DIR)
+
+
+def load_available_avatars():
+ """Read every file in avatars/ and return a list of (data, mime_type) tuples."""
+ return _load_images_from_dir(AVATARS_DIR)
+
+
def generate_keypair():
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
signing_key = SigningKey.generate()
@@ -277,6 +288,18 @@ def generate_keypair():
return private_hex, public_hex
+def load_or_generate_keypair(key_path):
+ """Return (hex_private_key, hex_public_key), reusing the key at key_path if it already
+ exists instead of generating (and thereby invalidating) a new identity."""
+ if os.path.exists(key_path):
+ with open(key_path, 'r') as f:
+ private_hex = f.read().strip()
+ signing_key = SigningKey(private_hex.encode('utf-8'), encoder=HexEncoder)
+ public_hex = signing_key.verify_key.encode(encoder=HexEncoder).decode('utf-8')
+ return private_hex, public_hex
+ return generate_keypair()
+
+
def generate_locations_csv(count):
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
@@ -442,6 +465,24 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
return output.getvalue().encode('utf-8'), files
+def generate_profile_data(username, domain, first_name, last_name, avatar_data, avatar_mime_type):
+ """Build profile.json content and the (arcname, data) for its picture, matching
+ `toolshed.offlinedata.profile_data()`/`profile_picture_files()`'s 'files/' convention.
+ """
+ extension = mimetypes.guess_extension(avatar_mime_type) or ''
+ arcname = f'files/{sha256(avatar_data).hexdigest()}{extension}'
+
+ profile = {
+ 'username': username,
+ 'domain': domain,
+ 'email': f'{username}@{domain}',
+ 'first_name': first_name,
+ 'last_name': last_name,
+ 'profile_picture': arcname,
+ }
+ return json.dumps(profile, indent=2).encode('utf-8'), arcname
+
+
def generate_sample_files():
"""Generate sample files for the 'files/' folder in the zip."""
files = {}
@@ -458,7 +499,8 @@ def generate_sample_files():
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
- num_locations=10, num_items=150, item_range=None):
+ num_locations=10, num_items=150, item_range=None,
+ first_name='', last_name='', avatar=None):
"""Generate a zip file (bytes) like user_data() produces.
Args:
@@ -470,6 +512,9 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
num_locations: Number of locations to generate
num_items: Number of items to generate
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
+ first_name: First name for profile.json
+ last_name: Last name for profile.json
+ avatar: (data, mime_type) tuple for the profile picture, or None to skip profile.json
"""
num_locations = max(5, min(20, num_locations))
num_items = max(100, min(200, num_items))
@@ -495,6 +540,13 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
for arcname, content in sample_files.items():
zip_file.writestr(arcname, content)
+ if avatar:
+ avatar_data, avatar_mime_type = avatar
+ profile_json, avatar_arcname = generate_profile_data(
+ username, domain, first_name, last_name, avatar_data, avatar_mime_type)
+ zip_file.writestr('profile.json', profile_json)
+ zip_file.writestr(avatar_arcname, avatar_data)
+
return zip_buffer.getvalue()
@@ -506,20 +558,30 @@ def main():
all_tags, all_categories, all_properties = load_shared_data()
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
- print("\nGenerating keypairs...")
- user_a_private, user_a_public = generate_keypair()
- user_b_private, user_b_public = generate_keypair()
+ print("\nLoading/generating keypairs...")
+ user_a_key_path = os.path.join(script_dir, 'user-a.key')
+ user_b_key_path = os.path.join(script_dir, 'user-b.key')
+ user_a_existed = os.path.exists(user_a_key_path)
+ user_b_existed = os.path.exists(user_b_key_path)
+ user_a_private, user_a_public = load_or_generate_keypair(user_a_key_path)
+ user_b_private, user_b_public = load_or_generate_keypair(user_b_key_path)
- print(f" user-a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
- print(f" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
+ print(f" test_a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
+ print(f" test_b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
print("\nWriting key files...")
- with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
- f.write(user_a_private)
- with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
- f.write(user_b_private)
- print(" ✓ user-a.key")
- print(" ✓ user-b.key")
+ if user_a_existed:
+ print(" ✓ user-a.key (reused existing)")
+ else:
+ with open(user_a_key_path, 'w') as f:
+ f.write(user_a_private)
+ print(" ✓ user-a.key (generated)")
+ if user_b_existed:
+ print(" ✓ user-b.key (reused existing)")
+ else:
+ with open(user_b_key_path, 'w') as f:
+ f.write(user_b_private)
+ print(" ✓ user-b.key (generated)")
print("\nGenerating realistic export zips with partitioned items...")
# Partition the ITEMS_DATABASE randomly
@@ -531,13 +593,21 @@ def main():
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
+ available_avatars = load_available_avatars()
+ if len(available_avatars) >= 2:
+ user_a_avatar, user_b_avatar = random.sample(available_avatars, 2)
+ else:
+ user_a_avatar = user_b_avatar = available_avatars[0] if available_avatars else None
+
user_a_zip = generate_export_zip(
- 'user-a', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
- num_locations=12, num_items=175, item_range=user_a_item_range
+ 'test_a', 'a.localhost', 'test_b', 'b.localhost', user_b_public,
+ num_locations=12, num_items=175, item_range=user_a_item_range,
+ first_name='Alice', last_name='Anderson', avatar=user_a_avatar
)
user_b_zip = generate_export_zip(
- 'user-b', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
- num_locations=8, num_items=140, item_range=user_b_item_range
+ 'test_b', 'b.localhost', 'test_a', 'a.localhost', user_a_public,
+ num_locations=8, num_items=140, item_range=user_b_item_range,
+ first_name='Bob', last_name='Baker', avatar=user_b_avatar
)
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:
diff --git a/testdata/imgs/unsplash-1.jpg b/testdata/imgs/unsplash-1.jpg
new file mode 100644
index 0000000..ccc3963
Binary files /dev/null and b/testdata/imgs/unsplash-1.jpg differ
diff --git a/testdata/imgs/unsplash-2.jpg b/testdata/imgs/unsplash-2.jpg
new file mode 100644
index 0000000..3a33349
Binary files /dev/null and b/testdata/imgs/unsplash-2.jpg differ
diff --git a/testdata/imgs/unsplash-3.jpg b/testdata/imgs/unsplash-3.jpg
new file mode 100644
index 0000000..bc287e5
Binary files /dev/null and b/testdata/imgs/unsplash-3.jpg differ
diff --git a/testdata/user-a.key b/testdata/user-a.key
index 17abb3c..af2a12d 100644
--- a/testdata/user-a.key
+++ b/testdata/user-a.key
@@ -1 +1 @@
-982abc6680c1358c9d4d2436c96c00aebe36e9dd50fe184c12f1ac58fc7f8565
\ No newline at end of file
+53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b
\ No newline at end of file
diff --git a/testdata/user-a.zip b/testdata/user-a.zip
index e1a7993..7af8e6c 100644
Binary files a/testdata/user-a.zip and b/testdata/user-a.zip differ
diff --git a/testdata/user-b.key b/testdata/user-b.key
index 3c60be4..aa0f0cc 100644
--- a/testdata/user-b.key
+++ b/testdata/user-b.key
@@ -1 +1 @@
-2e81e04093250c647cb2c364e39017be5aeac0303e138d6ded2e2cc7c3cdcdde
\ No newline at end of file
+8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9
\ No newline at end of file
diff --git a/testdata/user-b.zip b/testdata/user-b.zip
index 31b027d..8c1279c 100644
Binary files a/testdata/user-b.zip and b/testdata/user-b.zip differ