Skip to content

Commit cdf16ee

Browse files
committed
Can create, edit, and delete an alias in the UI
2 parents 1895021 + 21e75a5 commit cdf16ee

17 files changed

+2259
-87
lines changed

RELEASE_NOTES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ Release Notes ⋮
1212

1313
### main
1414

15+
* Workflow improvements
16+
* Aliases are now supported for redirecting similar patterns of URLs,
17+
such as `https://www.folklore.org/ → https://folklore.org/`.
18+
* Use the "New Alias..." menuitem in the "Entity" menu to create an Alias.
19+
* Aliases can be used to redirect to external URLs outside the project,
20+
on the live internet.
21+
* This can be useful if a subset of a project's URLs are hosted in
22+
a different internet-accessible subsystem, such as AWS Glacier.
23+
1524
* Shell improvements
1625
* Uses the new [interactive interpreter] from Python 3.13.
1726
* Supports using `await` with Crystal's testing utilities

src/crystal/browser/__init__.py

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from crystal.app_preferences import app_prefs
77
from crystal.browser.entitytree import EntityTree
88
from crystal.browser.icons import TREE_NODE_ICONS
9+
from crystal.browser.new_alias import NewAliasDialog
910
from crystal.browser.new_group import NewGroupDialog
1011
from crystal.browser.new_root_url import ChangePrefixCommand, NewRootUrlDialog
1112
from crystal.browser.preferences import PreferencesDialog
1213
from crystal.browser.tasktree import TaskTree, TaskTreeNode
1314
from crystal.model import (
14-
Project, ProjectReadOnlyError, Resource, ResourceGroup, ResourceGroupSource, RootResource,
15+
Alias, Project, ProjectReadOnlyError, Resource, ResourceGroup, ResourceGroupSource, RootResource,
1516
)
1617
from crystal.progress import (
1718
CancelLoadUrls, CancelSaveAs, DummyOpenProjectProgressListener,
@@ -316,6 +317,11 @@ def _create_actions(self) -> None:
316317
enabled=(not self._readonly),
317318
button_bitmap=dict(DEFAULT_FOLDER_ICON_SET())[wx.TreeItemIcon_Normal],
318319
button_label='New &Group...')
320+
self._new_alias_action = Action(wx.ID_ANY,
321+
'New &Alias...',
322+
wx.AcceleratorEntry(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord('A')),
323+
self._on_new_alias,
324+
enabled=(not self._readonly))
319325
self._edit_action = Action(wx.ID_ANY,
320326
self._EDIT_ACTION_LABEL if not self._readonly else self._GET_INFO_ACTION_LABEL,
321327
accel=wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_RETURN),
@@ -438,6 +444,7 @@ def _create_menu_bar(self, raw_frame: wx.Frame) -> wx.MenuBar:
438444
entity_menu.AppendSeparator()
439445
self._new_root_url_action.append_menuitem_to(entity_menu)
440446
self._new_group_action.append_menuitem_to(entity_menu)
447+
self._new_alias_action.append_menuitem_to(entity_menu)
441448
self._edit_action.append_menuitem_to(entity_menu)
442449
self._forget_action.append_menuitem_to(entity_menu)
443450
entity_menu.AppendSeparator()
@@ -815,7 +822,8 @@ def _update_entity_pane_empty_state_visibility(self) -> bool:
815822
"""
816823
is_project_empty = (
817824
is_iterable_empty(self.project.root_resources) and
818-
is_iterable_empty(self.project.resource_groups)
825+
is_iterable_empty(self.project.resource_groups) and
826+
is_iterable_empty(self.project.aliases)
819827
)
820828

821829
sizer_index = self._entity_tree_sizer_index # cache
@@ -1412,16 +1420,56 @@ def _on_edit_group_dialog_ok(self,
14121420

14131421
assert download_immediately == False
14141422

1415-
def _saving_source_would_create_cycle(self, rg: ResourceGroup, source: ResourceGroupSource) -> bool:
1416-
ancestor_source = source # type: ResourceGroupSource
1417-
while ancestor_source is not None:
1418-
if ancestor_source == rg:
1419-
return True
1420-
if isinstance(ancestor_source, ResourceGroup):
1421-
ancestor_source = ancestor_source.source # reinterpret
1422-
else:
1423-
ancestor_source = None
1424-
return False
1423+
# === Entity Pane: New/Edit Alias ===
1424+
1425+
def _on_new_alias(self, event: wx.CommandEvent) -> None:
1426+
restore_menuitems = self._disable_menus_during_showwindowmodal()
1427+
NewAliasDialog(
1428+
self._frame,
1429+
self._on_new_alias_dialog_ok,
1430+
alias_exists_func=self._alias_exists,
1431+
on_close=restore_menuitems,
1432+
)
1433+
1434+
def _alias_exists(self, source_url_prefix: str) -> bool:
1435+
return self.project.get_alias(source_url_prefix) is not None
1436+
1437+
@fg_affinity
1438+
def _on_new_alias_dialog_ok(self,
1439+
source_url_prefix: str,
1440+
target_url_prefix: str,
1441+
target_is_external: bool,
1442+
) -> None:
1443+
if source_url_prefix == '':
1444+
raise ValueError('Invalid blank source URL prefix')
1445+
if target_url_prefix == '':
1446+
raise ValueError('Invalid blank target URL prefix')
1447+
1448+
try:
1449+
alias = Alias(self.project, source_url_prefix, target_url_prefix,
1450+
target_is_external=target_is_external)
1451+
except Alias.AlreadyExists:
1452+
raise ValueError('Invalid duplicate source URL prefix')
1453+
1454+
@fg_affinity
1455+
def _on_edit_alias_dialog_ok(self,
1456+
alias: Alias,
1457+
source_url_prefix: str,
1458+
target_url_prefix: str,
1459+
target_is_external: bool,
1460+
) -> None:
1461+
if source_url_prefix != alias.source_url_prefix:
1462+
raise ValueError(
1463+
'Attempted to change source_url_prefix of existing Alias. '
1464+
'Source URL prefix cannot be changed after alias is created.'
1465+
)
1466+
1467+
if target_url_prefix == '':
1468+
raise ValueError('Invalid blank target URL prefix')
1469+
1470+
# NOTE: Entity tree update will be triggered by property setters
1471+
alias.target_url_prefix = target_url_prefix
1472+
alias.target_is_external = target_is_external
14251473

14261474
# === Entity Pane: Other Commands ===
14271475

@@ -1486,9 +1534,38 @@ def _on_edit_entity(self, event) -> None:
14861534
raise
14871535
except CancelLoadUrls:
14881536
pass
1537+
elif isinstance(selected_entity, Alias):
1538+
alias = selected_entity
1539+
restore_menuitems = self._disable_menus_during_showwindowmodal()
1540+
try:
1541+
NewAliasDialog(
1542+
self._frame,
1543+
partial(self._on_edit_alias_dialog_ok, alias),
1544+
alias_exists_func=self._alias_exists,
1545+
initial_source_url_prefix=alias.source_url_prefix,
1546+
initial_target_url_prefix=alias.target_url_prefix,
1547+
initial_target_is_external=alias.target_is_external,
1548+
is_edit=True,
1549+
readonly=self._readonly,
1550+
on_close=restore_menuitems,
1551+
)
1552+
except:
1553+
restore_menuitems()
1554+
raise
14891555
else:
14901556
raise AssertionError()
14911557

1558+
def _saving_source_would_create_cycle(self, rg: ResourceGroup, source: ResourceGroupSource) -> bool:
1559+
ancestor_source = source # type: ResourceGroupSource
1560+
while ancestor_source is not None:
1561+
if ancestor_source == rg:
1562+
return True
1563+
if isinstance(ancestor_source, ResourceGroup):
1564+
ancestor_source = ancestor_source.source # reinterpret
1565+
else:
1566+
ancestor_source = None
1567+
return False
1568+
14921569
def _on_forget_entity(self, event) -> None:
14931570
selected_entity = self.entity_tree.selected_entity
14941571
assert selected_entity is not None
@@ -1498,6 +1575,7 @@ def _on_forget_entity(self, event) -> None:
14981575
def _on_download_entity(self, event) -> None:
14991576
selected_entity = self.entity_tree.selected_entity
15001577
assert selected_entity is not None
1578+
assert not isinstance(selected_entity, Alias)
15011579

15021580
# Show progress dialog in advance if will need to load all project URLs
15031581
if isinstance(selected_entity, ResourceGroup):
@@ -1612,21 +1690,33 @@ def resource_group_did_instantiate(self, group: ResourceGroup) -> None:
16121690
def resource_group_did_forget(self, group: ResourceGroup) -> None:
16131691
self._update_entity_pane_empty_state_visibility()
16141692

1693+
# NOTE: Can't capture to the Entity Tree itself reliably since may not be visible
1694+
@capture_crashes_to_stderr
1695+
@cloak
1696+
def alias_did_instantiate(self, alias: Alias) -> None:
1697+
self._update_entity_pane_empty_state_visibility()
1698+
1699+
# NOTE: Can't capture to the Entity Tree itself reliably since may not be visible
1700+
@capture_crashes_to_stderr
1701+
@cloak
1702+
def alias_did_forget(self, alias: Alias) -> None:
1703+
self._update_entity_pane_empty_state_visibility()
1704+
16151705
def _on_selected_entity_changed(self, event: wx.TreeEvent | None=None) -> None:
16161706
selected_entity = self.entity_tree.selected_entity # cache
16171707

16181708
readonly = self._readonly # cache
16191709
self._edit_action.enabled = (
1620-
isinstance(selected_entity, (ResourceGroup, RootResource)))
1710+
isinstance(selected_entity, (Alias, ResourceGroup, RootResource)))
16211711
self._forget_action.enabled = (
16221712
(not readonly) and
1623-
isinstance(selected_entity, (ResourceGroup, RootResource)))
1713+
isinstance(selected_entity, (Alias, ResourceGroup, RootResource)))
16241714
self._update_members_action.enabled = (
16251715
(not readonly) and
16261716
isinstance(selected_entity, ResourceGroup))
16271717
self._download_action.enabled = (
16281718
(not readonly) and
1629-
selected_entity is not None)
1719+
isinstance(selected_entity, (Resource, ResourceGroup, RootResource)))
16301720
self._view_action.enabled = (
16311721
isinstance(selected_entity, (Resource, RootResource)))
16321722

src/crystal/browser/entitytree.py

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from crystal.doc.generic import Link
88
from crystal.doc.html.soup import TEXT_LINK_TYPE_TITLE
99
from crystal.model import (
10-
Project, ProjectHasTooManyRevisionsError, Resource, ResourceGroup,
10+
Alias, Project, ProjectHasTooManyRevisionsError, Resource, ResourceGroup,
1111
ResourceGroupSource, ResourceRevision, RevisionBodyMissingError,
1212
RevisionDeletedError, RootResource,
1313
)
@@ -306,6 +306,39 @@ def resource_group_did_forget(self, group: ResourceGroup) -> None:
306306
# may need to be updated
307307
self.root.update_icon_set_of_descendants_in_group(group)
308308

309+
# === Events: Alias Lifecycle ===
310+
311+
@capture_crashes_to_self
312+
def alias_did_instantiate(self, alias: Alias) -> None:
313+
selection_was_empty = self.view.selected_node in [None, self.view.root] # capture
314+
315+
self.update()
316+
317+
if selection_was_empty:
318+
# Select the newly-created entity
319+
for child in self.root.children:
320+
if child.entity == alias:
321+
child.view.peer.SelectItem()
322+
break
323+
324+
@capture_crashes_to_self
325+
# TODO: Do not recommend asserting that a listener method will be called
326+
# from any particular thread
327+
@fg_affinity
328+
def alias_did_change(self, alias: Alias) -> None:
329+
# Update the title of the AliasNode
330+
for child in self.root.children:
331+
if isinstance(child, AliasNode) and child.alias == alias:
332+
child.view.title = child.calculate_title()
333+
break
334+
335+
@capture_crashes_to_self
336+
# TODO: Do not recommend asserting that a listener method will be called
337+
# from any particular thread
338+
@fg_affinity
339+
def alias_did_forget(self, alias: Alias) -> None:
340+
self.update()
341+
309342
# === Event: Min Fetch Date Did Change ===
310343

311344
@capture_crashes_to_self
@@ -524,7 +557,7 @@ def _sequence_with_matching_elements_replaced(new_seq, old_seq):
524557
return [old_seq_selfdict.get(x, x) for x in new_seq]
525558

526559

527-
NodeEntity: TypeAlias = Union['RootResource', 'Resource', 'ResourceGroup']
560+
NodeEntity: TypeAlias = Union['RootResource', 'Resource', 'ResourceGroup', 'Alias']
528561

529562

530563
class Node(Bulkhead):
@@ -771,6 +804,9 @@ def update_children(self,
771804
for (index, rg) in enumerate(self._project.resource_groups):
772805
children.append(ResourceGroupNode(rg))
773806

807+
for alias in self._project.aliases:
808+
children.append(AliasNode(alias))
809+
774810
self.set_children(children, progress_listener)
775811

776812

@@ -1013,7 +1049,7 @@ def update_children(self) -> None:
10131049
if self.resource_links:
10141050
for link in self.resource_links:
10151051
url = urljoin(self.resource.url, link.relative_url)
1016-
resource = Resource(self._project, url)
1052+
resource = Resource(self._project, url, _external_ok=True)
10171053
resources_2_links[resource].append(link)
10181054

10191055
linked_root_resources = []
@@ -1556,6 +1592,80 @@ def __hash__(self) -> int:
15561592
return hash(self.resource_group)
15571593

15581594

1595+
class AliasNode(Node):
1596+
"""Node representing an Alias in the entity tree."""
1597+
entity_tooltip = 'alias'
1598+
1599+
ICON = '🔗'
1600+
ICON_TRUNCATION_FIX = ''
1601+
1602+
def __init__(self, alias: Alias) -> None:
1603+
super().__init__(source=None)
1604+
self.alias = alias
1605+
1606+
self.view = NodeView()
1607+
# NOTE: Defer expensive calculation until if/when the icon_set is used
1608+
self.view.icon_set = self.calculate_icon_set
1609+
self.view.title = self.calculate_title()
1610+
self.view.expandable = False
1611+
1612+
# Aliases have no children
1613+
self.children = []
1614+
1615+
# === Properties ===
1616+
1617+
@override
1618+
def calculate_icon_set(self) -> IconSet | None:
1619+
return (
1620+
(wx.TreeItemIcon_Normal, TREE_NODE_ICONS()['entitytree_alias']),
1621+
)
1622+
1623+
@override
1624+
@property
1625+
def icon_tooltip(self) -> str | None:
1626+
return self.entity_tooltip.capitalize()
1627+
1628+
@override
1629+
def calculate_title(self) -> str:
1630+
return self.calculate_title_of(self.alias)
1631+
1632+
@staticmethod
1633+
def calculate_title_of(alias: Alias) -> str:
1634+
target_url_prefix = alias.target_url_prefix
1635+
if alias.target_is_external:
1636+
target_url_prefix = Alias.format_external_url_for_display(target_url_prefix)
1637+
assert alias.source_url_prefix.endswith('/')
1638+
assert target_url_prefix.endswith('/')
1639+
return f'{alias.source_url_prefix}** → {target_url_prefix}**'
1640+
1641+
@override
1642+
@property
1643+
def label_tooltip(self) -> str:
1644+
tooltip = (
1645+
f'Source: {self.alias.source_url_prefix}**\n'
1646+
f'Target: {self.alias.target_url_prefix}**'
1647+
)
1648+
if self.alias.target_is_external:
1649+
tooltip += '\nExternal: Yes (on internet, outside project)'
1650+
return tooltip
1651+
1652+
@override
1653+
@property
1654+
def entity(self) -> Alias:
1655+
return self.alias
1656+
1657+
# === Comparison ===
1658+
1659+
def __eq__(self, other):
1660+
return (
1661+
isinstance(other, AliasNode) and
1662+
self.alias == other.alias
1663+
)
1664+
1665+
def __hash__(self):
1666+
return hash(self.alias)
1667+
1668+
15591669
class GroupedLinkedResourcesNode(_GroupedNode):
15601670
entity_tooltip = 'grouped URLs'
15611671

src/crystal/browser/icons.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def TREE_NODE_ICONS() -> dict[str, wx.Bitmap]:
1313
(icon_name, _load_png_resource(f'treenodeicon_{icon_name}.png'))
1414
for icon_name in [
1515
# Entity Tree Icons
16+
'entitytree_alias',
1617
'entitytree_cluster_embedded',
1718
'entitytree_cluster_offsite',
1819
'entitytree_loading',

0 commit comments

Comments
 (0)