1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331
|
#!/usr/bin/env python3
"""
Nautilus plugin for Syncthing.
This program is part of Syncthing-GTK, but can be used independently
with small modification
"""
from gi.repository import GObject
from syncthing_gtk.tools import init_logging, set_logging_level
from syncthing_gtk.daemon import Daemon
import os, logging, urllib.parse
log = logging.getLogger("SyncthingPlugin")
# Output options
VERBOSE = True
DEBUG = False
# Magic numbers
STATE_IDLE = 1
STATE_SYNCING = 2
STATE_OFFLINE = 3
STATE_STOPPED = 4
class NautiluslikeExtension(GObject.GObject):
_plugin_module = None
def __init__(self):
# Prepare stuff
init_logging()
set_logging_level(VERBOSE, DEBUG)
log.info("Initializing...")
# ready field is set to True while connection to Syncthing
# daemon is maintained.
self.ready = False
try:
self.daemon = Daemon()
except Exception as e:
# Syncthing is not configured, most likely never launched.
log.error("%s", e)
log.error("Failed to read Syncthing configuration.")
return
# List of known repos + their states
self.repos = {}
self.rid_to_path = {}
self.path_to_rid = {}
# Dict of known repos -> set of associated devices
self.rid_to_dev = {}
# Set of online devices
self.online_nids = set()
# Set of online repos (at least one associated device connected)
self.onlide_rids = set()
# List (cache) for folders that are known to be placed below
# some syncthing repo
self.subfolders = set()
# List (cache) for files that plugin were asked about
self.files = {}
self.downloads = set()
# Connect to Daemon object signals
self.daemon.connect("connected", self.cb_connected)
self.daemon.connect("connection-error", self.cb_syncthing_con_error)
self.daemon.connect("disconnected", self.cb_syncthing_disconnected)
self.daemon.connect("device-connected", self.cb_device_connected)
self.daemon.connect("device-disconnected", self.cb_device_disconnected)
self.daemon.connect("folder-added", self.cb_syncthing_folder_added)
self.daemon.connect("folder-sync-started", self.cb_syncthing_folder_state_changed, STATE_SYNCING)
self.daemon.connect("folder-sync-finished", self.cb_syncthing_folder_state_changed, STATE_IDLE)
self.daemon.connect("folder-stopped", self.cb_syncthing_folder_stopped)
self.daemon.connect("item-started", self.cb_syncthing_item_started)
self.daemon.connect("item-updated", self.cb_syncthing_item_updated)
log.info("Initialized.")
# Let Daemon object connect to Syncthing
self.daemon.set_refresh_interval(20)
self.daemon.reconnect()
### Internal stuff
def _clear_emblems(self):
""" Clear emblems on all files that had emblem added """
for path in self.files:
self._invalidate(path)
def _clear_emblems_in_dir(self, path):
"""
Same as _clear_emblems, but only for one directory and its
subdirectories.
"""
for f in self.files:
if f.startswith(path + os.path.sep) or f == path :
self._invalidate(f)
def _invalidate(self, path):
""" Forces Nautilus to re-read emblems on specified file """
if path in self.files:
file = self.files[path]
file.invalidate_extension_info()
def _get_parent_repo_state(self, path):
"""
If file belongs to any known repository, returns state of if.
Returns None otherwise.
"""
# TODO: Probably convert to absolute paths and check for '/' at
# end. It shouldn't be needed, in theory.
for x in self.repos:
if path.startswith(x + os.path.sep):
return self.repos[x]
return None
def _get_path(self, file):
""" Returns path for provided FileInfo object """
if hasattr(file, "get_location"):
if not file.get_location().get_path() is None:
return file.get_location().get_path().decode('utf-8')
return urllib.parse.unquote(file.get_uri().replace("file://", ""))
### Daemon callbacks
def cb_connected(self, *a):
"""
Called when connection to Syncthing daemon is created.
Clears list of known folders and all caches.
Also asks Nautilus to clear all emblems.
"""
self.repos = {}
self.rid_to_dev = {}
self.online_nids = set()
self.onlide_rids = set()
self.subfolders = set()
self.downloads = set()
self._clear_emblems()
self.ready = True
log.info("Connected to Syncthing daemon")
def cb_device_connected(self, daemon, nid):
self.online_nids.add(nid)
# Mark any repo attached to this device online
for rid in self.rid_to_dev:
if not rid in self.onlide_rids:
if nid in self.rid_to_dev[rid]:
log.debug("Repo '%s' now online", rid)
self.onlide_rids.add(rid)
if self.repos[self.rid_to_path[rid]] == STATE_OFFLINE:
self.repos[self.rid_to_path[rid]] = STATE_IDLE
self._clear_emblems_in_dir(self.rid_to_path[rid])
def cb_device_disconnected(self, daemon, nid):
self.online_nids.remove(nid)
# Check for all online repos attached to this device
for rid in self.rid_to_dev:
if rid in self.onlide_rids:
# Check if repo is attached to any other, online device
if len([ x for x in self.rid_to_dev[rid] if x in self.online_nids ]) == 0:
# Nope
log.debug("Repo '%s' now offline", rid)
self.onlide_rids.remove(rid)
self.repos[self.rid_to_path[rid]] = STATE_OFFLINE
self._clear_emblems_in_dir(self.rid_to_path[rid])
def cb_syncthing_folder_added(self, daemon, rid, r):
"""
Called when folder is readed from configuration (by syncthing
daemon, not locally).
Adds path to list of known repositories and asks Nautilus to
re-read emblem.
"""
path = os.path.expanduser(r["path"])
if path.endswith(os.path.sep):
path = path.rstrip("/")
self.rid_to_path[rid] = path
self.path_to_rid[path] = rid
self.repos[path] = STATE_OFFLINE
self._invalidate(path)
# Store repo id in dict of associated devices
self.rid_to_dev[rid] = set()
for d in r['devices']:
self.rid_to_dev[rid].add(d['deviceID'])
def cb_syncthing_con_error(self, *a):
pass
def cb_syncthing_disconnected(self, *a):
"""
Called when connection to Syncthing daemon is lost or Daemon
object fails to (re)connect.
Check if connection was already finished before and clears up
stuff in that case.
"""
if self.ready:
log.info("Connection to Syncthing daemon lost")
self.ready = False
self._clear_emblems()
self.daemon.reconnect()
def cb_syncthing_folder_state_changed(self, daemon, rid, state):
""" Called when folder synchronization starts or stops """
if rid in self.rid_to_path:
path = self.rid_to_path[rid]
if self.repos[path] != STATE_OFFLINE:
self.repos[path] = state
log.debug("State of %s changed to %s", path, state)
self._invalidate(path)
# Invalidate all files in repository as well
self._clear_emblems_in_dir(path)
def cb_syncthing_folder_stopped(self, daemon, rid, *a):
""" Called when synchronization error is detected """
self.cb_syncthing_folder_state_changed(daemon, rid, STATE_STOPPED)
def cb_syncthing_item_started(self, daemon, rid, filename, *a):
""" Called when file download starts """
if rid in self.rid_to_path:
path = self.rid_to_path[rid]
filepath = os.path.join(path, filename)
log.debug("Download started %s", filepath)
self.downloads.add(filepath)
self._invalidate(filepath)
placeholderpath = os.path.join(path, ".syncthing.%s.tmp" % filename)
if placeholderpath in self.files:
self._invalidate(placeholderpath)
def cb_syncthing_item_updated(self, daemon, rid, filename, *a):
""" Called after file is downloaded """
if rid in self.rid_to_path:
path = self.rid_to_path[rid]
filepath = os.path.join(path, filename)
log.debug("Download finished %s", filepath)
if filepath in self.downloads:
self.downloads.remove(filepath)
self._invalidate(filepath)
### InfoProvider stuff
def update_file_info(self, file):
# TODO: This remembers every file user ever saw in Nautilus.
# There *has* to be memory efficient alternative...
path = self._get_path(file)
pathonly, filename = os.path.split(path)
self.files[path] = file
if not self.ready: return NautiluslikeExtension._plugin_module.OperationResult.COMPLETE
# Check if folder is one of repositories managed by syncthing
if path in self.downloads:
file.add_emblem("syncthing-active")
if filename.startswith(".syncthing.") and filename.endswith(".tmp"):
# Check for placeholder files
realpath = os.path.join(pathonly, filename[11:-4])
if realpath in self.downloads:
file.add_emblem("syncthing-active")
return NautiluslikeExtension._plugin_module.OperationResult.COMPLETE
elif path in self.repos:
# Determine what emblem should be used
state = self.repos[path]
if state == STATE_IDLE:
# File manager probably shouldn't care about folder being scanned
file.add_emblem("syncthing")
elif state == STATE_STOPPED:
file.add_emblem("syncthing-error")
elif state == STATE_SYNCING:
file.add_emblem("syncthing-active")
else:
# Default (i-have-no-idea-what-happened) state
file.add_emblem("syncthing-offline")
else:
state = self._get_parent_repo_state(path)
if state is None:
# _get_parent_repo_state returns None if file doesn't
# belongs to repo
pass
elif state in (STATE_IDLE, STATE_SYNCING):
# File manager probably shouldn't care about folder being scanned
file.add_emblem("syncthing")
else:
# Default (i-have-no-idea-what-happened) state
file.add_emblem("syncthing-offline")
return NautiluslikeExtension._plugin_module.OperationResult.COMPLETE
### MenuProvider stuff
def get_file_items(self, window, sel_items):
if len(sel_items) == 1:
# Display context menu only if one item is selected and
# that item is directory
return self.get_background_items(window, sel_items[0])
return []
def cb_remove_repo_menu(self, menuitem, path):
if path in self.path_to_rid:
path = os.path.abspath(os.path.expanduser(path))
path = path.replace("'", "\'")
os.system("syncthing-gtk --remove-repo '%s' &" % path)
def cb_add_repo_menu(self, menuitem, path):
path = os.path.abspath(os.path.expanduser(path))
path = path.replace("'", "\'")
os.system("syncthing-gtk --add-repo '%s' &" % path)
def get_background_items(self, window, item):
if not item.is_directory():
# Context menu is enabled only for directories
# (file can't be used as repo)
return []
path = self._get_path(item).rstrip("/")
if path in self.repos:
# Folder is already repository.
# Add 'remove from ST' item
menu = NautiluslikeExtension._plugin_module.MenuItem(
name='STPlugin::remove_repo',
label='Remove Directory from Syncthing',
tip='Remove selected directory from Syncthing',
icon='syncthing-offline')
menu.connect('activate', self.cb_remove_repo_menu, path)
return [menu]
elif self._get_parent_repo_state(path) is None:
# Folder doesn't belongs to any repository.
# Add 'add to ST' item
menu = NautiluslikeExtension._plugin_module.MenuItem(
name='STPlugin::add_repo',
label='Synchronize with Syncthing',
tip='Add selected directory to Syncthing',
icon='syncthing')
menu.connect('activate', self.cb_add_repo_menu, path)
return [menu]
# Folder belongs to some repository.
# Don't add anything
return []
@staticmethod
def set_plugin_module(m):
NautiluslikeExtension._plugin_module = m
|