Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428const std = @import("std");const Io = std.Io;const client_mod = @import("../ipc/client.zig");const Config = @import("../config/loader.zig").Config;const fmt = @import("format.zig");
const DREAM_APP_URL = "https://blocks.adjarnor.dev";
pub fn run(io: Io, allocator: std.mem.Allocator, args: []const []const u8, cfg: Config) !void { _ = cfg;
if (args.len == 0) { try printUsage(io); return; }
const subcommand = args[0];
if (std.mem.eql(u8, subcommand, "push")) { try pushToDream(io, allocator, args[1..]); } else if (std.mem.eql(u8, subcommand, "pull")) { try pullFromDream(io, allocator, args[1..]); } else if (std.mem.eql(u8, subcommand, "status")) { try syncStatus(io); } else if (std.mem.eql(u8, subcommand, "calendar")) { try syncFromCalendar(io, allocator, args[1..]); } else if (std.mem.eql(u8, subcommand, "calendar-update")) { try updateCalendar(io, allocator, args[1..]); } else if (std.mem.eql(u8, subcommand, "help") or std.mem.eql(u8, subcommand, "--help")) { try printUsage(io); } else { try fmt.err(io, "unknown sync command"); try printUsage(io); }}
fn pushToDream(io: Io, allocator: std.mem.Allocator, args: []const []const u8) !void { const date = if (args.len > 0) args[0] else "today";
try fmt.dimText(Io.File.stdout(), io, "Pushing timeblocks to blocks.adjarnor.dev...\n\n");
const c = client_mod.Client.init(allocator, io); const schedule_raw = c.sendRaw("schedule list") catch { try fmt.errWithHint(io, "could not connect to daemon", "start with: sudo focusd daemon"); return; }; defer allocator.free(schedule_raw);
var blocks_json: std.ArrayList(u8) = .empty; defer blocks_json.deinit(allocator); try blocks_json.appendSlice(allocator, "{\"date\":\""); try blocks_json.appendSlice(allocator, date); try blocks_json.appendSlice(allocator, "\",\"blocks\":[");
var lines = std.mem.splitScalar(u8, schedule_raw, '\n'); var first_block = true; var block_count: u32 = 0;
while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); if (trimmed.len == 0) continue; if (std.mem.startsWith(u8, trimmed, "Start") or std.mem.startsWith(u8, trimmed, "-----") or std.mem.startsWith(u8, trimmed, "Schedule")) continue;
var parts = std.mem.splitScalar(u8, trimmed, ' '); var start_time: ?[]const u8 = null; var end_time: ?[]const u8 = null; var mode: ?[]const u8 = null;
while (parts.next()) |part| { if (part.len == 0) continue; if (start_time == null) { start_time = part; } else if (end_time == null) { end_time = part; } else if (mode == null) { mode = part; break; } }
if (start_time == null or end_time == null or mode == null) continue;
const start_mins = timeToMinutes(start_time.?) catch continue; const end_mins = timeToMinutes(end_time.?) catch continue;
if (!first_block) try blocks_json.appendSlice(allocator, ","); first_block = false;
try blocks_json.appendSlice(allocator, "{\"task_text\":\""); try blocks_json.appendSlice(allocator, mode.?); try blocks_json.appendSlice(allocator, "\",\"start_time\":"); var start_buf: [16]u8 = undefined; const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start_mins}) catch "0"; try blocks_json.appendSlice(allocator, start_str); try blocks_json.appendSlice(allocator, ",\"end_time\":"); var end_buf: [16]u8 = undefined; const end_str = std.fmt.bufPrint(&end_buf, "{d}", .{end_mins}) catch "0"; try blocks_json.appendSlice(allocator, end_str); try blocks_json.appendSlice(allocator, "}"); block_count += 1; }
try blocks_json.appendSlice(allocator, "]}");
if (block_count == 0) { try fmt.warn(io, "no schedule blocks found to sync"); try fmt.dimText(Io.File.stdout(), io, "Add schedule with: focusd schedule add <start> <end> <mode>\n"); return; }
try fmt.dimText(Io.File.stdout(), io, "Sending "); var count_buf: [16]u8 = undefined; const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{block_count}) catch "?"; try Io.File.stdout().writeStreamingAll(io, count_str); try fmt.dimText(Io.File.stdout(), io, " blocks to dream app...\n");
const result = std.process.run(allocator, io, .{ .argv = &.{ "curl", "-s", "-X", "POST", "https://blocks.adjarnor.dev/api/sync", "-H", "Content-Type: application/json", "-d", blocks_json.items, }, }) catch |err| { try fmt.err(io, @errorName(err)); return; }; defer { if (result.stdout.len > 0) allocator.free(result.stdout); if (result.stderr.len > 0) allocator.free(result.stderr); }
if (result.stdout.len > 0) { try Io.File.stdout().writeStreamingAll(io, result.stdout); try Io.File.stdout().writeStreamingAll(io, "\n"); } if (result.stderr.len > 0 and !std.mem.eql(u8, result.stderr, "")) { try fmt.err(io, result.stderr); }}
fn pullFromDream(io: Io, allocator: std.mem.Allocator, args: []const []const u8) !void { const date = if (args.len > 0) args[0] else "today"; try fmt.dimText(Io.File.stdout(), io, "Pulling timeblocks from blocks.adjarnor.dev...\n\n");
var url_buf: [128]u8 = undefined; const url = std.fmt.bufPrint(&url_buf, "https://blocks.adjarnor.dev/api/sync?date={s}", .{date}) catch { try fmt.err(io, "invalid date format"); return; };
const result = std.process.run(allocator, io, .{ .argv = &.{ "curl", "-s", url }, }) catch |err| { try fmt.err(io, @errorName(err)); return; }; defer { if (result.stdout.len > 0) allocator.free(result.stdout); if (result.stderr.len > 0) allocator.free(result.stderr); }
if (result.stdout.len > 0) { try Io.File.stdout().writeStreamingAll(io, result.stdout); try Io.File.stdout().writeStreamingAll(io, "\n"); }}
fn syncStatus(io: Io) !void { const w = Io.File.stdout();
try fmt.writeHeader(w, io, "Sync Status"); try w.writeStreamingAll(io, "\n");
try fmt.writeStatusLine(w, io, " Dream App", DREAM_APP_URL, fmt.Color.cyan); try fmt.writeStatusLine(w, io, " Local", "~/.config/focusd/schedule.toml", fmt.Color.dim); try fmt.writeStatusLine(w, io, " Daemon", "/tmp/focusd.sock", fmt.Color.dim);
try fmt.writeSection(w, io, "SYNC DIRECTION"); try fmt.writeStatusLine(w, io, " FocusD -> Dream", "Push timeblocks", fmt.Color.green); try fmt.writeStatusLine(w, io, " Dream -> FocusD", "Pull timeblocks", fmt.Color.green);
try fmt.writeSection(w, io, "API ENDPOINTS"); try w.writeStreamingAll(io, " POST /api/sync Push timeblocks\n"); try w.writeStreamingAll(io, " GET /api/sync?date= Pull timeblocks\n\n");}
fn syncFromCalendar(io: Io, allocator: std.mem.Allocator, args: []const []const u8) !void { const date = if (args.len > 0) args[0] else "today"; try fmt.dimText(Io.File.stdout(), io, "Syncing from Google Calendar...\n\n");
const home = std.c.getenv("HOME") orelse "/tmp"; const home_span = std.mem.span(home); const creds_path = try std.fmt.allocPrint(allocator, "{s}/.config/gcloud/application_default_credentials.json", .{home_span}); defer allocator.free(creds_path);
const creds_file = Io.Dir.cwd().openFile(io, creds_path, .{}) catch { try fmt.err(io, "could not open credentials file"); try fmt.dimText(Io.File.stderr(), io, "Run: python3 ~/.config/gcloud/auth.py\n"); return; }; defer creds_file.close(io);
var content: std.ArrayList(u8) = .empty; defer content.deinit(allocator);
var buf: [8192]u8 = undefined; var reader = creds_file.reader(io, &buf); reader.interface.appendRemainingUnlimited(allocator, &content) catch { try fmt.err(io, "could not read credentials"); return; };
const client_id = extractJsonValue(content.items, "client_id") orelse { try fmt.err(io, "could not find client_id"); return; }; const client_secret = extractJsonValue(content.items, "client_secret") orelse { try fmt.err(io, "could not find client_secret"); return; }; const refresh_token = extractJsonValue(content.items, "refresh_token") orelse { try fmt.err(io, "could not find refresh_token"); return; };
const refresh_body = try std.fmt.allocPrint(allocator, "client_id={s}&client_secret={s}&refresh_token={s}&grant_type=refresh_token", .{client_id, client_secret, refresh_token} ); defer allocator.free(refresh_body);
const refresh_result = std.process.run(allocator, io, .{ .argv = &.{ "curl", "-s", "-X", "POST", "https://oauth2.googleapis.com/token", "-H", "Content-Type: application/x-www-form-urlencoded", "-d", refresh_body, }, }) catch |err| { try fmt.err(io, @errorName(err)); return; }; defer { if (refresh_result.stdout.len > 0) allocator.free(refresh_result.stdout); if (refresh_result.stderr.len > 0) allocator.free(refresh_result.stderr); }
const access_token = extractJsonValue(refresh_result.stdout, "access_token") orelse { try fmt.err(io, "could not get access token"); return; };
var auth_header_buf: [512]u8 = undefined; const auth_header = std.fmt.bufPrint(&auth_header_buf, "Authorization: Bearer {s}", .{access_token}) catch { try fmt.err(io, "could not format auth header"); return; };
var time_min_buf: [32]u8 = undefined; const time_min = std.fmt.bufPrint(&time_min_buf, "{s}T00:00:00Z", .{date}) catch { try fmt.err(io, "invalid date format"); return; }; var time_max_buf: [32]u8 = undefined; const time_max = std.fmt.bufPrint(&time_max_buf, "{s}T23:59:59Z", .{date}) catch { try fmt.err(io, "invalid date format"); return; };
var url_buf: [512]u8 = undefined; const url = std.fmt.bufPrint(&url_buf, "https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={s}&timeMax={s}&singleEvents=true&orderBy=startTime", .{time_min, time_max} ) catch { try fmt.err(io, "could not format URL"); return; };
const result = std.process.run(allocator, io, .{ .argv = &.{ "curl", "-s", "-H", auth_header, url }, }) catch |err| { try fmt.err(io, @errorName(err)); return; }; defer { if (result.stdout.len > 0) allocator.free(result.stdout); if (result.stderr.len > 0) allocator.free(result.stderr); }
if (result.stdout.len > 0) { const w = Io.File.stdout(); try fmt.writeHeader(w, io, "Calendar Events"); try fmt.dimText(w, io, date); try w.writeStreamingAll(io, "\n\n"); var output = result.stdout; var event_count: u32 = 0; while (std.mem.indexOf(u8, output, "\"summary\": \"")) |summary_start| { const value_start = summary_start + 12; const value_end = std.mem.indexOf(u8, output[value_start..], "\"") orelse break; const summary = output[value_start..value_start + value_end]; var start_time: []const u8 = "unknown"; if (std.mem.indexOf(u8, output, "\"dateTime\": \"")) |dt_start| { const dt_value_start = dt_start + 13; const dt_end = std.mem.indexOf(u8, output[dt_value_start..], "\"") orelse break; const dt = output[dt_value_start..dt_value_start + dt_end]; if (std.mem.lastIndexOf(u8, dt, "T")) |t_pos| { start_time = dt[t_pos + 1 .. t_pos + 6]; } } try w.writeStreamingAll(io, " "); try fmt.coloredText(w, io, start_time, fmt.Color.white); try fmt.dimText(w, io, " "); try fmt.coloredText(w, io, summary, fmt.Color.white); try w.writeStreamingAll(io, "\n"); event_count += 1; output = output[value_start + value_end ..]; } if (event_count == 0) { try fmt.warn(io, "no events found"); } else { var count_buf: [16]u8 = undefined; const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{event_count}) catch "?"; try w.writeStreamingAll(io, "\n"); try fmt.dimText(w, io, count_str); try fmt.dimText(w, io, " event(s) found\n"); } } else { try fmt.err(io, "no response from Google Calendar API"); }}
fn updateCalendar(io: Io, allocator: std.mem.Allocator, args: []const []const u8) !void { const date = if (args.len > 0) args[0] else "today"; try fmt.dimText(Io.File.stdout(), io, "Updating Google Calendar...\n\n");
const home = std.c.getenv("HOME") orelse "/tmp"; const home_span = std.mem.span(home); const script_path = try std.fmt.allocPrint(allocator, "{s}/.config/gcloud/update-calendar.py", .{home_span}); defer allocator.free(script_path);
const result = std.process.run(allocator, io, .{ .argv = &.{ "python3", script_path, date }, }) catch |err| { try fmt.err(io, @errorName(err)); return; }; defer { if (result.stdout.len > 0) allocator.free(result.stdout); if (result.stderr.len > 0) allocator.free(result.stderr); }
if (result.stdout.len > 0) { try Io.File.stdout().writeStreamingAll(io, result.stdout); } if (result.stderr.len > 0 and !std.mem.eql(u8, result.stderr, "")) { try fmt.err(io, result.stderr); }}
fn timeToMinutes(time_str: []const u8) !u32 { if (time_str.len != 5 or time_str[2] != ':') return error.InvalidTimeFormat; const hours = std.fmt.parseInt(u32, time_str[0..2], 10) catch return error.InvalidTimeFormat; const mins = std.fmt.parseInt(u32, time_str[3..5], 10) catch return error.InvalidTimeFormat; if (hours > 23 or mins > 59) return error.InvalidTimeFormat; return hours * 60 + mins;}
fn extractJsonValue(json: []const u8, key: []const u8) ?[]const u8 { // Build pattern at runtime: "key" var pattern_buf: [256]u8 = undefined; const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\"", .{key}) catch return null; const pattern_start = std.mem.indexOf(u8, json, pattern) orelse return null; const colon_pos = std.mem.indexOf(u8, json[pattern_start..], "\"") orelse return null; const value_start = pattern_start + colon_pos + 1; const quote_start = std.mem.indexOf(u8, json[value_start..], "\"") orelse return null; const actual_value_start = value_start + quote_start + 1; const value_end = std.mem.indexOf(u8, json[actual_value_start..], "\"") orelse return null; return json[actual_value_start..actual_value_start + value_end];}
fn printUsage(io: Io) !void { const w = Io.File.stdout();
try fmt.boldText(w, io, "focusd sync"); try fmt.dimText(w, io, " — sync with external services\n\n");
try fmt.boldText(w, io, "COMMANDS\n"); try fmt.coloredText(w, io, " push", fmt.Color.bold); try fmt.dimText(w, io, " [date] Push to blocks.adjarnor.dev\n"); try fmt.coloredText(w, io, " pull", fmt.Color.bold); try fmt.dimText(w, io, " [date] Pull from blocks.adjarnor.dev\n"); try fmt.coloredText(w, io, " calendar", fmt.Color.bold); try fmt.dimText(w, io, " [date] Show Calendar events\n"); try fmt.coloredText(w, io, " calendar-update", fmt.Color.bold); try fmt.dimText(w, io, " [date] Write schedule to Calendar\n"); try fmt.coloredText(w, io, " status", fmt.Color.bold); try fmt.dimText(w, io, " Show sync status\n");
try fmt.writeSection(w, io, "EXAMPLES"); try w.writeStreamingAll(io, " focusd sync push # Push today's schedule\n"); try w.writeStreamingAll(io, " focusd sync push 2026-06-07 # Push specific date\n"); try w.writeStreamingAll(io, " focusd sync pull # Pull from dream app\n"); try w.writeStreamingAll(io, " focusd sync calendar # Show today's events\n"); try w.writeStreamingAll(io, " focusd sync calendar-update # Update Calendar\n\n");}