qcanvas 1.0.11__py3-none-any.whl → 2026.1.19__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.
Files changed (97) hide show
  1. qcanvas/__init__.py +60 -0
  2. qcanvas/app.py +72 -0
  3. qcanvas/backend_connectors/frontend_resource_manager.py +13 -5
  4. qcanvas/backend_connectors/qcanvas_task_master.py +2 -2
  5. qcanvas/icons/__init__.py +55 -6
  6. qcanvas/icons/_icon_type.py +42 -0
  7. qcanvas/icons/icons.qrc +48 -8
  8. qcanvas/icons/rc_icons.py +2477 -566
  9. qcanvas/settings/__init__.py +6 -0
  10. qcanvas/{util/settings → settings}/_client_settings.py +15 -6
  11. qcanvas/settings/_course_settings.py +54 -0
  12. qcanvas/{util/settings → settings}/_mapped_setting.py +8 -6
  13. qcanvas/{util/settings → settings}/_ui_settings.py +5 -5
  14. qcanvas/theme.py +101 -0
  15. qcanvas/ui/course_viewer/content_tree.py +37 -19
  16. qcanvas/ui/course_viewer/course_tree/__init__.py +1 -0
  17. qcanvas/ui/course_viewer/course_tree/_course_icon_generator.py +86 -0
  18. qcanvas/ui/course_viewer/{course_tree.py → course_tree/course_tree.py} +29 -14
  19. qcanvas/ui/course_viewer/course_viewer.py +79 -46
  20. qcanvas/ui/course_viewer/tabs/assignment_tab/assignment_tab.py +107 -29
  21. qcanvas/ui/course_viewer/tabs/assignment_tab/assignment_tree.py +19 -18
  22. qcanvas/ui/course_viewer/tabs/content_tab.py +33 -39
  23. qcanvas/ui/course_viewer/tabs/file_tab/__init__.py +1 -0
  24. qcanvas/ui/course_viewer/tabs/file_tab/file_tab.py +46 -0
  25. qcanvas/ui/course_viewer/tabs/file_tab/file_tree.py +96 -0
  26. qcanvas/ui/course_viewer/tabs/file_tab/pages_file_tree.py +55 -0
  27. qcanvas/ui/course_viewer/tabs/mail_tab/mail_tab.py +50 -27
  28. qcanvas/ui/course_viewer/tabs/mail_tab/mail_tree.py +18 -19
  29. qcanvas/ui/course_viewer/tabs/page_tab/page_tab.py +3 -3
  30. qcanvas/ui/course_viewer/tabs/page_tab/page_tree.py +18 -16
  31. qcanvas/ui/course_viewer/tabs/resource_rich_browser.py +61 -74
  32. qcanvas/ui/course_viewer/tree_widget_data_item.py +22 -0
  33. qcanvas/ui/memory_tree/_tree_memory.py +45 -41
  34. qcanvas/ui/memory_tree/memory_tree_widget.py +22 -18
  35. qcanvas/ui/memory_tree/memory_tree_widget_item.py +3 -3
  36. qcanvas/ui/qcanvas_window/__init__.py +1 -0
  37. qcanvas/ui/qcanvas_window/course_viewer_container.py +95 -0
  38. qcanvas/ui/{main_ui → qcanvas_window}/options/auto_download_resources_option.py +8 -6
  39. qcanvas/ui/{main_ui → qcanvas_window}/options/quick_sync_option.py +7 -6
  40. qcanvas/ui/{main_ui → qcanvas_window}/options/sync_on_start_option.py +7 -6
  41. qcanvas/ui/{main_ui → qcanvas_window}/options/theme_selection_menu.py +12 -10
  42. qcanvas/ui/{main_ui → qcanvas_window}/qcanvas_window.py +74 -45
  43. qcanvas/ui/{main_ui → qcanvas_window}/status_bar_progress_display.py +20 -12
  44. qcanvas/ui/qml_components/__init__.py +4 -0
  45. qcanvas/ui/qml_components/attachments_pane.py +70 -0
  46. qcanvas/ui/qml_components/comments_pane.py +83 -0
  47. qcanvas/ui/qml_components/qml/AttachmentsList.ui.qml +15 -0
  48. qcanvas/ui/qml_components/qml/AttachmentsListDelegate.qml +77 -0
  49. qcanvas/ui/qml_components/qml/AttachmentsListModel.qml +19 -0
  50. qcanvas/ui/qml_components/qml/AttachmentsPane.qml +11 -0
  51. qcanvas/ui/qml_components/qml/CommentsList.ui.qml +15 -0
  52. qcanvas/ui/qml_components/qml/CommentsListDelegate.ui.qml +118 -0
  53. qcanvas/ui/qml_components/qml/CommentsListModel.qml +56 -0
  54. qcanvas/ui/qml_components/qml/CommentsPane.qml +11 -0
  55. qcanvas/ui/qml_components/qml/DecoratedText.ui.qml +44 -0
  56. qcanvas/ui/qml_components/qml/Spacer.ui.qml +7 -0
  57. qcanvas/ui/qml_components/qml/ThemedRectangle.qml +53 -0
  58. qcanvas/ui/qml_components/qml/__init__.py +3 -0
  59. qcanvas/ui/qml_components/qml/rc_qml.py +709 -0
  60. qcanvas/ui/qml_components/qml/rc_qml.qrc +16 -0
  61. qcanvas/ui/qml_components/qml_bridge_types.py +95 -0
  62. qcanvas/ui/qml_components/qml_pane.py +21 -0
  63. qcanvas/ui/setup/setup_checker.py +3 -3
  64. qcanvas/ui/setup/setup_dialog.py +173 -80
  65. qcanvas/util/__init__.py +0 -2
  66. qcanvas/util/auto_downloader.py +9 -8
  67. qcanvas/util/basic_fonts.py +2 -2
  68. qcanvas/util/context_dict.py +12 -0
  69. qcanvas/util/file_icons.py +46 -0
  70. qcanvas/util/html_cleaner.py +2 -0
  71. qcanvas/util/layouts.py +9 -8
  72. qcanvas/util/paths.py +26 -22
  73. qcanvas/util/qurl_util.py +1 -1
  74. qcanvas/util/runtime.py +20 -0
  75. qcanvas/util/ui_tools.py +121 -7
  76. qcanvas/util/url_checker.py +1 -1
  77. qcanvas-2026.1.19.dist-info/METADATA +95 -0
  78. qcanvas-2026.1.19.dist-info/RECORD +92 -0
  79. {qcanvas-1.0.11.dist-info → qcanvas-2026.1.19.dist-info}/WHEEL +1 -1
  80. qcanvas-2026.1.19.dist-info/entry_points.txt +3 -0
  81. qcanvas/app_start/__init__.py +0 -54
  82. qcanvas/icons/file-download-failed.svg +0 -6
  83. qcanvas/icons/file-downloaded.svg +0 -6
  84. qcanvas/icons/file-not-downloaded.svg +0 -6
  85. qcanvas/icons/file-unknown.svg +0 -6
  86. qcanvas/icons/main_icon.svg +0 -325
  87. qcanvas/icons/sync.svg +0 -7
  88. qcanvas/run.py +0 -30
  89. qcanvas/ui/main_ui/__init__.py +0 -0
  90. qcanvas/ui/main_ui/course_viewer_container.py +0 -52
  91. qcanvas/util/settings/__init__.py +0 -9
  92. qcanvas/util/themes.py +0 -24
  93. qcanvas-1.0.11.dist-info/METADATA +0 -61
  94. qcanvas-1.0.11.dist-info/RECORD +0 -68
  95. qcanvas-1.0.11.dist-info/entry_points.txt +0 -3
  96. /qcanvas/ui/course_viewer/tabs/{util.py → constants.py} +0 -0
  97. /qcanvas/ui/{main_ui → qcanvas_window}/options/__init__.py +0 -0
@@ -0,0 +1,96 @@
1
+ import logging
2
+ from abc import ABC
3
+
4
+ from libqcanvas import db
5
+ from libqcanvas.net.resources.download.resource_manager import ResourceManager
6
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
7
+ from PySide6.QtCore import QPoint, Qt, Slot
8
+ from PySide6.QtWidgets import QHeaderView, QMenu, QTreeWidgetItem
9
+
10
+ from qcanvas.ui.course_viewer.content_tree import ContentTree
11
+ from qcanvas.ui.course_viewer.tree_widget_data_item import (
12
+ AnyTreeDataItem,
13
+ TreeWidgetDataItem,
14
+ )
15
+ from qcanvas.ui.memory_tree import MemoryTreeWidgetItem
16
+ from qcanvas.util.file_icons import icon_for_filename
17
+ from qcanvas.util.ui_tools import create_qaction
18
+
19
+ _logger = logging.getLogger(__name__)
20
+
21
+
22
+ class FileTree(ContentTree[db.Course], ABC):
23
+ @classmethod
24
+ def create_from_receipt(
25
+ cls,
26
+ course: db.Course,
27
+ *,
28
+ sync_receipt: SyncReceipt,
29
+ resource_manager: ResourceManager,
30
+ ) -> "FileTree":
31
+ tree = cls(tree_name=course.id, resource_manager=resource_manager)
32
+ tree.reload(course, sync_receipt=sync_receipt)
33
+ return tree
34
+
35
+ def __init__(self, tree_name: str, *, resource_manager: ResourceManager):
36
+ super().__init__(tree_name, emit_selection_signal_for_type=object)
37
+ self._resource_manager = resource_manager
38
+
39
+ self.ui_setup(header_text=["File", "Date"])
40
+ self.set_columns_resize_mode(
41
+ [QHeaderView.ResizeMode.Stretch, QHeaderView.ResizeMode.ResizeToContents]
42
+ )
43
+
44
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
45
+ self.customContextMenuRequested.connect(self._context_menu)
46
+
47
+ def _create_group_widget(
48
+ self, group: db.ContentGroup, sync_receipt: SyncReceipt
49
+ ) -> MemoryTreeWidgetItem:
50
+ group_widget = MemoryTreeWidgetItem(
51
+ id=group.id, data=group, strings=[group.name]
52
+ )
53
+
54
+ group_widget.setFlags(Qt.ItemFlag.ItemIsEnabled)
55
+
56
+ if sync_receipt.was_updated(group):
57
+ self.mark_as_unseen(group_widget)
58
+
59
+ return group_widget
60
+
61
+ def _create_resource_widget(
62
+ self, resource: db.Resource, sync_receipt: SyncReceipt
63
+ ) -> QTreeWidgetItem:
64
+ # fixme the reesource widget items shouls NOT be a memory widget item because they can't be collapsed, but
65
+ # mostly because the same file can appear in the tree multiple times in different places, which memory tree
66
+ # can NOT deal with!
67
+ item_widget = TreeWidgetDataItem(
68
+ id=resource.id,
69
+ data=resource,
70
+ strings=[resource.file_name, str(resource.discovery_date.date())],
71
+ )
72
+ item_widget.setIcon(
73
+ 0,
74
+ icon_for_filename(resource.file_name),
75
+ )
76
+ item_widget.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
77
+
78
+ if sync_receipt.was_updated(resource):
79
+ self.mark_as_unseen(item_widget)
80
+
81
+ return item_widget
82
+
83
+ @Slot(QPoint)
84
+ def _context_menu(self, point: QPoint) -> None:
85
+ item = self.itemAt(point)
86
+
87
+ if isinstance(item, AnyTreeDataItem):
88
+ menu = QMenu()
89
+ create_qaction(
90
+ name="Test",
91
+ parent=menu,
92
+ triggered=lambda: print(f"Clicked {item.extra_data.file_name}"),
93
+ )
94
+ menu.addAction("Another thing")
95
+
96
+ menu.exec(self.mapToGlobal(point))
@@ -0,0 +1,55 @@
1
+ import logging
2
+ from typing import Sequence
3
+
4
+ from libqcanvas import db
5
+ from libqcanvas.net.resources.download.resource_manager import ResourceManager
6
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
7
+
8
+ from qcanvas.ui.course_viewer.tabs.file_tab.file_tree import FileTree
9
+ from qcanvas.ui.memory_tree import MemoryTreeWidgetItem
10
+
11
+ _logger = logging.getLogger(__name__)
12
+
13
+
14
+ class PagesFileTree(FileTree):
15
+ def __init__(self, tree_name: str, *, resource_manager: ResourceManager):
16
+ super().__init__(
17
+ tree_name=f"{tree_name}.pages", resource_manager=resource_manager
18
+ )
19
+
20
+ def create_tree_items(
21
+ self, data: db.Course, sync_receipt: SyncReceipt
22
+ ) -> Sequence[MemoryTreeWidgetItem]:
23
+ widgets = []
24
+
25
+ for group in data.modules: # type: db.Module
26
+ if not group.pages:
27
+ continue
28
+
29
+ # Init group_widget lazily to prevent creating pointless tree widgets
30
+ group_widget: MemoryTreeWidgetItem | None = None
31
+ items_in_group = set()
32
+
33
+ for item in group.pages:
34
+ resource_widgets = []
35
+
36
+ for resource in item.resources: # type: db.Resource
37
+ if resource.id not in items_in_group:
38
+ items_in_group.add(resource.id)
39
+
40
+ if group_widget is None:
41
+ group_widget = self._create_group_widget(
42
+ group, sync_receipt
43
+ )
44
+
45
+ resource_widgets.append(
46
+ self._create_resource_widget(resource, sync_receipt)
47
+ )
48
+
49
+ if len(resource_widgets) > 0:
50
+ group_widget.addChildren(resource_widgets)
51
+
52
+ if group_widget is not None:
53
+ widgets.append(group_widget)
54
+
55
+ return widgets
@@ -1,16 +1,18 @@
1
1
  import logging
2
- from typing import *
2
+ from typing import override
3
3
 
4
- import qcanvas_backend.database.types as db
5
- from qcanvas_backend.net.resources.download.resource_manager import ResourceManager
6
- from qcanvas_backend.net.sync.sync_receipt import SyncReceipt
7
- from qtpy.QtWidgets import *
4
+ from PySide6.QtCore import Qt
5
+ from libqcanvas import db
6
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
7
+ from libqcanvas.util import as_local
8
+ from PySide6.QtWidgets import QLabel, QLayout, QMainWindow, QDockWidget
8
9
 
10
+ from qcanvas.backend_connectors import FrontendResourceManager
9
11
  from qcanvas.ui.course_viewer.tabs.content_tab import ContentTab
10
12
  from qcanvas.ui.course_viewer.tabs.mail_tab.mail_tree import MailTree
11
- from qcanvas.ui.course_viewer.tabs.util import date_strftime_format
12
- from qcanvas.util.basic_fonts import bold_label
13
- from qcanvas.util.layouts import grid_layout
13
+ from qcanvas.ui.course_viewer.tabs.constants import date_strftime_format
14
+ import qcanvas.util.ui_tools as ui
15
+ from qcanvas.ui.qml_components import AttachmentsPane
14
16
 
15
17
  _logger = logging.getLogger(__name__)
16
18
 
@@ -22,38 +24,59 @@ class MailTab(ContentTab):
22
24
  *,
23
25
  course: db.Course,
24
26
  sync_receipt: SyncReceipt,
25
- downloader: ResourceManager,
27
+ downloader: FrontendResourceManager,
26
28
  ):
29
+ self._main_container = QMainWindow()
30
+
27
31
  super().__init__(
28
32
  explorer=MailTree.create_from_receipt(course, sync_receipt=sync_receipt),
29
33
  title_placeholder_text="No mail selected",
30
34
  downloader=downloader,
31
35
  )
32
36
 
37
+ self._main_container.setCentralWidget(self._viewer)
38
+ self._files_pane = AttachmentsPane(downloader)
39
+ self._files_dock = ui.dock_widget(
40
+ widget=self._files_pane,
41
+ title="Attachments",
42
+ name="attachments",
43
+ min_size=ui.size(150, 100),
44
+ features=QDockWidget.DockWidgetFeature.DockWidgetMovable,
45
+ )
46
+ self._main_container.addDockWidget(
47
+ Qt.DockWidgetArea.TopDockWidgetArea, self._files_dock
48
+ )
33
49
  self._date_sent_label = QLabel("")
34
50
  self._sender_label = QLabel("")
35
51
 
36
52
  self.enable_info_grid()
37
53
 
38
- def setup_info_grid(self) -> QGridLayout:
39
- grid = grid_layout(
40
- [
41
- [
42
- bold_label("From:"),
43
- self._sender_label,
44
- ],
45
- [
46
- bold_label("Date:"),
47
- self._date_sent_label,
48
- ],
49
- ]
54
+ @override
55
+ def _setup_layout(self) -> None:
56
+ super()._setup_layout()
57
+ self.content_grid.replaceWidget(
58
+ self._viewer,
59
+ self._main_container,
50
60
  )
51
61
 
52
- grid.setColumnStretch(0, 0)
53
- grid.setColumnStretch(1, 1)
54
-
55
- return grid
62
+ def setup_info_grid(self) -> QLayout:
63
+ return ui.form_layout(
64
+ {"From": self._sender_label, "Date": self._date_sent_label},
65
+ )
56
66
 
57
- def update_info_grid(self, mail: db.CourseMessage) -> None:
58
- self._date_sent_label.setText(mail.creation_date.strftime(date_strftime_format))
67
+ def update_info_grid(self, mail: db.Message) -> None:
68
+ self._date_sent_label.setText(
69
+ as_local(mail.creation_date).strftime(date_strftime_format)
70
+ )
59
71
  self._sender_label.setText(mail.sender_name)
72
+
73
+ if mail.attachments:
74
+ self._files_pane.load_files(mail.attachments)
75
+ self._files_dock.show()
76
+ else:
77
+ self._files_dock.hide()
78
+
79
+ @override
80
+ def _show_blank(self) -> None:
81
+ super()._show_blank()
82
+ self._files_dock.hide()
@@ -1,53 +1,52 @@
1
1
  import logging
2
- from typing import *
2
+ from typing import Sequence
3
3
 
4
- import qcanvas_backend.database.types as db
5
- from qcanvas_backend.net.sync.sync_receipt import SyncReceipt
6
- from qtpy.QtWidgets import *
4
+ from libqcanvas import db
5
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
6
+ from PySide6.QtWidgets import QHeaderView
7
7
 
8
+ from qcanvas import icons
8
9
  from qcanvas.ui.course_viewer.content_tree import ContentTree
9
- from qcanvas.ui.memory_tree import MemoryTreeWidgetItem
10
+ from qcanvas.ui.course_viewer.tree_widget_data_item import TreeWidgetDataItem
10
11
 
11
12
  _logger = logging.getLogger(__name__)
12
13
 
13
14
 
14
15
  class MailTree(ContentTree[db.Course]):
15
-
16
16
  def __init__(self, course_id: str):
17
17
  super().__init__(
18
18
  tree_name=f"course.{course_id}.mail",
19
- emit_selection_signal_for_type=db.CourseMessage,
19
+ emit_selection_signal_for_type=db.Message,
20
20
  )
21
+
21
22
  self.ui_setup(
22
23
  header_text=["Subject", "Sender"],
23
24
  max_width=500,
24
25
  min_width=300,
25
26
  indentation=20,
27
+ alternating_row_colours=True,
26
28
  )
27
29
 
28
- self._adjust_header()
29
-
30
- def _adjust_header(self) -> None:
31
- header = self.header()
32
- header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
33
- header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
34
- header.setStretchLastSection(False)
30
+ self.set_columns_resize_mode(
31
+ [QHeaderView.ResizeMode.Stretch, QHeaderView.ResizeMode.ResizeToContents]
32
+ )
35
33
 
36
34
  def create_tree_items(
37
35
  self, course: db.Course, sync_receipt: SyncReceipt
38
- ) -> Sequence[MemoryTreeWidgetItem]:
36
+ ) -> Sequence[TreeWidgetDataItem]:
39
37
  widgets = []
40
38
 
41
- for message in course.messages: # type: db.CourseMessage
39
+ for message in course.messages: # type: db.Message
42
40
  message_widget = self._create_mail_widget(message, sync_receipt)
41
+ message_widget.setIcon(0, icons.tree_items.mail)
43
42
  widgets.append(message_widget)
44
43
 
45
44
  return widgets
46
45
 
47
46
  def _create_mail_widget(
48
- self, message: db.CourseMessage, sync_receipt: SyncReceipt
49
- ) -> MemoryTreeWidgetItem:
50
- message_widget = MemoryTreeWidgetItem(
47
+ self, message: db.Message, sync_receipt: SyncReceipt
48
+ ) -> TreeWidgetDataItem:
49
+ message_widget = TreeWidgetDataItem(
51
50
  id=message.id,
52
51
  data=message,
53
52
  strings=[message.name, message.sender_name],
@@ -1,8 +1,8 @@
1
1
  import logging
2
2
 
3
- import qcanvas_backend.database.types as db
4
- from qcanvas_backend.net.resources.download.resource_manager import ResourceManager
5
- from qcanvas_backend.net.sync.sync_receipt import SyncReceipt
3
+ from libqcanvas import db
4
+ from libqcanvas.net.resources.download.resource_manager import ResourceManager
5
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
6
6
 
7
7
  from qcanvas.ui.course_viewer.tabs.content_tab import ContentTab
8
8
  from qcanvas.ui.course_viewer.tabs.page_tab.page_tree import PageTree
@@ -1,11 +1,13 @@
1
1
  import logging
2
- from typing import Optional, Sequence
2
+ from typing import Sequence
3
3
 
4
- import qcanvas_backend.database.types as db
5
- from qcanvas_backend.net.sync.sync_receipt import SyncReceipt
6
- from qtpy.QtCore import Qt
4
+ from libqcanvas import db
5
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt
6
+ from PySide6.QtCore import Qt
7
7
 
8
+ from qcanvas import icons
8
9
  from qcanvas.ui.course_viewer.content_tree import ContentTree
10
+ from qcanvas.ui.course_viewer.tree_widget_data_item import TreeWidgetDataItem
9
11
  from qcanvas.ui.memory_tree import MemoryTreeWidgetItem
10
12
 
11
13
  _logger = logging.getLogger(__name__)
@@ -15,8 +17,9 @@ class PageTree(ContentTree[db.Course]):
15
17
  def __init__(self, course_id: str):
16
18
  super().__init__(
17
19
  tree_name=f"course.{course_id}.modules",
18
- emit_selection_signal_for_type=db.ModulePage,
20
+ emit_selection_signal_for_type=db.Page,
19
21
  )
22
+
20
23
  self.ui_setup(
21
24
  header_text="Content", indentation=15, max_width=300, min_width=150
22
25
  )
@@ -27,7 +30,10 @@ class PageTree(ContentTree[db.Course]):
27
30
  widgets = []
28
31
 
29
32
  for module in course.modules: # type: db.Module
30
- module_widget = self._create_module_widget(module, sync_receipt)
33
+ if len(module.pages) == 0:
34
+ continue
35
+
36
+ module_widget = self._create_module_widget(module)
31
37
  widgets.append(module_widget)
32
38
 
33
39
  for page in module.pages: # type: db.ModulePage
@@ -36,25 +42,21 @@ class PageTree(ContentTree[db.Course]):
36
42
 
37
43
  return widgets
38
44
 
39
- def _create_module_widget(
40
- self, module: db.Module, sync_receipt: SyncReceipt
41
- ) -> MemoryTreeWidgetItem:
45
+ def _create_module_widget(self, module: db.Module) -> MemoryTreeWidgetItem:
42
46
  module_widget = MemoryTreeWidgetItem(
43
47
  id=module.id, data=module, strings=[module.name]
44
48
  )
45
49
  module_widget.setFlags(Qt.ItemFlag.ItemIsEnabled)
46
-
47
- # Todo not sure if modules should get highlighted since they can't be unhighlighted by selecting them...
48
- if sync_receipt.was_updated(module):
49
- self.mark_as_unseen(module_widget)
50
+ module_widget.setIcon(0, icons.tree_items.module)
50
51
 
51
52
  return module_widget
52
53
 
53
54
  def _create_page_widget(
54
- self, page: db.ModulePage, sync_receipt: SyncReceipt
55
- ) -> MemoryTreeWidgetItem:
56
- page_widget = MemoryTreeWidgetItem(id=page.id, data=page, strings=[page.name])
55
+ self, page: db.Page, sync_receipt: SyncReceipt
56
+ ) -> TreeWidgetDataItem:
57
+ page_widget = TreeWidgetDataItem(id=page.id, data=page, strings=[page.name])
57
58
  page_widget.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
59
+ page_widget.setIcon(0, icons.tree_items.page)
58
60
 
59
61
  if sync_receipt.was_updated(page):
60
62
  self.mark_as_unseen(page_widget)
@@ -1,42 +1,24 @@
1
+ import html
1
2
  import logging
2
3
  from typing import Optional
3
4
 
4
- import qcanvas_backend.database.types as db
5
+ from libqcanvas import db
5
6
  from bs4 import BeautifulSoup, Tag
7
+ from libqcanvas.net.resources.extracting.no_extractor_error import NoExtractorError
8
+ from libqcanvas.util import is_link_invisible
9
+ from PySide6.QtCore import QUrl, Slot
10
+ from PySide6.QtGui import QDesktopServices
11
+ from PySide6.QtWidgets import QTextBrowser
6
12
  from qasync import asyncSlot
7
- from qcanvas_backend.net.resources.download.resource_manager import ResourceManager
8
- from qcanvas_backend.net.resources.extracting.no_extractor_error import NoExtractorError
9
- from qcanvas_backend.net.resources.scanning.resource_scanner import ResourceScanner
10
- from qtpy.QtCore import QUrl, Slot
11
- from qtpy.QtGui import QDesktopServices
12
- from qtpy.QtWidgets import QTextBrowser
13
-
14
- from qcanvas import icons
13
+
15
14
  from qcanvas.backend_connectors import FrontendResourceManager
16
15
  from qcanvas.util.html_cleaner import clean_up_html
17
- from qcanvas.util.qurl_util import file_url
18
16
 
19
17
  _logger = logging.getLogger(__name__)
20
18
 
21
19
 
22
- # class _DarkListener(QObject):
23
- # theme_changed = Signal(str)
24
- #
25
- # def __init__(self):
26
- # super().__init__()
27
- #
28
- # self._thread = threading.Thread(target=darkdetect.listener, args=(self._emit,))
29
- # self._thread.daemon = True
30
- # self._thread.start()
31
- #
32
- # def _emit(self, theme: str) -> None:
33
- # self.theme_changed.emit(theme)
34
- #
35
- # _dark_listener = _DarkListener()
36
-
37
-
38
20
  class ResourceRichBrowser(QTextBrowser):
39
- def __init__(self, downloader: ResourceManager):
21
+ def __init__(self, downloader: FrontendResourceManager):
40
22
  super().__init__()
41
23
  self._downloader = downloader
42
24
  self._content: Optional[db.CourseContentItem] = None
@@ -46,15 +28,8 @@ class ResourceRichBrowser(QTextBrowser):
46
28
  self.setOpenLinks(False)
47
29
  self.anchorClicked.connect(self._open_url)
48
30
 
49
- if isinstance(self._downloader, FrontendResourceManager):
50
- self._downloader.download_finished.connect(self._download_updated)
51
- self._downloader.download_failed.connect(self._download_updated)
52
-
53
- # _dark_listener.theme_changed.connect(self._theme_changed)
54
-
55
- # @Slot()
56
- # def _theme_changed(self, theme: str) -> None:
57
- # print(theme)
31
+ self._downloader.download_finished.connect(self._download_updated)
32
+ self._downloader.download_failed.connect(self._download_updated)
58
33
 
59
34
  def show_blank(self, completely_blank: bool = False) -> None:
60
35
  if completely_blank:
@@ -65,14 +40,14 @@ class ResourceRichBrowser(QTextBrowser):
65
40
  self._content = None
66
41
  self._current_content_resources.clear()
67
42
 
68
- def show_content(self, page: db.CourseContentItem) -> None:
43
+ def show_content(self, page: db.AnyContentItem) -> None:
69
44
  if page.body is None:
70
45
  self.show_blank()
71
46
  else:
72
47
  self._collect_resources(page)
73
48
  self._show_page_content(page)
74
49
 
75
- def _collect_resources(self, page: db.CourseContentItem):
50
+ def _collect_resources(self, page: db.AnyContentItem):
76
51
  self._current_content_resources = {
77
52
  resource.id: resource for resource in page.resources
78
53
  }
@@ -91,8 +66,7 @@ class ResourceRichBrowser(QTextBrowser):
91
66
  extractor = self._extractors.extractor_for_tag(resource_link)
92
67
  resource_id = extractor.resource_id_from_tag(resource_link)
93
68
 
94
- # FIXME private method
95
- if ResourceScanner._is_link_invisible(resource_link):
69
+ if is_link_invisible(resource_link):
96
70
  _logger.debug("Found dead link for %s, removing", resource_id)
97
71
  resource_link.decompose()
98
72
  continue
@@ -102,50 +76,49 @@ class ResourceRichBrowser(QTextBrowser):
102
76
  )
103
77
  continue
104
78
 
105
- file_link_tag = self._create_resource_link_tag(doc, resource_id)
79
+ file_link_tag = self._create_resource_link_tag(
80
+ resource_id, resource_link.name == "img"
81
+ )
106
82
  resource_link.replace_with(file_link_tag)
107
83
  except NoExtractorError:
108
84
  pass
109
85
 
110
86
  return str(doc)
111
87
 
112
- def _create_resource_link_tag(self, doc: BeautifulSoup, resource_id: str) -> Tag:
88
+ def _create_resource_link_tag(self, resource_id: str, is_image: bool) -> Tag:
113
89
  resource = self._current_content_resources[resource_id]
114
90
 
115
- file_link_tag = doc.new_tag(
116
- "a",
117
- attrs={
118
- "href": f"data:{resource_id}",
119
- },
120
- )
121
-
122
- file_link_tag.append(self._file_icon_tag(doc, resource.download_state))
123
- file_link_tag.append("\N{NO-BREAK SPACE}" + resource.file_name)
124
-
125
- _logger.debug(str(file_link_tag))
126
-
127
- return file_link_tag
128
-
129
- def _file_icon_tag(
130
- self, document: BeautifulSoup, download_state: db.ResourceDownloadState
131
- ) -> Tag:
132
- return document.new_tag(
133
- "img",
134
- attrs={
135
- "src": self._download_state_icon(download_state),
136
- "style": "vertical-align:middle",
137
- "width": 18,
138
- },
139
- )
91
+ # todo not sure if this is a good idea or not
92
+ # if is_image and resource.download_state == db.ResourceDownloadState.DOWNLOADED:
93
+ # location = self._downloader.resource_download_location(resource)
94
+ #
95
+ # file_link_tag = doc.new_tag(
96
+ # "img",
97
+ # attrs={
98
+ # "source": location.absolute(),
99
+ # },
100
+ # )
101
+ # else:
102
+
103
+ return BeautifulSoup(
104
+ markup=f"""
105
+ <a href="data:{html.escape(resource_id)}" style="font-weight: normal;">
106
+ <img height="18" src="{html.escape(self._download_state_icon(resource.download_state))}"/>
107
+ {html.escape(resource.file_name)}
108
+ </a>
109
+ """,
110
+ features="html.parser",
111
+ ).a
140
112
 
141
113
  def _download_state_icon(self, download_state: db.ResourceDownloadState) -> str:
114
+ icon_path = ":icons/universal/downloads"
142
115
  match download_state:
143
116
  case db.ResourceDownloadState.DOWNLOADED:
144
- return icons.file_downloaded
117
+ return f"{icon_path}/downloaded.svg"
145
118
  case db.ResourceDownloadState.NOT_DOWNLOADED:
146
- return icons.file_not_downloaded
119
+ return f"{icon_path}/not_downloaded.svg"
147
120
  case db.ResourceDownloadState.FAILED:
148
- return icons.file_download_failed
121
+ return f"{icon_path}/download_failed.svg"
149
122
  case _:
150
123
  raise ValueError()
151
124
 
@@ -161,17 +134,31 @@ class ResourceRichBrowser(QTextBrowser):
161
134
  resource = self._current_content_resources[resource_id]
162
135
 
163
136
  try:
164
- await self._downloader.download(resource)
137
+ await self._downloader.download_and_open(resource)
165
138
  except Exception as e:
166
139
  _logger.warning(
167
140
  "Download of resource id=%s failed", resource_id, exc_info=e
168
141
  )
169
142
  return
170
143
 
171
- resource_path = file_url(self._downloader.resource_download_location(resource))
172
- QDesktopServices.openUrl(resource_path)
173
-
174
144
  @Slot(db.Resource)
175
145
  def _download_updated(self, resource: db.Resource) -> None:
176
146
  if self._content is not None and resource.id in self._current_content_resources:
147
+ # BANDAID FIX: In the following situation:
148
+ # - Download is started
149
+ # - Synchronisation is started
150
+ # - Download finishes AFTER the sync
151
+ # --> `resource` is NOT `self._current_content_resources[resource.id]`, because the sync will reload the
152
+ # resource from the DB, but the downloader will still only know about the old resource object.
153
+ # This causes resources not update their download state in the viewer. This line "fixes" that, but does NOT
154
+ # address the root cause. I think reloading the resource from the DB somewhere is the only true fix for this
155
+
156
+ if self._current_content_resources[resource.id] is not resource:
157
+ _logger.warning(
158
+ "Resource has diverged from current loaded data, applying bandaid fix"
159
+ )
160
+ self._current_content_resources[
161
+ resource.id
162
+ ].download_state = resource.download_state
163
+
177
164
  self._show_page_content(self._content)
@@ -0,0 +1,22 @@
1
+ from typing import List, Optional
2
+
3
+ from PySide6.QtWidgets import QTreeWidgetItem
4
+
5
+ from qcanvas.ui.memory_tree import MemoryTreeWidgetItem
6
+
7
+
8
+ class TreeWidgetDataItem(QTreeWidgetItem):
9
+ def __init__(
10
+ self, id: str, data: Optional[object], strings: Optional[List[str]] = None
11
+ ):
12
+ super().__init__(strings)
13
+ # Still needs ID because it is used to reselect the item
14
+ self._id = id
15
+ self.extra_data = data
16
+
17
+ @property
18
+ def id(self) -> str:
19
+ return self._id
20
+
21
+
22
+ AnyTreeDataItem = TreeWidgetDataItem | MemoryTreeWidgetItem