diff --git a/python/configs/plug/plugins/ybatchrename.py b/python/configs/plug/plugins/ybatchrename.py new file mode 100644 index 000000000..dc1e50824 --- /dev/null +++ b/python/configs/plug/plugins/ybatchrename.py @@ -0,0 +1,122 @@ + +""" +Plugin to rename list of files all at once, using full power of +FAR text editor. +""" + +__author__ = 'Yaroslav Yanovsky' + +import os +import tempfile + +from yfar import FarPlugin + + +RENAME_INFO = b'''Rename list of files. File names to the right of slashes +are destination names. File names to the left are given for reference only, +you can modify them too - only the line order is important. +Full destination paths are supported too - either absolute or relative. + +''' + +class Plugin(FarPlugin): + label = 'Batch Rename Files and Directories' + openFrom = ['PLUGINSMENU', 'FILEPANEL'] + + def OpenPlugin(self, _): + panel = self.get_panel() + + dir = panel.directory + names = [] + for sel in panel.selected: + full_fn = sel.file_name + if '/' in full_fn: + if dir: + self.error('Panel directory {} and full panel name ' + '{} conflict'.format(dir, full_fn)) + return + elif dir: + full_fn = os.path.join(dir, full_fn) + else: + self.error('Panel name {} is local and panel directory not ' + 'specified') + return + names.append([full_fn]) + if not names: + self.notice('No files selected') + return + + max_len = max(len(os.path.basename(x[0])) for x in names) + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(RENAME_INFO) + for fn, in names: + fn = os.path.basename(fn) + f.write(('{} / {}\n'.format(fn.ljust(max_len), fn)).encode()) + + renamed = skipped = 0 + failed = [] + if self.editor(f.name, 'Rename list of files', + line=RENAME_INFO.count(b'\n') + 1, column=max_len + 4): + i = 0 + with open(f.name, 'rb') as f: + used_names = set() + for line in f: + if b'/' in line: + if i >= len(names): + self.error('Aborted: edited list has more ' + 'names than required') + break + dst_fn = line.split(b'/', 1)[1].strip().decode() + if dst_fn.startswith('~'): + dst_fn = os.path.expanduser(dst_fn) + elif not dst_fn.startswith('/'): + dst_fn = os.path.abspath(os.path.join( + os.path.dirname(names[i][0]), dst_fn)) + if dst_fn in used_names: + self.error('Aborted: multiple destination names ' + 'point to the same file\n' + '{}'.format(dst_fn)) + break + names[i].append(dst_fn) + used_names.add(dst_fn) + i += 1 + else: + if i == len(names): + for src_fn, dst_fn in names: + if src_fn == dst_fn: + skipped += 1 + continue + if os.path.exists(dst_fn): + failed.append(dst_fn + ': already exists') + continue + dst_dir, fn = os.path.split(dst_fn) + if not os.path.exists(dst_dir): + # create dst dir if not exist + try: + os.makedirs(dst_dir) + except Exception as e: + failed.append(fn + ' - mkdir: ' + str(e)) + continue + try: + os.rename(src_fn, dst_fn) + except Exception as e: + failed.append(fn + ': ' + str(e)) + else: + renamed += 1 + else: + self.error('Aborted: edited list has less names than ' + 'required') + panel.refresh() + if renamed or skipped or failed: + msg = '{:d} file(s) renamed'.format(renamed) + if skipped: + msg += ', {:d} skipped'.format(skipped) + if failed: + self.error(msg + ', {:d} failed:\n{}'.format( + len(failed), '\n'.join(failed))) + else: + self.notice(msg) + + if os.path.exists(f.name): + os.unlink(f.name) + \ No newline at end of file diff --git a/python/configs/plug/plugins/yfar.py b/python/configs/plug/plugins/yfar.py new file mode 100644 index 000000000..b5bd6b8e8 --- /dev/null +++ b/python/configs/plug/plugins/yfar.py @@ -0,0 +1,317 @@ + +""" +This is a work-in-progress library which tries +to make writing FAR plugins as simple as possible by hiding +all of the C API behind pythonic abstractions. + +Everything that is not underscored is for public usage, +but until majority of FAR API will be accessible via +this library, interfaces will be changed frequently. +""" + +__author__ = 'Yaroslav Yanovsky' + +import logging +import os + +from far2l.plugin import PluginBase + +_log = logging.getLogger(__name__) + + +class _File: + """ + Class that represents file on FAR panel. + Do not create directly, only use properites. + """ + def __init__(self, panel, data, index=None, is_selected=None): + """ + ``data`` is a FAR_FIND_DATA to build file info around. + ``index`` is a position of the file on panel. + """ + self._panel = panel + self.creation_time = data.ftCreationTime + self.last_access_time = data.ftLastAccessTime + self.last_write_time = data.ftLastWriteTime + self.physical_size = data.nPhysicalSize + self.file_size = data.nFileSize + self.file_attributes = data.dwFileAttributes + self.unix_mode = data.dwUnixMode + self.file_name = panel._plugin.f2s(data.lpwszFileName) + self._index = index + self._is_selected = is_selected + + @property + def full_file_name(self): + """ + Tries to provide full file name if possible. + """ + return os.path.join(self._panel.directory, self.file_name) + + @property + def index(self): + """ + Returns file position on the panel. + """ + if self._index is not None: + return self._index + # index not provided on creation - scan through panel to find it. + for f in self._panel: + if f.file_name == self.file_name: + self._index = f.index + return f.index + raise ValueError('Unable to determine index for file {!r}'.format( + self.file_name)) + + @property + def is_selected(self): + """ + Returns whether file is selected or not. + """ + if self._is_selected is None: + # selection status not provided on creation - fetch this + # info from file panel + self._is_selected = (self.file_name + in self._panel._get_selection_dict()) + return self._is_selected + + +class _Panel: + """ + Class that represents FAR panel. + Do not create directly, only use properites. + """ + def __init__(self, plugin, is_active): + self._plugin = plugin + self._handle = plugin.ffi.cast('HANDLE', -1 if is_active else -2) + + PPI = (self._get_control_sized, 'struct PluginPanelItem *') + STR = (self._get_control_str, None) + PI = (self._get_control_fixed, 'struct PanelInfo *') + c = plugin.ffic + self._CONTROL_RETURN_TYPES = { + c.FCTL_GETPANELITEM: PPI, + c.FCTL_GETCURRENTPANELITEM: PPI, + c.FCTL_GETSELECTEDPANELITEM: PPI, + c.FCTL_GETPANELINFO: PI, + c.FCTL_GETPANELDIR: STR, + c.FCTL_GETPANELFORMAT: STR, + c.FCTL_GETPANELHOSTFILE: STR, + } + + @property + def directory(self): + """ + Returns current panel directory. + """ + return self._get_control(self._plugin.ffic.FCTL_GETPANELDIR) + + @property + def cursor(self): + """ + Returns index of file under cursor. + """ + return self._get_info().CurrentItem + + @cursor.setter + def cursor(self, index): + """ + Changes file cursor - ``index`` is a file index in the panel. + """ + if index < 0 or index >= len(self): + raise IndexError('Out of range') + redraw = self._plugin.ffi.new('struct PanelRedrawInfo *') + redraw.CurrentItem = index + redraw.TopPanelItem = -1 + self._plugin.info.Control(self._handle, + self._plugin.ffic.FCTL_REDRAWPANEL, 0, + self._plugin.ffi.cast('LONG_PTR', redraw)) + + @property + def current(self): + """ + Returns current file. + """ + return self[self.cursor] + + def __len__(self): + """ + Returns total amount of files on the panel. + """ + return self._get_info().ItemsNumber + + def __getitem__(self, index): + """ + Returns panel file at given ``index``. + """ + if index < 0 or index >= len(self): + raise IndexError('Out of range') + return self._get_item_impl(index) + + def _get_item_impl(self, index, selection_dict=None): + item = self._get_control(self._plugin.ffic.FCTL_GETPANELITEM, index) + f = _File(self, item.FindData, index) + if selection_dict is not None: + f._is_selected = f.file_name in selection_dict + return f + + @property + def selected(self): + """ + Returns list of selected files on the panel. + """ + res = [] + for i in range(self._get_info().SelectedItemsNumber): + item = self._get_control( + self._plugin.ffic.FCTL_GETSELECTEDPANELITEM, i) + res.append(_File(self, item.FindData, is_selected=True)) + return res + + def _get_selection_dict(self): + return {item.file_name: item for item in self.selected} + + def __iter__(self): + """ + Iterate over all files present on current panel. + """ + selection = self._get_selection_dict() + for i in range(len(self)): + yield self._get_item_impl(i, selection) + + def refresh(self): + """ + Updates and redraws this panel. + """ + p = self._plugin + p.info.Control(self._handle, p.ffic.FCTL_UPDATEPANEL, 0, 0) + p.info.Control(self._handle, p.ffic.FCTL_REDRAWPANEL, 0, 0) + + def _get_control_str(self, _, command, arg=0): + p = self._plugin + char_count = p.info.Control(self._handle, command, 0, 0) + data = p.ffi.new('wchar_t []', char_count) + p.info.Control(self._handle, command, char_count, + p.ffi.cast('LONG_PTR', data)) + return p.f2s(data) + + def _get_control_sized(self, cast_type, command, arg=0): + p = self._plugin + size = p.info.Control(self._handle, command, arg, 0) + if size: + data = p.ffi.new('char []', size) + p.info.Control(self._handle, command, arg, + p.ffi.cast('LONG_PTR', data)) + return p.ffi.cast(cast_type, data) + return None + + def _get_control_fixed(self, cast_type, command, arg=0): + p = self._plugin + data = p.ffi.new(cast_type) + if p.info.Control(self._handle, command, arg, + p.ffi.cast('LONG_PTR', data)): + return data + return None + + def _get_control(self, command, arg=0): + func, cast_type = self._CONTROL_RETURN_TYPES[command] + result = func(cast_type, command, arg) + if result is None: + _log.error('Control({}, {}, {}, ...) failed, expecting {}'.format( + self._handle, command, arg, cast_type)) + return result + + def _get_info(self): + return self._get_control(self._plugin.ffic.FCTL_GETPANELINFO) + + +class FarPlugin(PluginBase): + """ + More simplistic FAR plugin interface. Provides functions that + hide away lower level Plugin C API and allow to write "pythonic" plugins. + """ + def __init__(self, *args, **kwargs): + PluginBase.__init__(self, *args, **kwargs) + # type, first call returns size + + def get_panel(self, is_active=True): + """ + Returns active or passive panel. + """ + return _Panel(self, is_active) + + def notice(self, body, title=None): + """ + Simple message box. + ``body`` is a string or a sequence of strings. + """ + return self._popup(body, title, 0) + + def error(self, body, title=None): + """ + Same as .notice(), but message box type is error + """ + return self._popup(body, title, self.ffic.FMSG_WARNING) + + def _popup(self, body, title, flags): + items = [self.s2f(title)] if title else [self.s2f('')] + if isinstance(body, str): + if '\n' in body: + body = body.split('\n') + else: + items.append(self.s2f(body)) + if not isinstance(body, str): + items.extend(self.s2f(s) for s in body) + # note: needs to be at least 2 items, otherwise message + # box is not shown + citems = self.ffi.new('wchar_t *[]', items) + self.info.Message( + self.info.ModuleNumber, # GUID + flags | self.ffic.FMSG_MB_OK, # Flags + self.ffi.NULL, # HelpTopic + citems, len(citems), 1, # ButtonsNumber + ) + + def editor(self, fn, title=None, line=1, column=1, encoding=65001): + """ + Invokes Far editor on file ``fn``. If ``title`` is given, that + text is used as editor title, otherwise edited file name is used. + + ``line`` and ``column`` indicate where cursor position will be + placed after opening. + + Returns True if file was changed, False if it was not and None + in case of error. + """ + if title is None: + title = fn + fn = self.s2f(fn) + title = self.s2f(title) + result = self.info.Editor(fn, title, 0, 0, -1, -1, + self.ffic.EF_DISABLEHISTORY, line, column, + encoding) + if result == self.ffic.EEC_MODIFIED: + return True + elif result == self.ffic.EEC_NOT_MODIFIED: + return False + return None + + def menu(self, names, title=''): + """ + Simple menu. ``names`` is a list of items. Optional + ``title`` can be provided. + """ + items = self.ffi.new('struct FarMenuItem []', len(names)) + refs = [] + for i, name in enumerate(names): + item = items[i] + item.Selected = item.Checked = item.Separator = 0 + item.Text = txt = self.s2f(name) + refs.append(txt) + items[0].Selected = 1 + title = self.s2f(title) + NULL = self.ffi.NULL + return self.info.Menu(self.info.ModuleNumber, -1, -1, 0, + self.ffic.FMENU_AUTOHIGHLIGHT + | self.ffic.FMENU_WRAPMODE, title, + NULL, NULL, NULL, NULL, items, len(items)); + diff --git a/python/configs/plug/plugins/yjumpsel.py b/python/configs/plug/plugins/yjumpsel.py new file mode 100644 index 000000000..28137d56a --- /dev/null +++ b/python/configs/plug/plugins/yjumpsel.py @@ -0,0 +1,38 @@ + +""" +First of all this plugin is an example of how easy writing plugins +for FAR should be when having proper pythonic library. + +yfar is a work-in-progress attempt to write such library. + +This plugin allows to jump between selected files on the panel. +This was very helpful if you have thousands or even tens of thousands +files in the directory, select some of them by mask and want to figure +out which files were actually selected. For convenience bind +them to hotkeys likes Alt+Up / Alt+Down. +""" + +__author__ = 'Yaroslav Yanovsky' + +from yfar import FarPlugin + + +class Plugin(FarPlugin): + label = 'Jump Between Selected Files' + openFrom = ['PLUGINSMENU', 'FILEPANEL'] + + def OpenPlugin(self, _): + panel = self.get_panel() + option = self.menu(('Jump to &Previous Selected File', + 'Jump to &Next Selected File'), self.label) + if option == 0: # move up + for f in reversed(panel.selected): + if f.index < panel.cursor: + panel.cursor = f.index + break + elif option == 1: # move down + for f in panel.selected: + if f.index > panel.cursor: + panel.cursor = f.index + break + \ No newline at end of file