Skip to content

Instantly share code, notes, and snippets.

@jbjornson
Created August 27, 2012 09:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jbjornson/3486921 to your computer and use it in GitHub Desktop.
Save jbjornson/3486921 to your computer and use it in GitHub Desktop.
SublimeText plugin to make, view and diff backups for a file.
'''
@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"})
@jbjornson
Copy link
Author

Motivated by the following forum post:
http://www.sublimetext.com/forum/viewtopic.php?f=4&t=8271

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment