Created
August 27, 2012 09:30
-
-
Save jbjornson/3486921 to your computer and use it in GitHub Desktop.
SublimeText plugin to make, view and diff backups for a file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
@author Josh Bjornson | |
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0 Unported License. | |
To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/3.0/ | |
or send a letter to Creative Commons, 171 Second Street, Suite 300, San Francisco, California, 94105, USA. | |
''' | |
import sublime, sublime_plugin | |
import glob, os, shutil, codecs, time, difflib, datetime | |
''' | |
------------ | |
Description: | |
------------ | |
Plugin to backup a file (or even an unsaved view). If the current view has unsaved changes | |
then the backup will include these unsaved changes. This should be cross platform but has | |
only been tested on windows. | |
The plugin has three modes: "backup", "view" and "diff". | |
- backup : Backs up the file to the location defined by the settings. A file | |
will only be backed up if there are differences when compared to | |
the most recent backup. | |
- view : Shows a quick panel with the saved backups of the current file. | |
The selected backup is opened. | |
- diff : Shows a quick panel with the saved backups of the current file. | |
The selected backup is compared with the current file and the | |
resulting diff is displayed. | |
Github location: | |
https://gist.github.com/3486921 | |
------------- | |
Alternatives: | |
------------- | |
There are a few alternatives that are available through the Package Manager: | |
http://vishalrana.com/local-history/ | |
https://github.com/joelpt/sublimetext-automatic-backups | |
I have not used these other plugins so cannot comment on their completeness or stability. | |
--------- | |
Settings: | |
--------- | |
The supported settings are: | |
- default_unsaved_view_name : The default filename to use for an unsaved view with no title | |
- backup_prefix : Appended to the end of the name of the file being backed up | |
- backup_timestamp_mask : Timestamp to append after the backup_prefix (1) | |
- backup_dir_style : | |
backup-exploded - the directory tree from the source file is recreated | |
under the folder specified in the backup_dir setting | |
(eg c:\data\file.txt -> c:\backup\c\data\file.txt-Backup_2012-08-27_...) | |
backup-flat - directly in the folder specified in the backup_dir setting | |
(eg c:\data\file.txt -> c:\backup\file.txt-Backup_2012-08-27_...) | |
inline - in the same directory as the file being backed up | |
(eg c:\data\file.txt -> c:\data\file.txt-Backup_2012-08-27_...) | |
- backup_dir : Where the backups should be stored | |
(ignored if backup_dir_style=inline is used) | |
(1) The backup filenames are used to discover which file contains the most recent backup, | |
so the backup_timestamp_mask settings should ensure sortability | |
(eg "yyyy-mm-dd" is ok, but "dd-mm-yyyy" is not) | |
The plugin will attempt to load settings from "BackupFile.sublime-settings". | |
------------- | |
Key Bindings: | |
------------- | |
{ "keys": ["alt+e", "alt+b"], "command": "backup_file", "args": {"mode" : "backup"}} | |
{ "keys": ["alt+e", "alt+v"], "command": "backup_file", "args": {"mode" : "view"}} | |
{ "keys": ["alt+e", "alt+d"], "command": "backup_file", "args": {"mode" : "diff"}} | |
----- | |
TODO: | |
----- | |
- There are some issues with handling umlaut characters and I'm not sure how to fix this. | |
- If a backup fails on an existing file with unsaved changes, any subsequent backups (before saving) | |
will result if the filename being lost (and therefore the default_unsaved_view_name being used). | |
It looks like Sublime return null from self.view.file_name(), so I'm not sure how to fix this. | |
- Is it possible to get the default extension for an unsaved view from the syntax definition? | |
- Confirm this works on Linix and Macs | |
''' | |
class BackupFileCommand(sublime_plugin.TextCommand): | |
def run(self, edit, mode='view'): | |
settings = sublime.load_settings('BackupFile.sublime-settings') | |
default_unsaved_view_name = settings.get('default_unsaved_view_name', 'Untitled') | |
backup_prefix = settings.get('backup_prefix', '-Backup_') | |
backup_timestamp_mask = settings.get('backup_timestamp_mask', '%Y-%m-%d_%H-%M-%S_%f') | |
# valid backup_dir_style options are: {backup-exploded, backup-flat, inline} | |
backup_dir_style = settings.get('backup_dir_style', 'inline') | |
backup_dir = os.path.normpath(settings.get('backup_dir')) | |
backup_dir = backup_dir if backup_dir else os.getcwd() | |
# Get the file information | |
if not self.view.file_name(): | |
# If there is a view name then use it, otherwise use the default | |
filename = self.view.name() if self.view.name() else default_unsaved_view_name | |
# TODO Get the extension from the syntax definition? | |
filename = filename + '.txt' | |
current_dir = os.getcwd() | |
use_view_contents = True | |
else: | |
(current_dir, filename) = os.path.split(self.view.file_name()) | |
use_view_contents = self.view.is_dirty() | |
current_dir = os.path.normpath(current_dir) | |
current_full_path = os.path.normpath(os.path.join(current_dir, filename)) | |
# If the current file is in the backup directory then exit | |
# (refuse to run the backup plugin on a backed-up file) | |
common_path = os.path.commonprefix([backup_dir, current_dir]) | |
if common_path.startswith(backup_dir): | |
self.message('Refusing to work with a backup file: please use the origional file') | |
return | |
# figure out where to save the backup | |
if backup_dir_style == 'backup-exploded': | |
target_dir = os.path.normpath(os.path.join(backup_dir, current_dir.replace(':', ''))) | |
elif backup_dir_style == 'backup-flat': | |
target_dir = backup_dir | |
elif backup_dir_style == 'inline': | |
target_dir = current_dir | |
else: | |
target_dir = current_dir | |
# Calculate the backup file list | |
# Iterate over the list of files and add them to the display list | |
backup_filter = '%s%s*' % (filename, backup_prefix) | |
file_list = glob.glob(os.path.join(target_dir, backup_filter)) | |
# Build up the file list for display | |
self.backup_list = [] | |
for file_path in file_list: | |
self.backup_list.append([os.path.basename(file_path), file_path]) | |
# Execute the request | |
if mode in ('view', 'diff'): | |
# Reverse the backup list so the most recent backup shows up first | |
self.backup_list.reverse() | |
func_dict = {'view' : self.view_callback, 'diff' : self.diff_callback} | |
self.view.window().show_quick_panel(self.backup_list, lambda i: func_dict[mode](current_full_path, i, use_view_contents), sublime.MONOSPACE_FONT) | |
elif mode == 'backup': | |
# Make sure there are differences between the current file and the last backup | |
# (avoid needless backups that are all the same) | |
if len(self.backup_list) > 0: | |
prev = self.backup_list[-1][1] | |
difftxt = self.get_diff(current_full_path, prev, use_view_contents) | |
if difftxt == "": | |
self.message('No need to make a backup - the files are identical: [%s] = [%s]' % (current_full_path, prev)) | |
return | |
elif difftxt == "ERROR": | |
self.message('Diff failed: Making backup even if the current version is the same as the latest backup.') | |
#else: | |
# print difftxt | |
# Calculate the target filename | |
index = 1 | |
while index < 1000: | |
timestamp = datetime.datetime.now().strftime(backup_timestamp_mask) | |
backup_filename = '%s%s%s' % (filename, backup_prefix, timestamp) | |
backup_target = os.path.join(target_dir, backup_filename) | |
if not os.path.exists(backup_target): | |
break | |
index = index + 1 | |
if not os.path.exists(target_dir): | |
os.makedirs(target_dir) | |
# Make the backup | |
if use_view_contents: | |
self.message('Backing up contents of view [%s] to [%s]' % (filename, backup_target), False) | |
# Write the current view contents to the backup file | |
file_contents = self.get_view_content() | |
try: | |
file = open(backup_target, "w") | |
try: | |
file.write(file_contents) | |
finally: | |
file.close() | |
except IOError: | |
self.message('IOError writing file [%s]' % (filename, backup_target)) | |
pass | |
else: | |
# Simply copy the file | |
self.message('Backing up file from [%s] to [%s]' % (current_full_path, backup_target), False) | |
shutil.copyfile(current_full_path, backup_target) | |
def view_callback(self, curr, index, use_view_contents): | |
if index >= 0: | |
self.view.window().open_file(self.backup_list[index][1]) | |
def diff_callback(self, curr, index, use_view_contents): | |
if index >= 0: | |
prev = self.backup_list[index][1] | |
difftxt = self.get_diff(curr, prev, use_view_contents) | |
if difftxt == "": | |
self.message('Files are identical', False) | |
else: | |
v = self.view.window().new_file() | |
v.set_name(os.path.basename(curr) + " -> " + os.path.basename(prev)) | |
v.set_scratch(True) | |
v.set_syntax_file('Packages/Diff/Diff.tmLanguage') | |
edit = v.begin_edit() | |
v.insert(edit, 0, difftxt) | |
v.end_edit(edit) | |
def get_diff(self, curr, prev, use_view_contents): | |
if not os.path.exists(prev): | |
self.message('Backup file does not exist!') | |
return 'ERROR' | |
# Get the contents of the two files | |
try: | |
before = codecs.open(prev, "r", "utf-8").readlines() | |
before_date = time.ctime(os.stat(prev).st_mtime) | |
if use_view_contents: | |
after = self.get_view_content().splitlines(True) | |
after_date = time.ctime() | |
else: | |
after = codecs.open(curr, "r", "utf-8").readlines() | |
after_date = time.ctime(os.stat(curr).st_mtime) | |
# Diff file contents of the two files and return the result | |
diff = difflib.unified_diff(self.normalize_eols(before), self.normalize_eols(after), prev, curr, before_date, after_date) | |
difftxt = u"".join(line for line in diff) | |
return difftxt | |
except UnicodeDecodeError: | |
self.message('Diff only works with UTF-8 files') | |
return 'ERROR' | |
def normalize_eols(self, list): | |
return [item.replace('\r\n', '\n') for item in list] | |
def get_view_content(self): | |
# Get the default encoding from the settings | |
encoding = self.view.encoding() if self.view.encoding() != 'Undefined' else 'UTF-8' | |
# Get the correctly encoded contents of the view | |
file_contents = self.view.substr(sublime.Region(0, self.view.size())).encode(encoding) | |
return file_contents | |
def message(self, text, show_console=True): | |
print text | |
sublime.status_message(text) | |
if show_console: | |
self.view.window().run_command("show_panel", {"panel": "console"}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Motivated by the following forum post:
http://www.sublimetext.com/forum/viewtopic.php?f=4&t=8271