11# -*- coding: utf-8 -*-
22# Copyright (c) 2024 Manuel Schneider
33
4- """
5- Quickly pause/resume/open/scan shares and devices.
6- """
7-
4+ import json
5+ import urllib .error
6+ import urllib .request
87from pathlib import Path
98
109from albert import *
11- from syncthing import Syncthing
1210
1311md_iid = "3.0"
14- md_version = "2 .0"
12+ md_version = "3 .0"
1513md_name = "Syncthing"
16- md_description = "Trigger basic syncthing actions ."
14+ md_description = "Control the local Syncthing instance ."
1715md_license = "MIT"
1816md_url = "https://github.com/albertlauncher/python/tree/main/syncthing"
1917md_authors = "@manuelschneid3r"
20- md_lib_dependencies = "syncthing2"
18+
19+
20+ # https://docs.syncthing.net/dev/rest.html
21+ class Syncthing :
22+ def __init__ (self , api_key , base_url = "http://localhost:8384" ):
23+ self .api_key = api_key
24+ self .base_url = base_url
25+
26+ def _request (self , method , endpoint , data = None ) -> dict :
27+ url = f"{ self .base_url } { endpoint } "
28+ headers = {
29+ "X-API-Key" : self .api_key ,
30+ "Content-Type" : "application/json"
31+ }
32+ body = json .dumps (data ).encode ("utf-8" ) if data else None
33+ req = urllib .request .Request (url , data = body , headers = headers , method = method )
34+
35+ try :
36+ with urllib .request .urlopen (req ) as resp :
37+ if not (200 <= resp .status < 300 ):
38+ raise Exception (f"Unexpected status { resp .status } " )
39+ content = resp .read ().decode ()
40+ return json .loads (content ) if content else {}
41+ except urllib .error .HTTPError as e :
42+ raise Exception (f"HTTP { e .code } : { e .read ().decode ()} " )
43+
44+ def _get (self , endpoint ):
45+ return self ._request ("GET" , endpoint )
46+
47+ def _post (self , endpoint , data = None ):
48+ return self ._request ("POST" , endpoint , data )
49+
50+ def _patch (self , endpoint , data = None ):
51+ return self ._request ("PATCH" , endpoint , data )
52+
53+ def config (self ):
54+ return self ._get ('/rest/config' )
55+
56+ def resumeDevice (self , device_id :str ):
57+ return self ._patch (f'/rest/config/devices/{ device_id } ' , {'paused' : False })
58+
59+ def pauseDevice (self , device_id :str ):
60+ return self ._patch (f'/rest/config/devices/{ device_id } ' , {'paused' : True })
61+
62+ def resumeFolder (self , folder_id :str ):
63+ return self ._patch (f'/rest/config/folders/{ folder_id } ' , {'paused' : False })
64+
65+ def pauseFolder (self , folder_id :str ):
66+ return self ._patch (f'/rest/config/folders/{ folder_id } ' , {'paused' : True })
67+
68+ def scanFolder (self , folder_id :str ):
69+ return self ._post (f'/rest/db/scan?folder={ folder_id } ' )
2170
2271
2372class Plugin (PluginInstance , GlobalQueryHandler ):
2473
2574 config_key = 'syncthing_api_key'
75+ icon_urls_active = [f"file:{ Path (__file__ ).parent } /syncthing_active.svg" ]
76+ icon_urls_inactive = [f"file:{ Path (__file__ ).parent } /syncthing_inactive.svg" ]
2677
2778 def __init__ (self ):
2879 PluginInstance .__init__ (self )
2980 GlobalQueryHandler .__init__ (self )
30-
31- self .iconUrls = ["xdg:syncthing" , f"file:{ Path (__file__ ).parent } /syncthing.svg" ]
32- self ._api_key = self .readConfig (self .config_key , str )
33- if self ._api_key :
34- self .st = Syncthing (self ._api_key )
81+ self .st = Syncthing (self .readConfig (self .config_key , str ) or '' )
3582
3683 def defaultTrigger (self ):
3784 return 'st '
3885
3986 @property
4087 def api_key (self ) -> str :
41- return self ._api_key
88+ return self .st . api_key
4289
4390 @api_key .setter
4491 def api_key (self , value : str ):
45- if self ._api_key != value :
46- self ._api_key = value
92+ if self .st . api_key != value :
93+ self .st . api_key = value
4794 self .writeConfig (self .config_key , value )
48- self .st = Syncthing (self ._api_key )
49-
5095
5196 def configWidget (self ):
5297 return [
53- {
54- 'type' : 'label' ,
55- 'text' : __doc__ .strip (),
56- },
5798 {
5899 'type' : 'lineedit' ,
59100 'property' : 'api_key' ,
@@ -62,79 +103,80 @@ def configWidget(self):
62103 }
63104 ]
64105
106+ def handleTriggerQuery (self , query ):
107+ try :
108+ super ().handleTriggerQuery (query )
109+ except Exception as e :
110+ query .add (StandardItem (id = "err" , text = "Error" , subtext = str (e ), iconUrls = self .icon_urls_active ))
111+
65112 def handleGlobalQuery (self , query ):
66113
67- results = []
114+ config = self .st .config ()
115+
116+ devices = dict ()
117+ for d in config ['devices' ]:
118+ if not d ['name' ]:
119+ d ['name' ] = d ['deviceID' ]
120+ d ['_shared_folders' ] = {}
121+ devices [d ['deviceID' ]] = d
68122
69- if self .st :
70-
71- config = self .st .system .config ()
72-
73- devices = dict ()
74- for d in config ['devices' ]:
75- if not d ['name' ]:
76- d ['name' ] = d ['deviceID' ]
77- d ['_shared_folders' ] = {}
78- devices [d ['deviceID' ]] = d
79-
80- folders = dict ()
81- for f in config ['folders' ]:
82- if not f ['label' ]:
83- f ['label' ] = f ['id' ]
84- for d in f ['devices' ]:
85- devices [d ['deviceID' ]]['_shared_folders' ][f ['id' ]] = f
86- folders [f ['id' ]] = f
87-
88- matcher = Matcher (query .string )
89-
90- # create device items
91- for device_id , d in devices .items ():
92- device_name = d ['name' ]
93-
94- if match := matcher .match (device_name ):
95- device_folders = ", " .join ([f ['label' ] for f in d ['_shared_folders' ].values ()])
96-
97- actions = []
98- if d ['paused' ]:
99- actions .append (
100- Action ("resume" , "Resume synchronization" ,
101- lambda did = device_id : self .st .system .resume (did ))
102- )
103- else :
104- actions .append (
105- Action ("pause" , "Pause synchronization" ,
106- lambda did = device_id : self .st .system .pause (did ))
107- )
108-
109- item = StandardItem (
110- id = device_id ,
111- text = f"{ device_name } " ,
112- subtext = f"{ 'Paused ' if d ['paused' ] else '' } Syncthing device. "
113- f"Shared: { device_folders if device_folders else 'Nothing' } ." ,
114- iconUrls = self .iconUrls ,
115- actions = actions
116- )
117-
118- results .append (RankItem (item , match ))
119-
120- # create folder items
121- for folder_id , f in folders .items ():
122- folder_name = f ['label' ]
123- if match := matcher .match (folder_name ):
124- folders_devices = ", " .join ([devices [d ['deviceID' ]]['name' ] for d in f ['devices' ]])
125- item = StandardItem (
126- id = folder_id ,
127- text = folder_name ,
128- subtext = f"Syncthing folder { f ['path' ]} . "
129- f"Shared with { folders_devices if folders_devices else 'nobody' } ." ,
130- iconUrls = self .iconUrls ,
131- actions = [
132- Action ("scan" , "Scan the folder" ,
133- lambda fid = folder_id : self .st .database .scan (fid )),
134- Action ("open" , "Open this folder in file browser" ,
135- lambda p = f ['path' ]: openFile (p ))
136- ]
137- )
138- results .append (RankItem (item , match ))
123+ folders = dict ()
124+ for f in config ['folders' ]:
125+ if not f ['label' ]:
126+ f ['label' ] = f ['id' ]
127+ for d in f ['devices' ]:
128+ devices [d ['deviceID' ]]['_shared_folders' ][f ['id' ]] = f
129+ folders [f ['id' ]] = f
130+
131+ results = []
132+ matcher = Matcher (query .string )
133+
134+ # create device items
135+ for device_id , d in devices .items ():
136+ device_name = d ['name' ]
137+
138+ if match := matcher .match (device_name ):
139+ device_folders = ", " .join ([f ['label' ] for f in d ['_shared_folders' ].values ()])
140+
141+ actions = []
142+ if d ['paused' ]:
143+ actions .append (Action ("resume" , "Resume" , lambda did = device_id : self .st .resumeDevice (did )))
144+ else :
145+ actions .append (Action ("pause" , "Pause" , lambda did = device_id : self .st .pauseDevice (did )))
146+
147+ item = StandardItem (
148+ id = device_id ,
149+ text = f"{ device_name } " ,
150+ subtext = f"{ 'PAUSED · ' if d ['paused' ] else '' } Device · "
151+ f"Shared: { device_folders if device_folders else 'Nothing' } ." ,
152+ iconUrls = self .icon_urls_inactive if d ['paused' ] else self .icon_urls_active ,
153+ actions = actions
154+ )
155+
156+ results .append (RankItem (item , match ))
157+
158+ # create folder items
159+ for folder_id , f in folders .items ():
160+ folder_name = f ['label' ]
161+ if match := matcher .match (folder_name ):
162+ folders_devices = ", " .join ([devices [d ['deviceID' ]]['name' ] for d in f ['devices' ]])
163+
164+ actions = []
165+ if f ['paused' ]:
166+ actions .append (Action ("resume" , "Resume" , lambda fid = folder_id : self .st .resumeFolder (fid )))
167+ else :
168+ actions .append (Action ("pause" , "Pause" , lambda fid = folder_id : self .st .pauseFolder (fid )))
169+ actions .append (Action ("open" , "Open" , lambda p = f ['path' ]: openFile (p )))
170+ actions .append (Action ("scan" , "Scan" , lambda fid = folder_id : self .st .scanFolder (fid )))
171+
172+ item = StandardItem (
173+ id = folder_id ,
174+ text = folder_name ,
175+ subtext = f"{ 'PAUSED · ' if f ['paused' ] else '' } Folder · { f ['path' ]} · "
176+ f"Shared with { folders_devices if folders_devices else 'nobody' } ." ,
177+ iconUrls = self .icon_urls_inactive if f ['paused' ] else self .icon_urls_active ,
178+ actions = actions
179+ )
180+ results .append (RankItem (item , match ))
139181
140182 return results
0 commit comments