const 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 \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"); }