Status: | Superseded | ||||||||
---|---|---|---|---|---|---|---|---|---|
Proposed branch: | lp:~phill-ridout/openlp/pathlib12 | ||||||||
Merge into: | lp:openlp | ||||||||
Diff against target: |
682 lines (+181/-291) 6 files modified
openlp/core/lib/serviceitem.py (+9/-8) openlp/core/ui/mainwindow.py (+8/-6) openlp/core/ui/servicemanager.py (+128/-241) tests/functional/openlp_core/lib/test_serviceitem.py (+2/-1) tests/functional/openlp_core/ui/test_mainwindow.py (+29/-2) tests/functional/openlp_core/ui/test_servicemanager.py (+5/-33) |
||||||||
To merge this branch: | bzr merge lp:~phill-ridout/openlp/pathlib12 | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tim Bentley | Needs Fixing | ||
Review via email: mp+336398@code.launchpad.net |
This proposal has been superseded by a proposal from 2018-01-24.
Commit message
Description of the change
Started work on storing path objects in service file.
Refactored save code and reduced duplication.
Fixed + improved the loading / saving progress bars
improved performance
loading powerpoint from a service still does work
lp:~phill-ridout/openlp/pathlib12 (revision 2815)
https:/
[RUNNING]
[SUCCESS]
https:/
[RUNNING]
[SUCCESS]
https:/
[SUCCESS]
https:/
[SUCCESS]
https:/
[RUNNING]
[SUCCESS]
https:/
[SUCCESS]
https:/
[SUCCESS]
https:/
[RUNNING]
[FAILURE]
Stopping after failure
Failed builds:
- Branch-
Unmerged revisions
Preview Diff
1 | === modified file 'openlp/core/lib/serviceitem.py' | |||
2 | --- openlp/core/lib/serviceitem.py 2017-12-29 09:15:48 +0000 | |||
3 | +++ openlp/core/lib/serviceitem.py 2018-01-22 21:37:22 +0000 | |||
4 | @@ -36,6 +36,7 @@ | |||
5 | 36 | from openlp.core.common.applocation import AppLocation | 36 | from openlp.core.common.applocation import AppLocation |
6 | 37 | from openlp.core.common.i18n import translate | 37 | from openlp.core.common.i18n import translate |
7 | 38 | from openlp.core.common.mixins import RegistryProperties | 38 | from openlp.core.common.mixins import RegistryProperties |
8 | 39 | from openlp.core.common.path import Path | ||
9 | 39 | from openlp.core.common.settings import Settings | 40 | from openlp.core.common.settings import Settings |
10 | 40 | from openlp.core.lib import ImageSource, build_icon, clean_tags, expand_tags, expand_chords | 41 | from openlp.core.lib import ImageSource, build_icon, clean_tags, expand_tags, expand_chords |
11 | 41 | 42 | ||
12 | @@ -427,13 +428,13 @@ | |||
13 | 427 | self.has_original_files = True | 428 | self.has_original_files = True |
14 | 428 | if 'background_audio' in header: | 429 | if 'background_audio' in header: |
15 | 429 | self.background_audio = [] | 430 | self.background_audio = [] |
20 | 430 | for filename in header['background_audio']: | 431 | for file_path in header['background_audio']: |
21 | 431 | # Give them real file paths. | 432 | # In OpenLP 3.0 we switched to storing Path objects in JSON files |
22 | 432 | filepath = str(filename) | 433 | if isinstance(file_path, str): |
23 | 433 | if path: | 434 | # Handle service files prior to OpenLP 3.0 |
24 | 434 | # Windows can handle both forward and backward slashes, so we use ntpath to get the basename | 435 | # Windows can handle both forward and backward slashes, so we use ntpath to get the basename |
27 | 435 | filepath = os.path.join(path, ntpath.basename(str(filename))) | 436 | file_path = Path(path, ntpath.basename(file_path)) |
28 | 436 | self.background_audio.append(filepath) | 437 | self.background_audio.append(file_path) |
29 | 437 | self.theme_overwritten = header.get('theme_overwritten', False) | 438 | self.theme_overwritten = header.get('theme_overwritten', False) |
30 | 438 | if self.service_item_type == ServiceItemType.Text: | 439 | if self.service_item_type == ServiceItemType.Text: |
31 | 439 | for slide in service_item['serviceitem']['data']: | 440 | for slide in service_item['serviceitem']['data']: |
32 | @@ -444,8 +445,8 @@ | |||
33 | 444 | if path: | 445 | if path: |
34 | 445 | self.has_original_files = False | 446 | self.has_original_files = False |
35 | 446 | for text_image in service_item['serviceitem']['data']: | 447 | for text_image in service_item['serviceitem']['data']: |
38 | 447 | filename = os.path.join(path, text_image) | 448 | file_path = os.path.join(path, text_image) |
39 | 448 | self.add_from_image(filename, text_image, background) | 449 | self.add_from_image(file_path, text_image, background) |
40 | 449 | else: | 450 | else: |
41 | 450 | for text_image in service_item['serviceitem']['data']: | 451 | for text_image in service_item['serviceitem']['data']: |
42 | 451 | self.add_from_image(text_image['path'], text_image['title'], background) | 452 | self.add_from_image(text_image['path'], text_image['title'], background) |
43 | 452 | 453 | ||
44 | === modified file 'openlp/core/ui/mainwindow.py' | |||
45 | --- openlp/core/ui/mainwindow.py 2018-01-12 18:29:32 +0000 | |||
46 | +++ openlp/core/ui/mainwindow.py 2018-01-22 21:37:22 +0000 | |||
47 | @@ -1314,11 +1314,13 @@ | |||
48 | 1314 | self.load_progress_bar.setValue(0) | 1314 | self.load_progress_bar.setValue(0) |
49 | 1315 | self.application.process_events() | 1315 | self.application.process_events() |
50 | 1316 | 1316 | ||
56 | 1317 | def increment_progress_bar(self): | 1317 | def increment_progress_bar(self, increment=1): |
57 | 1318 | """ | 1318 | """ |
58 | 1319 | Increase the Progress Bar value by 1 | 1319 | Increase the Progress Bar by the value in increment. |
59 | 1320 | """ | 1320 | |
60 | 1321 | self.load_progress_bar.setValue(self.load_progress_bar.value() + 1) | 1321 | :param int increment: The value you to increase the progress bar by. |
61 | 1322 | """ | ||
62 | 1323 | self.load_progress_bar.setValue(self.load_progress_bar.value() + increment) | ||
63 | 1322 | self.application.process_events() | 1324 | self.application.process_events() |
64 | 1323 | 1325 | ||
65 | 1324 | def finished_progress_bar(self): | 1326 | def finished_progress_bar(self): |
66 | @@ -1386,4 +1388,4 @@ | |||
67 | 1386 | if not isinstance(filename, str): | 1388 | if not isinstance(filename, str): |
68 | 1387 | filename = str(filename, sys.getfilesystemencoding()) | 1389 | filename = str(filename, sys.getfilesystemencoding()) |
69 | 1388 | if filename.endswith(('.osz', '.oszl')): | 1390 | if filename.endswith(('.osz', '.oszl')): |
71 | 1389 | self.service_manager_contents.load_file(filename) | 1391 | self.service_manager_contents.load_file(Path(filename)) |
72 | 1390 | 1392 | ||
73 | === modified file 'openlp/core/ui/servicemanager.py' | |||
74 | --- openlp/core/ui/servicemanager.py 2017-12-29 09:15:48 +0000 | |||
75 | +++ openlp/core/ui/servicemanager.py 2018-01-22 21:37:22 +0000 | |||
76 | @@ -27,8 +27,9 @@ | |||
77 | 27 | import os | 27 | import os |
78 | 28 | import shutil | 28 | import shutil |
79 | 29 | import zipfile | 29 | import zipfile |
80 | 30 | from contextlib import suppress | ||
81 | 30 | from datetime import datetime, timedelta | 31 | from datetime import datetime, timedelta |
83 | 31 | from tempfile import mkstemp | 32 | from tempfile import NamedTemporaryFile |
84 | 32 | 33 | ||
85 | 33 | from PyQt5 import QtCore, QtGui, QtWidgets | 34 | from PyQt5 import QtCore, QtGui, QtWidgets |
86 | 34 | 35 | ||
87 | @@ -36,11 +37,13 @@ | |||
88 | 36 | from openlp.core.common.actions import ActionList, CategoryOrder | 37 | from openlp.core.common.actions import ActionList, CategoryOrder |
89 | 37 | from openlp.core.common.applocation import AppLocation | 38 | from openlp.core.common.applocation import AppLocation |
90 | 38 | from openlp.core.common.i18n import UiStrings, format_time, translate | 39 | from openlp.core.common.i18n import UiStrings, format_time, translate |
91 | 40 | from openlp.core.common.json import OpenLPJsonDecoder, OpenLPJsonEncoder | ||
92 | 39 | from openlp.core.common.mixins import LogMixin, RegistryProperties | 41 | from openlp.core.common.mixins import LogMixin, RegistryProperties |
94 | 40 | from openlp.core.common.path import Path, create_paths, str_to_path | 42 | from openlp.core.common.path import Path, str_to_path |
95 | 41 | from openlp.core.common.registry import Registry, RegistryBase | 43 | from openlp.core.common.registry import Registry, RegistryBase |
96 | 42 | from openlp.core.common.settings import Settings | 44 | from openlp.core.common.settings import Settings |
97 | 43 | from openlp.core.lib import ServiceItem, ItemCapabilities, PluginStatus, build_icon | 45 | from openlp.core.lib import ServiceItem, ItemCapabilities, PluginStatus, build_icon |
98 | 46 | from openlp.core.lib.exceptions import ValidationError | ||
99 | 44 | from openlp.core.lib.ui import critical_error_message_box, create_widget_action, find_and_set_in_combo_box | 47 | from openlp.core.lib.ui import critical_error_message_box, create_widget_action, find_and_set_in_combo_box |
100 | 45 | from openlp.core.ui import ServiceNoteForm, ServiceItemEditForm, StartTimeForm | 48 | from openlp.core.ui import ServiceNoteForm, ServiceItemEditForm, StartTimeForm |
101 | 46 | from openlp.core.widgets.dialogs import FileDialog | 49 | from openlp.core.widgets.dialogs import FileDialog |
102 | @@ -449,7 +452,7 @@ | |||
103 | 449 | else: | 452 | else: |
104 | 450 | file_path = str_to_path(load_file) | 453 | file_path = str_to_path(load_file) |
105 | 451 | Settings().setValue(self.main_window.service_manager_settings_section + '/last directory', file_path.parent) | 454 | Settings().setValue(self.main_window.service_manager_settings_section + '/last directory', file_path.parent) |
107 | 452 | self.load_file(str(file_path)) | 455 | self.load_file(file_path) |
108 | 453 | 456 | ||
109 | 454 | def save_modified_service(self): | 457 | def save_modified_service(self): |
110 | 455 | """ | 458 | """ |
111 | @@ -475,7 +478,7 @@ | |||
112 | 475 | elif result == QtWidgets.QMessageBox.Save: | 478 | elif result == QtWidgets.QMessageBox.Save: |
113 | 476 | self.decide_save_method() | 479 | self.decide_save_method() |
114 | 477 | sender = self.sender() | 480 | sender = self.sender() |
116 | 478 | self.load_file(sender.data()) | 481 | self.load_file(Path(sender.data())) |
117 | 479 | 482 | ||
118 | 480 | def new_file(self): | 483 | def new_file(self): |
119 | 481 | """ | 484 | """ |
120 | @@ -503,7 +506,32 @@ | |||
121 | 503 | service.append({'openlp_core': core}) | 506 | service.append({'openlp_core': core}) |
122 | 504 | return service | 507 | return service |
123 | 505 | 508 | ||
125 | 506 | def save_file(self, field=None): | 509 | def get_write_file_list(self): |
126 | 510 | """ | ||
127 | 511 | Get a list of files used in the service and files that are missing. | ||
128 | 512 | |||
129 | 513 | :return: A list of files used in the service that exist, and a list of files that don't. | ||
130 | 514 | :rtype: (list[openlp.core.common.path.Path], list[openlp.core.common.path.Path]) | ||
131 | 515 | """ | ||
132 | 516 | write_list = [] | ||
133 | 517 | missing_list = [] | ||
134 | 518 | for item in self.service_items: | ||
135 | 519 | if item['service_item'].uses_file(): | ||
136 | 520 | for frame in item['service_item'].get_frames(): | ||
137 | 521 | path_from = item['service_item'].get_frame_path(frame=frame) | ||
138 | 522 | if path_from in write_list or path_from in missing_list: | ||
139 | 523 | continue | ||
140 | 524 | if not os.path.exists(path_from): | ||
141 | 525 | missing_list.append(Path(path_from)) | ||
142 | 526 | else: | ||
143 | 527 | write_list.append(Path(path_from)) | ||
144 | 528 | for audio_path in item['service_item'].background_audio: | ||
145 | 529 | if audio_path in write_list: | ||
146 | 530 | continue | ||
147 | 531 | write_list.append(audio_path) | ||
148 | 532 | return write_list, missing_list | ||
149 | 533 | |||
150 | 534 | def save_file(self): | ||
151 | 507 | """ | 535 | """ |
152 | 508 | Save the current service file. | 536 | Save the current service file. |
153 | 509 | 537 | ||
154 | @@ -511,178 +539,74 @@ | |||
155 | 511 | there be an error when saving. Audio files are also copied into the service manager directory, and then packaged | 539 | there be an error when saving. Audio files are also copied into the service manager directory, and then packaged |
156 | 512 | into the zip file. | 540 | into the zip file. |
157 | 513 | """ | 541 | """ |
170 | 514 | if not self.file_name(): | 542 | file_path = self.file_name() |
171 | 515 | return self.save_file_as() | 543 | self.log_debug('ServiceManager.save_file - {name}'.format(name=file_path)) |
172 | 516 | temp_file, temp_file_name = mkstemp('.osz', 'openlp_') | 544 | self.application.set_busy_cursor() |
173 | 517 | # We don't need the file handle. | 545 | |
162 | 518 | os.close(temp_file) | ||
163 | 519 | self.log_debug(temp_file_name) | ||
164 | 520 | path_file_name = str(self.file_name()) | ||
165 | 521 | path, file_name = os.path.split(path_file_name) | ||
166 | 522 | base_name = os.path.splitext(file_name)[0] | ||
167 | 523 | service_file_name = '{name}.osj'.format(name=base_name) | ||
168 | 524 | self.log_debug('ServiceManager.save_file - {name}'.format(name=path_file_name)) | ||
169 | 525 | Settings().setValue(self.main_window.service_manager_settings_section + '/last directory', Path(path)) | ||
174 | 526 | service = self.create_basic_service() | 546 | service = self.create_basic_service() |
175 | 547 | |||
176 | 527 | write_list = [] | 548 | write_list = [] |
177 | 528 | missing_list = [] | 549 | missing_list = [] |
208 | 529 | audio_files = [] | 550 | |
209 | 530 | total_size = 0 | 551 | if not self._save_lite: |
210 | 531 | self.application.set_busy_cursor() | 552 | write_list, missing_list = self.get_write_file_list() |
211 | 532 | # Number of items + 1 to zip it | 553 | |
212 | 533 | self.main_window.display_progress_bar(len(self.service_items) + 1) | 554 | if missing_list: |
213 | 534 | # Get list of missing files, and list of files to write | 555 | self.application.set_normal_cursor() |
214 | 535 | for item in self.service_items: | 556 | title = translate('OpenLP.ServiceManager', 'Service File(s) Missing') |
215 | 536 | if not item['service_item'].uses_file(): | 557 | message = translate('OpenLP.ServiceManager', |
216 | 537 | continue | 558 | 'The following file(s) in the service are missing: {name}\n\n' |
217 | 538 | for frame in item['service_item'].get_frames(): | 559 | 'These files will be removed if you continue to save.' |
218 | 539 | path_from = item['service_item'].get_frame_path(frame=frame) | 560 | ).format(name='\n\t'.join(missing_list)) |
219 | 540 | if path_from in write_list or path_from in missing_list: | 561 | answer = QtWidgets.QMessageBox.critical(self, title, message, |
220 | 541 | continue | 562 | QtWidgets.QMessageBox.StandardButtons( |
221 | 542 | if not os.path.exists(path_from): | 563 | QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)) |
222 | 543 | missing_list.append(path_from) | 564 | if answer == QtWidgets.QMessageBox.Cancel: |
223 | 544 | else: | 565 | return False |
194 | 545 | write_list.append(path_from) | ||
195 | 546 | if missing_list: | ||
196 | 547 | self.application.set_normal_cursor() | ||
197 | 548 | title = translate('OpenLP.ServiceManager', 'Service File(s) Missing') | ||
198 | 549 | message = translate('OpenLP.ServiceManager', | ||
199 | 550 | 'The following file(s) in the service are missing: {name}\n\n' | ||
200 | 551 | 'These files will be removed if you continue to save.' | ||
201 | 552 | ).format(name="\n\t".join(missing_list)) | ||
202 | 553 | answer = QtWidgets.QMessageBox.critical(self, title, message, | ||
203 | 554 | QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok | | ||
204 | 555 | QtWidgets.QMessageBox.Cancel)) | ||
205 | 556 | if answer == QtWidgets.QMessageBox.Cancel: | ||
206 | 557 | self.main_window.finished_progress_bar() | ||
207 | 558 | return False | ||
224 | 559 | # Check if item contains a missing file. | 566 | # Check if item contains a missing file. |
225 | 560 | for item in list(self.service_items): | 567 | for item in list(self.service_items): |
239 | 561 | self.main_window.increment_progress_bar() | 568 | if not self._save_lite: |
240 | 562 | item['service_item'].remove_invalid_frames(missing_list) | 569 | item['service_item'].remove_invalid_frames(missing_list) |
241 | 563 | if item['service_item'].missing_frames(): | 570 | if item['service_item'].missing_frames(): |
242 | 564 | self.service_items.remove(item) | 571 | self.service_items.remove(item) |
243 | 565 | else: | 572 | continue |
244 | 566 | service_item = item['service_item'].get_service_repr(self._save_lite) | 573 | service_item = item['service_item'].get_service_repr(self._save_lite) |
245 | 567 | if service_item['header']['background_audio']: | 574 | # Add the service item to the service. |
246 | 568 | for i, file_name in enumerate(service_item['header']['background_audio']): | 575 | service.append({'serviceitem': service_item}) |
234 | 569 | new_file = os.path.join('audio', item['service_item'].unique_identifier, str(file_name)) | ||
235 | 570 | audio_files.append((file_name, new_file)) | ||
236 | 571 | service_item['header']['background_audio'][i] = new_file | ||
237 | 572 | # Add the service item to the service. | ||
238 | 573 | service.append({'serviceitem': service_item}) | ||
247 | 574 | self.repaint_service_list(-1, -1) | 576 | self.repaint_service_list(-1, -1) |
248 | 577 | service_content = json.dumps(service, cls=OpenLPJsonEncoder) | ||
249 | 578 | service_content_size = len(bytes(service_content, encoding='utf-8')) | ||
250 | 579 | total_size = service_content_size | ||
251 | 575 | for file_item in write_list: | 580 | for file_item in write_list: |
254 | 576 | file_size = os.path.getsize(file_item) | 581 | total_size += file_item.stat().st_size |
253 | 577 | total_size += file_size | ||
255 | 578 | self.log_debug('ServiceManager.save_file - ZIP contents size is %i bytes' % total_size) | 582 | self.log_debug('ServiceManager.save_file - ZIP contents size is %i bytes' % total_size) |
263 | 579 | service_content = json.dumps(service) | 583 | self.main_window.display_progress_bar(total_size) |
257 | 580 | # Usual Zip file cannot exceed 2GiB, file with Zip64 cannot be extracted using unzip in UNIX. | ||
258 | 581 | allow_zip_64 = (total_size > 2147483648 + len(service_content)) | ||
259 | 582 | self.log_debug('ServiceManager.save_file - allowZip64 is {text}'.format(text=allow_zip_64)) | ||
260 | 583 | zip_file = None | ||
261 | 584 | success = True | ||
262 | 585 | self.main_window.increment_progress_bar() | ||
264 | 586 | try: | 584 | try: |
316 | 587 | zip_file = zipfile.ZipFile(temp_file_name, 'w', zipfile.ZIP_STORED, allow_zip_64) | 585 | with NamedTemporaryFile(dir=str(file_path.parent), prefix='.') as temp_file, \ |
317 | 588 | # First we add service contents.. | 586 | zipfile.ZipFile(temp_file, 'w') as zip_file: |
318 | 589 | zip_file.writestr(service_file_name, service_content) | 587 | # First we add service contents.. |
319 | 590 | # Finally add all the listed media files. | 588 | zip_file.writestr('service_data.osj', service_content) |
320 | 591 | for write_from in write_list: | 589 | self.main_window.increment_progress_bar(service_content_size) |
321 | 592 | zip_file.write(write_from, write_from) | 590 | # Finally add all the listed media files. |
322 | 593 | for audio_from, audio_to in audio_files: | 591 | for write_path in write_list: |
323 | 594 | audio_from = str(audio_from) | 592 | zip_file.write(str(write_path), str(write_path)) |
324 | 595 | audio_to = str(audio_to) | 593 | self.main_window.increment_progress_bar(write_path.stat().st_size) |
325 | 596 | if audio_from.startswith('audio'): | 594 | with suppress(FileNotFoundError): |
326 | 597 | # When items are saved, they get new unique_identifier. Let's copy the file to the new location. | 595 | file_path.unlink() |
327 | 598 | # Unused files can be ignored, OpenLP automatically cleans up the service manager dir on exit. | 596 | os.link(temp_file.name, str(file_path)) |
328 | 599 | audio_from = os.path.join(self.service_path, audio_from) | 597 | Settings().setValue(self.main_window.service_manager_settings_section + '/last directory', file_path.parent) |
329 | 600 | save_file = os.path.join(self.service_path, audio_to) | 598 | except (PermissionError, OSError) as error: |
330 | 601 | save_path = os.path.split(save_file)[0] | 599 | self.log_exception('Failed to save service to disk: {name}'.format(name=file_path)) |
331 | 602 | create_paths(Path(save_path)) | 600 | self.main_window.error_message( |
332 | 603 | if not os.path.exists(save_file): | 601 | translate('OpenLP.ServiceManager', 'Error Saving File'), |
333 | 604 | shutil.copy(audio_from, save_file) | 602 | translate('OpenLP.ServiceManager', |
334 | 605 | zip_file.write(audio_from, audio_to) | 603 | 'There was an error saving your file.\n\n{error}').format(error=error)) |
284 | 606 | except OSError: | ||
285 | 607 | self.log_exception('Failed to save service to disk: {name}'.format(name=temp_file_name)) | ||
286 | 608 | self.main_window.error_message(translate('OpenLP.ServiceManager', 'Error Saving File'), | ||
287 | 609 | translate('OpenLP.ServiceManager', 'There was an error saving your file.')) | ||
288 | 610 | success = False | ||
289 | 611 | finally: | ||
290 | 612 | if zip_file: | ||
291 | 613 | zip_file.close() | ||
292 | 614 | self.main_window.finished_progress_bar() | ||
293 | 615 | self.application.set_normal_cursor() | ||
294 | 616 | if success: | ||
295 | 617 | try: | ||
296 | 618 | shutil.copy(temp_file_name, path_file_name) | ||
297 | 619 | except (shutil.Error, PermissionError): | ||
298 | 620 | return self.save_file_as() | ||
299 | 621 | except OSError as ose: | ||
300 | 622 | QtWidgets.QMessageBox.critical(self, translate('OpenLP.ServiceManager', 'Error Saving File'), | ||
301 | 623 | translate('OpenLP.ServiceManager', 'An error occurred while writing the ' | ||
302 | 624 | 'service file: {error}').format(error=ose.strerror), | ||
303 | 625 | QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok)) | ||
304 | 626 | success = False | ||
305 | 627 | self.main_window.add_recent_file(path_file_name) | ||
306 | 628 | self.set_modified(False) | ||
307 | 629 | delete_file(Path(temp_file_name)) | ||
308 | 630 | return success | ||
309 | 631 | |||
310 | 632 | def save_local_file(self): | ||
311 | 633 | """ | ||
312 | 634 | Save the current service file but leave all the file references alone to point to the current machine. | ||
313 | 635 | This format is not transportable as it will not contain any files. | ||
314 | 636 | """ | ||
315 | 637 | if not self.file_name(): | ||
335 | 638 | return self.save_file_as() | 604 | return self.save_file_as() |
336 | 639 | temp_file, temp_file_name = mkstemp('.oszl', 'openlp_') | ||
337 | 640 | # We don't need the file handle. | ||
338 | 641 | os.close(temp_file) | ||
339 | 642 | self.log_debug(temp_file_name) | ||
340 | 643 | path_file_name = str(self.file_name()) | ||
341 | 644 | path, file_name = os.path.split(path_file_name) | ||
342 | 645 | base_name = os.path.splitext(file_name)[0] | ||
343 | 646 | service_file_name = '{name}.osj'.format(name=base_name) | ||
344 | 647 | self.log_debug('ServiceManager.save_file - {name}'.format(name=path_file_name)) | ||
345 | 648 | Settings().setValue(self.main_window.service_manager_settings_section + '/last directory', Path(path)) | ||
346 | 649 | service = self.create_basic_service() | ||
347 | 650 | self.application.set_busy_cursor() | ||
348 | 651 | # Number of items + 1 to zip it | ||
349 | 652 | self.main_window.display_progress_bar(len(self.service_items) + 1) | ||
350 | 653 | for item in self.service_items: | ||
351 | 654 | self.main_window.increment_progress_bar() | ||
352 | 655 | service_item = item['service_item'].get_service_repr(self._save_lite) | ||
353 | 656 | # TODO: check for file item on save. | ||
354 | 657 | service.append({'serviceitem': service_item}) | ||
355 | 658 | self.main_window.increment_progress_bar() | ||
356 | 659 | service_content = json.dumps(service) | ||
357 | 660 | zip_file = None | ||
358 | 661 | success = True | ||
359 | 662 | self.main_window.increment_progress_bar() | ||
360 | 663 | try: | ||
361 | 664 | zip_file = zipfile.ZipFile(temp_file_name, 'w', zipfile.ZIP_STORED, True) | ||
362 | 665 | # First we add service contents. | ||
363 | 666 | zip_file.writestr(service_file_name, service_content) | ||
364 | 667 | except OSError: | ||
365 | 668 | self.log_exception('Failed to save service to disk: {name}'.format(name=temp_file_name)) | ||
366 | 669 | self.main_window.error_message(translate('OpenLP.ServiceManager', 'Error Saving File'), | ||
367 | 670 | translate('OpenLP.ServiceManager', 'There was an error saving your file.')) | ||
368 | 671 | success = False | ||
369 | 672 | finally: | ||
370 | 673 | if zip_file: | ||
371 | 674 | zip_file.close() | ||
372 | 675 | self.main_window.finished_progress_bar() | 605 | self.main_window.finished_progress_bar() |
373 | 676 | self.application.set_normal_cursor() | 606 | self.application.set_normal_cursor() |
383 | 677 | if success: | 607 | self.main_window.add_recent_file(file_path) |
384 | 678 | try: | 608 | self.set_modified(False) |
385 | 679 | shutil.copy(temp_file_name, path_file_name) | 609 | return True |
377 | 680 | except (shutil.Error, PermissionError): | ||
378 | 681 | return self.save_file_as() | ||
379 | 682 | self.main_window.add_recent_file(path_file_name) | ||
380 | 683 | self.set_modified(False) | ||
381 | 684 | delete_file(Path(temp_file_name)) | ||
382 | 685 | return success | ||
386 | 686 | 610 | ||
387 | 687 | def save_file_as(self, field=None): | 611 | def save_file_as(self, field=None): |
388 | 688 | """ | 612 | """ |
389 | @@ -743,87 +667,49 @@ | |||
390 | 743 | """ | 667 | """ |
391 | 744 | if not self.file_name(): | 668 | if not self.file_name(): |
392 | 745 | return self.save_file_as() | 669 | return self.save_file_as() |
397 | 746 | if self._save_lite: | 670 | return self.save_file() |
394 | 747 | return self.save_local_file() | ||
395 | 748 | else: | ||
396 | 749 | return self.save_file() | ||
398 | 750 | 671 | ||
400 | 751 | def load_file(self, file_name): | 672 | def load_file(self, file_path): |
401 | 752 | """ | 673 | """ |
402 | 753 | Load an existing service file | 674 | Load an existing service file |
404 | 754 | :param file_name: | 675 | :param file_path: |
405 | 755 | """ | 676 | """ |
413 | 756 | if not file_name: | 677 | if not file_path.exists(): |
414 | 757 | return False | 678 | return False |
415 | 758 | file_name = str(file_name) | 679 | service_data = None |
409 | 759 | if not os.path.exists(file_name): | ||
410 | 760 | return False | ||
411 | 761 | zip_file = None | ||
412 | 762 | file_to = None | ||
416 | 763 | self.application.set_busy_cursor() | 680 | self.application.set_busy_cursor() |
417 | 764 | try: | 681 | try: |
445 | 765 | zip_file = zipfile.ZipFile(file_name) | 682 | with zipfile.ZipFile(str(file_path)) as zip_file: |
446 | 766 | for zip_info in zip_file.infolist(): | 683 | compressed_size = 0 |
447 | 767 | try: | 684 | for zip_info in zip_file.infolist(): |
448 | 768 | ucs_file = zip_info.filename | 685 | compressed_size += zip_info.compress_size |
449 | 769 | except UnicodeDecodeError: | 686 | self.main_window.display_progress_bar(compressed_size) |
450 | 770 | self.log_exception('file_name "{name}" is not valid UTF-8'.format(name=zip_info.file_name)) | 687 | for zip_info in zip_file.infolist(): |
451 | 771 | critical_error_message_box(message=translate('OpenLP.ServiceManager', | 688 | self.log_debug('Extract file: {name}'.format(name=zip_info.filename)) |
452 | 772 | 'File is not a valid service.\n The content encoding is not UTF-8.')) | 689 | # The json file has been called 'service_data.osj' since OpenLP 3.0 |
453 | 773 | continue | 690 | if zip_info.filename == 'service_data.osj' or zip_info.filename.endswith('osj'): |
454 | 774 | os_file = ucs_file.replace('/', os.path.sep) | 691 | with zip_file.open(zip_info, 'r') as json_file: |
455 | 775 | os_file = os.path.basename(os_file) | 692 | service_data = json_file.read() |
456 | 776 | self.log_debug('Extract file: {name}'.format(name=os_file)) | 693 | else: |
457 | 777 | zip_info.filename = os_file | 694 | zip_info.filename = os.path.basename(zip_info.filename) |
458 | 778 | zip_file.extract(zip_info, self.service_path) | 695 | zip_file.extract(zip_info, str(self.service_path)) |
459 | 779 | if os_file.endswith('osj') or os_file.endswith('osd'): | 696 | self.main_window.increment_progress_bar(zip_info.compress_size) |
460 | 780 | p_file = os.path.join(self.service_path, os_file) | 697 | if service_data: |
461 | 781 | if 'p_file' in locals(): | 698 | items = json.loads(service_data, cls=OpenLPJsonDecoder) |
435 | 782 | file_to = open(p_file, 'r') | ||
436 | 783 | if p_file.endswith('osj'): | ||
437 | 784 | items = json.load(file_to) | ||
438 | 785 | else: | ||
439 | 786 | critical_error_message_box(message=translate('OpenLP.ServiceManager', | ||
440 | 787 | 'The service file you are trying to open is in an old ' | ||
441 | 788 | 'format.\n Please save it using OpenLP 2.0.2 or ' | ||
442 | 789 | 'greater.')) | ||
443 | 790 | return | ||
444 | 791 | file_to.close() | ||
462 | 792 | self.new_file() | 699 | self.new_file() |
463 | 793 | self.set_file_name(str_to_path(file_name)) | ||
464 | 794 | self.main_window.display_progress_bar(len(items)) | ||
465 | 795 | self.process_service_items(items) | 700 | self.process_service_items(items) |
468 | 796 | delete_file(Path(p_file)) | 701 | self.set_file_name(file_path) |
469 | 797 | self.main_window.add_recent_file(file_name) | 702 | self.main_window.add_recent_file(file_path) |
470 | 798 | self.set_modified(False) | 703 | self.set_modified(False) |
499 | 799 | Settings().setValue('servicemanager/last file', Path(file_name)) | 704 | Settings().setValue('servicemanager/last file', file_path) |
500 | 800 | else: | 705 | else: |
501 | 801 | critical_error_message_box(message=translate('OpenLP.ServiceManager', 'File is not a valid service.')) | 706 | raise ValidationError(msg='No service data found') |
502 | 802 | self.log_error('File contains no service data') | 707 | except (NameError, OSError, ValidationError, zipfile.BadZipFile) as e: |
503 | 803 | except (OSError, NameError): | 708 | self.log_exception('Problem loading service file {name}'.format(name=file_path)) |
504 | 804 | self.log_exception('Problem loading service file {name}'.format(name=file_name)) | 709 | critical_error_message_box( |
505 | 805 | critical_error_message_box(message=translate('OpenLP.ServiceManager', | 710 | message=translate('OpenLP.ServiceManager', |
506 | 806 | 'File could not be opened because it is corrupt.')) | 711 | 'The service file {file_path} could not be loaded because it is either corrupt, or ' |
507 | 807 | except zipfile.BadZipFile: | 712 | 'not a valid OpenLP 2 or OpenLP 3 service file.'.format(file_path=file_path))) |
480 | 808 | if os.path.getsize(file_name) == 0: | ||
481 | 809 | self.log_exception('Service file is zero sized: {name}'.format(name=file_name)) | ||
482 | 810 | QtWidgets.QMessageBox.information(self, translate('OpenLP.ServiceManager', 'Empty File'), | ||
483 | 811 | translate('OpenLP.ServiceManager', | ||
484 | 812 | 'This service file does not contain ' | ||
485 | 813 | 'any data.')) | ||
486 | 814 | else: | ||
487 | 815 | self.log_exception('Service file is cannot be extracted as zip: {name}'.format(name=file_name)) | ||
488 | 816 | QtWidgets.QMessageBox.information(self, translate('OpenLP.ServiceManager', 'Corrupt File'), | ||
489 | 817 | translate('OpenLP.ServiceManager', | ||
490 | 818 | 'This file is either corrupt or it is not an OpenLP 2 ' | ||
491 | 819 | 'service file.')) | ||
492 | 820 | self.application.set_normal_cursor() | ||
493 | 821 | return | ||
494 | 822 | finally: | ||
495 | 823 | if file_to: | ||
496 | 824 | file_to.close() | ||
497 | 825 | if zip_file: | ||
498 | 826 | zip_file.close() | ||
508 | 827 | self.main_window.finished_progress_bar() | 713 | self.main_window.finished_progress_bar() |
509 | 828 | self.application.set_normal_cursor() | 714 | self.application.set_normal_cursor() |
510 | 829 | self.repaint_service_list(-1, -1) | 715 | self.repaint_service_list(-1, -1) |
511 | @@ -838,7 +724,8 @@ | |||
512 | 838 | self.main_window.increment_progress_bar() | 724 | self.main_window.increment_progress_bar() |
513 | 839 | service_item = ServiceItem() | 725 | service_item = ServiceItem() |
514 | 840 | if 'openlp_core' in item: | 726 | if 'openlp_core' in item: |
516 | 841 | item = item.get('openlp_core') | 727 | item = item['openlp_core'] |
517 | 728 | self._save_lite = item.get('lite-service', False) | ||
518 | 842 | theme = item.get('service-theme', None) | 729 | theme = item.get('service-theme', None) |
519 | 843 | if theme: | 730 | if theme: |
520 | 844 | find_and_set_in_combo_box(self.theme_combo_box, theme, set_missing=False) | 731 | find_and_set_in_combo_box(self.theme_combo_box, theme, set_missing=False) |
521 | @@ -861,9 +748,9 @@ | |||
522 | 861 | Load the last service item from the service manager when the service was last closed. Can be blank if there was | 748 | Load the last service item from the service manager when the service was last closed. Can be blank if there was |
523 | 862 | no service present. | 749 | no service present. |
524 | 863 | """ | 750 | """ |
528 | 864 | file_name = str_to_path(Settings().value('servicemanager/last file')) | 751 | file_path = Settings().value('servicemanager/last file') |
529 | 865 | if file_name: | 752 | if file_path: |
530 | 866 | self.load_file(file_name) | 753 | self.load_file(file_path) |
531 | 867 | 754 | ||
532 | 868 | def context_menu(self, point): | 755 | def context_menu(self, point): |
533 | 869 | """ | 756 | """ |
534 | 870 | 757 | ||
535 | === modified file 'tests/functional/openlp_core/lib/test_serviceitem.py' | |||
536 | --- tests/functional/openlp_core/lib/test_serviceitem.py 2017-12-28 08:22:55 +0000 | |||
537 | +++ tests/functional/openlp_core/lib/test_serviceitem.py 2018-01-22 21:37:22 +0000 | |||
538 | @@ -27,6 +27,7 @@ | |||
539 | 27 | from unittest.mock import MagicMock, patch | 27 | from unittest.mock import MagicMock, patch |
540 | 28 | 28 | ||
541 | 29 | from openlp.core.common import md5_hash | 29 | from openlp.core.common import md5_hash |
542 | 30 | from openlp.core.common.path import Path | ||
543 | 30 | from openlp.core.common.registry import Registry | 31 | from openlp.core.common.registry import Registry |
544 | 31 | from openlp.core.common.settings import Settings | 32 | from openlp.core.common.settings import Settings |
545 | 32 | from openlp.core.lib import ItemCapabilities, ServiceItem, ServiceItemType, FormattingTags | 33 | from openlp.core.lib import ItemCapabilities, ServiceItem, ServiceItemType, FormattingTags |
546 | @@ -351,5 +352,5 @@ | |||
547 | 351 | '"Amazing Grace! how sweet the s" has been returned as the title' | 352 | '"Amazing Grace! how sweet the s" has been returned as the title' |
548 | 352 | assert '’Twas grace that taught my hea' == service_item.get_frame_title(1), \ | 353 | assert '’Twas grace that taught my hea' == service_item.get_frame_title(1), \ |
549 | 353 | '"’Twas grace that taught my hea" has been returned as the title' | 354 | '"’Twas grace that taught my hea" has been returned as the title' |
551 | 354 | assert '/test/amazing_grace.mp3' == service_item.background_audio[0], \ | 355 | assert Path('/test/amazing_grace.mp3') == service_item.background_audio[0], \ |
552 | 355 | '"/test/amazing_grace.mp3" should be in the background_audio list' | 356 | '"/test/amazing_grace.mp3" should be in the background_audio list' |
553 | 356 | 357 | ||
554 | === modified file 'tests/functional/openlp_core/ui/test_mainwindow.py' | |||
555 | --- tests/functional/openlp_core/ui/test_mainwindow.py 2018-01-07 05:24:55 +0000 | |||
556 | +++ tests/functional/openlp_core/ui/test_mainwindow.py 2018-01-22 21:37:22 +0000 | |||
557 | @@ -23,6 +23,7 @@ | |||
558 | 23 | Package to test openlp.core.ui.mainwindow package. | 23 | Package to test openlp.core.ui.mainwindow package. |
559 | 24 | """ | 24 | """ |
560 | 25 | import os | 25 | import os |
561 | 26 | from pathlib import Path | ||
562 | 26 | from unittest import TestCase | 27 | from unittest import TestCase |
563 | 27 | from unittest.mock import MagicMock, patch | 28 | from unittest.mock import MagicMock, patch |
564 | 28 | 29 | ||
565 | @@ -84,14 +85,13 @@ | |||
566 | 84 | """ | 85 | """ |
567 | 85 | # GIVEN a service as an argument to openlp | 86 | # GIVEN a service as an argument to openlp |
568 | 86 | service = os.path.join(TEST_RESOURCES_PATH, 'service', 'test.osz') | 87 | service = os.path.join(TEST_RESOURCES_PATH, 'service', 'test.osz') |
569 | 87 | self.main_window.arguments = [service] | ||
570 | 88 | 88 | ||
571 | 89 | # WHEN the argument is processed | 89 | # WHEN the argument is processed |
572 | 90 | with patch.object(self.main_window.service_manager, 'load_file') as mocked_load_file: | 90 | with patch.object(self.main_window.service_manager, 'load_file') as mocked_load_file: |
573 | 91 | self.main_window.open_cmd_line_files(service) | 91 | self.main_window.open_cmd_line_files(service) |
574 | 92 | 92 | ||
575 | 93 | # THEN the service from the arguments is loaded | 93 | # THEN the service from the arguments is loaded |
577 | 94 | mocked_load_file.assert_called_with(service) | 94 | mocked_load_file.assert_called_with(Path(service)) |
578 | 95 | 95 | ||
579 | 96 | @patch('openlp.core.ui.servicemanager.ServiceManager.load_file') | 96 | @patch('openlp.core.ui.servicemanager.ServiceManager.load_file') |
580 | 97 | def test_cmd_line_arg(self, mocked_load_file): | 97 | def test_cmd_line_arg(self, mocked_load_file): |
581 | @@ -242,3 +242,30 @@ | |||
582 | 242 | 242 | ||
583 | 243 | # THEN: projector_manager_dock.setVisible should had been called once | 243 | # THEN: projector_manager_dock.setVisible should had been called once |
584 | 244 | mocked_dock.setVisible.assert_called_once_with(False) | 244 | mocked_dock.setVisible.assert_called_once_with(False) |
585 | 245 | |||
586 | 246 | def test_increment_progress_bar_default_increment(self): | ||
587 | 247 | """ | ||
588 | 248 | Test that increment_progress_bar increments the progress bar by 1 when called without the `increment` arg. | ||
589 | 249 | """ | ||
590 | 250 | # GIVEN: A mocked progress bar | ||
591 | 251 | with patch.object(self.main_window, 'load_progress_bar', **{'value.return_value': 0}) as mocked_progress_bar: | ||
592 | 252 | |||
593 | 253 | # WHEN: Calling increment_progress_bar without the `increment` arg | ||
594 | 254 | self.main_window.increment_progress_bar() | ||
595 | 255 | |||
596 | 256 | # THEN: The progress bar value should have been incremented by 1 | ||
597 | 257 | mocked_progress_bar.setValue.assert_called_once_with(1) | ||
598 | 258 | |||
599 | 259 | def test_increment_progress_bar_custom_increment(self): | ||
600 | 260 | """ | ||
601 | 261 | Test that increment_progress_bar increments the progress bar by the `increment` arg when called with the | ||
602 | 262 | `increment` arg with a set value. | ||
603 | 263 | """ | ||
604 | 264 | # GIVEN: A mocked progress bar | ||
605 | 265 | with patch.object(self.main_window, 'load_progress_bar', **{'value.return_value': 0}) as mocked_progress_bar: | ||
606 | 266 | |||
607 | 267 | # WHEN: Calling increment_progress_bar with `increment` set to 10 | ||
608 | 268 | self.main_window.increment_progress_bar(increment=10) | ||
609 | 269 | |||
610 | 270 | # THEN: The progress bar value should have been incremented by 10 | ||
611 | 271 | mocked_progress_bar.setValue.assert_called_once_with(10) | ||
612 | 245 | 272 | ||
613 | === modified file 'tests/functional/openlp_core/ui/test_servicemanager.py' | |||
614 | --- tests/functional/openlp_core/ui/test_servicemanager.py 2017-12-20 20:38:43 +0000 | |||
615 | +++ tests/functional/openlp_core/ui/test_servicemanager.py 2018-01-22 21:37:22 +0000 | |||
616 | @@ -623,10 +623,10 @@ | |||
617 | 623 | # THEN: make_preview() should not have been called | 623 | # THEN: make_preview() should not have been called |
618 | 624 | assert mocked_make_preview.call_count == 0, 'ServiceManager.make_preview() should not be called' | 624 | assert mocked_make_preview.call_count == 0, 'ServiceManager.make_preview() should not be called' |
619 | 625 | 625 | ||
620 | 626 | @patch('openlp.core.ui.servicemanager.shutil.copy') | ||
621 | 627 | @patch('openlp.core.ui.servicemanager.zipfile') | 626 | @patch('openlp.core.ui.servicemanager.zipfile') |
622 | 628 | @patch('openlp.core.ui.servicemanager.ServiceManager.save_file_as') | 627 | @patch('openlp.core.ui.servicemanager.ServiceManager.save_file_as') |
624 | 629 | def test_save_file_raises_permission_error(self, mocked_save_file_as, mocked_zipfile, mocked_shutil_copy): | 628 | @patch('openlp.core.ui.servicemanager.os') |
625 | 629 | def test_save_file_raises_permission_error(self, mocked_os, mocked_save_file_as, mocked_zipfile): | ||
626 | 630 | """ | 630 | """ |
627 | 631 | Test that when a PermissionError is raised when trying to save a file, it is handled correctly | 631 | Test that when a PermissionError is raised when trying to save a file, it is handled correctly |
628 | 632 | """ | 632 | """ |
629 | @@ -636,50 +636,22 @@ | |||
630 | 636 | Registry().register('main_window', mocked_main_window) | 636 | Registry().register('main_window', mocked_main_window) |
631 | 637 | Registry().register('application', MagicMock()) | 637 | Registry().register('application', MagicMock()) |
632 | 638 | service_manager = ServiceManager(None) | 638 | service_manager = ServiceManager(None) |
634 | 639 | service_manager._service_path = os.path.join('temp', 'filename.osz') | 639 | service_manager._service_path = MagicMock() |
635 | 640 | service_manager._save_lite = False | 640 | service_manager._save_lite = False |
636 | 641 | service_manager.service_items = [] | 641 | service_manager.service_items = [] |
637 | 642 | service_manager.service_theme = 'Default' | 642 | service_manager.service_theme = 'Default' |
638 | 643 | service_manager.service_manager_list = MagicMock() | 643 | service_manager.service_manager_list = MagicMock() |
639 | 644 | mocked_save_file_as.return_value = True | 644 | mocked_save_file_as.return_value = True |
640 | 645 | mocked_zipfile.ZipFile.return_value = MagicMock() | 645 | mocked_zipfile.ZipFile.return_value = MagicMock() |
642 | 646 | mocked_shutil_copy.side_effect = PermissionError | 646 | mocked_os.link.side_effect = PermissionError |
643 | 647 | 647 | ||
645 | 648 | # WHEN: The service is saved and a PermissionError is thrown | 648 | # WHEN: The service is saved and a PermissionError is raised |
646 | 649 | result = service_manager.save_file() | 649 | result = service_manager.save_file() |
647 | 650 | 650 | ||
648 | 651 | # THEN: The "save_as" method is called to save the service | 651 | # THEN: The "save_as" method is called to save the service |
649 | 652 | assert result is True | 652 | assert result is True |
650 | 653 | mocked_save_file_as.assert_called_with() | 653 | mocked_save_file_as.assert_called_with() |
651 | 654 | 654 | ||
652 | 655 | @patch('openlp.core.ui.servicemanager.shutil.copy') | ||
653 | 656 | @patch('openlp.core.ui.servicemanager.zipfile') | ||
654 | 657 | @patch('openlp.core.ui.servicemanager.ServiceManager.save_file_as') | ||
655 | 658 | def test_save_local_file_raises_permission_error(self, mocked_save_file_as, mocked_zipfile, mocked_shutil_copy): | ||
656 | 659 | """ | ||
657 | 660 | Test that when a PermissionError is raised when trying to save a local file, it is handled correctly | ||
658 | 661 | """ | ||
659 | 662 | # GIVEN: A service manager, a service to save | ||
660 | 663 | mocked_main_window = MagicMock() | ||
661 | 664 | mocked_main_window.service_manager_settings_section = 'servicemanager' | ||
662 | 665 | Registry().register('main_window', mocked_main_window) | ||
663 | 666 | Registry().register('application', MagicMock()) | ||
664 | 667 | service_manager = ServiceManager(None) | ||
665 | 668 | service_manager._service_path = os.path.join('temp', 'filename.osz') | ||
666 | 669 | service_manager._save_lite = False | ||
667 | 670 | service_manager.service_items = [] | ||
668 | 671 | service_manager.service_theme = 'Default' | ||
669 | 672 | mocked_save_file_as.return_value = True | ||
670 | 673 | mocked_zipfile.ZipFile.return_value = MagicMock() | ||
671 | 674 | mocked_shutil_copy.side_effect = PermissionError | ||
672 | 675 | |||
673 | 676 | # WHEN: The service is saved and a PermissionError is thrown | ||
674 | 677 | result = service_manager.save_local_file() | ||
675 | 678 | |||
676 | 679 | # THEN: The "save_as" method is called to save the service | ||
677 | 680 | assert result is True | ||
678 | 681 | mocked_save_file_as.assert_called_with() | ||
679 | 682 | |||
680 | 683 | @patch('openlp.core.ui.servicemanager.ServiceManager.regenerate_service_items') | 655 | @patch('openlp.core.ui.servicemanager.ServiceManager.regenerate_service_items') |
681 | 684 | def test_theme_change_global(self, mocked_regenerate_service_items): | 656 | def test_theme_change_global(self, mocked_regenerate_service_items): |
682 | 685 | """ | 657 | """ |
Tried to save a file and got this error
There was an error saving your file.
[Errno 18] Invalid cross-device link: '/tmp/tmpz25kj0a1' -> '/home/ tim/Projects/ OpenLP/ Service 2018-01-21 17-30.osz'