From: Oleg Broytman Date: Wed, 10 Jan 2024 00:50:54 +0000 (+0300) Subject: Merge branch 'master' into wx X-Git-Tag: 0.3.0~9^2~1 X-Git-Url: https://git.phdru.name/?a=commitdiff_plain;h=f02f8a6f7fd47bb6ab22966d783a017d821c4609;hp=13e26556157955b2a2c33e6524a10116d818b685;p=m_librarian.git Merge branch 'master' into wx --- diff --git a/.gitattributes b/.gitattributes index b7dbfd2..cf217cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ .git* export-ignore /README.rus.txt encoding=utf-8 +*.py encoding=utf-8 *.rst encoding=utf-8 *.txt text diff --git a/docs/install.rst b/docs/install.rst index 7fbe8d0..7e4a26e 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -35,6 +35,9 @@ Other extras ``pip install m_librarian[web]`` installs libraries needed for web-ui. +``pip install m_librarian[wx]`` installs a library needed for wx GUI +(``wxPython``). + Installation from sources ========================= diff --git a/m_librarian/config.py b/m_librarian/config.py index b4a7b60..a6b88f0 100755 --- a/m_librarian/config.py +++ b/m_librarian/config.py @@ -29,11 +29,11 @@ def find_config_dirs(): return None -def find_config_file(config_dirs=None): +def find_config_file(config_dirs=None, config_filename='m_librarian.conf'): if config_dirs is None: config_dirs = find_config_dirs() for d in config_dirs: - ml_conf_file = os.path.join(d, 'm_librarian.conf') + ml_conf_file = os.path.join(d, config_filename) if os.path.exists(ml_conf_file): return ml_conf_file else: diff --git a/m_librarian/wx/AWindow.py b/m_librarian/wx/AWindow.py new file mode 100644 index 0000000..8c46324 --- /dev/null +++ b/m_librarian/wx/AWindow.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +import wx, wx.adv # noqa: E401 multiple imports on one line +from ..__version__ import __version__ +from .session_config import get_session_config + + +class AWindow(wx.Frame): + + ''' + A universal parent class for all top-level application windows + + Standard menu and ability to save/restore window size. + ''' + + # Subclasses should override these + session_config_section_name = None + window_title = None + + def __init__(self, parent=None): + if self.session_config_section_name: + session_config = get_session_config() + width = session_config.getint( + self.session_config_section_name, 'width', 600) + height = session_config.getint( + self.session_config_section_name, 'height', 400) + else: + width = 600 + height = 400 + wx.Frame.__init__( + self, + parent=parent, id=-1, title=self.window_title, + size=wx.Size(width=width, height=height), + ) + self.OnInit() + self.Show(True) + + def OnInit(self): + if self.Parent: + self.Parent.Disable() + self.InitMenu() + if self.session_config_section_name: + self.Bind(wx.EVT_SIZE, self.OnSize) + self.Bind(wx.EVT_CLOSE, self.OnClose) + + def InitMenu(self): + MenuBar = wx.MenuBar() + self.SetMenuBar(MenuBar) + + file_menu = wx.Menu() + + if self.Parent: + close_win = \ + file_menu.Append(wx.ID_CLOSE, u"&Закрыть", u"Закрыть окно") + self.Bind(wx.EVT_MENU, self.OnCloseCommand, close_win) + + quit = file_menu.Append(wx.ID_EXIT, u"&Выход", u"Выйти из программы") + self.Bind(wx.EVT_MENU, self.OnQuit, quit) + MenuBar.Append(file_menu, u"&Файл") + + about_menu = wx.Menu() + about = about_menu.Append(wx.ID_ABOUT, + u"&О m_Librarian", u"О m_Librarian") + self.Bind(wx.EVT_MENU, self.OnAbout, about) + MenuBar.Append(about_menu, u"&О программе") + + def OnCloseCommand(self, event): + self.Close(True) + + def OnClose(self, event): + if self.Parent: + self.Parent.Enable() + event.Skip() # Call other handlers + + def OnQuit(self, event): + wx.GetApp().ExitMainLoop() + + def OnAbout(self, event): + aboutInfo = wx.adv.AboutDialogInfo() + aboutInfo.SetName(u'm_Librarian') + aboutInfo.SetVersion(__version__) + aboutInfo.SetDescription( + u'Библиотекарь для библиотек LibRusEc/Flibusta') + aboutInfo.AddDeveloper(u'Олег Бройтман') + aboutInfo.SetWebSite( + u'https://phdru.name/Software/Python/m_librarian/') + aboutInfo.SetCopyright(u'(C) 2023, 2024 Олег Бройтман') + aboutInfo.SetLicense(u'GPL') + wx.adv.AboutBox(aboutInfo) + + def OnSize(self, event): + """Save window size in the session config""" + if self.session_config_section_name: + size = event.GetSize() + session_config = get_session_config() + session_config.set( + self.session_config_section_name, 'width', str(size.width)) + session_config.set( + self.session_config_section_name, 'height', str(size.height)) + session_config.save() + event.Skip() # Call other handlers diff --git a/m_librarian/wx/Application.py b/m_librarian/wx/Application.py new file mode 100644 index 0000000..d99edb6 --- /dev/null +++ b/m_librarian/wx/Application.py @@ -0,0 +1,27 @@ +import wx +from .AWindow import AWindow +from .SearchPanels import SearchAuthorsPanel, SearchBooksPanel + + +class MainWindow(AWindow): + + session_config_section_name = 'main_window' + window_title = u"m_Librarian" + + def OnInit(self): + AWindow.OnInit(self) + vsizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(vsizer) + + search_authors_panel = SearchAuthorsPanel(self) + search_books_panel = SearchBooksPanel(self) + vsizer.Add(search_authors_panel, 0, wx.EXPAND, 0) + vsizer.Add(search_books_panel, 0, wx.EXPAND, 0) + + +class Application(wx.App): + + def OnInit(self): + frame = MainWindow() + self.SetTopWindow(frame) + return True diff --git a/m_librarian/wx/Grids.py b/m_librarian/wx/Grids.py new file mode 100644 index 0000000..fe2f8d0 --- /dev/null +++ b/m_librarian/wx/Grids.py @@ -0,0 +1,58 @@ +import wx, wx.grid # noqa: E401 multiple imports on one line +from .AWindow import AWindow + + +class GridPanel(wx.Panel): + + def __init__(self, parent, param): + wx.Panel.__init__(self, parent) + self.param = param + + vsizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(vsizer) + + self.grid = grid = wx.grid.Grid(self) + vsizer.Add(grid, 0, wx.EXPAND, 0) + + grid.Bind(wx.grid.EVT_GRID_CELL_LEFT_DCLICK, self.OnDClick) + grid.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown) + + self.InitGrid() + grid.SetFocus() + + parent.Bind(wx.EVT_ACTIVATE, self.OnActivate) + parent.Bind(wx.EVT_SET_FOCUS, self.OnSetFocus) + self.Bind(wx.EVT_ACTIVATE, self.OnActivate) + self.Bind(wx.EVT_SET_FOCUS, self.OnSetFocus) + + def InitGrid(self): + raise NotImplementedError + + def OnDClick(self, event): + raise NotImplementedError + + def OnKeyDown(self, event): + raise NotImplementedError + + def OnActivate(self, event): + if event.GetActive(): + self.grid.SetFocus() + + def OnSetFocus(self, event): + self.grid.SetFocus() + + +class GridWindow(AWindow): + + # Subclasses must override these + session_config_section_name = None + window_title = None + GridPanelClass = GridPanel + + def __init__(self, parent, param): + self.param = param + AWindow.__init__(self, parent) + + def OnInit(self): + AWindow.OnInit(self) + self.panel = self.GridPanelClass(self, self.param) diff --git a/m_librarian/wx/ListAuthors.py b/m_librarian/wx/ListAuthors.py new file mode 100644 index 0000000..8f18a7a --- /dev/null +++ b/m_librarian/wx/ListAuthors.py @@ -0,0 +1,63 @@ +# coding: utf-8 + +import wx, wx.grid # noqa: E401 multiple imports on one line +from ..compat import string_type, unicode_type +from ..search import books_by_author +from ..translations import translations +from .Grids import GridWindow, GridPanel +from .ListBooks import ListBooksWindow + + +class ListAuthorsPanel(GridPanel): + + def InitGrid(self): + _ = getattr(translations, 'ugettext', None) or translations.gettext + authors = self.param['authors'] + columns = self.param['columns'] + grid = self.grid + grid.CreateGrid(len(authors), len(columns)) + grid.EnableEditing(False) + for row in range(len(authors)): + grid.SetRowLabelValue(row, str(row)) + grid.AutoSizeRowLabelSize(row) + for col, col_name in enumerate(columns): + grid.SetColLabelValue(col, _(col_name)) + grid.AutoSizeColLabelSize(col) + if col_name == 'count': + cell_attr = wx.grid.GridCellAttr() + cell_attr.SetAlignment(wx.ALIGN_RIGHT, wx. ALIGN_CENTRE) + grid.SetColAttr(col, cell_attr) + for row, author in enumerate(authors): + for col, col_name in enumerate(columns): + value = getattr(author, col_name) + if not isinstance(value, (string_type, unicode_type)): + value = str(value) + grid.SetCellValue(row, col, value) + grid.AutoSizeColumns() + grid.AutoSizeRows() + + def listBooks(self, row): + authors = self.param['authors'] + author = authors[row] + _books_by_author = books_by_author(author.id) + ListBooksWindow(self, _books_by_author) + + def OnDClick(self, event): + row = event.GetRow() + self.listBooks(row) + + def OnKeyDown(self, event): + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.Parent.Close() + elif event.GetKeyCode() in (wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER): + row = self.grid.GetGridCursorRow() + self.listBooks(row) + else: + event.Skip() + + +class ListAuthorsWindow(GridWindow): + + session_config_section_name = 'list_authors' + window_title = u"m_Librarian: Список авторов" + GridPanelClass = ListAuthorsPanel diff --git a/m_librarian/wx/ListBooks.py b/m_librarian/wx/ListBooks.py new file mode 100644 index 0000000..fb15d1f --- /dev/null +++ b/m_librarian/wx/ListBooks.py @@ -0,0 +1,196 @@ +# coding: utf-8 + +import wx, wx.grid # noqa: E401 multiple imports on one line +from ..compat import string_type, unicode_type +from ..download import download +from ..translations import translations +from .Grids import GridWindow, GridPanel + + +_ = getattr(translations, 'ugettext', None) or translations.gettext + + +class BooksDataTable(wx.grid.GridTableBase): + def __init__(self, rows_count, column_names): + wx.grid.GridTableBase.__init__(self) + self.rows_count = rows_count + self.column_names = column_names + self.data = [] + for row in range(rows_count + 1): + row_data = [] + self.data.append(row_data) + for col in range(len(column_names)): + row_data.append('') + + # required methods for the wxPyGridTableBase interface + + def GetNumberRows(self): + return self.rows_count + + def GetNumberCols(self): + return len(self.column_names) + + def IsEmptyCell(self, row, col): + return False + + # Get/Set values in the table. The Python version of these + # methods can handle any data-type, (as long as the Editor and + # Renderer understands the type too,) not just strings as in the + # C++ version. + def GetValue(self, row, col): + return self.data[row][col] + + def SetValue(self, row, col, value): + self.data[row][col] = value + + # Optional methods + + # Called when the grid needs to display labels + def GetRowLabelValue(self, row): + return str(row) + + def GetColLabelValue(self, col): + return _(self.column_names[col]) + + # Called to determine the kind of editor/renderer to use by + # default, doesn't necessarily have to be the same type used + # natively by the editor/renderer if they know how to convert. + def GetTypeName(self, row, col): + if col == 0: + return wx.grid.GRID_VALUE_BOOL + else: + return wx.grid.GRID_VALUE_STRING + + # Called to determine how the data can be fetched and stored by the + # editor and renderer. This allows you to enforce some type-safety + # in the grid. + def CanGetValueAs(self, row, col, typeName): + colType = self.GetTypeName(row, col) + if typeName == colType: + return True + else: + return False + + def CanSetValueAs(self, row, col, typeName): + return self.CanGetValueAs(row, col, typeName) + + +class ListBooksPanel(GridPanel): + + def InitGrid(self): + books_by_author = self.param['books_by_author'] + columns = self.param['columns'] + columns.insert(0, u'Выбрать') + total_rows = 0 + for author in books_by_author: + books = books_by_author[author] + series = {book.series for book in books} + total_rows += len(books) + len(series) + 1 + grid = self.grid + grid.SetTable( + BooksDataTable(total_rows+1, columns), + takeOwnership=True, + ) + grid.EnableEditing(False) + for col, col_name in enumerate(columns): + grid.AutoSizeColLabelSize(col) + if col == 0: + cell_attr = wx.grid.GridCellAttr() + cell_attr.SetAlignment(wx.ALIGN_CENTRE, wx. ALIGN_CENTRE) + grid.SetColAttr(col, cell_attr) + elif col_name in ('ser_no', 'size'): + cell_attr = wx.grid.GridCellAttr() + cell_attr.SetAlignment(wx.ALIGN_RIGHT, wx. ALIGN_CENTRE) + grid.SetColAttr(col, cell_attr) + row = 0 + grid.SetCellAlignment(row, 1, wx.ALIGN_CENTRE, wx. ALIGN_CENTRE) + grid.SetCellSize(row, 1, 1, len(columns)-1) + row = 1 + self.book_by_row = book_by_row = {} # map {row: book} + self.toggle_rows = toggle_rows = {} # map {row: [list of subrows]} + for author in sorted(books_by_author): + grid.SetCellAlignment(row, 1, wx.ALIGN_LEFT, wx. ALIGN_CENTRE) + grid.SetCellSize(row, 1, 1, len(columns)-1) + grid.SetCellValue(row, 1, u'%s' % (author,)) + author_row = row + toggle_rows[author_row] = [] + row += 1 + books = books_by_author[author] + series = None + for book in books: + if book.series != series: + if book.series: + value = book.series + else: + value = u'Вне серий' + grid.SetCellAlignment(row, 1, + wx.ALIGN_LEFT, wx. ALIGN_CENTRE) + grid.SetCellSize(row, 1, 1, len(columns)-1) + grid.SetCellValue(row, 1, + u'%s — %s' % (book.author1, value)) + series_row = row + toggle_rows[author_row].append(row) + toggle_rows[series_row] = [] + row += 1 + series = book.series + for col, col_name in enumerate(columns[1:]): + value = getattr(book, col_name) + if value is None: + value = u'' + elif not isinstance(value, (string_type, unicode_type)): + value = str(value) + grid.SetCellValue(row, col+1, value) + book_by_row[row] = book + toggle_rows[author_row].append(row) + toggle_rows[series_row].append(row) + row += 1 + toggle_rows[0] = [row_ for row_ in range(1, row)] + grid.AutoSizeColumns() + grid.AutoSizeRows() + grid.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, self.OnClick) + + search_button = wx.Button(self, label=u'Скачать') + self.GetSizer().Add(search_button, 0, wx.ALIGN_CENTER, 0) + search_button.Bind(wx.EVT_BUTTON, self.Download) + + def toggleCB(self, row): + value = self.grid.GetCellValue(row, 0) + if value: + value = '' + else: + value = '1' + self.grid.SetCellValue(row, 0, value) + toggle_rows = self.toggle_rows + if row in toggle_rows: + for row_ in toggle_rows[row]: + self.grid.SetCellValue(row_, 0, value) + + def OnClick(self, event): + if event.GetCol() > 0: + return + self.toggleCB(event.GetRow()) + + def OnDClick(self, event): + if event.GetCol() == 0: + return + self.toggleCB(event.GetRow()) + + def OnKeyDown(self, event): + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.Parent.Close() + else: + event.Skip() + + def Download(self, event): + book_by_row = self.book_by_row + for row in self.toggle_rows[0]: + value = self.grid.GetCellValue(row, 0) + if value and row in book_by_row: + download(book_by_row[row]) + + +class ListBooksWindow(GridWindow): + + session_config_section_name = 'list_books' + window_title = u"m_Librarian: Список книг" + GridPanelClass = ListBooksPanel diff --git a/m_librarian/wx/SearchPanels.py b/m_librarian/wx/SearchPanels.py new file mode 100644 index 0000000..06f9021 --- /dev/null +++ b/m_librarian/wx/SearchPanels.py @@ -0,0 +1,91 @@ +# coding: utf-8 + +import wx +from ..config import get_config +from ..search import search_authors_raw, search_books_raw +from .ListAuthors import ListAuthorsWindow, ListBooksWindow + + +_search_types = ['start', 'substring', 'full'] + + +class SearchPanel(wx.Panel): + + # Subclasses must override these + search_title = None + search_button_title = None + + def __init__(self, parent): + wx.Panel.__init__(self, parent) + search_vsizer = \ + wx.StaticBoxSizer(wx.VERTICAL, self, self.search_title) + self.SetSizer(search_vsizer) + + self.search = search = \ + wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) + search_vsizer.Add(search, 0, wx.EXPAND, 0) + search.Bind(wx.EVT_TEXT_ENTER, self.DoSearch) + + self.search_substr = search_substr = wx.RadioBox( + self, + choices=[ + u'Подстрока в начале', + u'Подстрока', + u'Точное совпадение', + ], + majorDimension=1, style=wx.RA_SPECIFY_ROWS + ) + search_vsizer.Add(search_substr) + + self.search_case = search_case = wx.CheckBox( + self, label=u'Различать прописные/строчные') + search_vsizer.Add(search_case) + + search_button = wx.Button(self, label=self.search_button_title) + search_vsizer.Add(search_button, 0, wx.ALIGN_CENTER, 0) + search_button.Bind(wx.EVT_BUTTON, self.DoSearch) + + def DoSearch(self, event): + search = self.search.GetValue() + if not search: + self.search.SetFocus() + return + search_substr = _search_types[self.search_substr.GetSelection()] + search_case = self.search_case.GetValue() + if search_case is False: + search_case = None + self.realSearch(search, search_substr, search_case) + + +class SearchAuthorsPanel(SearchPanel): + + search_title = u'Поиск авторов' + search_button_title = u'Искать авторов' + + def realSearch(self, value, search_substr, search_case): + search_results = \ + search_authors_raw(value, search_substr, search_case) + ListAuthorsWindow(self.Parent, search_results) + + +class SearchBooksPanel(SearchPanel): + + search_title = u'Поиск книг' + search_button_title = u'Искать книги' + + def __init__(self, parent): + SearchPanel.__init__(self, parent) + self.use_filters = use_filters = wx.CheckBox( + self, label=u'Использовать фильтры') + use_filters_cfg = \ + get_config().getint('filters', 'use_in_search_forms', 1) + use_filters.SetValue(use_filters_cfg) + sizer = self.GetSizer() + s_count = len(sizer.GetChildren()) + sizer.Insert(s_count-1, use_filters) # Insert before the cutton + + def realSearch(self, value, search_substr, search_case): + use_filters = self.use_filters.GetValue() + search_results = \ + search_books_raw(value, search_substr, search_case, use_filters) + ListBooksWindow(self.Parent, search_results) diff --git a/m_librarian/wx/__init__.py b/m_librarian/wx/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/m_librarian/wx/__init__.py @@ -0,0 +1 @@ +# diff --git a/m_librarian/wx/session_config.py b/m_librarian/wx/session_config.py new file mode 100755 index 0000000..93b725c --- /dev/null +++ b/m_librarian/wx/session_config.py @@ -0,0 +1,73 @@ +#! /usr/bin/env python + +from __future__ import print_function +import os + +from ..config import RawConfigParser, ConfigWrapper, find_config_file + + +__all__ = ['get_session_config'] + + +def _find_config_dirs_posix(): + config_dirs = [] + if 'XDG_CACHE_HOME' in os.environ: + config_dirs.append(os.environ['XDG_CACHE_HOME']) + home_cache = os.path.expanduser('~/.cache') + if home_cache not in config_dirs: + config_dirs.append(home_cache) + return config_dirs + + +def _find_config_dirs(): + if os.name == 'posix': + return _find_config_dirs_posix() + return None + + +_ml_session_config = None + + +class SessionConfigWrapper(ConfigWrapper): + def __init__(self, config, config_path): + ConfigWrapper.__init__(self, config) + self.config_path = config_path + + def set(self, section, option, value): + if not self.config.has_section(section): + self.config.add_section(section) + super(SessionConfigWrapper, self).set(section, option, value) + + def save(self): + if self.config_path is None: + config_dirs = _find_config_dirs() + self.config_path = \ + os.path.join(config_dirs[0], 'm_librarian_session.conf') + with open(self.config_path, 'wt') as fp: + self.config.write(fp) + + +def get_session_config(config_path=None): + global _ml_session_config + if _ml_session_config is None: + _ml_session_config = RawConfigParser() + if config_path is None: + config_dirs = _find_config_dirs() + config_path = \ + find_config_file(config_dirs, 'm_librarian_session.conf') + if config_path is not None: + _ml_session_config.read(config_path) + _ml_session_config = \ + SessionConfigWrapper(_ml_session_config, config_path) + return _ml_session_config + + +def _test(): + config_dirs = _find_config_dirs() + print("Config dirs:", config_dirs) + print("Config file:", + find_config_file(config_dirs, 'm_librarian_session.conf')) + + +if __name__ == '__main__': + _test() diff --git a/scripts/ml-wx.py b/scripts/ml-wx.py new file mode 100755 index 0000000..c0a6b46 --- /dev/null +++ b/scripts/ml-wx.py @@ -0,0 +1,18 @@ +#! /usr/bin/env python + +import sys + +from m_librarian.db import open_db +from m_librarian.wx.Application import Application + + +def main(): + if len(sys.argv) > 1: + sys.exit("This program doesn't accept any arguments") + open_db() + app = Application() + app.MainLoop() + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 2dc274f..8670397 100755 --- a/setup.py +++ b/setup.py @@ -79,5 +79,6 @@ setup( extras_require={ 'pbar': ['m_lib>=3.1'], 'web': ['bottle', 'CT3'], + 'wx': ['wxPython'], }, )