Skip to content
This repository was archived by the owner on Oct 15, 2025. It is now read-only.

Commit 7b0c532

Browse files
[syncthing] v3
- Drop PyPi dependendencies `syncthing` is broken for years. `syncthing2` simply fixes the silly dependency issue but is still heavily outdated. - Add pause/resume folder action (was not available in the PyPi pkgs) - Show exceptions as item in trigger query handler - Visualize paused devices/folders with desaturated icons
1 parent ab649e7 commit 7b0c532

File tree

4 files changed

+137
-119
lines changed

4 files changed

+137
-119
lines changed

syncthing/__init__.py

Lines changed: 135 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,100 @@
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
87
from pathlib import Path
98

109
from albert import *
11-
from syncthing import Syncthing
1210

1311
md_iid = "3.0"
14-
md_version = "2.0"
12+
md_version = "3.0"
1513
md_name = "Syncthing"
16-
md_description = "Trigger basic syncthing actions."
14+
md_description = "Control the local Syncthing instance."
1715
md_license = "MIT"
1816
md_url = "https://github.com/albertlauncher/python/tree/main/syncthing"
1917
md_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

2372
class 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

syncthing/syncthing.svg

Lines changed: 0 additions & 26 deletions
This file was deleted.

syncthing/syncthing_active.svg

Lines changed: 1 addition & 0 deletions
Loading

syncthing/syncthing_inactive.svg

Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)