.git* export-ignore
/README.rus.txt encoding=utf-8
+*.py encoding=utf-8
*.rst encoding=utf-8
*.txt text
``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
=========================
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:
--- /dev/null
+# 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
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+# 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
--- /dev/null
+# 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
+ found_books = False
+ try:
+ for row in self.toggle_rows[0]:
+ value = self.grid.GetCellValue(row, 0)
+ if value and row in book_by_row:
+ found_books = True
+ download(book_by_row[row])
+ except Exception as e:
+ self.report_error(str(e))
+ else:
+ if not found_books:
+ self.report_error(u'Не выбрано книг для сохранения.')
+
+ def report_error(self, error):
+ wx.MessageBox(
+ error, caption='m_Librarian download error',
+ style=wx.OK|wx.ICON_ERROR, parent=self.Parent)
+
+
+class ListBooksWindow(GridWindow):
+
+ session_config_section_name = 'list_books'
+ window_title = u"m_Librarian: Список книг"
+ GridPanelClass = ListBooksPanel
--- /dev/null
+# 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)
--- /dev/null
+#! /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()
--- /dev/null
+#! /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()
extras_require={
'pbar': ['m_lib>=3.1'],
'web': ['bottle', 'CT3'],
+ 'wx': ['wxPython'],
},
)