class GoogleCalendarController < ApplicationController
def connect
state = SecureRandom.hex(16)
session[:google_calendar_state] = state
redirect_to GoogleCalendar::Client.authorize_url(redirect_uri: google_calendar_callback_url, state: state), allow_other_host: true
end
def callback
if params[:state].blank? || params[:state] != session.delete(:google_calendar_state)
return render json: { error: "state mismatch" }, status: :unprocessable_entity
end
return render json: { error: params[:error] }, status: :unprocessable_entity if params[:code].blank?
tokens = GoogleCalendar::Client.exchange_code(params[:code], redirect_uri: google_calendar_callback_url)
email = GoogleCalendar::Client.userinfo(tokens.fetch("access_token")).fetch("email")
connection = GoogleCalendarConnection.from_tokens!(tokens, email: email)
SyncGoogleCalendarJob.perform_later(connection.id)
render plain: "Google Calendar connected for #{email}. Syncing in the background."
end
def disconnect
connection = GoogleCalendarConnection.find(params[:id])
connection.destroy
CalendarEvent.where(source: "google").delete_all if GoogleCalendarConnection.none?
head :no_content
end
end
class SyncAllGoogleCalendarsJob < ApplicationJob
queue_as :default
def perform
GoogleCalendarConnection.pluck(:id).each { |id| SyncGoogleCalendarJob.perform_later(id) }
end
end
class SyncGoogleCalendarJob < ApplicationJob
queue_as :default
limits_concurrency to: 1, key: ->(connection_id) { "google_calendar_sync_#{connection_id}" }, duration: 10.minutes
discard_on ActiveRecord::RecordNotFound
def perform(connection_id)
GoogleCalendar::Sync.new(GoogleCalendarConnection.find(connection_id)).run
end
end
class CalendarEvent < ApplicationRecord
validates :title, presence: true
validates :starts_at, presence: true
# Everything overlapping the window: an event that started before `from`
# but runs into it still belongs on that day.
scope :between, ->(from, to) {
where("starts_at < ? AND COALESCE(ends_at, starts_at) >= ?", to, from)
}
scope :upcoming, ->(limit = 5) {
where("COALESCE(ends_at, starts_at) >= ?", Time.current).order(:starts_at).limit(limit)
}
def google?
source == "google"
end
def ends_at
super || starts_at
end
end
# One Google account whose calendars are mirrored into calendar_events.
# Connect through /calendar/google/connect; tokens refresh themselves.
class GoogleCalendarConnection < ApplicationRecord
# MariaDB stores JSON as text and the trilogy adapter hands it back raw.
attribute :sync_tokens, :json, default: -> { {} }
validates :email, :access_token, :refresh_token, :token_expires_at, presence: true
def self.from_tokens!(tokens, email:)
connection = find_or_initialize_by(email: email)
connection.assign_attributes(
access_token: tokens.fetch("access_token"),
token_expires_at: tokens.fetch("expires_in").to_i.seconds.from_now,
last_sync_error: nil
)
# Google only hands out the refresh token on the first consent.
connection.refresh_token = tokens["refresh_token"] if tokens["refresh_token"].present?
connection.save!
connection
end
def token_expired?
token_expires_at <= 1.minute.from_now
end
def fresh_access_token
refresh_access_token! if token_expired?
access_token
end
def refresh_access_token!
tokens = GoogleCalendar::Client.refresh(refresh_token)
update!(access_token: tokens.fetch("access_token"), token_expires_at: tokens.fetch("expires_in").to_i.seconds.from_now)
end
# The calendar new events are written to.
def primary_calendar
@primary_calendar ||= GoogleCalendar::Client.calendar_list(fresh_access_token).find { |c| c["primary"] } ||
raise(GoogleCalendar::Client::Error.new("No primary calendar on #{email}"))
end
def sync_token_for(calendar_id)
sync_tokens[calendar_id]
end
def store_sync_token(calendar_id, token)
self.sync_tokens = sync_tokens.merge(calendar_id => token)
end
end
require "net/http"
module GoogleCalendar
# Thin wrapper over Google OAuth and the Calendar v3 REST API. Needs
# GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from a "Web application" OAuth
# client in Google Cloud, with the callback URL registered as a redirect URI.
module Client
extend self
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth".freeze
TOKEN_URL = "https://oauth2.googleapis.com/token".freeze
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo".freeze
API_URL = "https://www.googleapis.com/calendar/v3".freeze
SCOPES = %w[https://www.googleapis.com/auth/calendar email].freeze
TIMEOUT_SECONDS = 15
class Error < StandardError
attr_reader :status
def initialize(message, status: nil)
super(message)
@status = status
end
end
class SyncTokenExpired < Error; end
def client_id = ENV.fetch("GOOGLE_CLIENT_ID")
def client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET")
def authorize_url(redirect_uri:, state:)
"#{AUTH_URL}?" + URI.encode_www_form(
client_id: client_id,
redirect_uri: redirect_uri,
response_type: "code",
scope: SCOPES.join(" "),
access_type: "offline",
prompt: "consent", # forces a refresh token even on re-consent
include_granted_scopes: "true",
state: state
)
end
def exchange_code(code, redirect_uri:)
post_form(TOKEN_URL, code: code, client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri, grant_type: "authorization_code")
end
def refresh(refresh_token)
post_form(TOKEN_URL, refresh_token: refresh_token, client_id: client_id, client_secret: client_secret, grant_type: "refresh_token")
end
def userinfo(access_token)
get(USERINFO_URL, access_token)
end
def calendar_list(access_token)
paginate("#{API_URL}/users/me/calendarList", access_token, { minAccessRole: "reader" })
end
# Yields every page of events. With a sync token Google returns only the
# changes since the last pull; otherwise `params` sets the time window.
def events(access_token, calendar_id, params)
url = "#{API_URL}/calendars/#{CGI.escape(calendar_id)}/events"
next_sync_token = nil
items = paginate(url, access_token, params) { |page| next_sync_token = page["nextSyncToken"] if page["nextSyncToken"] }
[items, next_sync_token]
end
def create_event(access_token, calendar_id, body)
json(:post, "#{API_URL}/calendars/#{CGI.escape(calendar_id)}/events", access_token, body)
end
def update_event(access_token, calendar_id, event_id, body)
json(:patch, "#{API_URL}/calendars/#{CGI.escape(calendar_id)}/events/#{CGI.escape(event_id)}", access_token, body)
end
def delete_event(access_token, calendar_id, event_id)
json(:delete, "#{API_URL}/calendars/#{CGI.escape(calendar_id)}/events/#{CGI.escape(event_id)}", access_token)
rescue Error => e
raise unless e.status == 410 || e.status == 404 # already gone in Google
end
private
def json(method, url, access_token, body = nil)
uri = URI(url)
request = { post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete }.fetch(method).new(uri)
request["Authorization"] = "Bearer #{access_token}"
if body
request["Content-Type"] = "application/json"
request.body = JSON.generate(body)
end
handle(perform(uri, request))
end
def paginate(url, access_token, params)
items = []
page_token = nil
loop do
query = params.merge(maxResults: 2500)
query[:pageToken] = page_token if page_token
page = get("#{url}?#{URI.encode_www_form(query)}", access_token)
items.concat(page.fetch("items", []))
yield page if block_given?
page_token = page["nextPageToken"]
break if page_token.blank?
end
items
end
def get(url, access_token)
uri = URI(url)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{access_token}"
handle(perform(uri, request))
end
def post_form(url, params)
uri = URI(url)
request = Net::HTTP::Post.new(uri)
request.set_form_data(params)
handle(perform(uri, request))
end
def perform(uri, request)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS) do |http|
http.request(request)
end
end
def handle(response)
body = response.body.present? ? JSON.parse(response.body) : {}
return body if response.is_a?(Net::HTTPSuccess)
message = body.dig("error", "message") || body["error_description"] || body["error"] || response.message
raise SyncTokenExpired.new(message, status: 410) if response.code.to_i == 410
raise Error.new("Google responded with #{response.code}: #{message}", status: response.code.to_i)
end
end
end
module GoogleCalendar
# Mirrors the account's calendars into calendar_events. Recurring events are
# expanded server-side (singleEvents) so each occurrence is a row, which
# keeps the month grid and sync tokens simple. The first pull takes a fixed
# window; after that Google only sends changes.
#
# Only calendars the account owns are synced (plus any ids listed in
# GOOGLE_CALENDAR_IDS), so colleagues' shared calendars don't flood the
# agenda.
class Sync
PAST = 1.month
FUTURE = 6.months
def initialize(connection)
@connection = connection
end
def run
token = @connection.fresh_access_token
calendars = Client.calendar_list(token).select { |calendar| wanted?(calendar) }
active_ids = calendars.map { |c| c["id"] }
calendars.each { |calendar| sync_calendar(token, calendar) }
# Calendars that were dropped or unsubscribed since the last run.
CalendarEvent.where(source: "google").where.not(calendar_id: active_ids).delete_all
@connection.update!(last_synced_at: Time.current, last_sync_error: nil)
rescue StandardError => e
@connection.update_columns(last_sync_error: "#{e.class}: #{e.message}")
raise
end
# Writes one Google event (as returned by the API) into calendar_events.
def self.mirror!(item, calendar)
row = new(nil).send(:row_for, item, calendar)
return if row.nil?
event = CalendarEvent.find_or_initialize_by(calendar_id: row[:calendar_id], external_id: row[:external_id])
event.update!(row)
event
end
def self.extra_calendar_ids
ENV.fetch("GOOGLE_CALENDAR_IDS", "").split(",").map(&:strip).reject(&:blank?)
end
private
def wanted?(calendar)
calendar["primary"] || calendar["accessRole"] == "owner" || self.class.extra_calendar_ids.include?(calendar["id"])
end
def sync_calendar(token, calendar)
calendar_id = calendar.fetch("id")
sync_token = @connection.sync_token_for(calendar_id)
begin
items, next_token = Client.events(token, calendar_id, sync_token ? { syncToken: sync_token } : full_window_params)
rescue Client::SyncTokenExpired
items, next_token = Client.events(token, calendar_id, full_window_params)
CalendarEvent.where(source: "google", calendar_id: calendar_id).delete_all
end
apply(items, calendar)
@connection.store_sync_token(calendar_id, next_token) if next_token
@connection.save!
end
def full_window_params
{
singleEvents: true,
showDeleted: true,
timeMin: PAST.ago.utc.iso8601,
timeMax: FUTURE.from_now.utc.iso8601
}
end
# One delete and one upsert per calendar instead of a round trip per event.
def apply(items, calendar)
calendar_id = calendar.fetch("id")
cancelled, live = items.partition { |item| item["status"] == "cancelled" }
rows = live.filter_map { |item| row_for(item, calendar) }
now = Time.current
rows.each { |row| row[:created_at] = now; row[:updated_at] = now }
CalendarEvent.where(calendar_id: calendar_id, external_id: cancelled.map { |i| i["id"] }).delete_all if cancelled.any?
CalendarEvent.upsert_all(rows, update_only: rows.first.keys - [ :created_at ]) if rows.any?
end
def row_for(item, calendar)
starts_at, ends_at, all_day = times(item)
return if starts_at.nil?
{
source: "google",
calendar_id: calendar.fetch("id"),
external_id: item.fetch("id"),
calendar_name: calendar["summaryOverride"].presence || calendar["summary"],
color: calendar["backgroundColor"],
title: item["summary"].presence || "(No title)",
description: plain_text(item["description"]),
location: item["location"],
starts_at: starts_at,
ends_at: ends_at,
all_day: all_day,
html_link: item["htmlLink"]
}
end
# Google sends all-day events as dates with an exclusive end date, timed
# events as dateTimes. Normalise to our inclusive end-of-day convention.
# Out-of-office and similar blocks arrive as midnight-to-midnight
# dateTimes; those read better as all-day too.
def times(item)
start = item["start"] || {}
finish = item["end"] || {}
if start["date"]
starts_at = Time.zone.parse(start["date"]).beginning_of_day
ends_at = (Time.zone.parse(finish["date"] || start["date"]) - 1.day).end_of_day
[starts_at, [ends_at, starts_at].max, true]
elsif start["dateTime"]
starts_at = Time.zone.parse(start["dateTime"])
ends_at = Time.zone.parse(finish["dateTime"] || start["dateTime"])
if starts_at == starts_at.beginning_of_day && ends_at == ends_at.beginning_of_day && ends_at > starts_at
[starts_at, (ends_at - 1.day).end_of_day, true]
else
[starts_at, ends_at, false]
end
end
end
# Descriptions come as HTML fragments (<br>, <u>, entities).
def plain_text(html)
return nil if html.blank?
text = html.gsub(/<br\s*\/?>/i, "\n").gsub(/<\/?(p|div|li)>/i, "\n")
text = ActionController::Base.helpers.strip_tags(text)
CGI.unescapeHTML(text).gsub(/\n{3,}/, "\n\n").strip
end
end
end
# These settings can be overridden in specific environments using the files
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
# in config/environments, which are processed later.
#
#
# config.time_zone = "Central Time (US & Canada)"
config.time_zone = "Europe/Amsterdam"
# config.eager_load_paths << Rails.root.join("extras")
# config.eager_load_paths << Rails.root.join("extras")
# Dart Sass compiles app/assets/stylesheets/*.scss into app/assets/builds/.
# Dart Sass compiles app/assets/stylesheets/*.scss into app/assets/builds/.
class: SyncAllBankConnectionsJob
class: SyncAllBankConnectionsJob
queue: default
queue: default
schedule: every 6 hours
schedule: every 6 hours
# Google only returns changes since the last pull (sync tokens), so this is cheap.
sync_all_google_calendars:
class: SyncAllGoogleCalendarsJob
queue: default
schedule: every 10 minutes
post "/reminders", to: "reminders#create", as: :create_reminder
post "/reminders", to: "reminders#create", as: :create_reminder
post "/reminders/:id", to: "reminders#update", as: :update_reminder
post "/reminders/:id", to: "reminders#update", as: :update_reminder
get "/calendar/google/connect", to: "google_calendar#connect", as: :google_calendar_connect
get "/calendar/google/callback", to: "google_calendar#callback", as: :google_calendar_callback
delete "/calendar/google/:id", to: "google_calendar#disconnect", as: :google_calendar_disconnect
get "/server_data", to: "server#index", as: :server_data
get "/server_data", to: "server#index", as: :server_data
get "/notifications", to: "notification#index", as: :notifications
get "/notifications", to: "notification#index", as: :notifications
class CreateCalendarEvents < ActiveRecord::Migration[8.0]
def change
create_table :calendar_events do |t|
t.timestamps
t.string :title, null: false
t.text :description
t.string :location
t.datetime :starts_at, null: false
t.datetime :ends_at
t.boolean :all_day, default: false, null: false
end
add_index :calendar_events, :starts_at
end
end
class AddGoogleCalendar < ActiveRecord::Migration[8.0]
def change
create_table :google_calendar_connections do |t|
t.timestamps
t.string :email, null: false
t.text :access_token, null: false
t.text :refresh_token, null: false
t.datetime :token_expires_at, null: false
# calendar id => incremental sync token from the last successful pull
t.json :sync_tokens
t.datetime :last_synced_at
t.text :last_sync_error
end
add_index :google_calendar_connections, :email, unique: true
change_table :calendar_events do |t|
t.string :source, null: false, default: "local"
t.string :calendar_id
t.string :calendar_name
t.string :external_id
t.string :color
t.string :html_link
end
add_index :calendar_events, [:calendar_id, :external_id], unique: true
end
end
#
#
# It's strongly recommended that you check this file into your version control system.
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_01_201328) do
ActiveRecord::Schema[8.0].define(version: 2026_10_02_100000) do
create_table "bank_accounts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
create_table "bank_accounts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "bank_connection_id", null: false
t.bigint "bank_connection_id", null: false
t.string "uid", null: false
t.string "uid", null: false
t.check_constraint "json_valid(`raw`)", name: "raw"
t.check_constraint "json_valid(`raw`)", name: "raw"
end
end
create_table "calendar_events", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "title", null: false
t.text "description"
t.string "location"
t.datetime "starts_at", null: false
t.datetime "ends_at"
t.boolean "all_day", default: false, null: false
t.string "source", default: "local", null: false
t.string "calendar_id"
t.string "calendar_name"
t.string "external_id"
t.string "color"
t.string "html_link"
t.index ["calendar_id", "external_id"], name: "index_calendar_events_on_calendar_id_and_external_id", unique: true
t.index ["starts_at"], name: "index_calendar_events_on_starts_at"
end
create_table "device_tokens", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
create_table "device_tokens", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "token", null: false
t.string "token", null: false
t.string "platform", null: false
t.string "platform", null: false
t.index ["subject"], name: "ft_subject", type: :fulltext
t.index ["subject"], name: "ft_subject", type: :fulltext
end
end
create_table "google_calendar_connections", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "email", null: false
t.text "access_token", null: false
t.text "refresh_token", null: false
t.datetime "token_expires_at", null: false
t.text "sync_tokens", size: :long, collation: "utf8mb4_bin"
t.datetime "last_synced_at"
t.text "last_sync_error"
t.index ["email"], name: "index_google_calendar_connections_on_email", unique: true
t.check_constraint "json_valid(`sync_tokens`)", name: "sync_tokens"
end
create_table "imap_credentials", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
create_table "imap_credentials", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.timestamp "created_at"
t.timestamp "created_at"
t.timestamp "updated_at"
t.timestamp "updated_at"
import {AppProvider, useAppContext} from './context/AppContext';
import {AppProvider, useAppContext} from './context/AppContext';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
import {StyleSheet, View} from 'react-native';
import {StyleSheet, View} from 'react-native';
import {CodePage} from './components/code/CodePage';
import {CodePage} from './components/code/CodePage';
import {MusicPage} from './components/music/MusicPage';
import {MusicPage} from './components/music/MusicPage';
import {NowPlayingBar} from './components/music/NowPlayingBar';
import {NowPlayingBar} from './components/music/NowPlayingBar';
import {NowPlayingDrawer} from './components/music/NowPlayingDrawer';
import {NowPlayingDrawer} from './components/music/NowPlayingDrawer';
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
import {Sidebar} from './components/sidebar/Sidebar';
import {Sidebar} from './components/sidebar/Sidebar';
import {TransparentWindow} from './components/TransparentWindow';
import {TransparentWindow} from './components/TransparentWindow';
import {glass} from './components/theme';
import {glass} from './components/theme';