285 lines
9.6 KiB
Zig
285 lines
9.6 KiB
Zig
const std = @import("std");
|
|
const testing = std.testing;
|
|
const mvzr = @import("mvzr");
|
|
|
|
pub const Config = struct {
|
|
syncthing_url: []const u8 = "http://localhost:8384",
|
|
max_retries: usize = std.math.maxInt(usize),
|
|
retry_delay_ms: u32 = 1000,
|
|
watchers: []Watcher,
|
|
|
|
pub fn deinit(self: *Config, allocator: std.mem.Allocator) void {
|
|
for (self.watchers) |*watcher| {
|
|
watcher.deinit(allocator);
|
|
}
|
|
allocator.free(self.watchers);
|
|
}
|
|
};
|
|
|
|
pub const Watcher = struct {
|
|
folder: []const u8,
|
|
path_pattern: []const u8,
|
|
command: []const u8,
|
|
compiled_pattern: ?mvzr.Regex = null,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, folder: []const u8, path_pattern: []const u8, command: []const u8) !Watcher {
|
|
var watcher = Watcher{
|
|
.folder = try allocator.dupe(u8, folder),
|
|
.path_pattern = try allocator.dupe(u8, path_pattern),
|
|
.command = try allocator.dupe(u8, command),
|
|
.compiled_pattern = null,
|
|
};
|
|
watcher.compiled_pattern = try mvzr.Pattern.compile(allocator, path_pattern);
|
|
return watcher;
|
|
}
|
|
|
|
pub fn deinit(self: *Watcher, allocator: std.mem.Allocator) void {
|
|
if (self.compiled_pattern) |*pattern| {
|
|
pattern.deinit();
|
|
}
|
|
allocator.free(self.folder);
|
|
allocator.free(self.path_pattern);
|
|
allocator.free(self.command);
|
|
}
|
|
|
|
pub fn matches(self: *const Watcher, folder: []const u8, path: []const u8) bool {
|
|
if (!std.mem.eql(u8, folder, self.folder)) {
|
|
return false;
|
|
}
|
|
if (self.compiled_pattern) |pattern| {
|
|
return pattern.match(path) != null;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
pub const SyncthingEvent = struct {
|
|
id: i64,
|
|
type: []const u8,
|
|
folder: []const u8,
|
|
path: []const u8,
|
|
|
|
pub fn fromJson(allocator: std.mem.Allocator, value: std.json.Value) !SyncthingEvent {
|
|
return SyncthingEvent{
|
|
.id = value.object.get("id").?.integer,
|
|
.type = try allocator.dupe(u8, value.object.get("type").?.string),
|
|
.folder = try allocator.dupe(u8, value.object.get("folder").?.string),
|
|
.path = try allocator.dupe(u8, value.object.get("path").?.string),
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *SyncthingEvent, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.type);
|
|
allocator.free(self.folder);
|
|
allocator.free(self.path);
|
|
}
|
|
};
|
|
|
|
pub const EventPoller = struct {
|
|
allocator: std.mem.Allocator,
|
|
config: Config,
|
|
last_id: ?i64,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, config: Config) !EventPoller {
|
|
return .{
|
|
.allocator = allocator,
|
|
.config = config,
|
|
.last_id = null,
|
|
};
|
|
}
|
|
|
|
pub fn poll(self: *EventPoller) ![]SyncthingEvent {
|
|
var client = std.http.Client{ .allocator = self.allocator };
|
|
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
try client.initDefaultProxies(aa);
|
|
|
|
var retry_count: usize = self.config.max_retries;
|
|
while (retry_count < self.config.max_retries) : (retry_count += 1) {
|
|
var url_buf: [1024]u8 = undefined;
|
|
var since_buf: [100]u8 = undefined;
|
|
const since = if (self.last_id) |id|
|
|
try std.fmt.bufPrint(&since_buf, "&since={d}", .{id})
|
|
else
|
|
"";
|
|
const url = try std.fmt.bufPrint(&url_buf, "{s}/rest/events?events=ItemFinished{s}", .{
|
|
self.config.syncthing_url, since,
|
|
});
|
|
|
|
var al = std.ArrayList(u8).init(self.allocator);
|
|
defer al.deinit();
|
|
|
|
const response = client.fetch(.{
|
|
.location = .{ .url = url },
|
|
.response_storage = .{ .dynamic = &al },
|
|
}) catch |err| {
|
|
std.log.err("HTTP request failed: {s}", .{@errorName(err)});
|
|
if (retry_count + 1 < self.config.max_retries) {
|
|
std.time.sleep(self.config.retry_delay_ms * std.time.ns_per_ms);
|
|
continue;
|
|
}
|
|
return err;
|
|
};
|
|
|
|
if (response.status != .ok) {
|
|
std.log.err("HTTP status code: {}", .{response.status});
|
|
if (retry_count + 1 < self.config.max_retries) {
|
|
std.time.sleep(self.config.retry_delay_ms * std.time.ns_per_ms);
|
|
continue;
|
|
}
|
|
return error.HttpError;
|
|
}
|
|
|
|
var events = std.ArrayList(SyncthingEvent).init(self.allocator);
|
|
errdefer events.deinit();
|
|
|
|
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, aa, al.items, .{});
|
|
|
|
const array = parsed.array;
|
|
for (array.items) |item| {
|
|
const event = try SyncthingEvent.fromJson(self.allocator, item);
|
|
try events.append(event);
|
|
if (self.last_id == null or event.id > self.last_id.?) {
|
|
self.last_id = event.id;
|
|
}
|
|
}
|
|
|
|
return try events.toOwnedSlice();
|
|
}
|
|
return error.MaxRetriesExceeded;
|
|
}
|
|
};
|
|
|
|
pub fn executeCommand(allocator: std.mem.Allocator, command: []const u8, event: SyncthingEvent) !void {
|
|
const expanded_cmd = try expandCommandVariables(allocator, command, event);
|
|
defer allocator.free(expanded_cmd);
|
|
|
|
// TODO: Should this spawn sh like this, or exec directly?
|
|
var process = std.process.Child.init(&[_][]const u8{ "sh", "-c", expanded_cmd }, allocator);
|
|
process.stdout_behavior = .Inherit;
|
|
process.stderr_behavior = .Inherit;
|
|
|
|
_ = try process.spawnAndWait();
|
|
}
|
|
|
|
fn expandCommandVariables(allocator: std.mem.Allocator, command: []const u8, event: SyncthingEvent) ![]const u8 {
|
|
var result = std.ArrayList(u8).init(allocator);
|
|
defer result.deinit();
|
|
|
|
var i: usize = 0;
|
|
while (i < command.len) {
|
|
if (command[i] == '$' and i + 1 < command.len and command[i + 1] == '{') {
|
|
var j = i + 2;
|
|
while (j < command.len and command[j] != '}') : (j += 1) {}
|
|
if (j < command.len) {
|
|
const var_name = command[i + 2 .. j];
|
|
if (std.mem.eql(u8, var_name, "path")) {
|
|
try result.appendSlice(event.path);
|
|
} else if (std.mem.eql(u8, var_name, "folder")) {
|
|
try result.appendSlice(event.folder);
|
|
} else if (std.mem.eql(u8, var_name, "type")) {
|
|
try result.appendSlice(event.type);
|
|
}
|
|
i = j + 1;
|
|
continue;
|
|
}
|
|
}
|
|
try result.append(command[i]);
|
|
i += 1;
|
|
}
|
|
|
|
return result.toOwnedSlice();
|
|
}
|
|
|
|
test "config parsing" {
|
|
const config_json =
|
|
\\{
|
|
\\ "syncthing_url": "http://test:8384",
|
|
\\ "max_retries": 3,
|
|
\\ "retry_delay_ms": 2000,
|
|
\\ "watchers": [
|
|
\\ {
|
|
\\ "folder": "test",
|
|
\\ "path_pattern": ".*\\.txt$",
|
|
\\ "command": "echo ${path}"
|
|
\\ }
|
|
\\ ]
|
|
\\}
|
|
;
|
|
|
|
var parser = std.json.Parser.init(testing.allocator, false);
|
|
defer parser.deinit();
|
|
|
|
var tree = try parser.parse(config_json);
|
|
defer tree.deinit();
|
|
|
|
const config = try std.json.parse(Config, &tree, .{ .allocator = testing.allocator });
|
|
defer config.deinit(testing.allocator);
|
|
|
|
try testing.expectEqualStrings("http://test:8384", config.syncthing_url);
|
|
try testing.expectEqual(@as(u32, 3), config.max_retries);
|
|
try testing.expectEqual(@as(u32, 2000), config.retry_delay_ms);
|
|
try testing.expectEqual(@as(usize, 1), config.watchers.len);
|
|
try testing.expectEqualStrings("test", config.watchers[0].folder);
|
|
try testing.expectEqualStrings(".*\\.txt$", config.watchers[0].path_pattern);
|
|
try testing.expectEqualStrings("echo ${path}", config.watchers[0].command);
|
|
}
|
|
|
|
test "event parsing" {
|
|
const event_json =
|
|
\\{
|
|
\\ "id": 123,
|
|
\\ "type": "ItemFinished",
|
|
\\ "folder": "default",
|
|
\\ "path": "test.txt"
|
|
\\}
|
|
;
|
|
|
|
var parser = std.json.Parser.init(testing.allocator, false);
|
|
defer parser.deinit();
|
|
|
|
var tree = try parser.parse(event_json);
|
|
defer tree.deinit();
|
|
|
|
var event = try SyncthingEvent.fromJson(testing.allocator, tree.root);
|
|
defer event.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(i64, 123), event.id);
|
|
try testing.expectEqualStrings("ItemFinished", event.type);
|
|
try testing.expectEqualStrings("default", event.folder);
|
|
try testing.expectEqualStrings("test.txt", event.path);
|
|
}
|
|
|
|
test "command variable expansion" {
|
|
const event = SyncthingEvent{
|
|
.id = 1,
|
|
.type = "ItemFinished",
|
|
.folder = "photos",
|
|
.path = "vacation.jpg",
|
|
};
|
|
|
|
const command = "convert ${path} -resize 800x600 thumb_${folder}_${type}.jpg";
|
|
const expanded = try expandCommandVariables(testing.allocator, command, event);
|
|
defer testing.allocator.free(expanded);
|
|
|
|
try testing.expectEqualStrings(
|
|
"convert vacation.jpg -resize 800x600 thumb_photos_ItemFinished.jpg",
|
|
expanded,
|
|
);
|
|
}
|
|
|
|
test "watcher pattern matching" {
|
|
var watcher = try Watcher.init(
|
|
testing.allocator,
|
|
"photos",
|
|
".*\\.jpe?g$",
|
|
"echo ${path}",
|
|
);
|
|
defer watcher.deinit(testing.allocator);
|
|
|
|
try testing.expect(watcher.matches("photos", "test.jpg"));
|
|
try testing.expect(watcher.matches("photos", "test.jpeg"));
|
|
try testing.expect(!watcher.matches("photos", "test.png"));
|
|
try testing.expect(!watcher.matches("documents", "test.jpg"));
|
|
}
|