diff --git a/src/main.pyw b/src/main.pyw index b85a64b..d506a3e 100644 --- a/src/main.pyw +++ b/src/main.pyw @@ -21,10 +21,11 @@ import wx.grid from background_music_player import BackgroundMusicPlayer from constants import Config, Colors, Columns, FileTypes, Strings from projector import ProjectorWindow -from settings import SettingsDialog, path_make_abs +from settings import SettingsDialog from logger import Logger from file_replacer import FileReplacer from text_window import TextWindow +from os_tools import path locale_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'locale') if os.path.isfile(os.path.join(locale_dir, 'ru', 'LC_MESSAGES', 'main.mo')): @@ -66,23 +67,22 @@ class MainWindow(wx.Frame): Config.COUNTDOWN_TIME_FMT: u"Ждём Вас в %s ^_^"} self.config_ok = False - self.session_file_path = '' + self.fest_file_path = '' if os.path.isfile(Config.LAST_SESSION_PATH): try: - self.session_file_path = open(Config.LAST_SESSION_PATH, 'r', encoding='utf-8-sig').read() - except UnicodeDecodeError as e: + self.fest_file_path = open(Config.LAST_SESSION_PATH, 'r', encoding='utf-8-sig').read() + except UnicodeDecodeError: try: - self.session_file_path = open(Config.LAST_SESSION_PATH, 'r', encoding='latin-1').read() - except UnicodeDecodeError as e: + self.fest_file_path = open(Config.LAST_SESSION_PATH, 'r', encoding='latin-1').read() + except UnicodeDecodeError: print("Fail to read last session file.") - self.session_file_path = '' + self.fest_file_path = '' - if not os.path.isabs(self.session_file_path): - self.session_file_path = os.path.normpath(os.path.join(os.path.abspath(__file__), self.session_file_path)) + self.fest_file_path = path.make_abs(self.fest_file_path) - if os.path.isfile(self.session_file_path): + if os.path.isfile(self.fest_file_path): try: - loaded_config = json.load(open(self.session_file_path, 'r', encoding='utf-8-sig')) + loaded_config = json.load(open(self.fest_file_path, 'r', encoding='utf-8-sig')) config_keys_diff = set(base_config.keys()) - set(loaded_config.keys()) if config_keys_diff: self.logger.log("[WARNING] Config file is missing the following keys: " + str(config_keys_diff)) @@ -91,8 +91,13 @@ class MainWindow(wx.Frame): except json.decoder.JSONDecodeError as e: msg = _("Unfortunately, you broke the JSON format...\n" "Please fix the configuration file%s ASAP.\n\nDetails: %s") % \ - ("\n(%s)" % self.session_file_path, str(e)) + ("\n(%s)" % self.fest_file_path, str(e)) wx.MessageBox(msg, "JSON Error", wx.OK | wx.ICON_ERROR, self) + else: + self.logger.log("Session path %s is not file" % self.fest_file_path) + self.fest_file_path = '' + + path.fest_file = self.fest_file_path # TODO: Remove self.fest_file_path if not self.config_ok: self.config = base_config @@ -117,7 +122,7 @@ class MainWindow(wx.Frame): self.Bind(wx.EVT_TIMER, self.on_background_timer, self.bg_player_timer) self.bg_tracks_dir = None - self.files_dirs = [path_make_abs(d, self.session_file_path) for d in self.config[Config.FILES_DIRS]] + self.files_dirs = [path.make_abs(d, path.fest_file) for d in self.config[Config.FILES_DIRS]] # ------------------ Menu ------------------ menu_bar = wx.MenuBar() @@ -130,8 +135,8 @@ class MainWindow(wx.Frame): menu_file.AppendSeparator() - if self.session_file_path: - session_folder, session_file = os.path.split(self.session_file_path) + if self.fest_file_path: + session_folder, session_file = os.path.split(self.fest_file_path) self.Bind(wx.EVT_MENU, lambda e: webbrowser.open(os.path.abspath(session_folder)), menu_file.Append(wx.ID_ANY, _("Open &Folder with '%s'") % session_file)) @@ -515,15 +520,16 @@ class MainWindow(wx.Frame): def on_settings(self, e=None): prev_config = copy.copy(self.config) - with SettingsDialog(self.session_file_path, self.config, self) as settings_dialog: + with SettingsDialog(self.fest_file_path, self.config, self) as settings_dialog: action = settings_dialog.ShowModal() - self.session_file_path = settings_dialog.session_file_path # To be sure. + self.fest_file_path = path.make_abs(settings_dialog.fest_file_path) # To be sure. + path.fest_file = self.fest_file_path self.config = settings_dialog.config # Maybe redundant self.config_ok = action in {wx.ID_SAVE, wx.ID_OPEN} if prev_config != self.config: # Safety is everything! - bkp_name = "%s-%s.bkp.fest" % (os.path.splitext(self.session_file_path)[0], + bkp_name = "%s-%s.bkp.fest" % (os.path.splitext(self.fest_file_path)[0], time.strftime("%d%m%y%H%M%S", time.localtime())) json.dump(prev_config, open(bkp_name, 'w', encoding='utf-8'), ensure_ascii=False, indent=4) @@ -627,7 +633,8 @@ class MainWindow(wx.Frame): return self.proj_win.switch_to_images() if self.config[Config.BG_ZAD_PATH] and not no_show: - self.proj_win.load_zad(path_make_abs(self.config[Config.BG_ZAD_PATH], self.session_file_path), True) + self.proj_win.load_zad(path.make_abs(self.config[Config.BG_ZAD_PATH], + path.fest_file), True) self.image_status("Background") else: self.proj_win.no_show() @@ -689,6 +696,7 @@ class MainWindow(wx.Frame): all_files = [item for sublist in all_files for item in sublist] # Flatten for file_path in all_files: + # FIXME: check that file_path is file. Otherwise there will be a crash. name, ext = os.path.basename(file_path).rsplit('.', 1) ext = ext.lower() # Never forget doing this! match = re.search(self.filename_re, name) @@ -738,7 +746,7 @@ class MainWindow(wx.Frame): self.add_countdown_row(False, 0, self.config[Config.COUNTDOWN_OPENING_TEXT]) - self.SetLabel("%s: %s" % (Strings.APP_NAME, self.session_file_path)) + self.SetLabel("%s: %s" % (Strings.APP_NAME, self.fest_file_path)) self.load_data_item.Enable(False) # Safety is everything! @@ -1131,7 +1139,7 @@ class MainWindow(wx.Frame): # -------------------------------------------- Background Music Player -------------------------------------------- def on_bg_load_files(self, e=None): - self.bg_tracks_dir = path_make_abs(self.config[Config.BG_TRACKS_DIR], self.session_file_path) + self.bg_tracks_dir = path.make_abs(self.config[Config.BG_TRACKS_DIR], path.fest_file) if not self.config[Config.BG_TRACKS_DIR] or not os.path.isdir(self.bg_tracks_dir): msg = _("Background MP3 path is invalid. Please specify a\n" "valid path with your background tracks in settings.\n\n" @@ -1249,7 +1257,7 @@ class MainWindow(wx.Frame): if Config.C2_DATABASE_PATH not in self.config or not self.config[Config.C2_DATABASE_PATH]: self.status(_("No Cosplay2 database in config")) return - db_path = path_make_abs(self.config[Config.C2_DATABASE_PATH], self.session_file_path) + db_path = path.make_abs(self.config[Config.C2_DATABASE_PATH], path.fest_file) if not os.path.isfile(db_path): self.status(_("Cosplay2 database not found")) return diff --git a/src/os_tools.py b/src/os_tools.py new file mode 100644 index 0000000..ea2201d --- /dev/null +++ b/src/os_tools.py @@ -0,0 +1,62 @@ +# This file contains OS-level stuff (paths, strings, locale, etc) +# By default, the app uses absolute paths with POSIX notation. +# All path translations are performed on start (change settings, read config, etc) + + +import os +import sys +from pathlib import Path, PureWindowsPath + + +class PathTools(object): + def __init__(self): + if getattr(sys, 'frozen', False): + self._work_dir = str(Path(sys._MEIPASS)) + else: + self._work_dir = str(Path(__file__).resolve().parent) + self._fest_file = None + + @property + def work_dir(self): + return self._work_dir + + @property + def fest_file(self): + return self._fest_file + + @fest_file.setter + def fest_file(self, fest_file): + self._fest_file = str(Path(fest_file).resolve()) + + def make_abs(self, path, anchor=None): + path, anchor = self._prepare_paths(path, anchor) + return str(Path(os.path.join(str(anchor), str(path))).resolve()) + + def make_rel(self, path, anchor=None): + path, anchor = self._prepare_paths(path, anchor) + + if self._can_make_rel(path, anchor): + # Path().relative_to() have differ semantic with os.path.relpath() + return str(os.path.relpath(str(path.resolve()), str(anchor))) + else: + return str(path.resolve()) + + def _prepare_paths(self, path, anchor): + path = Path(PureWindowsPath(path).as_posix()) if os.name == 'posix' and '\\' in str(path) else Path(path) + if anchor is None: + anchor = Path(self._work_dir) + else: + anchor = Path(anchor).resolve() + if anchor.is_file(): + anchor = anchor.parent + return path, anchor + + @staticmethod + def _can_make_rel(path, anchor): + if not path.exists() or not anchor.exists(): + return False + if os.lstat(path.resolve().as_posix()).st_dev != os.lstat(anchor.resolve().as_posix()).st_dev: + return False + return True + +path = PathTools() diff --git a/src/settings.py b/src/settings.py index 5beb1cf..d0071f2 100644 --- a/src/settings.py +++ b/src/settings.py @@ -5,39 +5,19 @@ import json import wx from constants import Config, FileTypes - - -def path_make_abs(path, session_file_path): - if not path or os.path.isabs(path): - return path - else: # this is relative, so we calculate a path relative to a directory where the .fest file resides - session_file_dir = os.path.dirname(session_file_path) - return os.path.normpath(os.path.join(session_file_dir, path)) - - -def path_session_try_to_relative(path): - session_file_stat = os.lstat(path) - if getattr(sys, 'frozen', False): - work_dir = sys._MEIPASS - else: - work_dir = os.path.dirname(os.path.abspath(__file__)) - fest_engine_run_location = os.lstat(work_dir) - if session_file_stat.st_dev != fest_engine_run_location.st_dev: - return os.path.abspath(path) - else: - return os.path.relpath(path, os.path.abspath(__file__)) +from os_tools import path class SettingsDialog(wx.Dialog): - def __init__(self, session_file_path, config, parent): + def __init__(self, fest_file_path, config, parent): wx.Dialog.__init__(self, parent, title=_("Settings"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) - self.session_file_path = session_file_path + self.fest_file_path = fest_file_path self.config = config - if not session_file_path: + if not self.fest_file_path: wx.MessageBox(_("Hi ^_^ Please select a new or existing *.fest file in the 'Current Fest' field.\n" "It will load automatically on each start unless you change it. The configuration\n" "may seem confusing, if you find it so, open the 'File | About' menu item.\n" @@ -53,7 +33,7 @@ def __init__(self, session_file_path, config, parent): style=wx.FLP_SAVE | wx.FLP_USE_TEXTCTRL, wildcard="Fest Engine sessions (*.fest)|*.fest;*.fest_bkp") self.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_fest_selected, self.session_picker) - self.session_picker.SetPath(self.session_file_path) + self.session_picker.SetPath(self.fest_file_path) session_sizer.Add(self.session_picker, 1, wx.EXPAND | wx.ALL, 5) session_sizer.Add(wx.StaticLine(self.panel, style=wx.LI_VERTICAL), 0, wx.EXPAND | wx.TOP | wx.BOTTOM | wx.RIGHT, 5) @@ -88,20 +68,20 @@ def __init__(self, session_file_path, config, parent): # Database File self.db_path = wx.FilePickerCtrl(self.panel, wildcard="SQLite Databases|*.sqlite;*.db;*.data") - self.db_path.SetInitialDirectory(self.session_file_path) - self.db_path.SetPath(path_make_abs(self.config[Config.C2_DATABASE_PATH], self.session_file_path)) + self.db_path.SetInitialDirectory(self.fest_file_path) + self.db_path.SetPath(path.make_abs(self.config[Config.C2_DATABASE_PATH], self.fest_file_path)) self.configs_grid.Add(wx.StaticText(self.panel, label=_("Cosplay2 Database Path")), 0, wx.ALIGN_CENTER_VERTICAL) self.configs_grid.Add(self.db_path, 1, wx.EXPAND) # Background Tracks self.bg_tracks = wx.DirPickerCtrl(self.panel) - self.bg_tracks.SetPath(path_make_abs(self.config[Config.BG_TRACKS_DIR], self.session_file_path)) + self.bg_tracks.SetPath(path.make_abs(self.config[Config.BG_TRACKS_DIR], path.fest_file)) self.configs_grid.Add(wx.StaticText(self.panel, label=_("Background Tracks Dir")), 0, wx.ALIGN_CENTER_VERTICAL) self.configs_grid.Add(self.bg_tracks, 1, wx.EXPAND) img_wc = "Images ({0})|{0}".format(";".join(["*.%s" % x for x in FileTypes.img_extensions])) self.bg_zad = wx.FilePickerCtrl(self.panel, wildcard=img_wc) - self.bg_zad.SetPath(path_make_abs(self.config[Config.BG_ZAD_PATH], self.session_file_path)) + self.bg_zad.SetPath(path.make_abs(self.config[Config.BG_ZAD_PATH], path.fest_file)) self.configs_grid.Add(wx.StaticText(self.panel, label=_("Background ZAD Path")), 0, wx.ALIGN_CENTER_VERTICAL) self.configs_grid.Add(self.bg_zad, 1, wx.EXPAND) @@ -150,7 +130,7 @@ def __init__(self, session_file_path, config, parent): buttons_sizer.Add(button_cancel, 1) self.top_sizer.Add(buttons_sizer, 0, wx.EXPAND | wx.ALL, 5) - self.on_fest_selected(first_run=not self.session_file_path) + self.on_fest_selected(first_run=not self.fest_file_path) def add_dir(self, path=None): dir_picker = wx.DirPickerCtrl(self.panel) @@ -188,7 +168,8 @@ def process_children(sizer): def on_fest_selected(self, e=None, first_run=False): fest_file_exists = os.path.isfile(e.Path) if e else False if fest_file_exists: - self.session_file_path = os.path.normpath(e.Path) + self.fest_file_path = path.make_abs(e.Path) + path.fest_file = self.fest_file_path try: self.config = json.load(open(e.Path, 'r', encoding='utf-8-sig')) except json.decoder.JSONDecodeError as e: @@ -206,16 +187,16 @@ def on_fest_selected(self, e=None, first_run=False): self.enable_settings(not fest_file_exists) self.button_save.Enable(not fest_file_exists) self.button_load.Enable(fest_file_exists) - self.session_file_edit_btn.Enable(os.path.exists(self.session_file_path)) + self.session_file_edit_btn.Enable(os.path.exists(self.fest_file_path)) def config_to_ui(self): self.screens_combobox.SetSelection(self.config[Config.PROJECTOR_SCREEN]) - self.db_path.SetPath(path_make_abs(self.config[Config.C2_DATABASE_PATH], self.session_file_path)) + self.db_path.SetPath(path.make_abs(self.config[Config.C2_DATABASE_PATH], path.fest_file)) self.filename_re.SetValue(self.config[Config.FILENAME_RE]) - self.bg_tracks.SetPath(path_make_abs(self.config[Config.BG_TRACKS_DIR], self.session_file_path)) - self.bg_zad.SetPath(path_make_abs(self.config[Config.BG_ZAD_PATH], self.session_file_path)) + self.bg_tracks.SetPath(path.make_abs(self.config[Config.BG_TRACKS_DIR], path.fest_file)) + self.bg_zad.SetPath(path.make_abs(self.config[Config.BG_ZAD_PATH], path.fest_file)) [self.rm_dir() for i in range(len(self.dir_pickers))] - [self.add_dir(path_make_abs(path, self.session_file_path)) for path in self.config[Config.FILES_DIRS]] + [self.add_dir(path.make_abs(d, path.fest_file)) for d in self.config[Config.FILES_DIRS]] self.panel.SetSizerAndFit(self.top_sizer) self.top_sizer.Fit(self) self.SetClientSize((self.GetClientSize()[0] + 300, self.GetClientSize()[1])) @@ -229,14 +210,12 @@ def path_validate(self, widget, msg): widget.SetPath("") return "" - def path_try_relative(self, path): - session_file_dir = os.path.dirname(self.session_file_path) + os.sep - if os.path.normpath(path).startswith(session_file_dir): - return './' + os.path.relpath(path, session_file_dir).replace(os.sep, '/') - return path + def path_try_relative(self, p): + return path.make_rel(p, path.fest_file) def ui_to_config(self): """ Saves selected values from UI to JSON config """ + # FIXME: prevent .fest file from saving if path validation failed self.config[Config.PROJECTOR_SCREEN] = self.screens_combobox.GetSelection() self.config[Config.FILENAME_RE] = self.filename_re.GetValue() @@ -269,22 +248,27 @@ def ui_to_config(self): ) def on_ok(self, e): - path = self.session_picker.GetPath() + fest_file_path = self.session_picker.GetPath() ext = '.bkp.fest' - if path.find(ext) == len(path) - len(ext): - self.session_file_path = path[:-4] - shutil.copy(path, self.session_file_path) + if fest_file_path.find(ext) == len(fest_file_path) - len(ext): + self.fest_file_path = fest_file_path[:-4] + shutil.copy(fest_file_path, self.fest_file_path) else: ext = '.fest' - self.session_file_path = path if path.endswith(ext) else path + ext + self.fest_file_path = fest_file_path if fest_file_path.endswith(ext) else fest_file_path + ext if e.Id == wx.ID_SAVE: + # For normal path translation .fest file must exist + f = open(self.fest_file_path, 'w', encoding='utf-8') + f.close() + self.ui_to_config() - json.dump(self.config, open(self.session_file_path, 'w', encoding='utf-8'), + json.dump(self.config, open(self.fest_file_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=4) + path.fest_file = self.fest_file_path with open(Config.LAST_SESSION_PATH, 'w', encoding='utf-8-sig') as f: - f.write(path_session_try_to_relative(self.session_file_path)) + f.write(path.make_rel(self.fest_file_path)) self.EndModal(e.Id) diff --git a/test/data b/test/data index 6ee69b0..d86832a 160000 --- a/test/data +++ b/test/data @@ -1 +1 @@ -Subproject commit 6ee69b0b237520df142b127a1a481abda948ffae +Subproject commit d86832ae69cb916036f3d6ff95209a7c2b65dd52 diff --git a/test/test_os_tools.py b/test/test_os_tools.py new file mode 100644 index 0000000..44c6c2e --- /dev/null +++ b/test/test_os_tools.py @@ -0,0 +1,66 @@ +import unittest + +# Ugly hack to allow absolute import from the root folder +import sys, os + +sys.path.insert(0, os.path.abspath('../..')) + +from src.os_tools import path as t +import pathlib as path + + +def delete_folder(pth): + for sub in pth.iterdir(): + if sub.is_dir(): + delete_folder(sub) + else: + sub.unlink() + pth.rmdir() + + +class OsToolsTests(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(OsToolsTests, self).__init__(*args, **kwargs) + + def setUp(self): + self.work_dir = path.Path('.') + self.test_dir = path.Path('phantom') + if self.test_dir.exists(): + delete_folder(self.test_dir) + + self.test_dir.mkdir() + self.sub_dir = path.Path('phantom/sub_dir') + self.sub_dir.mkdir() + + self.fest1_path = path.Path('phantom/test1.fest') + self.fest1_path.touch() + self.fest2_path = path.Path('phantom/sub_dir/test2.fest') + self.fest2_path.touch() + + # TODO check default values (need stable workdir path) + + def test_fest_file_set(self): + t.fest_file = self.fest1_path + print(t.fest_file) + self.assertEqual(t.fest_file, str(self.fest1_path.resolve())) + + def test_relative_path(self): + # Downstream relative path + t.fest_file = self.fest1_path + self.assertEqual(t.make_rel(self.fest2_path, t.fest_file), "sub_dir\\test2.fest") + # Upstream relative path + t.fest_file = self.fest2_path + self.assertEqual(t.make_rel(self.fest1_path, t.fest_file), "..\\test1.fest") + # TODO test workdir relative path (need stable workdir path) + + # TODO test abs paths (need stable enviroment) + + def tearDown(self): + self.fest2_path.unlink() + self.fest1_path.unlink() + self.sub_dir.rmdir() + self.test_dir.rmdir() + + +if __name__ == '__main__': + unittest.main()