├── .github ├── dependabot.yml ├── scripts │ ├── README.md │ ├── install_geckodriver.sh │ ├── setup_boot.sh │ └── setup_wifi_ap.sh ├── templates │ ├── README.md │ ├── bash_history │ ├── dhcpcd.conf │ ├── dnsmasq.conf │ ├── hostapd │ ├── hostapd.conf │ ├── sysctl.conf │ └── uap0.service └── workflows │ └── build.yml ├── .gitignore ├── README.md ├── gonotego ├── assets │ ├── beep_hi.wav │ └── beep_lo.wav ├── audio │ ├── audiolistener.py │ ├── runner.py │ └── trigger.py ├── command_center │ ├── assistant_commands.py │ ├── commands.py │ ├── custom_commands.py │ ├── email_commands.py │ ├── note_commands.py │ ├── registry.py │ ├── runner.py │ ├── scheduler.py │ ├── settings_commands.py │ ├── system_commands.py │ ├── twitter_commands.py │ └── wifi_commands.py ├── common │ ├── events.py │ ├── internet.py │ ├── interprocess.py │ ├── status.py │ └── test_events.py ├── leds │ └── indicators.py ├── scratch │ └── insert_note.py ├── settings-server │ ├── .gitignore │ ├── components.json │ ├── eslint.config.js │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── components │ │ │ └── ui │ │ │ │ ├── alert.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── hover-card.tsx │ │ │ │ ├── input.tsx │ │ │ │ └── select.tsx │ │ ├── index.css │ │ ├── lib │ │ │ └── utils.ts │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── settings │ ├── secure_settings_template.py │ ├── server.py │ ├── settings.py │ ├── tools │ │ └── generate_template.py │ └── wifi.py ├── supervisord.conf ├── text │ ├── runner.py │ └── shell.py ├── transcription │ ├── runner.py │ └── transcriber.py └── uploader │ ├── blob │ └── blob_uploader.py │ ├── browser │ ├── driver_utils.py │ └── template.js │ ├── email │ └── email_uploader.py │ ├── ideaflow │ └── ideaflow_uploader.py │ ├── mem │ └── mem_uploader.py │ ├── notion │ └── notion_uploader.py │ ├── remnote │ └── remnote_uploader.py │ ├── roam │ ├── helper.js │ └── roam_uploader.py │ ├── runner.py │ ├── slack │ └── slack_uploader.py │ └── twitter │ └── twitter_uploader.py ├── hardware.md ├── installation.md ├── pyproject.toml └── scripts └── install_settings.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/scripts/README.md: -------------------------------------------------------------------------------- 1 | # Go Note Go Build Scripts 2 | 3 | This directory contains scripts used in the build process for creating the Go Note Go Raspberry Pi image. 4 | 5 | ## Scripts 6 | 7 | - `setup_wifi_ap.sh`: Sets up the WiFi access point for the Go Note Go device 8 | - `setup_boot.sh`: Configures the system to start Go Note Go on boot and sets initial system settings 9 | - `install_geckodriver.sh`: Installs the geckodriver needed for browser automation 10 | 11 | These scripts are used by the GitHub workflow defined in `.github/workflows/build.yml`. 12 | -------------------------------------------------------------------------------- /.github/scripts/install_geckodriver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Install geckodriver for browser automation 3 | 4 | echo "Install geckodriver to known location" 5 | cd 6 | wget https://github.com/mozilla/geckodriver/releases/download/v0.23.0/geckodriver-v0.23.0-arm7hf.tar.gz 7 | tar -xvf geckodriver-v0.23.0-arm7hf.tar.gz 8 | rm geckodriver-v0.23.0-arm7hf.tar.gz 9 | sudo mv geckodriver /usr/local/bin 10 | -------------------------------------------------------------------------------- /.github/scripts/setup_boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Configure system to start Go Note Go on boot 3 | 4 | # Modify rc.local to start Go Note Go on boot 5 | sudo cat /etc/rc.local 6 | sudo sed '/^exit 0/i \ 7 | sudo -u pi mkdir -p /home/pi/out \ 8 | bash /home/pi/code/github/dbieber/GoNoteGo/scripts/install_settings.sh \ 9 | /home/pi/code/github/dbieber/GoNoteGo/env/bin/supervisord -c /home/pi/code/github/dbieber/GoNoteGo/gonotego/supervisord.conf \ 10 | ' /etc/rc.local > ./rc.local.modified && sudo mv ./rc.local.modified /etc/rc.local 11 | sudo chmod +x /etc/rc.local 12 | 13 | # Configure initial system settings 14 | # Attempt to not run config on first boot 15 | sudo apt purge piwiz -y 16 | sudo raspi-config nonint do_wifi_country US 17 | # Configure US keyboard layout 18 | sudo raspi-config nonint do_configure_keyboard us 19 | touch /boot/ssh 20 | echo "pi:$(echo 'raspberry' | openssl passwd -6 -stdin)" | sudo tee /boot/userconf > /dev/null 21 | 22 | # Enable SSH 23 | ssh-keygen -A && 24 | update-rc.d ssh enable 25 | -------------------------------------------------------------------------------- /.github/scripts/setup_wifi_ap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Setup WiFi Access Point for Go Note Go 3 | 4 | # Install required packages 5 | sudo apt install -y rng-tools hostapd dnsmasq 6 | 7 | # Configure dhcpcd 8 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/dhcpcd.conf /etc/dhcpcd.conf 9 | 10 | # Configure dnsmasq 11 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/dnsmasq.conf /etc/dnsmasq.conf 12 | 13 | # Create uap0 service 14 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/uap0.service /etc/systemd/system/uap0.service 15 | 16 | # Enable and start uap0 service 17 | sudo systemctl daemon-reload 18 | sudo systemctl start uap0.service 19 | sudo systemctl enable uap0.service 20 | 21 | # Configure hostapd 22 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/hostapd.conf /etc/hostapd/hostapd.conf 23 | 24 | # Update hostapd defaults 25 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/hostapd /etc/default/hostapd 26 | 27 | # Start and enable hostapd and dnsmasq 28 | sudo systemctl start hostapd 29 | sudo systemctl start dnsmasq 30 | sudo systemctl enable hostapd 31 | sudo systemctl enable dnsmasq 32 | 33 | # Enable IP forwarding 34 | sudo cp /home/pi/code/github/dbieber/GoNoteGo/.github/templates/sysctl.conf /etc/sysctl.conf 35 | -------------------------------------------------------------------------------- /.github/templates/README.md: -------------------------------------------------------------------------------- 1 | # Go Note Go Configuration Templates 2 | 3 | This directory contains configuration templates used during the build process for Go Note Go. 4 | 5 | ## Templates 6 | 7 | - `dhcpcd.conf`: DHCP client configuration with UAP0 interface setup 8 | - `dnsmasq.conf`: DNS and DHCP server configuration for the access point 9 | - `uap0.service`: Systemd service for creating UAP0 interface 10 | - `hostapd.conf`: WiFi access point configuration 11 | - `hostapd`: Default configuration for hostapd service 12 | - `sysctl.conf`: System configuration for IP forwarding 13 | - `bash_history`: Predefined bash history commands for debugging 14 | -------------------------------------------------------------------------------- /.github/templates/bash_history: -------------------------------------------------------------------------------- 1 | sudo reboot now 2 | sudo systemctl daemon-reload 3 | sudo systemctl status uap0.service 4 | sudo systemctl status dnsmasq.service 5 | sudo systemctl status hostapd.service 6 | sudo journalctl -u uap0.service 7 | sudo journalctl -u dnsmasq.service 8 | sudo journalctl -u hostapd.service 9 | ip addr show uap0 10 | sudo ip addr add 192.168.4.1/24 dev uap0 11 | cat /etc/rc.local 12 | cat /etc/default/hostapd 13 | cat /etc/hostapd/hostapd.conf 14 | cat /etc/systemd/system/uap0.service 15 | cat /etc/sysctl.conf 16 | cat /etc/dhcpcd.conf 17 | cat /etc/dnsmasq.conf 18 | python -m http.server 19 | sudo iptables -t nat -A POSTROUTING -o wlan0 -j MASQUERADE 20 | sudo iptables -t nat -L 21 | nmcli radio wifi on 22 | nmcli device wifi list 23 | nmcli -t -f SSID,SIGNAL,SECURITY device wifi list 24 | nmcli connection show 25 | nmcli connection up id "SSID" # connect to saved network 26 | nmcli device wifi connect "SSID" password "PASSWORD" # connect to new network 27 | nmcli connection modify "SSID" wifi-sec.psk "NEWPASSWORD" # change wifi password 28 | nmcli connection delete "SSID" # remove connection 29 | sudo systemctl restart NetworkManager # restart network service 30 | sudo cat /etc/wpa_supplicant/wpa_supplicant.conf 31 | sudo nano /etc/wpa_supplicant/wpa_supplicant.conf 32 | nano /home/pi/code/github/dbieber/GoNoteGo/gonotego/settings/secure_settings.py 33 | /home/pi/code/github/dbieber/GoNoteGo/env/bin/python 34 | /home/pi/code/github/dbieber/GoNoteGo/env/bin/supervisord -c /home/pi/code/github/dbieber/GoNoteGo/gonotego/supervisord.conf 35 | /home/pi/code/github/dbieber/GoNoteGo/env/bin/supervisorctl -u go -p notego status 36 | /home/pi/code/github/dbieber/GoNoteGo/env/bin/supervisorctl -u go -p notego restart all 37 | cd /home/pi/code/github/dbieber/GoNoteGo/ 38 | -------------------------------------------------------------------------------- /.github/templates/dhcpcd.conf: -------------------------------------------------------------------------------- 1 | # Allow users of this group to interact with dhcpcd via the control socket. 2 | #controlgroup wheel 3 | 4 | # Inform the DHCP server of our hostname for DDNS. 5 | hostname 6 | 7 | # Use the hardware address of the interface for the Client ID. 8 | clientid 9 | # or 10 | # Use the same DUID + IAID as set in DHCPv6 for DHCPv4 ClientID as per RFC4361. 11 | # Some non-RFC compliant DHCP servers do not reply with this set. 12 | # In this case, comment out duid and enable clientid above. 13 | #duid 14 | 15 | # Persist interface configuration when dhcpcd exits. 16 | persistent 17 | 18 | # Rapid commit support. 19 | # Safe to enable by default because it requires the equivalent option set 20 | # on the server to actually work. 21 | option rapid_commit 22 | 23 | # A list of options to request from the DHCP server. 24 | option domain_name_servers, domain_name, domain_search, host_name 25 | option classless_static_routes 26 | # Respect the network MTU. This is applied to DHCP routes. 27 | option interface_mtu 28 | 29 | # Most distributions have NTP support. 30 | #option ntp_servers 31 | 32 | # A ServerID is required by RFC2131. 33 | require dhcp_server_identifier 34 | 35 | # Generate SLAAC address using the Hardware Address of the interface 36 | #slaac hwaddr 37 | # OR generate Stable Private IPv6 Addresses based from the DUID 38 | slaac private 39 | 40 | # Example static IP configuration: 41 | #interface eth0 42 | #static ip_address=192.168.0.10/24 43 | #static ip6_address=fd51:42f8:caae:d92e::ff/64 44 | #static routers=192.168.0.1 45 | #static domain_name_servers=192.168.0.1 8.8.8.8 fd51:42f8:caae:d92e::1 46 | 47 | # It is possible to fall back to a static IP if DHCP fails: 48 | # define static profile 49 | #profile static_eth0 50 | #static ip_address=192.168.1.23/24 51 | #static routers=192.168.1.1 52 | #static domain_name_servers=192.168.1.1 53 | 54 | # fallback to static profile on eth0 55 | #interface eth0 56 | #fallback static_eth0 57 | 58 | # Interface for Go Note Go Access Point 59 | interface uap0 60 | static ip_address=192.168.4.1/24 61 | nohook wpa_supplicant 62 | -------------------------------------------------------------------------------- /.github/templates/dnsmasq.conf: -------------------------------------------------------------------------------- 1 | interface=uap0 2 | dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h 3 | -------------------------------------------------------------------------------- /.github/templates/hostapd: -------------------------------------------------------------------------------- 1 | DAEMON_CONF="/etc/hostapd/hostapd.conf" 2 | -------------------------------------------------------------------------------- /.github/templates/hostapd.conf: -------------------------------------------------------------------------------- 1 | interface=uap0 2 | ssid=GoNoteGo-Wifi 3 | hw_mode=g 4 | channel=4 5 | wmm_enabled=0 6 | macaddr_acl=0 7 | auth_algs=1 8 | ignore_broadcast_ssid=0 9 | wpa=2 10 | wpa_passphrase=swingset 11 | wpa_key_mgmt=WPA-PSK 12 | wpa_pairwise=TKIP 13 | rsn_pairwise=CCMP 14 | -------------------------------------------------------------------------------- /.github/templates/sysctl.conf: -------------------------------------------------------------------------------- 1 | net.ipv4.ip_forward=1 2 | -------------------------------------------------------------------------------- /.github/templates/uap0.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Create uap0 interface 3 | After=sys-subsystem-net-devices-wlan0.device 4 | 5 | [Service] 6 | Type=oneshot 7 | RemainAfterExit=true 8 | ExecStart=/sbin/iw phy phy0 interface add uap0 type __ap 9 | ExecStartPost=/usr/bin/ip link set dev uap0 address d8:3a:dd:06:5e:ca 10 | ExecStartPost=/sbin/ifconfig uap0 up 11 | ExecStop=/sbin/iw dev uap0 del 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build image, lint, and run tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [3.9] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install Python dependencies 19 | run: | 20 | python -m pip install uv 21 | python -m uv venv 22 | python -m uv pip install .[test] 23 | - name: Lint with ruff 24 | run: | 25 | source .venv/bin/activate 26 | ruff check . 27 | - name: Test with pytest 28 | run: | 29 | source .venv/bin/activate 30 | pytest 31 | 32 | - name: Build web app 33 | run: | 34 | cd gonotego/settings-server 35 | npm install 36 | npm run build 37 | cd ../.. 38 | 39 | - name: Archive web app build 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: web-app-dist 43 | path: gonotego/settings-server/dist 44 | 45 | build_image: 46 | runs-on: ubuntu-latest 47 | needs: build 48 | steps: 49 | - name: Checkout code 50 | uses: actions/checkout@v4 51 | 52 | - name: Download web app build 53 | uses: actions/download-artifact@v4 54 | with: 55 | name: web-app-dist 56 | path: web-app-dist 57 | # /home/runner/work/GoNoteGo/GoNoteGo/web-app-dist 58 | 59 | - name: Verify web app download 60 | run: | 61 | echo "Checking contents of web-app-dist:" 62 | ls -la web-app-dist 63 | if [ ! -f "web-app-dist/index.html" ]; then 64 | echo "ERROR: web-app-dist directory does not contain expected files!" 65 | exit 1 66 | fi 67 | 68 | - uses: dbieber/arm-runner-action@v1.0.9 69 | id: build_image 70 | with: 71 | base_image: https://downloads.raspberrypi.com/raspios_armhf/images/raspios_armhf-2024-03-15/2024-03-15-raspios-bookworm-armhf.img.xz 72 | bootpartition: 1 73 | rootpartition: 2 74 | image_additional_mb: 6000 75 | extra_files_path: /home/runner/work/GoNoteGo/GoNoteGo/web-app-dist 76 | extra_files_mnt_path: web-app-dist 77 | commands: | 78 | echo "Updating package lists..." 79 | sudo apt-get update 80 | 81 | # Clean up apt cache to free up space 82 | sudo apt-get clean 83 | 84 | echo "Installing dependencies!" 85 | sudo apt install -y git firefox-esr xvfb portaudio19-dev libatlas-base-dev redis-server espeak \ 86 | rustc python3-dev libopenblas-dev iptables iptables-persistent nodejs npm 87 | 88 | # Clean up after installation 89 | sudo apt-get clean 90 | 91 | echo "Installing Go Note Go!" 92 | mkdir -p /home/pi/code/github/dbieber 93 | cd /home/pi/code/github/dbieber 94 | git clone https://github.com/dbieber/GoNoteGo.git 95 | cd GoNoteGo 96 | # Checkout the specific commit 97 | git checkout $GITHUB_SHA 98 | 99 | # Make scripts executable 100 | chmod +x /home/pi/code/github/dbieber/GoNoteGo/.github/scripts/*.sh 101 | 102 | echo "Including web app" 103 | echo "Checking web app files at /web-app-dist:" 104 | ls -la /web-app-dist 105 | 106 | echo "Creating settings-server directory:" 107 | mkdir -p /home/pi/code/github/dbieber/GoNoteGo/gonotego/settings-server/ 108 | 109 | echo "Copying web app files:" 110 | cp -r /web-app-dist /home/pi/code/github/dbieber/GoNoteGo/gonotego/settings-server/dist 111 | 112 | echo "Verifying copied web app files:" 113 | ls -la /home/pi/code/github/dbieber/GoNoteGo/gonotego/settings-server/dist 114 | 115 | # Setup Python environment 116 | echo "Setting up Python environment" 117 | python3 -m venv env 118 | ./env/bin/pip install -e . # Install Python dependencies 119 | 120 | echo "Setting up Go Note Go:" 121 | cp gonotego/settings/secure_settings_template.py gonotego/settings/secure_settings.py 122 | echo "Manually edit secure_settings.py to configure your settings." 123 | 124 | mkdir -p /home/pi/secrets 125 | echo "Manually transfer secrets to /home/pi/secrets." 126 | 127 | # Ensure repository is owned by pi user instead of root 128 | chown -R pi:pi /home/pi/code/github/dbieber/GoNoteGo 129 | 130 | # Install geckodriver 131 | bash /home/pi/code/github/dbieber/GoNoteGo/.github/scripts/install_geckodriver.sh 132 | 133 | # Set up a wifi access point 134 | bash /home/pi/code/github/dbieber/GoNoteGo/.github/scripts/setup_wifi_ap.sh 135 | 136 | # Setup system boot configuration 137 | bash /home/pi/code/github/dbieber/GoNoteGo/.github/scripts/setup_boot.sh 138 | 139 | # Setup bash history 140 | cat /home/pi/code/github/dbieber/GoNoteGo/.github/templates/bash_history >> /home/pi/.bash_history 141 | 142 | - name: Compress the release image 143 | run: | 144 | sudo fdisk -l 145 | sudo ls /etc/xdg/autostart/ 146 | mv ${{ steps.build_image.outputs.image }} go-note-go.img 147 | xz -0 -T 0 -v go-note-go.img 148 | - name: Upload release image 149 | uses: actions/upload-artifact@v4 150 | with: 151 | name: Release image 152 | path: go-note-go.img.xz 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | *~ 4 | out 5 | *.egg-info 6 | events.txt 7 | secure_settings.py 8 | screenshot-*.png 9 | geckodriver.log 10 | scratch 11 | env 12 | tmp-say 13 | auth_url 14 | build 15 | draft 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Note Go 2 | 3 | _Go Note Go is a note-taking system for when you're on the go, with a focus on driving and camping._ 4 | 5 | [Read about it here.](https://davidbieber.com/projects/go-note-go/) 6 | 7 | ## Features 8 | 9 | * Take notes using audio or text 10 | * Take notes offline 11 | * Automatically export notes to your favorite note-taking systems when internet becomes available 12 | * Automatic transcription from voice to text 13 | * No monitor. No distractions. Audio notes are safe for driving. 14 | * Free and open source software, inexpensive hardware 15 | * Add custom voice and text commands 16 | * Lots of built in commands 17 | 18 | ## Supported Note-taking Systems 19 | 20 | * [Roam Research](https://roamresearch.com/) 21 | * [RemNote](https://www.remnote.com/) 22 | * [IdeaFlow](https://ideaflow.app/) 23 | * [Mem](https://mem.ai/) 24 | * [Notion](https://www.notion.so/) 25 | * [Slack](https://www.slack.com/) 26 | * [Twitter](https://www.twitter.com/) 27 | * [Email](https://en.wikipedia.org/wiki/Email) 28 | 29 | Want to contribute a new note-taking system? 30 | 31 | ## Built in commands 32 | 33 | Have a look at [command_center/commands.py](gonotego/command_center/commands.py) to see the currently supported commands. 34 | 35 | Some ideas for commands include: 36 | 37 | * Reading back old notes 38 | * Spaced Repetition 39 | * Perfect Pitch Practice 40 | * Reminders 41 | * Calculator 42 | * Sending messages 43 | * Setting alarms 44 | * Programming with Codex 45 | * Question answering 46 | * Hearing the Time 47 | 48 | ## Hardware Parts 49 | 50 | Go Note Go is designed to run on a [Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400/). 51 | 52 | Recommended additional hardware: 53 | 54 | * 1 USB speaker 55 | * 1 USB microphone 56 | 57 | Recommended hardware to install Go Note Go in your car, to truly take Go Note Go on the go: 58 | 59 | * Velcro 60 | * 10000 mAh battery 61 | * 3 ft USB - USB C cable 62 | * 6 in USB - USB C cable 63 | 64 | See the [hardware guide](hardware.md) to know exactly what to buy. 65 | 66 | ## Installation 67 | 68 | See the [installation instructions](installation.md) to get started. 69 | 70 | ## History 71 | 72 | [Learn about Go Note Go's predecessor "Shh Shell" here.](https://davidbieber.com/projects/shh-shell/) 73 | 74 | [Hear Go Note Go's origin story here.](https://davidbieber.com/post/2022-12-30-go-note-go-story/) 75 | -------------------------------------------------------------------------------- /gonotego/assets/beep_hi.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbieber/GoNoteGo/3d6fc0d4177e4eec5b66e732b71c57950a5da009/gonotego/assets/beep_hi.wav -------------------------------------------------------------------------------- /gonotego/assets/beep_lo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbieber/GoNoteGo/3d6fc0d4177e4eec5b66e732b71c57950a5da009/gonotego/assets/beep_lo.wav -------------------------------------------------------------------------------- /gonotego/audio/audiolistener.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import numpy as np 5 | import sounddevice as sd 6 | import soundfile as sf 7 | 8 | from gonotego.common import status 9 | from gonotego.leds import indicators 10 | 11 | Status = status.Status 12 | 13 | SILENCE_THRESHOLD = 0.10 14 | 15 | 16 | def get_max_volume(samples): 17 | return np.max(np.abs(samples)) 18 | 19 | 20 | def set_audio_recording_status(recording): 21 | # Navigate up two directories from the current file location 22 | base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 23 | beep_hi_path = os.path.join(base_path, 'assets', 'beep_hi.wav') 24 | beep_lo_path = os.path.join(base_path, 'assets', 'beep_lo.wav') 25 | 26 | status.set(Status.AUDIO_RECORDING, recording) 27 | if recording: 28 | indicators.set(state=1) 29 | if status.get(Status.VOLUME_SETTING) != 'off': 30 | subprocess.call(['aplay', beep_hi_path]) 31 | 32 | else: 33 | indicators.set(state=0) 34 | if status.get(Status.VOLUME_SETTING) != 'off': 35 | subprocess.call(['aplay', beep_lo_path]) 36 | 37 | 38 | class AudioListener: 39 | 40 | def __init__(self): 41 | self.samplerate = sd.default.samplerate = 44100 42 | self.channels = sd.default.channels = 1 43 | 44 | self.recording = False 45 | self.stream = None 46 | self.file = None 47 | 48 | def record(self, filepath): 49 | self.recording = True 50 | set_audio_recording_status(self.recording) 51 | 52 | self.file = sf.SoundFile( 53 | filepath, 54 | mode='x', # 'x' raises an error if the file already exists. 55 | samplerate=self.samplerate, 56 | channels=self.channels, 57 | # subtype='PCM_24', 58 | ) 59 | 60 | # Keep track of how much silence there is to allow for early stopping. 61 | self.max_volume = 0.001 62 | self.consecutive_loud = 0 63 | self.consecutive_quiet = 0 64 | self.consecutive_loud_frames = 0 65 | self.consecutive_quiet_frames = 0 66 | 67 | def record_callback(indata, frames, time, status): 68 | frame_max_volume = get_max_volume(indata) 69 | if frame_max_volume / self.max_volume > SILENCE_THRESHOLD: 70 | # Loud frame. 71 | self.consecutive_loud += 1 72 | self.consecutive_loud_frames += frames 73 | if self.consecutive_loud > 20: 74 | self.consecutive_quiet = 0 75 | self.consecutive_quiet_frames = 0 76 | else: 77 | # Quiet frame. 78 | self.consecutive_quiet += 1 79 | self.consecutive_quiet_frames += frames 80 | if self.consecutive_quiet > 20: 81 | self.consecutive_loud = 0 82 | self.consecutive_loud_frames = 0 83 | 84 | self.max_volume = max(self.max_volume, frame_max_volume) 85 | self.file.write(indata.copy()) 86 | 87 | assert self.stream is None 88 | try: 89 | self.stream = sd.InputStream(callback=record_callback) 90 | self.stream.start() 91 | except sd.PortAudioError: 92 | self.stream = None 93 | self.recording = False 94 | set_audio_recording_status(self.recording) 95 | 96 | def silence_length(self): 97 | return self.consecutive_quiet_frames / self.samplerate 98 | 99 | def stop(self): 100 | self.recording = False 101 | set_audio_recording_status(self.recording) 102 | self.stream.stop() 103 | self.stream.close() 104 | self.file.flush() 105 | self.file.close() 106 | self.stream = None 107 | -------------------------------------------------------------------------------- /gonotego/audio/runner.py: -------------------------------------------------------------------------------- 1 | from absl import logging 2 | 3 | from datetime import datetime 4 | import subprocess 5 | import time 6 | 7 | from gonotego.audio import audiolistener 8 | from gonotego.audio import trigger 9 | from gonotego.common import events 10 | from gonotego.common import interprocess 11 | from gonotego.common import status 12 | 13 | Status = status.Status 14 | 15 | 16 | def make_filepath(): 17 | now = datetime.now() 18 | date_str = now.strftime('%Y%m%d') 19 | milliseconds = int(round(time.time() * 1000)) 20 | return f'out/{date_str}-{milliseconds}.wav' 21 | 22 | 23 | def enqueue_recording(queue, filepath): 24 | event = events.AudioEvent(events.AUDIO_DONE, filepath) 25 | queue.put(bytes(event)) 26 | 27 | 28 | def main(): 29 | logging.info('Starting logging for audio listener') 30 | print('Starting audio listener.') 31 | listener = audiolistener.AudioListener() 32 | audio_events_queue = interprocess.get_audio_events_queue() 33 | status.set(Status.AUDIO_READY, True) 34 | 35 | filepath = None 36 | last_filepath = None 37 | last_pressed = False 38 | last_press_time = None 39 | hold_triggered = False 40 | press_time = None 41 | 42 | # Wait until not pressed before starting. 43 | while trigger.is_pressed(): 44 | time.sleep(0.01) 45 | 46 | print('Starting audio trigger loop.') 47 | while True: 48 | pressed = trigger.is_pressed() 49 | newly_pressed = pressed and not last_pressed 50 | still_pressed = pressed and last_pressed 51 | 52 | now = time.time() 53 | if newly_pressed: 54 | last_press_time = press_time 55 | press_time = now 56 | hold_triggered = False 57 | if still_pressed: 58 | press_duration = now - press_time 59 | 60 | # Three seconds of silence. 61 | if listener.recording and listener.silence_length() > 3: 62 | logging.info(f'Three seconds of silence. Stopping. {filepath}') 63 | print(f'Three seconds of silence. Stopping. {filepath}') 64 | listener.stop() 65 | enqueue_recording(audio_events_queue, filepath) 66 | last_filepath = filepath 67 | filepath = None 68 | 69 | # Double press. 70 | elif newly_pressed and last_press_time and press_time - last_press_time < 0.5: 71 | if listener.recording: 72 | # We just started recording with the first push. Now we're going to stop. 73 | listener.stop() 74 | subprocess.call(['rm', filepath]) 75 | filepath = None 76 | else: 77 | # We stopped recording with the first push. Let's delete it. 78 | subprocess.call(['rm', last_filepath]) 79 | 80 | logging.info('Double pressed. Cancel.') 81 | print('Double pressed. Cancel.') 82 | 83 | elif newly_pressed and not listener.recording: 84 | # Start a recording by press. 85 | filepath = make_filepath() 86 | logging.info(f'Start recording. {filepath}') 87 | print(f'Start recording. {filepath}') 88 | listener.record(filepath) 89 | elif newly_pressed and listener.recording: 90 | # Stop a recording by press. 91 | logging.info(f'Stop recording. {filepath}') 92 | print(f'Stop recording. {filepath}') 93 | listener.stop() 94 | # TODO(dbieber): Should wait to make sure it's not a double press. 95 | enqueue_recording(audio_events_queue, filepath) 96 | last_filepath = filepath 97 | filepath = None 98 | elif still_pressed and press_duration > 1 and not hold_triggered: 99 | hold_triggered = True 100 | logging.info('Held down for 1 second. Cancel and read back.') 101 | print('Held down for 1 second. Cancel and read back.') 102 | if listener.recording: 103 | listener.stop() 104 | subprocess.call(['rm', filepath]) 105 | filepath = None 106 | subprocess.call(['aplay', last_filepath]) 107 | 108 | last_pressed = pressed 109 | time.sleep(0.01) 110 | 111 | 112 | if __name__ == '__main__': 113 | main() 114 | -------------------------------------------------------------------------------- /gonotego/audio/trigger.py: -------------------------------------------------------------------------------- 1 | try: 2 | import board 3 | from digitalio import DigitalInOut, Direction, Pull 4 | except: 5 | print('Unable to import board and digitalio.') 6 | board = None 7 | import keyboard 8 | 9 | from gonotego.settings import settings 10 | 11 | 12 | if board is not None: 13 | # The "onboard button" is the physical button on the Voice Bonnet. 14 | onboard_button = DigitalInOut(board.D17) 15 | onboard_button.direction = Direction.INPUT 16 | onboard_button.pull = Pull.UP 17 | 18 | # The "red button" is the handheld round one that's really satisfying to push. 19 | red_button = DigitalInOut(board.D27) 20 | red_button.direction = Direction.INPUT 21 | red_button.pull = Pull.UP 22 | else: 23 | onboard_button = None 24 | red_button = None 25 | 26 | 27 | def is_pressed(): 28 | if onboard_button is not None: 29 | button_pressed = ( 30 | not onboard_button.value 31 | or not red_button.value 32 | ) 33 | else: 34 | button_pressed = False 35 | audio_hotkey_pressed = False 36 | try: 37 | audio_hotkey_pressed = keyboard.is_pressed(settings.get("HOTKEY")) 38 | except: 39 | pass 40 | return audio_hotkey_pressed or button_pressed 41 | -------------------------------------------------------------------------------- /gonotego/command_center/assistant_commands.py: -------------------------------------------------------------------------------- 1 | """Assistant commands. Commands for using the AI assistant.""" 2 | 3 | import openai 4 | 5 | from gonotego.command_center import note_commands 6 | from gonotego.command_center import registry 7 | from gonotego.command_center import system_commands 8 | from gonotego.common import events 9 | from gonotego.common import interprocess 10 | from gonotego.settings import settings 11 | 12 | register_command = registry.register_command 13 | 14 | 15 | def create_completion( 16 | prompt, 17 | *, 18 | model='gpt-3.5-turbo-instruct', 19 | temperature=0.7, 20 | max_tokens=256, 21 | top_p=1, 22 | frequency_penalty=0, 23 | presence_penalty=0, 24 | **kwargs 25 | ): 26 | client = openai.OpenAI(api_key=settings.get('OPENAI_API_KEY')) 27 | response = client.completions.create( 28 | model=model, 29 | prompt=prompt, 30 | temperature=temperature, 31 | max_tokens=max_tokens, 32 | top_p=top_p, 33 | frequency_penalty=frequency_penalty, 34 | presence_penalty=presence_penalty, 35 | **kwargs 36 | ) 37 | return response 38 | 39 | 40 | def chat_completion(messages, model='gpt-3.5-turbo'): 41 | client = openai.OpenAI(api_key=settings.get('OPENAI_API_KEY')) 42 | response = client.chat.completions.create( 43 | model=model, 44 | messages=messages 45 | ) 46 | return response 47 | 48 | 49 | @register_command('ask {}') 50 | @register_command('q {}') 51 | def ask(prompt): 52 | response = create_completion(prompt) 53 | response_text = response.choices[0].text 54 | system_commands.speak(response_text) 55 | note_commands.add_note(prompt) 56 | note_commands.add_indented_note(f'{response_text} #[[AI Response]]') 57 | return response_text 58 | 59 | 60 | @register_command('aix') 61 | @register_command('aix {}') 62 | def ask_with_context(prompt=None): 63 | messages = get_messages(prompt=prompt) 64 | texts = [message['content'] for message in messages] 65 | extended_prompt = '\n'.join(texts) + '\n' 66 | response = create_completion(extended_prompt) 67 | 68 | response_text = response.choices[0].text 69 | response_text = response_text.lstrip() 70 | 71 | system_commands.speak(response_text) 72 | if prompt: 73 | note_commands.add_note(prompt) 74 | note_commands.add_indented_note(f'{response_text} #[[AI Response]]') 75 | return response_text 76 | 77 | 78 | @register_command('ai3') 79 | @register_command('ai3 {}') 80 | def chat_with_context3(prompt=None): 81 | return chat_with_context(prompt=prompt, model='gpt-3.5-turbo') 82 | 83 | 84 | @register_command('ai') 85 | @register_command('ai {}') 86 | @register_command('ai4') 87 | @register_command('ai4 {}') 88 | def chat_with_context4(prompt=None): 89 | return chat_with_context(prompt=prompt, model='gpt-4') 90 | 91 | 92 | def chat_with_context(prompt=None, model='gpt-3.5-turbo'): 93 | messages = get_messages(prompt=prompt) 94 | messages.insert(0, {"role": "system", "content": "You are a helpful assistant."}) 95 | response = chat_completion(messages) 96 | response_text = response.choices[0].message.content 97 | 98 | system_commands.speak(response_text) 99 | if prompt: 100 | note_commands.add_note(prompt) 101 | note_commands.add_indented_note(f'{response_text} #[[AI Response]]') 102 | return response_text 103 | 104 | 105 | def get_messages(prompt=None): 106 | note_events_session_queue = interprocess.get_note_events_session_queue() 107 | note_event_bytes_list = note_events_session_queue.peek_all() 108 | note_events = [ 109 | events.NoteEvent.from_bytes(note_event_bytes) 110 | for note_event_bytes in note_event_bytes_list 111 | ] 112 | 113 | indent = 0 114 | messages = [] 115 | for note_event in note_events: 116 | if note_event.action == events.SUBMIT: 117 | text = note_event.text 118 | if ' #[[AI Response]]' in text: 119 | role = 'assistant' 120 | text = text.replace(' #[[AI Response]]', '') 121 | else: 122 | role = 'user' 123 | messages.append({'role': role, 'content': text}) 124 | elif note_event.action == events.INDENT: 125 | indent += 1 126 | elif note_event.action == events.UNINDENT: 127 | indent = max(0, indent - 1) 128 | elif note_event.action == events.CLEAR_EMPTY: 129 | indent = 0 130 | elif note_event.action == events.ENTER_EMPTY: 131 | indent = max(0, indent - 1) 132 | else: 133 | raise ValueError('Unexpected event action', note_event) 134 | del indent # Unused. 135 | 136 | if prompt: 137 | messages.append({'role': 'user', 'content': prompt}) 138 | return messages 139 | -------------------------------------------------------------------------------- /gonotego/command_center/commands.py: -------------------------------------------------------------------------------- 1 | """The module imports all other command modules. 2 | 3 | It also defines some miscellaneous commands itself. 4 | """ 5 | 6 | from gonotego.common import events 7 | from gonotego.common import interprocess 8 | from gonotego.command_center import assistant_commands # noqa: F401 9 | from gonotego.command_center import custom_commands # noqa: F401 10 | from gonotego.command_center import note_commands # noqa: F401 11 | from gonotego.command_center import settings_commands # noqa: F401 12 | from gonotego.command_center import system_commands # noqa: F401 13 | from gonotego.command_center import twitter_commands # noqa: F401 14 | from gonotego.command_center import wifi_commands # noqa: F401 15 | from gonotego.command_center import registry 16 | 17 | register_command = registry.register_command 18 | 19 | 20 | @register_command('r') 21 | @register_command('read') 22 | def read_latest(): 23 | note_events_queue = interprocess.get_note_events_queue() 24 | note_event_bytes = note_events_queue.latest() 25 | note_event = events.NoteEvent.from_bytes(note_event_bytes) 26 | system_commands.say(note_event.text) 27 | -------------------------------------------------------------------------------- /gonotego/command_center/custom_commands.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import sys 4 | 5 | from gonotego.settings import secure_settings 6 | 7 | 8 | def register_custom_commands(paths): 9 | for path in paths: 10 | if os.path.exists(path) and path.endswith('.py'): 11 | module_name = os.path.splitext(os.path.basename(path))[0] 12 | 13 | spec = importlib.util.spec_from_file_location(module_name, path) 14 | module = importlib.util.module_from_spec(spec) 15 | 16 | if module_name not in sys.modules: 17 | sys.modules[module_name] = module 18 | spec.loader.exec_module(module) 19 | print(f"Module '{module_name}' has been imported from '{path}'.") 20 | else: 21 | print(f"Module '{module_name}' is already imported.") 22 | else: 23 | print(f"Path '{path}' is not a valid Python file.") 24 | sys.stdout.flush() 25 | 26 | register_custom_commands(secure_settings.CUSTOM_COMMAND_PATHS) 27 | -------------------------------------------------------------------------------- /gonotego/command_center/email_commands.py: -------------------------------------------------------------------------------- 1 | from email import message 2 | import smtplib 3 | 4 | from gonotego.command_center import registry 5 | from gonotego.settings import settings 6 | 7 | register_command = registry.register_command 8 | 9 | 10 | @register_command('email {} {}: {}') 11 | def _email(to, subject, text): 12 | email(to, subject, text) 13 | 14 | 15 | def email(to, subject, text, attach=None): 16 | email_user = settings.get('EMAIL_USER') 17 | email_pwd = settings.get('EMAIL_PASSWORD') 18 | email_server = settings.get('EMAIL_SERVER') or 'smtp.gmail.com' 19 | 20 | msg = message.EmailMessage() 21 | msg.set_content(text) 22 | 23 | msg['Subject'] = subject 24 | msg['From'] = email_user or 'Go-Note-Go@example.com' 25 | msg['To'] = to 26 | if email_server and email_pwd: 27 | server = smtplib.SMTP(email_server, 587) 28 | server.ehlo() 29 | server.starttls() 30 | server.ehlo() 31 | server.login(email_user, email_pwd) 32 | else: 33 | server = smtplib.SMTP('localhost') 34 | server.send_message(msg) 35 | server.quit() 36 | -------------------------------------------------------------------------------- /gonotego/command_center/note_commands.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from gonotego.command_center import registry 4 | from gonotego.command_center import system_commands 5 | from gonotego.common import events 6 | from gonotego.common import interprocess 7 | 8 | 9 | register_command = registry.register_command 10 | 11 | 12 | def get_timestamp(): 13 | return time.time() 14 | 15 | 16 | @register_command('note {}') 17 | def add_note(text): 18 | note_events_queue = interprocess.get_note_events_queue() 19 | note_events_session_queue = interprocess.get_note_events_session_queue() 20 | 21 | note_event = events.NoteEvent( 22 | text=text, 23 | action=events.SUBMIT, 24 | audio_filepath=None, 25 | timestamp=get_timestamp()) 26 | note_events_queue.put(bytes(note_event)) 27 | note_events_session_queue.put(bytes(note_event)) 28 | 29 | 30 | @register_command('subnote {}') 31 | def add_indented_note(text): 32 | note_events_queue = interprocess.get_note_events_queue() 33 | note_events_session_queue = interprocess.get_note_events_session_queue() 34 | 35 | # Indent 36 | note_event = events.NoteEvent( 37 | text=None, 38 | action=events.INDENT, 39 | audio_filepath=None, 40 | timestamp=get_timestamp()) 41 | note_events_queue.put(bytes(note_event)) 42 | note_events_session_queue.put(bytes(note_event)) 43 | 44 | # The note 45 | note_event = events.NoteEvent( 46 | text=text, 47 | action=events.SUBMIT, 48 | audio_filepath=None, 49 | timestamp=get_timestamp()) 50 | note_events_queue.put(bytes(note_event)) 51 | note_events_session_queue.put(bytes(note_event)) 52 | 53 | # Dedent 54 | note_event = events.NoteEvent( 55 | text=None, 56 | action=events.UNINDENT, 57 | audio_filepath=None, 58 | timestamp=get_timestamp()) 59 | note_events_queue.put(bytes(note_event)) 60 | note_events_session_queue.put(bytes(note_event)) 61 | 62 | 63 | @register_command('todo {}') 64 | def add_todo(text): 65 | # TODO(dbieber): This syntax is Roam Research specific. 66 | return add_note(f'{{{{[[TODO]]}}}} {text}') 67 | 68 | 69 | @register_command('pending') 70 | def get_pending_note_count(): 71 | note_events_queue = interprocess.get_note_events_queue() 72 | size = note_events_queue.size() 73 | size_str = str(size) 74 | system_commands.say(size_str) 75 | return size 76 | -------------------------------------------------------------------------------- /gonotego/command_center/registry.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Pattern, Text, Tuple 2 | 3 | import dataclasses 4 | import re 5 | 6 | COMMANDS = [] 7 | 8 | 9 | @dataclasses.dataclass 10 | class Command(object): 11 | name: Text 12 | func: Callable 13 | regex: Pattern 14 | requirements: Tuple[Text] 15 | 16 | def execute_if_match(self, text, resources): 17 | match = self.regex.match(text) 18 | if match: 19 | args = match.groups() 20 | 21 | kwargs = {} 22 | for requirement in self.requirements: 23 | kwargs[requirement] = resources[requirement] 24 | 25 | self.func(*args, **kwargs) 26 | return True 27 | return False 28 | 29 | 30 | def register_command(pattern, **params): 31 | def command_decorator(func): 32 | regex_str = '^' + pattern.replace('{}', '(.*)') + '$' 33 | regex = re.compile(regex_str) 34 | name = params.pop('name', func.__name__) 35 | requirements = params.pop('requirements', ()) 36 | 37 | command = Command( 38 | name=name, 39 | func=func, 40 | regex=regex, 41 | requirements=requirements, 42 | ) 43 | COMMANDS.append(command) 44 | 45 | return func 46 | return command_decorator 47 | -------------------------------------------------------------------------------- /gonotego/command_center/runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | from gonotego.common import events 5 | from gonotego.common import interprocess 6 | from gonotego.command_center import commands # noqa: F401 7 | from gonotego.command_center import scheduler 8 | from gonotego.command_center import registry 9 | 10 | 11 | class Executor: 12 | 13 | def __init__(self, **resources): 14 | self.resources = resources 15 | 16 | def execute(self, text): 17 | for command in registry.COMMANDS: 18 | executed = command.execute_if_match(text, self.resources) 19 | if executed: 20 | print(f'Executed: {text}') 21 | return 22 | print(f'Did not execute: {text}') 23 | 24 | 25 | def main(): 26 | print('Starting command center.') 27 | command_events_queue = interprocess.get_command_events_queue() 28 | 29 | executor = Executor(scheduler=scheduler.Scheduler()) 30 | scheduler.executor_singleton = executor 31 | while True: 32 | while command_events_queue.size() > 0: 33 | # We commit the item before executing the command. 34 | # So, if the command fails, it will not be re-executed. 35 | command_event_bytes = command_events_queue.get() 36 | command_events_queue.commit(command_event_bytes) 37 | 38 | command_event = events.CommandEvent.from_bytes(command_event_bytes) 39 | command_text = command_event.command_text 40 | executor.execute(command_text) 41 | sys.stdout.flush() 42 | sys.stderr.flush() 43 | time.sleep(1) 44 | 45 | 46 | if __name__ == '__main__': 47 | main() 48 | -------------------------------------------------------------------------------- /gonotego/command_center/scheduler.py: -------------------------------------------------------------------------------- 1 | from apscheduler.executors.pool import ThreadPoolExecutor, ProcessPoolExecutor 2 | from apscheduler.jobstores.redis import RedisJobStore # noqa: F401 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.triggers.date import DateTrigger 5 | 6 | import parsedatetime 7 | 8 | 9 | # Global executor to be set by runner. 10 | executor_singleton = None 11 | 12 | 13 | def execute_command(cmd): 14 | executor_singleton.execute(cmd) 15 | 16 | 17 | class Scheduler: 18 | 19 | def __init__(self, start=True): 20 | self.scheduler = BackgroundScheduler(executors={ 21 | 'default': ThreadPoolExecutor(20), 22 | 'processpool': ProcessPoolExecutor(5) 23 | }) 24 | self.scheduler.add_jobstore( 25 | 'redis', 26 | jobs_key='GoNoteGo:jobs', 27 | run_times_key='GoNoteGo:run_times' 28 | ) 29 | 30 | if start: 31 | print('Starting') 32 | self.scheduler.start() 33 | print('Continuing') 34 | self.datetime_parser = parsedatetime.Calendar() 35 | 36 | def parse(self, at): 37 | return self.datetime_parser.parseDT(at)[0] 38 | 39 | def already_scheduled(self, what): 40 | # TODO(Bieber): Improve efficiency with a dict 41 | for scheduled_at, scheduled_what in self.get_jobs(): 42 | if what == scheduled_what: 43 | return True 44 | return False 45 | 46 | def schedule(self, at, what): 47 | dt = self.parse(at) 48 | trigger = DateTrigger(dt) 49 | self.scheduler.add_job( 50 | execute_command, 51 | trigger=trigger, 52 | args=[what.strip()], 53 | ) 54 | 55 | def get_jobs(self): 56 | jobs = self.scheduler.get_jobs() 57 | return [(job.next_run_time, job.args[0]) for job in jobs] 58 | -------------------------------------------------------------------------------- /gonotego/command_center/settings_commands.py: -------------------------------------------------------------------------------- 1 | # Settings commands. Commands for setting settings. 2 | 3 | import subprocess 4 | 5 | from dateutil import parser 6 | 7 | from gonotego.common import status 8 | from gonotego.command_center import registry 9 | from gonotego.command_center import system_commands 10 | from gonotego.settings import settings 11 | from gonotego.settings import secure_settings 12 | 13 | register_command = registry.register_command 14 | 15 | Status = status.Status 16 | 17 | SETTING_NAME_MAPPINGS = { 18 | 'uploader': 'NOTE_TAKING_SYSTEM', 19 | } 20 | SETTINGS_NAMES = [s.lower() for s in dir(secure_settings) if not s.startswith('_')] 21 | 22 | say = system_commands.say 23 | 24 | 25 | @register_command('set {} {}') 26 | def set(key, value): 27 | if key.lower() in SETTING_NAME_MAPPINGS: 28 | key = SETTING_NAME_MAPPINGS[key.lower()] 29 | if key.lower() in SETTINGS_NAMES: 30 | settings.set(key, value) 31 | if key.lower() in ('v', 'volume'): 32 | set_volume(value) 33 | if key.lower() in ('t', 'time'): 34 | set_time(value) 35 | if key.lower() in ('tz', 'timezone'): 36 | set_timezone(value) 37 | 38 | 39 | def set_time(value): 40 | try: 41 | t = parser.parse(value) 42 | except parser.ParserError: 43 | say('Time not set.') 44 | return 45 | time_string = ( 46 | f'{t.year:04d}-{t.month:02d}-{t.day:02d} ' 47 | f'{t.hour:02d}:{t.minute:02d}:{t.second:02d}' 48 | ) 49 | command = f'date -s "{time_string}"' 50 | system_commands.shell(command) 51 | 52 | 53 | def list_timezones(): 54 | output = subprocess.check_output(['timedatectl', 'list-timezones']) 55 | timezones_str = output.decode('utf-8') 56 | timezones = timezones_str.strip().split('\n') 57 | return timezones 58 | 59 | 60 | def set_timezone(value): 61 | timezone_mapping = { 62 | 'ET': 'America/New_York', 63 | 'EST': 'America/New_York', 64 | 'EDT': 'America/New_York', 65 | 'PT': 'America/Los_Angeles', 66 | 'PST': 'America/Los_Angeles', 67 | 'PDT': 'America/Los_Angeles', 68 | } 69 | if value in timezone_mapping: 70 | value = timezone_mapping[value] 71 | if value not in list_timezones(): 72 | say('Timezone not set.') 73 | return 74 | command = f'sudo timedatectl set-timezone {value}' 75 | system_commands.shell(command) 76 | 77 | 78 | @register_command('ntp on') 79 | def enable_ntp(): 80 | system_commands.shell('sudo timedatectl set-ntp true') 81 | 82 | 83 | @register_command('ntp off') 84 | def disable_ntp(): 85 | system_commands.shell('sudo timedatectl set-ntp false') 86 | 87 | 88 | @register_command('get status {}') 89 | def get_status(key): 90 | if 'secret' in key.lower() or 'password' in key.lower(): 91 | return 92 | status_key = getattr(status.Status, key) 93 | say(str(status.get(status_key))) 94 | 95 | 96 | @register_command('get {}') 97 | def get_setting(key): 98 | if 'secret' in key.lower() or 'password' in key.lower(): 99 | return 100 | if key.lower() in SETTING_NAME_MAPPINGS: 101 | key = SETTING_NAME_MAPPINGS[key.lower()] 102 | if key.lower() in SETTINGS_NAMES: 103 | say(settings.get(key)) 104 | 105 | 106 | @register_command('clear {}') 107 | def clear_setting(key): 108 | if key.lower() in SETTING_NAME_MAPPINGS: 109 | key = SETTING_NAME_MAPPINGS[key.lower()] 110 | if key.lower() in SETTINGS_NAMES: 111 | settings.clear(key) 112 | value = settings.get(key) 113 | say(f'New value: {value}') 114 | 115 | 116 | @register_command('clear') 117 | def clear_all_settings(): 118 | settings.clear_all() 119 | say('Cleared.') 120 | 121 | 122 | @register_command('v {}') 123 | @register_command('volume {}') 124 | def set_volume(value): 125 | if value in ('off', 'on'): 126 | status.set(Status.VOLUME_SETTING, value) 127 | -------------------------------------------------------------------------------- /gonotego/command_center/system_commands.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import os 3 | import platform 4 | import subprocess 5 | import sys 6 | 7 | from gonotego.common import internet 8 | from gonotego.common import status 9 | from gonotego.command_center import note_commands 10 | from gonotego.command_center import registry 11 | from gonotego.settings import settings 12 | 13 | register_command = registry.register_command 14 | 15 | Status = status.Status 16 | 17 | 18 | SAY_COMMAND = 'say' if platform.system() == 'Darwin' else 'espeak' 19 | 20 | 21 | @register_command('whoami') 22 | @register_command('who am i') 23 | def whoami(): 24 | note_taking_system = settings.get('NOTE_TAKING_SYSTEM') 25 | if note_taking_system == 'email': 26 | user = settings.get('EMAIL') 27 | elif note_taking_system == 'ideaflow': 28 | user = settings.get('IDEAFLOW_USER') 29 | elif note_taking_system == 'remnote': 30 | user = settings.get('REMNOTE_USER_ID')[:6] 31 | elif note_taking_system == 'roam': 32 | user = f'{settings.get("ROAM_GRAPH")} {settings.get("ROAM_USER")}' 33 | elif note_taking_system == 'mem': 34 | user = settings.get('MEM_API_KEY')[:6] 35 | elif note_taking_system == 'notion': 36 | user = settings.get('NOTION_DATABASE_ID')[:6] 37 | elif note_taking_system == 'slack': 38 | user = settings.get('SLACK_CHANNEL') 39 | elif note_taking_system == 'twitter': 40 | user = settings.get('twitter.screen_name') 41 | say(f'uploader {note_taking_system} ; user {user}') 42 | 43 | 44 | @register_command('t') 45 | @register_command('time') 46 | def time(): 47 | shell(f'date "+%A, %B%e %l:%M%p" | {SAY_COMMAND} &') 48 | 49 | 50 | @register_command('at {}:{}', requirements=('scheduler',)) 51 | def schedule(at, what, scheduler): 52 | scheduler.schedule(at, what) 53 | 54 | 55 | @register_command('status') 56 | @register_command('ok') 57 | def status_command(): 58 | say('ok') 59 | 60 | 61 | @register_command('speak {}') 62 | def speak(text): 63 | try: 64 | say_with_openai(text) 65 | except: 66 | print("Falling back on traditional say") 67 | say(text) 68 | 69 | 70 | @register_command('say {}') 71 | def say(text): 72 | dt = datetime.now().strftime('%k:%M:%S') 73 | with open('tmp-say', 'w') as tmp: 74 | print(f'[{dt}] Writing "{text}" to tmp-say') 75 | tmp.write(text) 76 | cmd = f'cat tmp-say | {SAY_COMMAND} &' 77 | shell(cmd) 78 | 79 | 80 | @register_command('say_openai {}') 81 | def say_with_openai(text): 82 | import openai 83 | client = openai.OpenAI(api_key=settings.get('OPENAI_API_KEY')) 84 | response = client.audio.speech.create( 85 | model="tts-1", 86 | voice="alloy", 87 | input=text 88 | ) 89 | response.write_to_file('output.mp3') 90 | play_mp3('output.mp3') 91 | 92 | 93 | def play_mp3(path): 94 | shell(f"cvlc {path} --play-and-exit") 95 | 96 | 97 | @register_command('silence') 98 | @register_command('silencio') 99 | def silence(): 100 | shell(f'pkill {SAY_COMMAND}') 101 | 102 | 103 | @register_command('shell {}') 104 | def shell(cmd): 105 | dt = datetime.now().strftime('%k:%M:%S') 106 | print(f"[{dt}] Executing command: '{cmd}'") 107 | os.system(cmd) 108 | 109 | 110 | @register_command('flush') 111 | def flush(): 112 | sys.stdout.flush() 113 | sys.stderr.flush() 114 | 115 | 116 | @register_command('update') 117 | def update(): 118 | shell('git pull') 119 | 120 | 121 | @register_command('restart') 122 | def restart(): 123 | shell('./env/bin/supervisorctl -u go -p notego restart all') 124 | 125 | 126 | @register_command('reboot') 127 | def reboot(): 128 | shell('sudo reboot') 129 | 130 | 131 | @register_command('env') 132 | def env(): 133 | shell('env | sort') 134 | 135 | 136 | @register_command('ip') 137 | def ip_address(): 138 | hostname_output = subprocess.check_output(['hostname', '-I']).decode('utf-8').strip() 139 | hostname = hostname_output.split(' ')[0] 140 | say(hostname) 141 | note_commands.add_note(hostname) 142 | 143 | 144 | @register_command('i') 145 | @register_command('internet') 146 | def check_internet(): 147 | say('yes' if internet.is_internet_available() else 'no') 148 | 149 | 150 | @register_command('server') 151 | @register_command('settings') 152 | @register_command('configure') 153 | def start_settings_server(): 154 | shell('sudo systemctl stop uap0.service') 155 | shell('sudo systemctl stop dnsmasq.service') 156 | shell('sudo systemctl stop hostapd.service') 157 | 158 | shell('sudo systemctl start uap0.service') 159 | shell('sudo ip addr show uap0') 160 | shell('sudo ip addr add 192.168.4.1/24 dev uap0') 161 | shell('sudo iptables -t nat -A POSTROUTING -o wlan0 -j MASQUERADE') 162 | shell('sudo systemctl start dnsmasq.service') 163 | shell('sudo systemctl start hostapd.service') 164 | 165 | @register_command('server stop') 166 | def stop_settings_server(): 167 | shell('sudo systemctl stop hostapd.service') 168 | -------------------------------------------------------------------------------- /gonotego/command_center/twitter_commands.py: -------------------------------------------------------------------------------- 1 | """Twitter commands. 2 | 3 | To use the Twitter uploader, run: 4 | :set uploader twitter 5 | or equivalently, 6 | :twitter 7 | 8 | To switch to a different Twitter account, run: 9 | :set twitter_user 10 | or equivalently, 11 | :twitter user 12 | 13 | Run the following to log in: 14 | :twitter auth 15 | or 16 | :twitter auth 17 | 18 | This will send you a URL where you can log in to Twitter and get a pin code. 19 | Run the following to finish logging in: 20 | :twitter pin 21 | """ 22 | 23 | import twython 24 | 25 | from gonotego.common import status 26 | from gonotego.command_center import email_commands 27 | from gonotego.command_center import system_commands 28 | from gonotego.command_center import registry 29 | from gonotego.settings import settings 30 | 31 | register_command = registry.register_command 32 | 33 | Status = status.Status 34 | 35 | 36 | @register_command('twitter') 37 | def twitter(): 38 | settings.set('NOTE_TAKING_SYSTEM', 'twitter') 39 | 40 | 41 | @register_command('twitter user') 42 | def get_twitter_user(): 43 | system_commands.say(settings.get('twitter.screen_name')) 44 | 45 | 46 | @register_command('twitter user {}') 47 | def set_twitter_user(screen_name): 48 | settings.set('twitter.screen_name', screen_name) 49 | user_id = settings.get(f'twitter.screen_names.{screen_name}.user_id') 50 | settings.set('twitter.user_id', user_id) 51 | 52 | 53 | @register_command('twitter auth') 54 | def start_auth(): 55 | api_key = settings.get('TWITTER_API_KEY') 56 | secret_key = settings.get('TWITTER_API_SECRET') 57 | client = twython.Twython(api_key, secret_key) 58 | auth = client.get_authentication_tokens(callback_url='oob') 59 | oauth_token = auth['oauth_token'] 60 | oauth_token_secret = auth['oauth_token_secret'] 61 | auth_url = auth['auth_url'] 62 | settings.set('twitter.oauth_token', oauth_token) 63 | settings.set('twitter.oauth_token_secret', oauth_token_secret) 64 | # TODO(dbieber): If there's a screen, open auth_url. 65 | with open('auth_url', 'w') as f: 66 | f.write(auth_url) 67 | print(auth_url) 68 | return auth_url 69 | 70 | 71 | @register_command('twitter auth {}') 72 | def start_auth_with_email(email): 73 | auth_url = start_auth() 74 | email_commands.email( 75 | to=email, subject='Connect Go Note Go with Twitter', 76 | text=f"""You requested to connect your Twitter account to your Go Note Go. 77 | 78 | You can do so at this link: 79 | {auth_url} 80 | 81 | Once you authorize Go Note Go to connect to your Twitter account, run the following on your Go Note Go: 82 | :twitter pin 83 | 84 | If you did not request to connect your Twitter account to your Go Note Go, you can safely ignore this email. 85 | """) 86 | 87 | 88 | @register_command('twitter pin {}') 89 | def complete_auth(pin): 90 | api_key = settings.get('TWITTER_API_KEY') 91 | secret_key = settings.get('TWITTER_API_SECRET') 92 | oauth_token = settings.get('twitter.oauth_token') 93 | oauth_token_secret = settings.get('twitter.oauth_token_secret') 94 | client = twython.Twython(api_key, secret_key, oauth_token, oauth_token_secret) 95 | auth = client.get_authorized_tokens(pin) 96 | 97 | oauth_token = auth['oauth_token'] 98 | oauth_token_secret = auth['oauth_token_secret'] 99 | user_id = auth['user_id'] 100 | screen_name = auth['screen_name'] 101 | 102 | settings.set('twitter.user_id', user_id) 103 | settings.set('twitter.screen_name', screen_name) 104 | settings.set(f'twitter.screen_names.{screen_name}.user_id', user_id) 105 | settings.set(f'twitter.user_ids.{user_id}.oauth_token', oauth_token) 106 | settings.set(f'twitter.user_ids.{user_id}.oauth_token_secret', oauth_token_secret) 107 | -------------------------------------------------------------------------------- /gonotego/command_center/wifi_commands.py: -------------------------------------------------------------------------------- 1 | """WiFi command handlers for Go Note Go using NetworkManager.""" 2 | import subprocess 3 | from gonotego.command_center import registry 4 | from gonotego.command_center import system_commands 5 | from gonotego.settings import wifi 6 | 7 | register_command = registry.register_command 8 | say = system_commands.say 9 | shell = system_commands.shell 10 | 11 | 12 | @register_command('wifi {} {}') 13 | @register_command('wpa {} {}') 14 | def add_wpa_wifi(ssid, psk): 15 | if '"' in ssid or '"' in psk: 16 | say('WiFi not set.') 17 | return 18 | 19 | # Load existing networks 20 | networks = wifi.get_networks() 21 | 22 | # Check if this network already exists 23 | for network in networks: 24 | if network.get('ssid') == ssid: 25 | network['psk'] = psk 26 | break 27 | else: 28 | # Network doesn't exist, add it 29 | networks.append({'ssid': ssid, 'psk': psk}) 30 | 31 | # Save updated networks 32 | wifi.save_networks(networks) 33 | 34 | # Configure NetworkManager connections 35 | if wifi.configure_network_connections(): 36 | wifi.reconfigure_wifi() 37 | say(f'WiFi network {ssid} added.') 38 | else: 39 | say('Failed to update WiFi configuration.') 40 | 41 | 42 | @register_command('wifi {}') 43 | def add_wifi_no_psk(ssid): 44 | if '"' in ssid: 45 | say('WiFi not set.') 46 | return 47 | 48 | # Load existing networks 49 | networks = wifi.get_networks() 50 | 51 | # Check if this network already exists 52 | for network in networks: 53 | if network.get('ssid') == ssid: 54 | network.pop('psk', None) # Remove password if it exists 55 | break 56 | else: 57 | # Network doesn't exist, add it 58 | networks.append({'ssid': ssid}) 59 | 60 | # Save updated networks 61 | wifi.save_networks(networks) 62 | 63 | # Configure NetworkManager connections 64 | if wifi.configure_network_connections(): 65 | wifi.reconfigure_wifi() 66 | say(f'Open WiFi network {ssid} added.') 67 | else: 68 | say('Failed to update WiFi configuration.') 69 | 70 | 71 | @register_command('wifi-list') 72 | def list_wifi_networks(): 73 | networks = wifi.get_networks() 74 | if not networks: 75 | say('No WiFi networks configured.') 76 | return 77 | 78 | network_list = [f"{i+1}. {network['ssid']}" for i, network in enumerate(networks)] 79 | say('Configured WiFi networks: ' + ', '.join(network_list)) 80 | 81 | 82 | @register_command('wifi-remove {}') 83 | def remove_wifi_network(ssid): 84 | networks = wifi.get_networks() 85 | initial_count = len(networks) 86 | 87 | # Remove networks with matching SSID 88 | networks = [network for network in networks if network.get('ssid') != ssid] 89 | 90 | if len(networks) < initial_count: 91 | # Save updated networks 92 | wifi.save_networks(networks) 93 | 94 | # Configure NetworkManager connections 95 | if wifi.configure_network_connections(): 96 | wifi.reconfigure_wifi() 97 | say(f'WiFi network {ssid} removed.') 98 | else: 99 | say('Failed to update WiFi configuration.') 100 | else: 101 | say(f'WiFi network {ssid} not found.') 102 | 103 | 104 | @register_command('reconnect') 105 | @register_command('wifi-refresh') 106 | @register_command('wifi-reconfigure') 107 | def reconfigure_wifi(): 108 | wifi.reconfigure_wifi() 109 | 110 | 111 | @register_command('wifi-scan') 112 | def scan_wifi_networks(): 113 | """Scan for available WiFi networks.""" 114 | try: 115 | # Ensure the WiFi adapter is on 116 | subprocess.run(['sudo', 'nmcli', 'radio', 'wifi', 'on'], check=True) 117 | 118 | # Scan for networks 119 | result = subprocess.run( 120 | ['nmcli', '-t', '-f', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list'], 121 | capture_output=True, text=True, check=True 122 | ) 123 | 124 | networks = [] 125 | for line in result.stdout.strip().split('\n'): 126 | if line: 127 | parts = line.split(':', 2) 128 | if len(parts) >= 3 and parts[0]: # Ensure SSID is not empty 129 | ssid, signal, security = parts 130 | networks.append((ssid, int(signal), security)) 131 | 132 | # Sort by signal strength (descending) 133 | networks.sort(key=lambda x: x[1], reverse=True) 134 | 135 | # Take the top 5 networks 136 | top_networks = networks[:5] 137 | 138 | if top_networks: 139 | network_list = [f"{i+1}. {ssid} ({signal}%)" 140 | for i, (ssid, signal, _) in enumerate(top_networks)] 141 | say('Available WiFi networks: ' + ', '.join(network_list)) 142 | else: 143 | say('No WiFi networks found.') 144 | except Exception as e: 145 | say(f'Error scanning for WiFi networks: {str(e)}') 146 | -------------------------------------------------------------------------------- /gonotego/common/events.py: -------------------------------------------------------------------------------- 1 | from typing import Text, Tuple 2 | 3 | import dataclasses 4 | from datetime import datetime 5 | import json 6 | 7 | AUDIO_DONE = 'done' 8 | 9 | SUBMIT = 'submit' 10 | UNINDENT = 'unindent' 11 | INDENT = 'indent' 12 | CLEAR_EMPTY = 'clear_empty' 13 | ENTER_EMPTY = 'enter_empty' 14 | END_SESSION = 'end_session' 15 | 16 | 17 | @dataclasses.dataclass 18 | class AudioEvent: 19 | action: Text 20 | filepath: Text 21 | 22 | def __bytes__(self): 23 | return f'{self.action}:{self.filepath}'.encode('utf-8') 24 | 25 | @staticmethod 26 | def from_bytes(b): 27 | action, filepath = b.decode('utf-8').split(':', 1) 28 | return AudioEvent(action, filepath) 29 | 30 | 31 | @dataclasses.dataclass 32 | class CommandEvent: 33 | command_text: Text 34 | 35 | def __bytes__(self): 36 | return self.command_text.encode('utf-8') 37 | 38 | def from_bytes(b): 39 | command_text = b.decode('utf-8') 40 | return CommandEvent(command_text) 41 | 42 | 43 | @dataclasses.dataclass 44 | class NoteEvent: 45 | text: Text 46 | action: Text 47 | audio_filepath: Text 48 | timestamp: datetime 49 | 50 | def __bytes__(self): 51 | return json.dumps(dataclasses.asdict(self)).encode('utf-8') 52 | 53 | def from_bytes(b): 54 | d = json.loads(b.decode('utf-8')) 55 | return NoteEvent(**d) 56 | 57 | 58 | @dataclasses.dataclass 59 | class LEDEvent: 60 | color: Tuple[int] 61 | ids: Tuple[int] 62 | 63 | def __bytes__(self): 64 | return json.dumps(dataclasses.asdict(self)).encode('utf-8') 65 | 66 | def from_bytes(b): 67 | d = json.loads(b.decode('utf-8')) 68 | return LEDEvent(**d) 69 | -------------------------------------------------------------------------------- /gonotego/common/internet.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import time 3 | 4 | from gonotego.common import status 5 | 6 | Status = status.Status 7 | 8 | 9 | def is_internet_available(url='www.google.com'): 10 | """Determines if we are connected to the Internet.""" 11 | connection = http.client.HTTPConnection(url, timeout=2) 12 | try: 13 | connection.request('HEAD', '/') 14 | connection.close() 15 | return True 16 | except: 17 | connection.close() 18 | return False 19 | 20 | 21 | def wait_for_internet(url='www.google.com', on_disconnect=None): 22 | first = True 23 | while not is_internet_available(url): 24 | if first: 25 | print('No internet connection available. Sleeping.') 26 | status.set(Status.INTERNET_AVAILABLE, False) 27 | first = False 28 | if on_disconnect is not None: 29 | on_disconnect() 30 | time.sleep(60) 31 | if not first: 32 | print('Internet connection restored.') 33 | status.set(Status.INTERNET_AVAILABLE, True) 34 | -------------------------------------------------------------------------------- /gonotego/common/interprocess.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | 4 | def get_redis_client(): 5 | return redis.Redis(host='localhost', port=6379, db=0) 6 | 7 | 8 | class InterprocessQueue: 9 | 10 | def __init__(self, key): 11 | self.key = key 12 | self.r = get_redis_client() 13 | self.index = 0 14 | 15 | def put(self, value): 16 | self.r.set(f'{self.key}:latest', value) 17 | return self.r.rpush(self.key, value) 18 | 19 | def get(self): 20 | """Gets the next item in the queue. Does not remove it from the queue.""" 21 | value = self.r.lindex(self.key, self.index) 22 | if value is not None: 23 | self.index += 1 24 | return value 25 | 26 | def peek_all(self): 27 | return self.r.lrange(self.key, self.index, -1) 28 | 29 | def commit(self, value): 30 | """Removes the next item in the queue, asserting it matches the provided value. 31 | 32 | To ensure each item is processed, use the following pattern. 33 | 1. value = queue.get() 34 | 2. process(value) 35 | 3. queue.commit(value) 36 | 37 | This can still result in an item being processed or partially processed multiple times, 38 | but importantly it guarantees each item is processed. 39 | 40 | Args: 41 | value: The expected value for the leftmost item in the queue. 42 | """ 43 | if value is None: 44 | return 45 | 46 | pop_value = self.r.lpop(self.key) 47 | self.index -= 1 48 | assert self.index >= 0 49 | assert value == pop_value 50 | 51 | def size(self): 52 | return self.r.llen(self.key) - self.index 53 | 54 | def latest(self): 55 | return self.r.get(f'{self.key}:latest') 56 | 57 | def clear(self): 58 | self.r.delete(self.key) 59 | 60 | 61 | def get_audio_events_queue(): 62 | return InterprocessQueue('audio_events_queue') 63 | 64 | 65 | def get_command_events_queue(): 66 | return InterprocessQueue('command_events_queue') 67 | 68 | 69 | def get_note_events_queue(): 70 | return InterprocessQueue('note_events_queue') 71 | 72 | 73 | def get_note_events_session_queue(): 74 | return InterprocessQueue('note_events_session_queue') 75 | -------------------------------------------------------------------------------- /gonotego/common/status.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import enum 3 | 4 | from gonotego.common import interprocess 5 | 6 | 7 | STATUS_KEY = 'GoNoteGo:status' 8 | 9 | 10 | class Status(enum.Enum): 11 | 12 | def _generate_next_value_(name, start, count, last_values): 13 | return name 14 | 15 | AUDIO_READY = enum.auto() 16 | AUDIO_RECORDING = enum.auto() 17 | TEXT_READY = enum.auto() 18 | TEXT_LAST_KEYPRESS = enum.auto() 19 | TRANSCRIPTION_READY = enum.auto() 20 | TRANSCRIPTION_ACTIVE = enum.auto() 21 | UPLOADER_READY = enum.auto() 22 | UPLOADER_ACTIVE = enum.auto() 23 | INTERNET_AVAILABLE = enum.auto() 24 | VOLUME_SETTING = enum.auto() 25 | 26 | 27 | def get_redis_key(key): 28 | return f'{STATUS_KEY}:{key.name}' 29 | 30 | 31 | def get(key): 32 | r = interprocess.get_redis_client() 33 | value_bytes = r.get(get_redis_key(key)) 34 | if value_bytes is None: 35 | return None 36 | value_repr = value_bytes.decode('utf-8') 37 | value = ast.literal_eval(value_repr) 38 | return value 39 | 40 | 41 | def set(key, value): 42 | r = interprocess.get_redis_client() 43 | value_repr = repr(value) 44 | value_bytes = value_repr.encode('utf-8') 45 | r.set(get_redis_key(key), value_bytes) 46 | -------------------------------------------------------------------------------- /gonotego/common/test_events.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from gonotego.common import events 3 | 4 | 5 | class EventsTest(unittest.TestCase): 6 | 7 | def test_audio_event(self): 8 | event = events.AudioEvent( 9 | action=events.AUDIO_DONE, 10 | filepath='/tmp/audio') 11 | event_bytes = bytes(event) 12 | event2 = events.AudioEvent.from_bytes(event_bytes) 13 | self.assertEqual(event, event2) 14 | 15 | def test_command_event(self): 16 | event = events.CommandEvent( 17 | command_text='time') 18 | event_bytes = bytes(event) 19 | event2 = events.CommandEvent.from_bytes(event_bytes) 20 | self.assertEqual(event, event2) 21 | 22 | def test_note_event(self): 23 | event = events.NoteEvent( 24 | text='Example note.', 25 | action=events.SUBMIT, 26 | audio_filepath='/tmp/audio', 27 | timestamp=None) 28 | event_bytes = bytes(event) 29 | event2 = events.NoteEvent.from_bytes(event_bytes) 30 | self.assertEqual(event, event2) 31 | 32 | def test_led_event(self): 33 | event = events.LEDEvent( 34 | color=[0, 0, 0], 35 | ids=[0]) 36 | event_bytes = bytes(event) 37 | event2 = events.LEDEvent.from_bytes(event_bytes) 38 | self.assertEqual(event, event2) 39 | -------------------------------------------------------------------------------- /gonotego/leds/indicators.py: -------------------------------------------------------------------------------- 1 | """A library for setting the num lock and caps lock indicator LEDs on the keyboard.""" 2 | import struct 3 | import time 4 | import os 5 | 6 | NUM_LOCK = 0x00 7 | CAPS_LOCK = 0x01 8 | 9 | LED_EVENT = 0x11 10 | 11 | 12 | def set(device_path='/dev/input/by-id/usb-_Raspberry_Pi_Internal_Keyboard-event-kbd', led_id=NUM_LOCK, state=0): 13 | """Sets the specified led (num lock or caps lock) to the specified state (on or off). 14 | 15 | Args: 16 | device_path: The path to the keyboard input device with the LEDs. The default value 17 | is for a Raspberry Pi 400. 18 | led_id: One of NUM_LOCK or CAPS_LOCK, indicating which light's status to modify. 19 | state: 0 indicates off, while 1 indicates on. 20 | """ 21 | fd = os.open(device_path, os.O_WRONLY) 22 | now = time.time() 23 | now_seconds = int(now) 24 | now_microseconds = int((now - now_seconds) * 1e6) 25 | data = struct.pack('@llHHI', now_seconds, now_microseconds, LED_EVENT, led_id, state) 26 | os.write(fd, data) 27 | -------------------------------------------------------------------------------- /gonotego/scratch/insert_note.py: -------------------------------------------------------------------------------- 1 | import fire 2 | 3 | from gonotego.common import events 4 | from gonotego.common import interprocess 5 | from gonotego.common import status 6 | 7 | 8 | def insert(text='test transcript', filepath=None): 9 | filepath = filepath or 'out/20210920-1632188898638.wav' 10 | note_events_queue = interprocess.get_note_events_queue() 11 | note_event = events.NoteEvent(text, filepath) 12 | note_events_queue.put(bytes(note_event)) 13 | print(note_events_queue.size()) 14 | return 'Success' 15 | 16 | 17 | def insert_command(text='test transcript'): 18 | command_events_queue = interprocess.get_command_events_queue() 19 | command_event = events.CommandEvent(text) 20 | command_events_queue.put(bytes(command_event)) 21 | print(command_events_queue.size()) 22 | return 'Success' 23 | 24 | 25 | def size(): 26 | note_events_queue = interprocess.get_note_events_queue() 27 | print(note_events_queue.size()) 28 | return 'Success' 29 | 30 | 31 | def get_status(): 32 | for key in list(status.Status): 33 | print(key, status.get(key)) 34 | 35 | 36 | if __name__ == '__main__': 37 | fire.Fire() 38 | -------------------------------------------------------------------------------- /gonotego/settings-server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /gonotego/settings-server/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /gonotego/settings-server/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /gonotego/settings-server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Go Note Go Settings 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /gonotego/settings-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "settings-server", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-hover-card": "^1.1.2", 14 | "@radix-ui/react-select": "^2.1.2", 15 | "@radix-ui/react-slot": "^1.1.0", 16 | "class-variance-authority": "^0.7.0", 17 | "clsx": "^2.1.1", 18 | "lucide-react": "^0.456.0", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "tailwind-merge": "^2.5.4", 22 | "tailwindcss-animate": "^1.0.7" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.13.0", 26 | "@types/node": "^22.9.0", 27 | "@types/react": "^18.3.12", 28 | "@types/react-dom": "^18.3.1", 29 | "@vitejs/plugin-react": "^4.3.3", 30 | "autoprefixer": "^10.4.20", 31 | "eslint": "^9.13.0", 32 | "eslint-plugin-react-hooks": "^5.0.0", 33 | "eslint-plugin-react-refresh": "^0.4.14", 34 | "globals": "^15.11.0", 35 | "postcss": "^8.4.47", 36 | "tailwindcss": "^3.4.14", 37 | "typescript": "~5.6.2", 38 | "typescript-eslint": "^8.11.0", 39 | "vite": "^5.4.10" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gonotego/settings-server/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; 3 | import { Input } from '@/components/ui/input'; 4 | import { Button } from '@/components/ui/button'; 5 | import { Eye, EyeOff, Save, Plus, Trash2, Info } from 'lucide-react'; 6 | import { Alert, AlertDescription } from '@/components/ui/alert'; 7 | import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 8 | import { 9 | HoverCard, 10 | HoverCardContent, 11 | HoverCardTrigger, 12 | } from "@/components/ui/hover-card"; 13 | 14 | const SettingsUI = () => { 15 | const NOTE_TAKING_SYSTEMS = [ 16 | { display: 'Roam Research', value: 'roam' }, 17 | { display: 'RemNote', value: 'remnote' }, 18 | { display: 'IdeaFlow', value: 'ideaflow' }, 19 | { display: 'Mem', value: 'mem' }, 20 | { display: 'Notion', value: 'notion' }, 21 | { display: 'Slack', value: 'slack' }, 22 | { display: 'Twitter', value: 'twitter' }, 23 | { display: 'Email', value: 'email' } 24 | ]; 25 | 26 | const BLOB_STORAGE_SYSTEMS = [ 27 | { display: 'Dropbox', value: 'dropbox' } 28 | ]; 29 | 30 | const [settings, setSettings] = useState({ 31 | HOTKEY: '', 32 | NOTE_TAKING_SYSTEM: '', 33 | BLOB_STORAGE_SYSTEM: '', 34 | ROAM_GRAPH: '', 35 | ROAM_USER: '', 36 | ROAM_PASSWORD: '', 37 | REMNOTE_USER_ID: '', 38 | REMNOTE_API_KEY: '', 39 | REMNOTE_ROOT_REM: '', 40 | IDEAFLOW_USER: '', 41 | IDEAFLOW_PASSWORD: '', 42 | MEM_API_KEY: '', 43 | NOTION_INTEGRATION_TOKEN: '', 44 | NOTION_DATABASE_ID: '', 45 | SLACK_API_TOKEN: '', 46 | SLACK_CHANNEL: '', 47 | TWITTER_API_KEY: '', 48 | TWITTER_API_SECRET: '', 49 | TWITTER_ACCESS_TOKEN: '', 50 | TWITTER_ACCESS_TOKEN_SECRET: '', 51 | EMAIL: '', 52 | EMAIL_USER: '', 53 | EMAIL_PASSWORD: '', 54 | EMAIL_SERVER: '', 55 | DROPBOX_ACCESS_TOKEN: '', 56 | OPENAI_API_KEY: '', 57 | WIFI_NETWORKS: [], 58 | CUSTOM_COMMAND_PATHS: [], 59 | }); 60 | 61 | // Keep track of the original settings to detect changes 62 | const [originalSettings, setOriginalSettings] = useState({}); 63 | 64 | const [newWifiNetwork, setNewWifiNetwork] = useState({ 65 | ssid: '', 66 | psk: '' 67 | }); 68 | 69 | const [showPasswords, setShowPasswords] = useState({}); 70 | const [saveStatus, setSaveStatus] = useState(null); 71 | const [loadingSettings, setLoadingSettings] = useState(true); 72 | const [loadError, setLoadError] = useState(false); 73 | const [customPath, setCustomPath] = useState(''); 74 | 75 | // Fetch settings when component mounts 76 | useEffect(() => { 77 | const fetchSettings = async () => { 78 | try { 79 | setLoadingSettings(true); 80 | setLoadError(false); 81 | 82 | const response = await fetch('/api/settings'); 83 | 84 | if (!response.ok) { 85 | throw new Error('Failed to fetch settings'); 86 | } 87 | 88 | const data = await response.json(); 89 | 90 | // Process the received settings 91 | const validSettings = Object.entries(data).reduce((acc, [key, value]) => { 92 | // Always include the value, even if it's empty 93 | // This ensures we show empty strings and other falsy values correctly 94 | acc[key] = value; 95 | return acc; 96 | }, {}); 97 | 98 | // Update settings state with fetched data 99 | const updatedSettings = { 100 | ...settings, 101 | ...validSettings 102 | }; 103 | setSettings(updatedSettings); 104 | 105 | // Store the original settings to detect changes 106 | setOriginalSettings(JSON.parse(JSON.stringify(updatedSettings))); 107 | } catch (error) { 108 | console.error('Error fetching settings:', error); 109 | setLoadError(true); 110 | } finally { 111 | setLoadingSettings(false); 112 | } 113 | }; 114 | 115 | fetchSettings(); 116 | }, []); 117 | 118 | // Function to detect if there are unsaved changes 119 | const hasUnsavedChanges = () => { 120 | // Check if the settings object has the same structure as originalSettings 121 | if (Object.keys(originalSettings).length === 0) return false; 122 | 123 | // Compare original and current settings using deep comparison 124 | try { 125 | return JSON.stringify(settings) !== JSON.stringify(originalSettings); 126 | } catch (e) { 127 | // If comparison fails (e.g., circular references), default to true 128 | return true; 129 | } 130 | }; 131 | 132 | const handleChange = (key, value) => { 133 | setSettings(prev => ({ 134 | ...prev, 135 | [key]: value 136 | })); 137 | }; 138 | 139 | const togglePasswordVisibility = (field) => { 140 | setShowPasswords(prev => ({ 141 | ...prev, 142 | [field]: !prev[field] 143 | })); 144 | }; 145 | 146 | const handleSave = async () => { 147 | setSaveStatus('saving'); 148 | try { 149 | // Create an object with only the changed settings 150 | const changedSettings = {}; 151 | 152 | // Compare each setting with its original value 153 | for (const [key, value] of Object.entries(settings)) { 154 | // Get the original value 155 | const originalValue = originalSettings[key]; 156 | 157 | // Check if this setting has changed using string comparison 158 | // This handles all types of settings including arrays (via JSON.stringify) 159 | if (JSON.stringify(value) !== JSON.stringify(originalValue)) { 160 | changedSettings[key] = value; 161 | } 162 | } 163 | 164 | console.log('Sending only changed settings:', changedSettings); 165 | 166 | const response = await fetch('/api/settings', { 167 | method: 'POST', 168 | headers: { 169 | 'Content-Type': 'application/json', 170 | }, 171 | body: JSON.stringify(changedSettings), 172 | }); 173 | 174 | if (!response.ok) { 175 | throw new Error('Failed to save settings'); 176 | } 177 | 178 | setSaveStatus('saved'); 179 | 180 | // Update the original settings after saving 181 | setOriginalSettings(JSON.parse(JSON.stringify(settings))); 182 | 183 | setTimeout(() => setSaveStatus(null), 2000); 184 | } catch (error) { 185 | console.error('Error saving settings:', error); 186 | setSaveStatus('error'); 187 | setTimeout(() => setSaveStatus(null), 2000); 188 | } 189 | }; 190 | 191 | const addCustomPath = () => { 192 | if (customPath.trim()) { 193 | setSettings(prev => ({ 194 | ...prev, 195 | CUSTOM_COMMAND_PATHS: [...prev.CUSTOM_COMMAND_PATHS, customPath.trim()] 196 | })); 197 | setCustomPath(''); 198 | } 199 | }; 200 | 201 | const removeCustomPath = (index) => { 202 | setSettings(prev => ({ 203 | ...prev, 204 | CUSTOM_COMMAND_PATHS: prev.CUSTOM_COMMAND_PATHS.filter((_, i) => i !== index) 205 | })); 206 | }; 207 | 208 | const renderSettingGroup = (title, description, fields, visible = true) => { 209 | if (!visible) return null; 210 | 211 | return ( 212 | 213 | 214 | {title} 215 | {description} 216 | 217 | 218 | {fields.map(({ key, label, type = 'text', placeholder = '', tooltip }) => ( 219 |
220 |
221 | 222 | {tooltip && ( 223 | 224 | 225 | 226 | 227 | 228 | {tooltip} 229 | 230 | 231 | )} 232 |
233 |
234 | handleChange(key, e.target.value)} 238 | placeholder={placeholder} 239 | className="w-full pr-10" 240 | /> 241 | {type === 'password' && ( 242 | 251 | )} 252 |
253 |
254 | ))} 255 |
256 |
257 | ); 258 | }; 259 | 260 | const shouldShowSection = (section) => { 261 | const system = settings.NOTE_TAKING_SYSTEM; 262 | return system === section; 263 | }; 264 | 265 | const getSystemDisplayName = (value) => { 266 | const system = NOTE_TAKING_SYSTEMS.find(sys => sys.value === value); 267 | return system ? system.display : value; 268 | }; 269 | 270 | const getBlobStorageDisplayName = (value) => { 271 | const system = BLOB_STORAGE_SYSTEMS.find(sys => sys.value === value); 272 | return system ? system.display : value; 273 | }; 274 | 275 | return ( 276 |
277 |

Go Note Go Settings

278 | 279 | {loadingSettings && ( 280 |
281 |

Loading settings...

282 |
283 | )} 284 | 285 | {loadError && ( 286 | 287 | 288 | Error loading settings. The settings API server might not be running. 289 |
290 | 297 |
298 |
299 |
300 | )} 301 | 302 | {!loadingSettings && !loadError && ( 303 | <> 304 | 305 | 306 | Core Settings 307 | Essential configuration options 308 | 309 | 310 |
311 | 312 | 331 |
332 |
333 |
334 | 335 | 336 | 337 | 338 | 339 | 340 |

341 |

    342 |
  • Single press → Start recording
  • 343 |
  • Single press again → Stop recording
  • 344 |
  • Double press → Cancel recording
  • 345 |
  • Hold 1 second → Play previous recording
  • 346 |
  • Recording also stops after 3 seconds of silence
  • 347 |
348 |

349 |
350 |
351 |
352 | handleChange('HOTKEY', e.target.value)} 355 | placeholder="e.g., Esc" 356 | /> 357 |
358 |
359 | 360 | 379 |
380 |
381 |
382 | 383 | {renderSettingGroup('Essential Integrations', 'Required API keys and configurations', [ 384 | { 385 | key: 'OPENAI_API_KEY', 386 | label: 'OpenAI API Key', 387 | type: 'password', 388 | tooltip: ( 389 |
390 |

Required for audio transcription and language model features.

391 |

392 | Get your API key at:{' '} 393 |

394 | 400 | platform.openai.com/api-keys 401 | 402 |
403 | ) 404 | }, 405 | { 406 | key: 'DROPBOX_ACCESS_TOKEN', 407 | label: 'Dropbox Access Token', 408 | type: 'password', 409 | tooltip: ( 410 |
411 |

Used for storing audio recordings in Dropbox.

412 |

413 | Get your access token at:{' '} 414 |

415 | 421 | dropbox.com/developers/apps 422 | 423 |
424 | ) 425 | }, 426 | ])} 427 | 428 | 429 | 430 |
431 | WiFi Configuration 432 | 433 | 434 | 435 | 436 | 437 |
438 |

Configure WiFi settings for your device.

439 |

440 | Changes will apply after saving and restarting the network service. 441 |

442 |
443 |
444 |
445 |
446 | Manage wireless network connections 447 |
448 | 449 | 450 |
451 |
452 |

WiFi Networks

453 | 454 | 455 | 456 | 457 | 458 |

459 | Add multiple WiFi networks. Your device will automatically connect to the strongest available network. 460 |

461 |
462 |
463 |
464 | 465 | {/* List of saved networks */} 466 |
467 | {settings.WIFI_NETWORKS && settings.WIFI_NETWORKS.length > 0 ? ( 468 | settings.WIFI_NETWORKS.map((network, index) => ( 469 |
470 |
471 |
{network.ssid}
472 |
473 | {network.psk ? 'Secured (WPA)' : 'Open Network'} 474 |
475 |
476 | 489 |
490 | )) 491 | ) : ( 492 |
493 | No WiFi networks configured 494 |
495 | )} 496 |
497 | 498 | {/* Add new network form */} 499 |
500 |

Add New Network

501 | 502 |
503 | 504 | setNewWifiNetwork(prev => ({ ...prev, ssid: e.target.value }))} 507 | placeholder="WiFi network name" 508 | /> 509 |
510 | 511 |
512 |
513 | 514 | Leave empty for open networks 515 |
516 |
517 | setNewWifiNetwork(prev => ({ ...prev, psk: e.target.value }))} 521 | placeholder="WiFi password" 522 | className="pr-10" 523 | /> 524 | 533 |
534 |
535 | 536 | 560 |
561 |
562 |
563 |
564 | 565 | 566 | 567 |
568 | Email Configuration 569 | 570 | 571 | 572 | 573 | 574 |
575 |

When are emails sent?

576 |
577 |
578 | 1. 579 | When Email is your note-taking system 580 |
581 |
582 | 2. 583 |
584 | When using the email command: 585 | :email recipient@example.com "Email Subject" "Email Body" 586 |
587 |
588 |
589 |
590 |
591 |
592 |
593 | Configure email delivery settings 594 |
595 | 596 |
597 |
598 | 599 | 600 | 601 | 602 | 603 | 604 |
605 |

This email address is only used when Email is selected as your note-taking system.

606 |

When using the :email command, specify the recipient in the command itself.

607 |
608 |
609 |
610 |
611 | handleChange('EMAIL', e.target.value)} 614 | placeholder="you@example.com" 615 | /> 616 |
617 | 618 |
619 |
620 | 621 | 622 | 623 | 624 | 625 | 626 |

627 | The email address that will be used to send your notes. This should be the address associated with your SMTP server account. 628 |

629 |
630 |
631 |
632 | handleChange('EMAIL_USER', e.target.value)} 635 | placeholder="your-sender@gmail.com" 636 | /> 637 |
638 | 639 |
640 |
641 | 642 | 643 | 644 | 645 | 646 | 647 |

648 | For Gmail, use an App Password generated from your Google Account settings. Regular password won't work with 2FA enabled. 649 |

650 |
651 |
652 |
653 |
654 | handleChange('EMAIL_PASSWORD', e.target.value)} 658 | placeholder="Your email password or app password" 659 | className="pr-10" 660 | /> 661 | 670 |
671 |
672 | 673 |
674 |
675 | 676 | 677 | 678 | 679 | 680 | 681 |

682 |

    683 |
  • Gmail: smtp.gmail.com
  • 684 |
  • Outlook: smtp.office365.com
  • 685 |
  • Yahoo: smtp.mail.yahoo.com
  • 686 |
687 |

688 |
689 |
690 |
691 | handleChange('EMAIL_SERVER', e.target.value)} 694 | placeholder="smtp.gmail.com" 695 | /> 696 |
697 |
698 |
699 | 700 | {/* Conditional Settings based on Note Taking System */} 701 | {renderSettingGroup('Roam Research', 'Roam Research integration settings', [ 702 | { key: 'ROAM_GRAPH', label: 'Graph Name' }, 703 | { key: 'ROAM_USER', label: 'Username' }, 704 | { key: 'ROAM_PASSWORD', label: 'Password', type: 'password' }, 705 | ], shouldShowSection('roam'))} 706 | 707 | {renderSettingGroup('RemNote', 'RemNote integration settings', [ 708 | { key: 'REMNOTE_USER_ID', label: 'User ID' }, 709 | { key: 'REMNOTE_API_KEY', label: 'API Key', type: 'password' }, 710 | { key: 'REMNOTE_ROOT_REM', label: 'Root Rem' }, 711 | ], shouldShowSection('remnote'))} 712 | 713 | {renderSettingGroup('IdeaFlow', 'IdeaFlow integration settings', [ 714 | { key: 'IDEAFLOW_USER', label: 'Username' }, 715 | { key: 'IDEAFLOW_PASSWORD', label: 'Password', type: 'password' }, 716 | ], shouldShowSection('ideaflow'))} 717 | 718 | {renderSettingGroup('Mem', 'Mem integration settings', [ 719 | { key: 'MEM_API_KEY', label: 'API Key', type: 'password' }, 720 | ], shouldShowSection('mem'))} 721 | 722 | {renderSettingGroup('Notion', 'Notion integration settings', [ 723 | { key: 'NOTION_INTEGRATION_TOKEN', label: 'Integration Token', type: 'password' }, 724 | { key: 'NOTION_DATABASE_ID', label: 'Database ID' }, 725 | ], shouldShowSection('notion'))} 726 | 727 | {renderSettingGroup('Slack', 'Slack integration settings', [ 728 | { key: 'SLACK_API_TOKEN', label: 'API Token', type: 'password', tip: 'Bot token starting with xoxb-' }, 729 | { key: 'SLACK_CHANNEL', label: 'Channel Name', tip: 'Channel name without the # symbol' }, 730 | ], shouldShowSection('slack'))} 731 | 732 | {renderSettingGroup('Twitter API', 'Twitter API credentials', [ 733 | { key: 'TWITTER_API_KEY', label: 'API Key', type: 'password' }, 734 | { key: 'TWITTER_API_SECRET', label: 'API Secret', type: 'password' }, 735 | { key: 'TWITTER_ACCESS_TOKEN', label: 'Access Token', type: 'password' }, 736 | { key: 'TWITTER_ACCESS_TOKEN_SECRET', label: 'Access Token Secret', type: 'password' }, 737 | ], shouldShowSection('twitter'))} 738 | 739 | 740 | 741 | Custom Command Paths 742 | Add custom command paths for extended functionality 743 | 744 | 745 |
746 | setCustomPath(e.target.value)} 749 | placeholder="Enter custom command path" 750 | className="flex-1" 751 | /> 752 | 756 |
757 |
758 | {settings.CUSTOM_COMMAND_PATHS.map((path, index) => ( 759 |
760 | {path} 761 | 769 |
770 | ))} 771 |
772 |
773 |
774 | 775 |
776 |
777 | {saveStatus === 'saved' && ( 778 | 779 | Settings saved successfully! 780 | 781 | )} 782 | {saveStatus === 'error' && ( 783 | 784 | Error saving settings. Please try again. 785 | 786 | )} 787 | 801 |
802 |
803 | 804 | )} 805 |
806 | ); 807 | }; 808 | 809 | function App() { 810 | return ( 811 |
812 |
813 | 814 |
815 |
816 | ); 817 | } 818 | 819 | export default App; 820 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border border-neutral-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-neutral-950 dark:border-neutral-800 dark:[&>svg]:text-neutral-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50", 12 | destructive: 13 | "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90", 13 | destructive: 14 | "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90", 15 | outline: 16 | "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 17 | secondary: 18 | "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", 19 | ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 20 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const HoverCard = HoverCardPrimitive.Root 7 | 8 | const HoverCardTrigger = HoverCardPrimitive.Trigger 9 | 10 | const HoverCardContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 24 | )) 25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 26 | 27 | export { HoverCard, HoverCardTrigger, HoverCardContent } 28 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-neutral-300", 21 | className 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | 46 | 47 | )) 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef, 52 | React.ComponentPropsWithoutRef 53 | >(({ className, ...props }, ref) => ( 54 | 62 | 63 | 64 | )) 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | 73 | 84 | 85 | 92 | {children} 93 | 94 | 95 | 96 | 97 | )) 98 | SelectContent.displayName = SelectPrimitive.Content.displayName 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, children, ...props }, ref) => ( 116 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | {children} 131 | 132 | )) 133 | SelectItem.displayName = SelectPrimitive.Item.displayName 134 | 135 | const SelectSeparator = React.forwardRef< 136 | React.ElementRef, 137 | React.ComponentPropsWithoutRef 138 | >(({ className, ...props }, ref) => ( 139 | 144 | )) 145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 146 | 147 | export { 148 | Select, 149 | SelectGroup, 150 | SelectValue, 151 | SelectTrigger, 152 | SelectContent, 153 | SelectLabel, 154 | SelectItem, 155 | SelectSeparator, 156 | SelectScrollUpButton, 157 | SelectScrollDownButton, 158 | } 159 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | a { 21 | font-weight: 500; 22 | color: #646cff; 23 | text-decoration: inherit; 24 | } 25 | a:hover { 26 | color: #535bf2; 27 | } 28 | 29 | body { 30 | margin: 0; 31 | display: flex; 32 | place-items: center; 33 | min-width: 320px; 34 | min-height: 100vh; 35 | } 36 | 37 | h1 { 38 | font-size: 3.2em; 39 | line-height: 1.1; 40 | } 41 | 42 | button { 43 | border-radius: 8px; 44 | border: 1px solid transparent; 45 | padding: 0.6em 1.2em; 46 | font-size: 1em; 47 | font-weight: 500; 48 | font-family: inherit; 49 | background-color: #1a1a1a; 50 | cursor: pointer; 51 | transition: border-color 0.25s; 52 | } 53 | button:hover { 54 | border-color: #646cff; 55 | } 56 | button:focus, 57 | button:focus-visible { 58 | outline: 4px auto -webkit-focus-ring-color; 59 | } 60 | 61 | @media (prefers-color-scheme: light) { 62 | :root { 63 | color: #213547; 64 | background-color: #ffffff; 65 | } 66 | a:hover { 67 | color: #747bff; 68 | } 69 | button { 70 | background-color: #f9f9f9; 71 | } 72 | } 73 | 74 | @layer base { 75 | :root { 76 | --radius: 0.5rem; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /gonotego/settings-server/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /gonotego/settings-server/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], 5 | theme: { 6 | extend: { 7 | borderRadius: { 8 | lg: 'var(--radius)', 9 | md: 'calc(var(--radius) - 2px)', 10 | sm: 'calc(var(--radius) - 4px)' 11 | }, 12 | colors: {} 13 | } 14 | }, 15 | plugins: [require("tailwindcss-animate")], 16 | } 17 | -------------------------------------------------------------------------------- /gonotego/settings-server/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /gonotego/settings-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /gonotego/settings-server/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /gonotego/settings-server/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import react from "@vitejs/plugin-react" 3 | import { defineConfig } from "vite" 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /gonotego/settings/secure_settings_template.py: -------------------------------------------------------------------------------- 1 | HOTKEY = '' 2 | NOTE_TAKING_SYSTEM = '' 3 | BLOB_STORAGE_SYSTEM = '' 4 | 5 | ROAM_GRAPH = '' 6 | ROAM_USER = '' 7 | ROAM_PASSWORD = '' 8 | 9 | REMNOTE_USER_ID = '' 10 | REMNOTE_API_KEY = '' 11 | REMNOTE_ROOT_REM = '' 12 | 13 | IDEAFLOW_USER = '' 14 | IDEAFLOW_PASSWORD = '' 15 | 16 | MEM_API_KEY = '' 17 | 18 | NOTION_INTEGRATION_TOKEN = '' 19 | NOTION_DATABASE_ID = '' 20 | 21 | TWITTER_API_KEY = '' 22 | TWITTER_API_SECRET = '' 23 | TWITTER_ACCESS_TOKEN = '' 24 | TWITTER_ACCESS_TOKEN_SECRET = '' 25 | 26 | SLACK_API_TOKEN = '' 27 | SLACK_CHANNEL = '' 28 | 29 | EMAIL = '' 30 | EMAIL_USER = '' 31 | EMAIL_PASSWORD = '' 32 | EMAIL_SERVER = '' 33 | 34 | DROPBOX_ACCESS_TOKEN = '' 35 | 36 | OPENAI_API_KEY = '' 37 | 38 | WIFI_NETWORKS = [] 39 | CUSTOM_COMMAND_PATHS = [] 40 | -------------------------------------------------------------------------------- /gonotego/settings/server.py: -------------------------------------------------------------------------------- 1 | """Settings server for Go Note Go. 2 | 3 | This server provides both static file serving for the settings UI and API endpoints 4 | for the settings UI to interact with the settings backend. 5 | """ 6 | import json 7 | import os 8 | import sys 9 | import mimetypes 10 | from http.server import BaseHTTPRequestHandler, HTTPServer 11 | from urllib.parse import urlparse 12 | 13 | from gonotego.settings import secure_settings 14 | from gonotego.settings import settings 15 | from gonotego.settings import wifi 16 | 17 | PORT = 8000 18 | 19 | # Path to the static files (React build) 20 | STATIC_FILES_DIR = os.path.abspath(os.path.join( 21 | os.path.dirname(__file__), "../settings-server/dist")) 22 | 23 | # Sensitive keys that should be masked 24 | SENSITIVE_KEYS = [ 25 | 'ROAM_PASSWORD', 26 | 'REMNOTE_API_KEY', 27 | 'IDEAFLOW_PASSWORD', 28 | 'MEM_API_KEY', 29 | 'NOTION_INTEGRATION_TOKEN', 30 | 'TWITTER_API_KEY', 31 | 'TWITTER_API_SECRET', 32 | 'TWITTER_ACCESS_TOKEN', 33 | 'TWITTER_ACCESS_TOKEN_SECRET', 34 | 'EMAIL_PASSWORD', 35 | 'DROPBOX_ACCESS_TOKEN', 36 | 'OPENAI_API_KEY', 37 | ] 38 | 39 | class SettingsCombinedHandler(BaseHTTPRequestHandler): 40 | """HTTP request handler for settings server and API.""" 41 | 42 | def _set_response_headers(self, status_code=200, content_type="application/json"): 43 | """Set common response headers.""" 44 | self.send_response(status_code) 45 | self.send_header("Content-type", content_type) 46 | self.end_headers() 47 | 48 | def do_OPTIONS(self): 49 | """Handle OPTIONS requests.""" 50 | self._set_response_headers() 51 | 52 | def serve_static_file(self, file_path): 53 | """Serve a static file.""" 54 | try: 55 | # If path is a directory, serve index.html 56 | if os.path.isdir(file_path): 57 | file_path = os.path.join(file_path, "index.html") 58 | 59 | # If file doesn't exist, return 404 60 | if not os.path.exists(file_path): 61 | self._set_response_headers(status_code=404, content_type="text/plain") 62 | self.wfile.write(b"404 Not Found") 63 | return 64 | 65 | # Get the file's MIME type 66 | content_type, _ = mimetypes.guess_type(file_path) 67 | if content_type is None: 68 | content_type = "application/octet-stream" 69 | 70 | # Read and serve the file 71 | with open(file_path, "rb") as f: 72 | content = f.read() 73 | 74 | self._set_response_headers(status_code=200, content_type=content_type) 75 | self.wfile.write(content) 76 | except Exception as e: 77 | print(f"Error serving static file: {e}") 78 | self._set_response_headers(status_code=500, content_type="text/plain") 79 | self.wfile.write(b"500 Internal Server Error") 80 | 81 | def do_GET(self): 82 | """Handle GET requests.""" 83 | parsed_path = urlparse(self.path) 84 | path = parsed_path.path 85 | 86 | # Handle API requests 87 | if path == "/api/reset": 88 | # Simple endpoint to reset test settings 89 | try: 90 | # Clear the CUSTOM_COMMAND_PATHS setting 91 | settings.clear("CUSTOM_COMMAND_PATHS") 92 | # You could clear other test settings here 93 | 94 | self._set_response_headers(content_type="application/json") 95 | self.wfile.write(json.dumps({"success": True, "message": "Settings reset"}).encode("utf-8")) 96 | except Exception as e: 97 | print(f"Error resetting settings: {e}") 98 | self._set_response_headers(status_code=500, content_type="application/json") 99 | self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8")) 100 | elif path == "/api/settings": 101 | try: 102 | # Get all available settings from secure_settings and mask sensitive values 103 | all_settings = {} 104 | # Get all settings keys available in secure_settings 105 | available_keys = [key for key in dir(secure_settings) if key.isupper() and not key.startswith("__")] 106 | 107 | # Process all available settings 108 | for key in available_keys: 109 | try: 110 | # Get the value using settings.get method (handles Redis + fallback) 111 | value = settings.get(key) 112 | 113 | # Check if it's a template placeholder like '' 114 | is_template = (isinstance(value, str) and 115 | value.startswith('<') and 116 | value.endswith('>') and 117 | value[1:-1].strip() == key) 118 | 119 | # Always add to response, marking template values clearly 120 | if is_template: 121 | # For template values, send an empty string to clear the field 122 | all_settings[key] = "" 123 | else: 124 | # For sensitive values, mask them 125 | if key in SENSITIVE_KEYS and value: 126 | all_settings[key] = "●●●●●●●●" 127 | # For non-sensitive or empty values, return as is 128 | else: 129 | # Special handling for WIFI_NETWORKS 130 | if key == 'WIFI_NETWORKS': 131 | # Get networks directly from the wifi module to ensure proper format 132 | networks = wifi.get_networks() 133 | all_settings[key] = networks 134 | else: 135 | all_settings[key] = value 136 | except Exception as e: 137 | print(f"Error getting setting {key}: {e}") 138 | 139 | self._set_response_headers(content_type="application/json") 140 | self.wfile.write(json.dumps(all_settings).encode("utf-8")) 141 | except Exception as e: 142 | print(f"Error handling GET request: {e}") 143 | self._set_response_headers(status_code=500, content_type="application/json") 144 | self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8")) 145 | # Serve static files 146 | else: 147 | # Remove leading slash for file path 148 | if path == "/": 149 | file_path = STATIC_FILES_DIR 150 | else: 151 | file_path = os.path.join(STATIC_FILES_DIR, path.lstrip("/")) 152 | 153 | self.serve_static_file(file_path) 154 | 155 | def do_POST(self): 156 | """Handle POST requests.""" 157 | parsed_path = urlparse(self.path) 158 | if parsed_path.path == "/api/settings": 159 | try: 160 | # Read the request body 161 | content_length = int(self.headers["Content-Length"]) 162 | post_data = self.rfile.read(content_length).decode("utf-8") 163 | settings_data = json.loads(post_data) 164 | 165 | # Update settings 166 | for key, value in settings_data.items(): 167 | # Skip masked values - we don't want to overwrite with placeholder text 168 | if value == "●●●●●●●●": 169 | continue 170 | 171 | # Skip non-settings keys 172 | if not key.isupper() or key.startswith("__"): 173 | continue 174 | 175 | try: 176 | # Handle WiFi networks specially 177 | if key == 'WIFI_NETWORKS': 178 | wifi.save_networks(value) 179 | wifi.configure_network_connections() 180 | wifi.reconfigure_wifi() 181 | else: 182 | # For all other settings, just use settings.set 183 | settings.set(key, value) 184 | except Exception as e: 185 | print(f"Error setting {key}: {e}") 186 | 187 | self._set_response_headers(content_type="application/json") 188 | self.wfile.write(json.dumps({"success": True}).encode("utf-8")) 189 | except Exception as e: 190 | print(f"Error handling POST request: {e}") 191 | self._set_response_headers(status_code=500, content_type="application/json") 192 | self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8")) 193 | else: 194 | self._set_response_headers(status_code=404, content_type="application/json") 195 | self.wfile.write(json.dumps({"error": "Not found"}).encode("utf-8")) 196 | 197 | def run_server(): 198 | """Run the combined settings server.""" 199 | # Make sure the static files directory exists 200 | if not os.path.exists(STATIC_FILES_DIR): 201 | print(f"Error: Static files directory {STATIC_FILES_DIR} does not exist.") 202 | print("Make sure to build the React app before running the server.") 203 | sys.exit(1) 204 | 205 | server_address = ("", PORT) 206 | httpd = HTTPServer(server_address, SettingsCombinedHandler) 207 | print(f"Starting combined settings server on port {PORT}") 208 | print(f"Serving static files from {STATIC_FILES_DIR}") 209 | httpd.serve_forever() 210 | 211 | if __name__ == "__main__": 212 | run_server() -------------------------------------------------------------------------------- /gonotego/settings/settings.py: -------------------------------------------------------------------------------- 1 | """To modify settings, edit secure_settings.py or run ":set KEY VALUE" on Go Note Go. 2 | 3 | Settings set on Go Note Go take precedence. 4 | Run ":clear all" to clear settings set on Go Note Go, reverting back to those set 5 | in secure_settings.py. 6 | Run ":clear KEY" to clear an individual setting on Go Note Go, reverting it back 7 | to its value from secure_settings.py. 8 | """ 9 | import ast 10 | from gonotego.settings import secure_settings 11 | from gonotego.common import interprocess 12 | 13 | SETTINGS_KEY = 'GoNoteGo:settings' 14 | 15 | 16 | def get_redis_key(key): 17 | return f'{SETTINGS_KEY}:{key}' 18 | 19 | 20 | def get(key): 21 | r = interprocess.get_redis_client() 22 | value_bytes = r.get(get_redis_key(key)) 23 | if value_bytes is None: 24 | # If the setting isn't set in redis, fall back to the value from secure_settings. 25 | return getattr(secure_settings, key) 26 | value_repr = value_bytes.decode('utf-8') 27 | value = ast.literal_eval(value_repr) 28 | return value 29 | 30 | 31 | def set(key, value): 32 | r = interprocess.get_redis_client() 33 | value_repr = repr(value) 34 | value_bytes = value_repr.encode('utf-8') 35 | r.set(get_redis_key(key), value_bytes) 36 | 37 | 38 | def clear(key): 39 | r = interprocess.get_redis_client() 40 | r.delete(get_redis_key(key)) 41 | 42 | 43 | def clear_all(): 44 | r = interprocess.get_redis_client() 45 | for key in r.keys(get_redis_key('*')): 46 | r.delete(key) 47 | -------------------------------------------------------------------------------- /gonotego/settings/tools/generate_template.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """Generates secure_settings_template.py from secure_settings.py.""" 3 | 4 | import re 5 | 6 | SETTING_RE = r"""(\w*) = (['"]).*\2""" 7 | SETTING_TEMPLATE = r"\1 = '<\1>'" 8 | 9 | 10 | def main(): 11 | with open('settings/secure_settings.py', 'r') as secure_settings_file: 12 | with open('settings/secure_settings_template.py', 'w') as secure_settings_template_file: 13 | secure_settings = secure_settings_file.read() 14 | secure_settings_template = secure_settings 15 | 16 | secure_settings_template = re.sub( 17 | SETTING_RE, 18 | SETTING_TEMPLATE, 19 | secure_settings 20 | ) 21 | 22 | secure_settings_template_file.write(secure_settings_template) 23 | 24 | 25 | if __name__ == '__main__': 26 | main() 27 | -------------------------------------------------------------------------------- /gonotego/settings/wifi.py: -------------------------------------------------------------------------------- 1 | """WiFi settings module for Go Note Go using NetworkManager.""" 2 | import json 3 | import subprocess 4 | from gonotego.settings import settings 5 | from gonotego.command_center import system_commands 6 | 7 | shell = system_commands.shell 8 | 9 | 10 | def get_networks(): 11 | """Get the list of WiFi networks from Redis settings.""" 12 | try: 13 | return json.loads(settings.get('WIFI_NETWORKS') or '[]') 14 | except (json.JSONDecodeError, TypeError): 15 | return [] 16 | 17 | 18 | def save_networks(networks): 19 | """Save the list of WiFi networks to Redis settings.""" 20 | settings.set('WIFI_NETWORKS', json.dumps(networks)) 21 | 22 | 23 | def configure_network_connections(): 24 | """Configure NetworkManager connections for Go Note Go managed WiFi networks.""" 25 | networks = get_networks() 26 | 27 | try: 28 | # Add or update connections - add_wifi_connection handles either adding new 29 | # or modifying existing connections as needed 30 | for network in networks: 31 | ssid = network['ssid'] 32 | 33 | if network.get('psk'): 34 | # WPA secured network 35 | add_wifi_connection(ssid, network['psk']) 36 | else: 37 | # Open network 38 | add_wifi_connection(ssid) 39 | 40 | return True 41 | except Exception as e: 42 | print(f"Error updating NetworkManager connections: {e}") 43 | return False 44 | 45 | 46 | def modify_wifi_connection(ssid, password=None): 47 | """Modify an existing WiFi connection. 48 | 49 | Args: 50 | ssid: The SSID/name of the connection to modify 51 | password: If provided, configures as WPA secured network. If None, configures as open network. 52 | 53 | Returns: 54 | True on success, False on error 55 | """ 56 | conn_name = ssid 57 | 58 | try: 59 | # Build the modify command 60 | modify_cmd = ["sudo", "nmcli", "connection", "modify", conn_name] 61 | 62 | # Add basic settings 63 | modify_cmd.extend(["802-11-wireless.ssid", ssid]) 64 | 65 | # Add security settings 66 | if password: 67 | modify_cmd.extend([ 68 | "802-11-wireless-security.key-mgmt", "wpa-psk", 69 | "802-11-wireless-security.psk", password 70 | ]) 71 | else: 72 | # For open networks, remove security 73 | modify_cmd.extend([ 74 | "802-11-wireless-security.key-mgmt", "", 75 | "-802-11-wireless-security.psk" # Remove PSK 76 | ]) 77 | 78 | # Run the modify command 79 | subprocess.run(modify_cmd, check=True, capture_output=True) 80 | return True 81 | except subprocess.CalledProcessError as e: 82 | print(f"Error modifying connection {ssid}: {e}") 83 | print(f"Error output: {e.stderr}") 84 | return False 85 | 86 | 87 | def add_wifi_connection(ssid, password=None): 88 | """Add a new WiFi connection (secure or open). 89 | 90 | Args: 91 | ssid: The SSID of the network to add 92 | password: If provided, adds a WPA secured network. If None, adds an open network. 93 | 94 | Returns: 95 | True on success, False on error 96 | """ 97 | conn_name = ssid 98 | 99 | # Base command for both connection types 100 | add_cmd = [ 101 | "sudo", "nmcli", "connection", "add", 102 | "type", "wifi", 103 | "con-name", conn_name, 104 | "ifname", "wlan0", 105 | "ssid", ssid 106 | ] 107 | 108 | # Add security parameters if password is provided 109 | if password: 110 | add_cmd.extend([ 111 | "wifi-sec.key-mgmt", "wpa-psk", 112 | "wifi-sec.psk", password 113 | ]) 114 | 115 | try: 116 | # Try to add the connection 117 | subprocess.run(add_cmd, check=True, capture_output=True) 118 | return True 119 | except subprocess.CalledProcessError as e: 120 | # If the error is that the connection already exists, try to modify it 121 | if "already exists" in str(e.stderr): 122 | return modify_wifi_connection(ssid, password) 123 | else: 124 | # Other error 125 | conn_type = "WPA" if password else "open" 126 | print(f"Error adding {conn_type} connection for {ssid}: {e}") 127 | print(f"Error output: {e.stderr}") 128 | return False 129 | 130 | def reconfigure_wifi(): 131 | """Reconnect to available WiFi networks.""" 132 | # Refresh all connections and activate the best available one 133 | try: 134 | # Restart NetworkManager service to apply changes 135 | shell('sudo systemctl restart NetworkManager') 136 | 137 | # Get list of available configured networks 138 | networks = get_networks() 139 | 140 | # Try to connect to the first available network 141 | for network in networks: 142 | ssid = network['ssid'] 143 | try: 144 | # Check if we can see this network 145 | result = subprocess.run( 146 | ["nmcli", "-t", "-f", "SSID", "device", "wifi", "list"], 147 | capture_output=True, text=True, check=True 148 | ) 149 | 150 | available_networks = [line.strip() for line in result.stdout.splitlines()] 151 | 152 | if ssid in available_networks: 153 | # Try to connect to this network 154 | subprocess.run( 155 | ["nmcli", "connection", "up", "id", ssid], 156 | check=True, capture_output=True 157 | ) 158 | print(f"Connected to {ssid}") 159 | break 160 | except Exception as e: 161 | print(f"Error connecting to {ssid}: {e}") 162 | continue 163 | 164 | return True 165 | except Exception as e: 166 | print(f"Error reconfiguring WiFi: {e}") 167 | return False 168 | 169 | -------------------------------------------------------------------------------- /gonotego/supervisord.conf: -------------------------------------------------------------------------------- 1 | [unix_http_server] 2 | file=/tmp/supervisor.sock 3 | chmod=0766 4 | username=go 5 | password=notego 6 | 7 | [inet_http_server] 8 | port=127.0.0.1:9001 9 | username=go 10 | password=notego 11 | 12 | [supervisord] 13 | logfile=/tmp/supervisord.log 14 | pidfile=/tmp/supervisord.pid 15 | 16 | [rpcinterface:supervisor] 17 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 18 | 19 | [supervisorctl] 20 | serverurl=unix:///tmp/supervisor.sock 21 | history_file=/home/pi/code/github/dbieber/GoNoteGo/.sc_history 22 | username=go 23 | password=notego 24 | 25 | [program:GoNoteGo-audio-listener] 26 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/audio/runner.py 27 | directory=/home/pi 28 | user=root 29 | 30 | [program:GoNoteGo-command-center] 31 | environment=XDG_RUNTIME_DIR="/run/user/1000" 32 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/command_center/runner.py 33 | directory=/home/pi 34 | user=pi 35 | 36 | [program:GoNoteGo-text-listener] 37 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/text/runner.py 38 | directory=/home/pi 39 | user=root 40 | 41 | [program:GoNoteGo-transcription] 42 | environment=GOOGLE_APPLICATION_CREDENTIALS="/home/pi/secrets/google_credentials.json",LD_PRELOAD=/usr/lib/arm-linux-gnueabihf/libatomic.so.1.2.0 43 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/transcription/runner.py 44 | directory=/home/pi 45 | user=pi 46 | 47 | [program:GoNoteGo-uploader] 48 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/uploader/runner.py 49 | directory=/home/pi 50 | user=pi 51 | 52 | [program:GoNoteGo-settings] 53 | command=/home/pi/code/github/dbieber/GoNoteGo/env/bin/python /home/pi/code/github/dbieber/GoNoteGo/gonotego/settings/server.py 54 | directory=/home/pi 55 | user=root 56 | -------------------------------------------------------------------------------- /gonotego/text/runner.py: -------------------------------------------------------------------------------- 1 | from gonotego.common import status 2 | from gonotego.text import shell 3 | 4 | Status = status.Status 5 | 6 | 7 | def main(): 8 | s = shell.Shell() 9 | s.start() 10 | status.set(Status.TEXT_READY, True) 11 | s.wait() 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /gonotego/text/shell.py: -------------------------------------------------------------------------------- 1 | import keyboard 2 | import platform 3 | try: 4 | import pyperclip 5 | except: 6 | print('Cannot import pyperclip') 7 | import time 8 | 9 | from gonotego.common import events 10 | from gonotego.common import interprocess 11 | from gonotego.common import status 12 | from gonotego.settings import settings 13 | 14 | Status = status.Status 15 | 16 | MINUS = chr(8722) 17 | assert MINUS == '−' # This is a unicode minus sign, not an ordinary hyphen. 18 | MAC_LEFT_SHIFT = 56 19 | 20 | shift_characters = { 21 | '1': '!', 22 | '2': '@', 23 | '3': '#', 24 | '4': '$', 25 | '5': '%', 26 | '6': '^', 27 | '7': '&', 28 | '8': '*', 29 | '9': '(', 30 | '0': ')', 31 | '-': '_', # Ordinary hyphen. ord(x) == 45. 32 | MINUS: '_', # The kind typed on the Raspberry Pi. ord(x) == 8722. 33 | '=': '+', 34 | '[': '{', 35 | ']': '}', 36 | '\\': '|', 37 | '`': '~', 38 | ';': ':', 39 | "'": '"', 40 | ',': '<', 41 | '.': '>', 42 | '/': '?', 43 | } 44 | 45 | character_substitutions = { 46 | MINUS: '-', # Replace unicode minus with ordinary hyphens. 47 | } 48 | 49 | 50 | def get_timestamp(): 51 | return time.time() 52 | 53 | 54 | def is_shift_pressed(): 55 | if keyboard.is_pressed('shift') or keyboard.is_pressed('right shift'): 56 | return True 57 | if platform.system() == 'Darwin': 58 | if keyboard.is_pressed(MAC_LEFT_SHIFT): 59 | return True 60 | return False 61 | 62 | 63 | class Shell: 64 | 65 | def __init__(self): 66 | self.command_event_queue = interprocess.get_command_events_queue() 67 | self.note_events_queue = interprocess.get_note_events_queue() 68 | self.note_events_session_queue = interprocess.get_note_events_session_queue() 69 | self.text = '' 70 | self.last_press = None 71 | 72 | def start(self): 73 | keyboard.on_press(self.on_press) 74 | 75 | def on_press(self, event): 76 | self.last_press = time.time() 77 | status.set(Status.TEXT_LAST_KEYPRESS, self.last_press) 78 | try: 79 | if keyboard.is_pressed(settings.get('HOTKEY')): 80 | # Ignore presses while the hotkey is pressed. 81 | return 82 | except ValueError: 83 | # If HOTKEY is not a valid key, continue. 84 | pass 85 | 86 | if event.name == 'tab': 87 | if is_shift_pressed(): 88 | # Shift-Tab 89 | note_event = events.NoteEvent( 90 | text=None, 91 | action=events.UNINDENT, 92 | audio_filepath=None, 93 | timestamp=get_timestamp()) 94 | self.note_events_queue.put(bytes(note_event)) 95 | self.note_events_session_queue.put(bytes(note_event)) 96 | else: 97 | # Tab 98 | note_event = events.NoteEvent( 99 | text=None, 100 | action=events.INDENT, 101 | audio_filepath=None, 102 | timestamp=get_timestamp()) 103 | self.note_events_queue.put(bytes(note_event)) 104 | self.note_events_session_queue.put(bytes(note_event)) 105 | elif event.name == 'delete' or event.name == 'backspace': 106 | if self.text == '': 107 | note_event = events.NoteEvent( 108 | text=None, 109 | action=events.CLEAR_EMPTY, 110 | audio_filepath=None, 111 | timestamp=get_timestamp()) 112 | self.note_events_queue.put(bytes(note_event)) 113 | self.note_events_session_queue.put(bytes(note_event)) 114 | self.text = self.text[:-1] 115 | if is_shift_pressed(): 116 | self.text = '' 117 | elif event.name == 'v' and keyboard.is_pressed('cmd'): 118 | # If on Mac, paste into the buffer. 119 | if platform.system() == 'Darwin': 120 | clipboard = pyperclip.paste() 121 | self.text += clipboard 122 | elif event.name == 'enter': 123 | if is_shift_pressed(): 124 | note_event = events.NoteEvent( 125 | text=None, 126 | action=events.END_SESSION, 127 | audio_filepath=None, 128 | timestamp=get_timestamp()) 129 | self.note_events_queue.put(bytes(note_event)) 130 | self.note_events_session_queue.clear() 131 | # Write both a text event (for the command center) 132 | # and a note event (for the uploader). 133 | if self.text == '': 134 | note_event = events.NoteEvent( 135 | text=None, 136 | action=events.ENTER_EMPTY, 137 | audio_filepath=None, 138 | timestamp=get_timestamp()) 139 | self.note_events_queue.put(bytes(note_event)) 140 | self.note_events_session_queue.put(bytes(note_event)) 141 | elif self.text.strip().startswith('::'): 142 | self.text = self.text.strip()[1:] 143 | self.submit_note() 144 | elif self.text.strip().startswith(':'): 145 | command_event = events.CommandEvent(command_text=self.text.strip()[1:]) 146 | self.command_event_queue.put(bytes(command_event)) 147 | self.text = '' 148 | else: 149 | self.submit_note() 150 | elif event.name == 'space': 151 | self.text += ' ' 152 | elif len(event.name) == 1: 153 | character = event.name 154 | if character in character_substitutions: 155 | character = character_substitutions[character] 156 | if is_shift_pressed(): 157 | character = shift_characters.get(character, character.upper()) 158 | self.text += character 159 | 160 | def submit_note(self): 161 | if self.text: 162 | note_event = events.NoteEvent( 163 | text=self.text, 164 | action=events.SUBMIT, 165 | audio_filepath=None, 166 | timestamp=get_timestamp()) 167 | self.note_events_queue.put(bytes(note_event)) 168 | self.note_events_session_queue.put(bytes(note_event)) 169 | # Reset the text buffer. 170 | self.text = '' 171 | 172 | def handle_inactivity(self): 173 | self.submit_note() 174 | 175 | note_event = events.NoteEvent( 176 | text=None, 177 | action=events.END_SESSION, 178 | audio_filepath=None, 179 | timestamp=get_timestamp()) 180 | self.note_events_queue.put(bytes(note_event)) 181 | self.note_events_session_queue.clear() 182 | 183 | def wait(self): 184 | while True: 185 | time.sleep(5) 186 | 187 | # If 3 minutes elapse, submit the buffer as a note and clear it. 188 | if self.last_press and time.time() - self.last_press > 180: 189 | self.last_press = None 190 | self.handle_inactivity() 191 | -------------------------------------------------------------------------------- /gonotego/transcription/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from gonotego.common import events 5 | from gonotego.common import internet 6 | from gonotego.common import interprocess 7 | from gonotego.common import status 8 | from gonotego.transcription import transcriber 9 | 10 | Status = status.Status 11 | 12 | 13 | def main(): 14 | print('Starting transcription.') 15 | audio_events_queue = interprocess.get_audio_events_queue() 16 | command_events_queue = interprocess.get_command_events_queue() 17 | note_events_queue = interprocess.get_note_events_queue() 18 | note_events_session_queue = interprocess.get_note_events_session_queue() 19 | 20 | t = transcriber.Transcriber() 21 | status.set(Status.TRANSCRIPTION_READY, True) 22 | while True: 23 | audio_event_bytes = audio_events_queue.get() 24 | 25 | if audio_event_bytes is not None: 26 | print(f'Event received: {audio_event_bytes}') 27 | 28 | # Don't start transcribing until we have an internet connection. 29 | internet.wait_for_internet() 30 | 31 | event = events.AudioEvent.from_bytes(audio_event_bytes) 32 | if event.action == events.AUDIO_DONE and os.path.exists(event.filepath): 33 | status.set(Status.TRANSCRIPTION_ACTIVE, True) 34 | transcript = t.transcribe(event.filepath) 35 | if transcript: 36 | text_filepath = event.filepath.replace('.wav', '.txt') 37 | with open(text_filepath, 'w') as f: 38 | f.write(transcript) 39 | print(transcript) 40 | # TODO(dbieber): Add the note event for audio when the audio is captured, 41 | # rather than waiting until its transcribed. Use a placeholder with an id. 42 | # Update that placeholder once the transcription is ready. 43 | note_event = events.NoteEvent( 44 | text=transcript, 45 | action=events.SUBMIT, 46 | audio_filepath=event.filepath, 47 | timestamp=time.time(), 48 | ) 49 | note_events_queue.put(bytes(note_event)) 50 | note_events_session_queue.put(bytes(note_event)) 51 | 52 | # Audio commands: 53 | for trigger in ['go go', 'GoGo', 'Go-Go']: 54 | extended_trigger = f'{trigger} ' 55 | if transcript.lower().startswith(extended_trigger.lower()): 56 | command_text = transcript[len(extended_trigger):] + ':' 57 | command_event = events.CommandEvent(command_text) 58 | command_events_queue.put(bytes(command_event)) 59 | 60 | status.set(Status.TRANSCRIPTION_ACTIVE, False) 61 | else: 62 | # No audio ready to transcribe. Sleep. 63 | time.sleep(1) 64 | 65 | audio_events_queue.commit(audio_event_bytes) 66 | 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /gonotego/transcription/transcriber.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import fire 4 | import openai 5 | from gonotego.settings import settings 6 | 7 | 8 | class Transcriber: 9 | 10 | def transcribe(self, filepath): 11 | client = openai.OpenAI(api_key=settings.get('OPENAI_API_KEY')) 12 | with io.open(filepath, 'rb') as audio_file: 13 | response = client.audio.transcriptions.create( 14 | model="whisper-1", 15 | file=audio_file 16 | ) 17 | transcription = response.text 18 | return transcription.strip() 19 | 20 | 21 | if __name__ == '__main__': 22 | fire.Fire() 23 | -------------------------------------------------------------------------------- /gonotego/uploader/blob/blob_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import dropbox 4 | 5 | from gonotego.settings import settings 6 | 7 | 8 | def make_client(): 9 | if settings.get('BLOB_STORAGE_SYSTEM') == 'dropbox': 10 | return dropbox.Dropbox(settings.get('DROPBOX_ACCESS_TOKEN')) 11 | 12 | 13 | def upload_blob(filepath, client): 14 | """Uploads a blob, and returns a URL to that blob.""" 15 | if not client: 16 | return '' 17 | 18 | if not os.path.exists(filepath): 19 | return '' 20 | dropbox_path = f'/{filepath}' 21 | with open(filepath, 'rb') as f: 22 | unused_file_metadata = client.files_upload(f.read(), dropbox_path) # noqa 23 | link_metadata = client.sharing_create_shared_link(dropbox_path) 24 | return link_metadata.url.replace('www.', 'dl.').replace('?dl=0', '') 25 | -------------------------------------------------------------------------------- /gonotego/uploader/browser/driver_utils.py: -------------------------------------------------------------------------------- 1 | """Selenium driver utilities.""" 2 | 3 | import os 4 | 5 | 6 | class DriverUtils: 7 | 8 | def __init__(self, driver): 9 | self.driver = driver 10 | 11 | def find_element_by_text_exact(self, text): 12 | return self.driver.find_element_by_xpath(f"//*[text()='{text}']") 13 | 14 | def find_elements_by_text_exact(self, text): 15 | assert "'" not in text 16 | return self.driver.find_elements_by_xpath(f"//*[text()='{text}']") 17 | 18 | def find_element_by_text(self, text): 19 | return self.driver.find_element_by_xpath(f"//*[contains(text(),'{text}')]") 20 | 21 | def find_elements_by_text(self, text): 22 | assert "'" not in text 23 | return self.driver.find_elements_by_xpath(f"//*[contains(text(),'{text}')]") 24 | 25 | def execute_script_tag(self, js): 26 | template_js_path = os.path.join(os.path.dirname(__file__), 'template.js') 27 | with open(template_js_path, 'r') as f: 28 | template = f.read() 29 | js = js.replace('`', r'\`').replace('${', r'\${') 30 | js = template.replace('', js) 31 | return self.driver.execute_script(js) 32 | -------------------------------------------------------------------------------- /gonotego/uploader/browser/template.js: -------------------------------------------------------------------------------- 1 | let script = document.createElement('script'); 2 | script.innerHTML = ``; 3 | document.getElementsByTagName('head')[0].appendChild(script); 4 | -------------------------------------------------------------------------------- /gonotego/uploader/email/email_uploader.py: -------------------------------------------------------------------------------- 1 | from gonotego.common import events 2 | from gonotego.settings import settings 3 | from gonotego.command_center import email_commands 4 | 5 | DRAFT_FILENAME = 'draft' 6 | 7 | 8 | def clip(x, a, b): 9 | """Clips x to be between a and b inclusive.""" 10 | x = max(a, x) 11 | x = min(x, b) 12 | return x 13 | 14 | 15 | class Uploader: 16 | 17 | def __init__(self): 18 | self.last_indent_level = -1 19 | self.indent_level = 0 20 | 21 | def upload(self, note_events): 22 | for note_event in note_events: 23 | if note_event.action == events.INDENT: 24 | self.indent_level = clip(self.indent_level + 1, 0, self.last_indent_level + 1) 25 | elif note_event.action == events.UNINDENT: 26 | self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) 27 | elif note_event.action == events.CLEAR_EMPTY: 28 | self.indent_level = 0 29 | elif note_event.action == events.ENTER_EMPTY: 30 | # Similar to unindent. 31 | self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) 32 | elif note_event.action == events.END_SESSION: 33 | self.end_session() 34 | elif note_event.action == events.SUBMIT: 35 | text = note_event.text.strip() 36 | line = ' ' * self.indent_level + text 37 | with open(DRAFT_FILENAME, 'a') as f: 38 | f.write(line + '\n') 39 | self.last_indent_level = self.indent_level 40 | return True 41 | 42 | def handle_inactivity(self): 43 | self.end_session() 44 | 45 | def handle_disconnect(self): 46 | self.end_session() 47 | 48 | def end_session(self): 49 | # First, send the message. 50 | to = settings.get('EMAIL') 51 | with open(DRAFT_FILENAME, 'r') as f: 52 | text = f.read() 53 | subject = text.split('\n', 1)[0] 54 | if text.strip(): 55 | email_commands.email(to, subject, text) 56 | 57 | # Then reset, the session. 58 | open(DRAFT_FILENAME, 'w').close() 59 | self.last_indent_level = -1 60 | self.indent_level = 0 61 | -------------------------------------------------------------------------------- /gonotego/uploader/ideaflow/ideaflow_uploader.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | import subprocess 4 | import time 5 | 6 | from selenium import webdriver 7 | from selenium.webdriver.common.keys import Keys 8 | from selenium.webdriver.firefox.options import Options 9 | 10 | from gonotego.common import events 11 | from gonotego.settings import settings 12 | from gonotego.uploader.blob import blob_uploader 13 | from gonotego.uploader.browser import driver_utils 14 | 15 | 16 | class IdeaflowBrowser: 17 | 18 | def __init__(self, driver): 19 | self.driver = driver 20 | self.utils = driver_utils.DriverUtils(driver) 21 | 22 | def go_home(self): 23 | self.driver.get('https://ideaflow.app/') 24 | 25 | def sign_in_attempt(self, username, password): 26 | """Sign in to Ideaflow.""" 27 | driver = self.driver 28 | driver.get('https://ideaflow.app/') 29 | login_el = self.utils.find_element_by_text('Log in') 30 | login_el.click() 31 | username_el = driver.find_element_by_name('username') 32 | username_el.clear() 33 | username_el.send_keys(username) 34 | password_el = driver.find_element_by_name('password') 35 | password_el.clear() 36 | password_el.send_keys(password) 37 | self.screenshot('screenshot-signing-in.png') 38 | password_el.send_keys(Keys.RETURN) 39 | time.sleep(5.0) 40 | 41 | def sign_in(self, username, password, retries=5): 42 | """Sign in to Ideaflow with retries.""" 43 | while retries > 0: 44 | print('Attempting sign in.') 45 | retries -= 1 46 | try: 47 | self.sign_in_attempt(username, password) 48 | if self.driver.find_element_by_css_selector('[role=presentation]'): 49 | return True 50 | except Exception as e: 51 | print(f'Attempt failed with exception: {repr(e)}') 52 | time.sleep(1) 53 | print('Failed to sign in. No retries left.') 54 | return False 55 | 56 | def insert_note(self, text): 57 | driver = self.driver 58 | el = driver.find_element_by_css_selector('#editor-container') 59 | el.send_keys(Keys.CONTROL + 'k') 60 | editor_el = driver.find_element_by_css_selector('.editor-div') 61 | editor_el.send_keys(text) 62 | 63 | def screenshot(self, name=None): 64 | filename = name or 'screenshot.png' 65 | print(f'Saving screenshot to {filename}') 66 | try: 67 | self.driver.save_screenshot(filename) 68 | except: 69 | print('Failed to save screenshot. Continuing.') 70 | 71 | 72 | class Uploader: 73 | 74 | def __init__(self, headless=True): 75 | self.headless = headless 76 | self._browser = None 77 | 78 | def get_browser(self): 79 | if self._browser is not None: 80 | return self._browser 81 | 82 | options = Options() 83 | if self.headless: 84 | options.add_argument('-headless') 85 | driver = webdriver.Firefox(options=options) 86 | browser = IdeaflowBrowser(driver) 87 | 88 | # Sign in to Ideaflow. 89 | username = settings.get('IDEAFLOW_USER') 90 | password = settings.get('IDEAFLOW_PASSWORD') or getpass.getpass() 91 | browser.sign_in(username, password) 92 | browser.screenshot('screenshot-post-sign-in.png') 93 | 94 | self._browser = browser 95 | return browser 96 | 97 | def upload(self, note_events): 98 | browser = self.get_browser() 99 | 100 | client = blob_uploader.make_client() 101 | for note_event in note_events: 102 | if note_event.action == events.SUBMIT: 103 | text = note_event.text.strip() 104 | if note_event.audio_filepath and os.path.exists(note_event.audio_filepath): 105 | audio_url = blob_uploader.upload_blob(note_event.audio_filepath, client) 106 | text = f'{text} #unverified-transcription ({audio_url})' 107 | browser.insert_note(text) 108 | return True 109 | 110 | def handle_inactivity(self): 111 | self.close_browser() 112 | 113 | def handle_disconnect(self): 114 | self.close_browser() 115 | 116 | def close_browser(self): 117 | browser = self._browser 118 | if browser: 119 | driver = browser.driver 120 | if driver is not None: 121 | driver.close() 122 | self._browser = None 123 | 124 | subprocess.call(['pkill', 'firefox']) 125 | subprocess.call(['pkill', 'geckodriver']) 126 | -------------------------------------------------------------------------------- /gonotego/uploader/mem/mem_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | 4 | from gonotego.common import events 5 | from gonotego.settings import settings 6 | from gonotego.uploader.blob import blob_uploader 7 | 8 | 9 | CREATE_URL = 'https://api.mem.ai/v0/mems' 10 | 11 | 12 | def upload_mem(text, is_read=True): 13 | return requests.post( 14 | url=CREATE_URL, 15 | json=dict( 16 | content=text, 17 | isRead=is_read, 18 | ), 19 | headers=dict(Authorization=f'ApiAccessToken {settings.get("MEM_API_KEY")}') 20 | ) 21 | 22 | 23 | class Uploader: 24 | 25 | def upload(self, note_events): 26 | client = blob_uploader.make_client() 27 | for note_event in note_events: 28 | if note_event.action == events.SUBMIT: 29 | text = note_event.text.strip() 30 | if note_event.audio_filepath and os.path.exists(note_event.audio_filepath): 31 | is_read = False # Notes with audio should be checked for accuracy. 32 | url = blob_uploader.upload_blob(note_event.audio_filepath, client) 33 | text = f'{text} #transcription ({url})' 34 | else: 35 | is_read = True 36 | upload_mem(text, is_read=is_read) 37 | return True 38 | 39 | def handle_inactivity(self): 40 | pass 41 | 42 | def handle_disconnect(self): 43 | pass 44 | -------------------------------------------------------------------------------- /gonotego/uploader/notion/notion_uploader.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import os 3 | import requests 4 | 5 | from gonotego.common import events 6 | from gonotego.settings import settings 7 | from gonotego.uploader.blob import blob_uploader 8 | 9 | 10 | PAGES_URL = 'https://api.notion.com/v1/pages' 11 | APPEND_CHILD_URL_FORMAT = 'https://api.notion.com/v1/blocks/{block_id}/children' 12 | 13 | 14 | def create_page(title): 15 | return requests.post( 16 | url=PAGES_URL, 17 | json=dict( 18 | parent={'database_id': settings.get('NOTION_DATABASE_ID')}, 19 | properties={ 20 | 'Name': {'title': [{'text': {'content': title}}]}, 21 | } 22 | ), 23 | headers={ 24 | 'Authorization': f'Bearer {settings.get("NOTION_INTEGRATION_TOKEN")}', 25 | 'Notion-Version': '2021-08-16', 26 | }, 27 | ) 28 | 29 | 30 | def make_text_block(text): 31 | return { 32 | 'object': 'block', 33 | 'type': 'paragraph', 34 | 'paragraph': { 35 | 'text': [{'type': 'text', 'text': {'content': text}}], 36 | }, 37 | } 38 | 39 | 40 | def make_audio_block(url): 41 | return { 42 | 'object': 'block', 43 | 'type': 'file', 44 | 'file': { 45 | 'type': 'external', 46 | 'external': {'url': url}, 47 | }, 48 | } 49 | 50 | 51 | def append_notes(blocks, page_id): 52 | return requests.patch( 53 | url=APPEND_CHILD_URL_FORMAT.format(block_id=page_id), 54 | json=dict(children=blocks), 55 | headers={ 56 | 'Authorization': f'Bearer {settings.get("NOTION_INTEGRATION_TOKEN")}', 57 | 'Notion-Version': '2021-08-16', 58 | }, 59 | ) 60 | 61 | 62 | def now_as_title(): 63 | now = datetime.now() 64 | return now.strftime('%B %d, %Y at %H:%M') 65 | 66 | 67 | class Uploader: 68 | 69 | def __init__(self): 70 | self.current_page_id = None 71 | 72 | def upload(self, note_events): 73 | if self.current_page_id is None: 74 | response = create_page(now_as_title()) 75 | self.current_page_id = response.json().get('id') 76 | 77 | client = blob_uploader.make_client() 78 | blocks = [] 79 | for note_event in note_events: 80 | if note_event.action == events.SUBMIT: 81 | text = note_event.text.strip() 82 | blocks.append(make_text_block(text)) 83 | if note_event.audio_filepath and os.path.exists(note_event.audio_filepath): 84 | url = blob_uploader.upload_blob(note_event.audio_filepath, client) 85 | blocks.append(make_audio_block(url)) 86 | append_notes(blocks, page_id=self.current_page_id) 87 | return True 88 | 89 | def handle_inactivity(self): 90 | self.current_page_id = None 91 | 92 | def handle_disconnect(self): 93 | self.current_page_id = None 94 | -------------------------------------------------------------------------------- /gonotego/uploader/remnote/remnote_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | 4 | from gonotego.common import events 5 | from gonotego.settings import settings 6 | from gonotego.uploader.blob import blob_uploader 7 | 8 | CREATE_URL = 'https://api.remnote.io/api/v0/create' 9 | 10 | 11 | def create_rem(text, edit_later, parent_id=None): 12 | data = dict( 13 | apiKey=settings.get('REMNOTE_API_KEY'), 14 | userId=settings.get('REMNOTE_USER_ID'), 15 | text=text, 16 | addToEditLater=edit_later, 17 | parentId=parent_id, 18 | isDocument=False, 19 | ) 20 | response = requests.post( 21 | url=CREATE_URL, 22 | json=data) 23 | return response.json().get('remId') 24 | 25 | 26 | class Uploader: 27 | 28 | def upload(self, note_events): 29 | if not settings.get('REMNOTE_ROOT_REM'): 30 | raise ValueError('Must provide REMNOTE_ROOT_REM') 31 | 32 | client = blob_uploader.make_client() 33 | for note_event in note_events: 34 | if note_event.action == events.SUBMIT: 35 | # Notes with audio should be checked for accuracy. 36 | edit_later = bool(note_event.audio_filepath) 37 | 38 | rem_id = create_rem( 39 | text=note_event.text, 40 | edit_later=edit_later, 41 | parent_id=settings.get('REMNOTE_ROOT_REM')) 42 | if note_event.audio_filepath and os.path.exists(note_event.audio_filepath): 43 | url = blob_uploader.upload_blob(note_event.audio_filepath, client) 44 | create_rem(url, edit_later=False, parent_id=rem_id) 45 | return True 46 | 47 | def handle_inactivity(self): 48 | pass 49 | 50 | def handle_disconnect(self): 51 | pass 52 | -------------------------------------------------------------------------------- /gonotego/uploader/roam/helper.js: -------------------------------------------------------------------------------- 1 | function sleep(ms) { 2 | // sleeps for the specified number of milliseconds. 3 | // _ms_: the number of milliseconds to sleep for. 4 | return new Promise(resolve => setTimeout(resolve, ms)); 5 | } 6 | 7 | function getPage(page) { 8 | // returns the uid of a specific page in your graph. 9 | // _page_: the title of the page. 10 | let results = window.roamAlphaAPI.q(` 11 | [:find ?uid 12 | :in $ ?title 13 | :where 14 | [?page :node/title ?title] 15 | [?page :block/uid ?uid] 16 | ]`, page); 17 | if (results.length) { 18 | return results[0][0]; 19 | } 20 | } 21 | 22 | async function getOrCreatePage(page) { 23 | // returns the uid of a specific page in your graph, creating it first if it does not already exist. 24 | // _page_: the title of the page. 25 | roamAlphaAPI.createPage({page: {title: page}}); 26 | let result; 27 | while (!result) { 28 | await sleep(25); 29 | result = getPage(page); 30 | } 31 | return result; 32 | } 33 | 34 | function getBlockOnPage(page, block) { 35 | // returns the uid of a specific block on a specific page. 36 | // _page_: the title of the page. 37 | // _block_: the text of the block. 38 | let results = window.roamAlphaAPI.q(` 39 | [:find ?block_uid 40 | :in $ ?page_title ?block_string 41 | :where 42 | [?page :node/title ?page_title] 43 | [?page :block/uid ?page_uid] 44 | [?block :block/parents ?page] 45 | [?block :block/string ?block_string] 46 | [?block :block/uid ?block_uid] 47 | ]`, page, block); 48 | if (results.length) { 49 | return results[0][0]; 50 | } 51 | } 52 | 53 | async function createBlockOnPage(page, block, order) { 54 | // creates a new top-level block on a specific page, returning the new block's uid. 55 | // _page_: the title of the page. 56 | // _block_: the text of the block. 57 | // _order_: (optional) controls where to create the block, 0 for top of page, -1 for bottom of page. 58 | let page_uid = getPage(page); 59 | return createChildBlock(page_uid, block, order); 60 | } 61 | 62 | async function getOrCreateBlockOnPage(page, block, order) { 63 | // returns the uid of a specific block on a specific page, creating it first as a top-level block if it's not already there. 64 | // _page_: the title of the page. 65 | // _block_: the text of the block. 66 | // _order_: (optional) controls where to create the block, 0 for top of page, -1 for bottom of page. 67 | let block_uid = getBlockOnPage(page, block); 68 | if (block_uid) return block_uid; 69 | return createBlockOnPage(page, block, order); 70 | } 71 | 72 | function getChildBlocks(parent_uid) { 73 | // returns the uids of all child blocks directly underneath a given parent block. 74 | // _parent_uid_: the uid of the parent block. 75 | let results = window.roamAlphaAPI.q(` 76 | [:find ?block_uid 77 | :in $ ?parent_uid 78 | :where 79 | [?parent :block/uid ?parent_uid] 80 | [?parent :block/children ?block] 81 | [?block :block/uid ?block_uid] 82 | ]`, parent_uid); 83 | return results.flat(); 84 | } 85 | 86 | function getChildBlock(parent_uid, block) { 87 | // returns the uid of a specific child block underneath a specific parent block. 88 | // _parent_uid_: the uid of the parent block. 89 | // _block_: the text of the child block. 90 | let results = window.roamAlphaAPI.q(` 91 | [:find ?block_uid 92 | :in $ ?parent_uid ?block_string 93 | :where 94 | [?parent :block/uid ?parent_uid] 95 | [?block :block/parents ?parent] 96 | [?block :block/string ?block_string] 97 | [?block :block/uid ?block_uid] 98 | ]`, parent_uid, block); 99 | if (results.length) { 100 | return results[0][0]; 101 | } 102 | } 103 | 104 | async function getOrCreateChildBlock(parent_uid, block, order) { 105 | // creates a new child block underneath a specific parent block, returning the new block's uid. 106 | // _parent_uid_: the uid of the parent block. 107 | // _block_: the text of the new block. 108 | // _order_: (optional) controls where to create the block, 0 for inserting at the top, -1 for inserting at the bottom. 109 | let block_uid = getChildBlock(parent_uid, block); 110 | if (block_uid) return block_uid; 111 | return createChildBlock(parent_uid, block, order); 112 | } 113 | 114 | async function createChildBlock(parent_uid, block, order) { 115 | // returns the uid of a specific child block underneath a specific parent block, creating it first if it's not already there. 116 | // _parent_uid_: the uid of the parent block. 117 | // _block_: the text of the child block. 118 | // _order_: (optional) controls where to create the block, 0 for inserting at the top, -1 for inserting at the bottom. 119 | if (!order) { 120 | order = 0; 121 | } 122 | if (order < 0) { 123 | let num_children = getChildBlocks(parent_uid).length; 124 | order = num_children + order + 1; 125 | } 126 | window.roamAlphaAPI.createBlock( 127 | { 128 | "location": {"parent-uid": parent_uid, "order": order}, 129 | "block": {"string": block} 130 | } 131 | ); 132 | let block_uid; 133 | while (!block_uid) { 134 | await sleep(25); 135 | block_uid = getChildBlock(parent_uid, block); 136 | } 137 | return block_uid; 138 | } 139 | 140 | 141 | let nthDate = n => { 142 | // returns the suffix of the nth day of the month, either 'th', 'st', 'nd', or 'rd'. 143 | // _n_: the number of the day of the month. 144 | if (n > 3 && n < 21) return 'th'; 145 | switch (n % 10) { 146 | case 1: 147 | return 'st'; 148 | case 2: 149 | return 'nd'; 150 | case 3: 151 | return 'rd'; 152 | default: 153 | return 'th'; 154 | } 155 | } 156 | 157 | let getRoamDate = dateString => { 158 | // returns the date in the format used by Roam Research page titles. 159 | // _dateString_: a javascript compatible date string. 160 | let monthsDateProcessing = [ 161 | 'January', 'February', 'March', 'April', 'May', 'June', 162 | 'July', 'August', 'September', 'October', 'November', 'December']; 163 | 164 | const d = new Date(dateString); 165 | const year = d.getFullYear(); 166 | const date = d.getDate(); 167 | const month = monthsDateProcessing[d.getMonth()]; 168 | const nthStr = nthDate(date); 169 | return `${month} ${date}${nthStr}, ${year}`; 170 | } 171 | 172 | 173 | async function insertGoNoteGoNote(note) { 174 | // inserts the note into the Go Note Go Notes section of your Daily Notes page. 175 | // _note_: a string to insert as a new note. 176 | return await insertNoteIntoSection(note, '[[Go Note Go Notes]]:') 177 | } 178 | 179 | 180 | async function insertNoteIntoSection(note, section) { 181 | // inserts the note into the specified section of your Daily Notes page. 182 | // _note_: a string to insert as a new note. 183 | // _section_: the section header at which to insert the note 184 | let $roam_date = getRoamDate(new Date()); 185 | 186 | // Add the note to the Daily Notes page. 187 | let block_uid = await getOrCreateBlockOnPage($roam_date, section, -1); 188 | let note_block = await createChildBlock(block_uid, note, -1); 189 | return note_block 190 | } 191 | 192 | 193 | window.sleep = sleep; 194 | window.getPage = getPage; 195 | window.getOrCreatePage = getOrCreatePage; 196 | window.getBlockOnPage = getBlockOnPage; 197 | window.createBlockOnPage = createBlockOnPage; 198 | window.getOrCreateBlockOnPage = getOrCreateBlockOnPage; 199 | window.getChildBlock = getChildBlock; 200 | window.getOrCreateChildBlock = getOrCreateChildBlock; 201 | window.createChildBlock = createChildBlock; 202 | 203 | window.nthDate = nthDate; 204 | window.getRoamDate = getRoamDate; 205 | window.insertGoNoteGoNote = insertGoNoteGoNote; 206 | -------------------------------------------------------------------------------- /gonotego/uploader/roam/roam_uploader.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import getpass 3 | import json 4 | import os 5 | import random 6 | import subprocess 7 | import sys 8 | import time 9 | 10 | from selenium import webdriver 11 | from selenium.webdriver.common.keys import Keys 12 | from selenium.webdriver.firefox.options import Options 13 | 14 | from gonotego.common import events 15 | from gonotego.settings import settings 16 | from gonotego.uploader.blob import blob_uploader 17 | from gonotego.uploader.browser import driver_utils 18 | 19 | 20 | def flush(): 21 | sys.stdout.flush() 22 | sys.stderr.flush() 23 | 24 | 25 | def json_encode(text): 26 | text = json.dumps(text) 27 | text = text.replace('\\', r'\\') 28 | return text 29 | 30 | 31 | class RoamBrowser: 32 | 33 | def __init__(self, driver): 34 | self.driver = driver 35 | self.utils = driver_utils.DriverUtils(driver) 36 | 37 | def go_home(self): 38 | self.driver.get('https://roamresearch.com/') 39 | 40 | def go_graph_attempt(self, graph_name): 41 | if graph_name.startswith('offline/') or graph_name.startswith('app/'): 42 | graph_url = f'https://roamresearch.com/#/{graph_name}' 43 | else: 44 | graph_url = f'https://roamresearch.com/#/app/{graph_name}' 45 | self.driver.get(graph_url) 46 | self.sleep_until_astrolabe_gone(timeout=90) 47 | time.sleep(1) 48 | self.sleep_until_astrolabe_gone(timeout=60) 49 | print('Graph loaded: ' + self.driver.current_url) 50 | self.screenshot('screenshot-graph.png') 51 | flush() 52 | 53 | def go_graph(self, graph_name, retries=5): 54 | while retries > 0: 55 | print('Attempting to go to graph.') 56 | self.go_graph_attempt(graph_name) 57 | retries -= 1 58 | 59 | print(self.driver.current_url) 60 | if self.is_element_with_class_name_stable('roam-app'): 61 | return True 62 | print('Failed to go to graph. No retries left.') 63 | return False 64 | 65 | def sign_in_attempt(self, username, password): 66 | """Sign in to Roam Research.""" 67 | driver = self.driver 68 | driver.get('https://roamresearch.com/#/signin') 69 | email_el = driver.find_element_by_name('email') 70 | email_el.clear() 71 | email_el.send_keys(username) 72 | password_el = driver.find_element_by_name('password') 73 | password_el.clear() 74 | password_el.send_keys(password) 75 | self.screenshot('screenshot-signing-in.png') 76 | password_el.send_keys(Keys.RETURN) 77 | time.sleep(5.0) 78 | self.sleep_until_astrolabe_gone() 79 | 80 | def sign_in(self, username, password, retries=5): 81 | """Sign in to Roam Research with retries.""" 82 | while retries > 0: 83 | print('Attempting sign in.') 84 | retries -= 1 85 | try: 86 | self.sign_in_attempt(username, password) 87 | 88 | print(self.driver.current_url) 89 | if self.is_element_with_class_name_stable('rm-plan'): 90 | return True 91 | except Exception as e: 92 | print(f'Attempt failed with exception: {repr(e)}') 93 | time.sleep(1) 94 | flush() 95 | print('Failed to sign in. No retries left.') 96 | return False 97 | 98 | def is_element_with_class_name_stable(self, class_name): 99 | if self.driver.find_elements_by_class_name(class_name): 100 | time.sleep(1) 101 | if self.driver.find_elements_by_class_name(class_name): 102 | return True 103 | return False 104 | 105 | def screenshot(self, name=None): 106 | filename = name or 'screenshot.png' 107 | print(f'Saving screenshot to {filename}') 108 | try: 109 | self.driver.save_screenshot(filename) 110 | except: 111 | print('Failed to save screenshot. Continuing.') 112 | 113 | def sleep(self): 114 | seconds = random.randint(10, 160) 115 | time.sleep(seconds) 116 | 117 | def execute_helper_js(self): 118 | helper_js_path = os.path.join(os.path.dirname(__file__), 'helper.js') 119 | with open(helper_js_path, 'r') as f: 120 | js = f.read() 121 | self.utils.execute_script_tag(js) 122 | 123 | def insert_top_level_note(self, text): 124 | text_json = json_encode(text) 125 | js = f'window.insertion_result = insertGoNoteGoNote({text_json});' 126 | try: 127 | self.utils.execute_script_tag(js) 128 | except Exception as e: 129 | print(f'Failed to insert note: {text}') 130 | raise e 131 | time.sleep(0.25) 132 | return self.get_insertion_result() 133 | 134 | def get_insertion_result(self): 135 | retries = 5 136 | while retries: 137 | try: 138 | return self.driver.execute_script('return window.insertion_result;') 139 | except: 140 | print('Retrying script: window.insertion_result.') 141 | time.sleep(1) 142 | retries -= 1 143 | 144 | def create_child_block(self, parent_uid, block, order=-1): 145 | parent_uid_json = json_encode(parent_uid) 146 | block_json = json_encode(block) 147 | js = f'window.insertion_result = createChildBlock({parent_uid_json}, {block_json}, {order});' 148 | self.utils.execute_script_tag(js) 149 | time.sleep(0.25) 150 | return self.get_insertion_result() 151 | 152 | def sleep_until_astrolabe_gone(self, timeout=90): 153 | while self.driver.find_elements_by_class_name('loading-astrolabe'): 154 | print('Astrolabe still there.') 155 | time.sleep(1) 156 | timeout -= 1 157 | if timeout <= 0: 158 | raise RuntimeError('Astrolabe still there after timeout.') 159 | print('Astrolabe gone.') 160 | 161 | 162 | class Uploader: 163 | 164 | def __init__(self, headless=True): 165 | self.headless = headless 166 | self._browser = None 167 | 168 | self.session_uid = None 169 | self.last_note_uid = None 170 | self.stack = [] 171 | 172 | # In case Roam crashed leaving the browser open, we close it on start. 173 | self.close_browser() 174 | 175 | def get_browser(self): 176 | if self._browser is not None: 177 | return self._browser 178 | 179 | options = Options() 180 | if self.headless: 181 | options.add_argument('-headless') 182 | driver = webdriver.Firefox(options=options) 183 | browser = RoamBrowser(driver) 184 | 185 | # Sign in to Roam. 186 | username = settings.get('ROAM_USER') 187 | password = settings.get('ROAM_PASSWORD') or getpass.getpass() 188 | browser.sign_in(username, password) 189 | browser.screenshot('screenshot-post-sign-in.png') 190 | 191 | self._browser = browser 192 | flush() 193 | return browser 194 | 195 | def new_session(self): 196 | browser = self.get_browser() 197 | time_str = datetime.now().strftime('%H:%M %p') 198 | block_uid = browser.insert_top_level_note(time_str) 199 | self.session_uid = block_uid 200 | 201 | def upload(self, note_events): 202 | browser = self.get_browser() 203 | in_graph = browser.go_graph(settings.get('ROAM_GRAPH')) 204 | if not in_graph: 205 | print("Failed to access Roam graph. Aborting upload.") 206 | browser.screenshot('screenshot-go_graph-failure.png') 207 | flush() 208 | return False 209 | 210 | time.sleep(0.5) 211 | browser.screenshot('screenshot-graph-later.png') 212 | browser.execute_helper_js() 213 | 214 | client = blob_uploader.make_client() 215 | for note_event in note_events: 216 | if note_event.action == events.INDENT: 217 | # When you press tab, that adds your most-recent note to a stack. 218 | if self.last_note_uid and self.last_note_uid not in self.stack: 219 | self.stack.append(self.last_note_uid) 220 | elif note_event.action == events.UNINDENT: 221 | # When you shift-tab, that pops from the stack. 222 | if self.stack: 223 | self.stack.pop() 224 | elif note_event.action == events.CLEAR_EMPTY: 225 | # When you shift-delete from an empty note, that clears the stack. 226 | self.stack = [] 227 | elif note_event.action == events.ENTER_EMPTY: 228 | # When you submit from an empty note, that pops from the stack. 229 | if self.stack: 230 | self.stack.pop() 231 | elif note_event.action == events.END_SESSION: 232 | self.end_session() 233 | elif note_event.action == events.SUBMIT: 234 | if self.session_uid is None: 235 | self.new_session() 236 | text = note_event.text.strip() 237 | has_audio = note_event.audio_filepath and os.path.exists(note_event.audio_filepath) 238 | if has_audio: 239 | text = f'{text} #[[unverified transcription]]' 240 | if self.stack: 241 | parent_uid = self.stack[-1] 242 | else: 243 | parent_uid = self.session_uid 244 | block_uid = browser.create_child_block(parent_uid, text) 245 | if not block_uid: 246 | print('create_child_block did not yield a block_uid. Aborting upload.') 247 | browser.screenshot('screenshot-create_child_block-failure.png') 248 | flush() 249 | return False 250 | self.last_note_uid = block_uid 251 | print(f'Inserted: "{text}" at block (({block_uid}))') 252 | if has_audio: 253 | embed_url = blob_uploader.upload_blob(note_event.audio_filepath, client) 254 | embed_text = '{{audio: ' + embed_url + '}}' 255 | print(f'Audio embed: {embed_text}') 256 | if block_uid: 257 | browser.create_child_block(block_uid, embed_text) 258 | 259 | flush() 260 | 261 | return True 262 | 263 | def handle_inactivity(self): 264 | self.end_session() 265 | self.close_browser() 266 | 267 | def handle_disconnect(self): 268 | self.end_session() 269 | self.close_browser() 270 | 271 | def end_session(self): 272 | self.session_uid = None 273 | self.last_note_uid = None 274 | self.stack = [] 275 | 276 | def close_browser(self): 277 | browser = self._browser 278 | if browser: 279 | driver = browser.driver 280 | if driver is not None: 281 | driver.quit() 282 | self._browser = None 283 | 284 | subprocess.call(['pkill', 'firefox']) 285 | subprocess.call(['pkill', 'geckodriver']) 286 | -------------------------------------------------------------------------------- /gonotego/uploader/runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | from gonotego.common import events 5 | from gonotego.common import internet 6 | from gonotego.common import interprocess 7 | from gonotego.common import status 8 | from gonotego.settings import settings 9 | from gonotego.uploader.email import email_uploader 10 | from gonotego.uploader.ideaflow import ideaflow_uploader 11 | from gonotego.uploader.remnote import remnote_uploader 12 | from gonotego.uploader.roam import roam_uploader 13 | from gonotego.uploader.mem import mem_uploader 14 | from gonotego.uploader.notion import notion_uploader 15 | from gonotego.uploader.slack import slack_uploader 16 | from gonotego.uploader.twitter import twitter_uploader 17 | 18 | Status = status.Status 19 | 20 | 21 | def print_configuration_help(): 22 | """Print helpful message when NOTE_TAKING_SYSTEM is not configured.""" 23 | print("NOTE_TAKING_SYSTEM is not configured. Please set it using ':set NOTE_TAKING_SYSTEM [system]'") 24 | print("Example: ':set NOTE_TAKING_SYSTEM roam'") 25 | 26 | 27 | def is_unconfigured(note_taking_system): 28 | """Check if the note taking system is unconfigured.""" 29 | return note_taking_system == '' or note_taking_system == '' 30 | 31 | 32 | def make_uploader(note_taking_system): 33 | if note_taking_system == 'email': 34 | return email_uploader.Uploader() 35 | elif note_taking_system == 'ideaflow': 36 | return ideaflow_uploader.Uploader() 37 | elif note_taking_system == 'remnote': 38 | return remnote_uploader.Uploader() 39 | elif note_taking_system == 'roam': 40 | return roam_uploader.Uploader() 41 | elif note_taking_system == 'mem': 42 | return mem_uploader.Uploader() 43 | elif note_taking_system == 'notion': 44 | return notion_uploader.Uploader() 45 | elif note_taking_system == 'slack': 46 | return slack_uploader.Uploader() 47 | elif note_taking_system == 'twitter': 48 | return twitter_uploader.Uploader() 49 | else: 50 | raise ValueError('Unexpected NOTE_TAKING_SYSTEM in settings', note_taking_system) 51 | 52 | 53 | def main(): 54 | print('Starting uploader.') 55 | note_events_queue = interprocess.get_note_events_queue() 56 | note_taking_system = settings.get('NOTE_TAKING_SYSTEM').lower() 57 | 58 | # Check if note taking system is still using the default unconfigured value 59 | if is_unconfigured(note_taking_system): 60 | print_configuration_help() 61 | return 62 | 63 | try: 64 | uploader = make_uploader(note_taking_system) 65 | except ValueError as e: 66 | print(e, file=sys.stderr) 67 | return 68 | status.set(Status.UPLOADER_READY, True) 69 | 70 | last_upload = None 71 | while True: 72 | 73 | # Don't even try uploading notes if we don't have a connection. 74 | internet.wait_for_internet(on_disconnect=uploader.handle_disconnect) 75 | 76 | note_taking_system_setting = settings.get('NOTE_TAKING_SYSTEM').lower() 77 | if note_taking_system_setting != note_taking_system: 78 | note_taking_system = note_taking_system_setting 79 | 80 | # Check if note taking system is using the default unconfigured value after a change 81 | if is_unconfigured(note_taking_system): 82 | print_configuration_help() 83 | continue 84 | 85 | uploader = make_uploader(note_taking_system) 86 | 87 | note_event_bytes_list = [] 88 | note_events = [] 89 | while note_events_queue.size() > 0: 90 | print('Note event received') 91 | note_event_bytes = note_events_queue.get() 92 | note_event_bytes_list.append(note_event_bytes) 93 | note_event = events.NoteEvent.from_bytes(note_event_bytes) 94 | note_events.append(note_event) 95 | 96 | if note_events: 97 | status.set(Status.UPLOADER_ACTIVE, True) 98 | # TODO(dbieber): Allow uploader to yield note events as it finishes them. 99 | # So that if it fails midway, we can still mark the completed events as done. 100 | upload_successful = uploader.upload(note_events) 101 | if upload_successful: 102 | last_upload = time.time() 103 | print('Uploaded.') 104 | else: 105 | print('Upload unsuccessful.') 106 | 107 | status.set(Status.UPLOADER_ACTIVE, False) 108 | if upload_successful: 109 | for note_event_bytes in note_event_bytes_list: 110 | note_events_queue.commit(note_event_bytes) 111 | 112 | if last_upload and time.time() - last_upload > 600: 113 | # X minutes have passed since the last upload. 114 | uploader.handle_inactivity() 115 | 116 | time.sleep(1) 117 | 118 | 119 | if __name__ == '__main__': 120 | main() 121 | -------------------------------------------------------------------------------- /gonotego/uploader/slack/slack_uploader.py: -------------------------------------------------------------------------------- 1 | """Uploader for Slack workspace channels.""" 2 | 3 | import logging 4 | from typing import List, Optional 5 | 6 | from slack_sdk import WebClient 7 | from slack_sdk.errors import SlackApiError 8 | 9 | from gonotego.common import events 10 | from gonotego.settings import settings 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Uploader: 16 | """Uploader implementation for Slack.""" 17 | 18 | def __init__(self): 19 | self._client: Optional[WebClient] = None 20 | self._channel_id: Optional[str] = None 21 | self._thread_ts: Optional[str] = None 22 | self._session_started: bool = False 23 | self._indent_level: int = 0 24 | 25 | @property 26 | def client(self) -> WebClient: 27 | """Get or create the Slack WebClient.""" 28 | if self._client: 29 | return self._client 30 | 31 | # Get token from settings 32 | token = settings.get('SLACK_API_TOKEN') 33 | if not token: 34 | logger.error("Missing Slack API token in settings") 35 | raise ValueError("Missing Slack API token in settings") 36 | 37 | # Initialize the client 38 | self._client = WebClient(token=token) 39 | return self._client 40 | 41 | def _get_channel_id(self) -> str: 42 | """Get the channel ID for the configured channel name.""" 43 | if self._channel_id: 44 | return self._channel_id 45 | 46 | channel_name = settings.get('SLACK_CHANNEL') 47 | if not channel_name: 48 | logger.error("Missing Slack channel name in settings") 49 | raise ValueError("Missing Slack channel name in settings") 50 | 51 | # Try to find the channel in the workspace 52 | try: 53 | result = self.client.conversations_list() 54 | for channel in result['channels']: 55 | if channel['name'] == channel_name: 56 | self._channel_id = channel['id'] 57 | return self._channel_id 58 | except SlackApiError as e: 59 | logger.error(f"Error fetching channels: {e}") 60 | raise 61 | 62 | logger.error(f"Channel {channel_name} not found in workspace") 63 | raise ValueError(f"Channel {channel_name} not found in workspace") 64 | 65 | def _start_session(self, first_note: str) -> bool: 66 | """Start a new session thread in the configured Slack channel.""" 67 | channel_id = self._get_channel_id() 68 | 69 | # Create the initial message with the note content 70 | try: 71 | message_text = f"{first_note}\n\n:keyboard: Go Note Go thread." 72 | response = self.client.chat_postMessage( 73 | channel=channel_id, 74 | text=message_text 75 | ) 76 | self._thread_ts = response['ts'] 77 | self._session_started = True 78 | return True 79 | except SlackApiError as e: 80 | logger.error(f"Error starting session: {e}") 81 | return False 82 | 83 | def _send_note_to_thread(self, text: str, indent_level: int = 0) -> bool: 84 | """Send a note as a reply in the current thread.""" 85 | if not self._thread_ts: 86 | logger.error("Trying to send to thread but no thread exists") 87 | return False 88 | 89 | channel_id = self._get_channel_id() 90 | 91 | # Format the text based on indentation 92 | formatted_text = text 93 | if indent_level > 0: 94 | # Add bullet and proper indentation 95 | bullet = "•" 96 | indentation = " " * (indent_level - 1) 97 | formatted_text = f"{indentation}{bullet} {text}" 98 | 99 | try: 100 | self.client.chat_postMessage( 101 | channel=channel_id, 102 | text=formatted_text, 103 | thread_ts=self._thread_ts 104 | ) 105 | return True 106 | except SlackApiError as e: 107 | logger.error(f"Error sending note to thread: {e}") 108 | return False 109 | 110 | def upload(self, note_events: List[events.NoteEvent]) -> bool: 111 | """Upload note events to Slack. 112 | 113 | Args: 114 | note_events: List of NoteEvent objects. 115 | 116 | Returns: 117 | bool: True if upload successful, False otherwise. 118 | """ 119 | for note_event in note_events: 120 | # Handle indent/unindent events to track indentation level 121 | if note_event.action == events.INDENT: 122 | self._indent_level += 1 123 | continue 124 | elif note_event.action == events.UNINDENT: 125 | self._indent_level = max(0, self._indent_level - 1) 126 | continue 127 | elif note_event.action == events.CLEAR_EMPTY: 128 | self._indent_level = 0 129 | continue 130 | elif note_event.action == events.ENTER_EMPTY: 131 | # When you submit from an empty note, that pops from the stack. 132 | self._indent_level = max(0, self._indent_level - 1) 133 | elif note_event.action == events.SUBMIT: 134 | text = note_event.text.strip() 135 | 136 | # Skip empty notes 137 | if not text: 138 | continue 139 | 140 | # Start a new session for the first note 141 | if not self._session_started: 142 | success = self._start_session(text) 143 | else: 144 | # Send as a reply to the thread with proper indentation 145 | success = self._send_note_to_thread(text, self._indent_level) 146 | 147 | if not success: 148 | logger.error("Failed to upload note to Slack") 149 | return False 150 | 151 | elif note_event.action == events.END_SESSION: 152 | self.end_session() 153 | 154 | return True 155 | 156 | def end_session(self) -> None: 157 | """End the current session.""" 158 | self._thread_ts = None 159 | self._session_started = False 160 | self._indent_level = 0 161 | 162 | def handle_inactivity(self) -> None: 163 | """Handle inactivity by ending the session and clearing client.""" 164 | self._client = None 165 | self.end_session() 166 | 167 | def handle_disconnect(self) -> None: 168 | """Handle disconnection by ending the session and clearing client.""" 169 | self._client = None 170 | self.end_session() 171 | -------------------------------------------------------------------------------- /gonotego/uploader/twitter/twitter_uploader.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | 4 | import twython 5 | 6 | from gonotego.common import events 7 | from gonotego.settings import settings 8 | 9 | 10 | def _tweet(client, text, tweet_id=None): 11 | # Assumes text is < 280 characters. 12 | # If tweet_id is None, tweets a non-reply tweet. 13 | # If tweet_id is not None, tweets a reply tweet. 14 | return client.update_status( 15 | status=text, in_reply_to_status_id=tweet_id) 16 | 17 | 18 | def _tweet_thread(client, texts, tweet_id=None): 19 | # Assume each text is < 280 characters. 20 | # If tweet_id is None, tweets a non-reply tweet thread. 21 | # If tweet_id is not None, tweets a reply tweet thread. 22 | tweets = [] 23 | for text in texts: 24 | if not tweets: 25 | tweet = _tweet(client, text, tweet_id=tweet_id) 26 | else: 27 | time.sleep(random.random()) 28 | tweet = _tweet( 29 | client, text, tweet_id=tweets[-1]['id_str']) 30 | tweets.append(tweet) 31 | return tweets 32 | 33 | 34 | def split_to_tweets(text, delimiter=None, limit=280): 35 | texts = [] 36 | if delimiter and delimiter in text: 37 | # Presence of delimiter forces splitting into a new tweet. 38 | for t in text.split(delimiter): 39 | texts.extend(split_to_tweets(t, delimiter=None, limit=limit)) 40 | return texts 41 | spacers = ['\n\n\n', '\n\n', '\n', ' '] 42 | preferred_breaks = ['.', '!', '?', ';', ',', '-', '+', '*', '&', ')'] 43 | while len(text) > limit: 44 | for spacer in spacers + preferred_breaks: 45 | try: 46 | index = text[:limit].rindex(spacer) 47 | end_index = index 48 | start_index = index + len(spacer) 49 | if spacer in preferred_breaks: 50 | end_index += len(spacer) 51 | texts.append(text[:end_index]) 52 | text = text[start_index:] 53 | break 54 | except ValueError: 55 | pass 56 | else: 57 | # None of the spacers matched. 58 | texts.append(text[:limit]) 59 | text = text[limit:] 60 | 61 | if text: 62 | texts.append(text) 63 | return texts 64 | 65 | 66 | def send(client, text, tweet_id=None): 67 | texts = split_to_tweets(text) 68 | return _tweet_thread(client, texts, tweet_id) 69 | 70 | 71 | def get_oauth_tokens(): 72 | user_id = settings.get('twitter.user_id') 73 | oauth_token = settings.get(f'twitter.user_ids.{user_id}.oauth_token') 74 | oauth_token_secret = settings.get(f'twitter.user_ids.{user_id}.oauth_token_secret') 75 | 76 | # TODO(dbieber): Add fallback to settings: 77 | # settings.get('TWITTER_ACCESS_TOKEN') 78 | # settings.get('TWITTER_ACCESS_TOKEN_SECRET') 79 | 80 | return oauth_token, oauth_token_secret 81 | 82 | 83 | class Uploader: 84 | 85 | def __init__(self): 86 | self._client = None 87 | self.last_tweet_id = None 88 | 89 | @property 90 | def client(self): 91 | if self._client: 92 | return self._client 93 | oauth_token, oauth_token_secret = get_oauth_tokens() 94 | self._client = twython.Twython( 95 | settings.get('TWITTER_API_KEY'), 96 | settings.get('TWITTER_API_SECRET'), 97 | oauth_token, 98 | oauth_token_secret) 99 | return self._client 100 | 101 | def upload(self, note_events): 102 | client = self.client 103 | for note_event in note_events: 104 | if note_event.action == events.SUBMIT: 105 | text = note_event.text.strip() 106 | tweets = send(client, text, self.last_tweet_id) 107 | self.last_tweet_id = tweets[-1]['id_str'] 108 | elif note_event.action == events.END_SESSION: 109 | self.end_session() 110 | 111 | def handle_inactivity(self): 112 | self._client = None 113 | self.end_session() 114 | 115 | def handle_disconnect(self): 116 | self._client = None 117 | self.end_session() 118 | 119 | def end_session(self): 120 | self.last_tweet_id = None 121 | -------------------------------------------------------------------------------- /hardware.md: -------------------------------------------------------------------------------- 1 | ## Hardware Guide 2 | 3 | Here is the required hardware you need to build your own Go Note Go: 4 | 5 | ### Required Hardware 6 | 7 | #### Raspberry Pi 400 8 | 9 | Link: https://www.raspberrypi.com/products/raspberry-pi-400-unit/ 10 | 11 | Details: The Raspberry Pi 400 with US keyboard is recommended. You can purchase from e.g. [PiShop](https://www.pishop.us/product/raspberry-pi-400-complete-kit/). 12 | 13 | It's recommended to order the complete kit which includes: 14 | - The Raspberry Pi 400 unit ($70) 15 | - Class 10 microSD Card With Raspbian - 16GB 16 | - USB-C Power Supply, 5.1V 3.0A, Black, UL Listed 17 | - Micro-HDMI to HDMI cable for Pi 4, 3ft, Black 18 | 19 | Cost: ~$70 (unit only) or ~$91 (complete kit) 20 | 21 | #### SD Card 22 | 23 | A Class 10 microSD Card - 16GB or larger is recommended. (This is included if you purchase the complete Raspberry Pi 400 kit). 24 | 25 | Link: https://www.amazon.com/SanDisk-microSDHC-Memory-Adapter-SDSQUNC-032G-GN6MA/dp/B010Q57T02/ 26 | 27 | Cost: ~$10-15 28 | 29 | #### USB Microphone 30 | 31 | Example: https://www.adafruit.com/product/3367 32 | 33 | Cost: ~$6 34 | 35 | #### USB Speaker 36 | 37 | Options: 38 | * Adafruit Speaker: https://www.adafruit.com/product/3369 (~$13) 39 | * Etsy Speaker: https://www.etsy.com/listing/1056790095/usb-speaker-stick (~$30) 40 | 41 | Cost: $13-30 42 | 43 | #### Power Source 44 | 45 | A USB-C Power Supply, 5.1V 3.0A for the Raspberry Pi 400. (This is included if you purchase the complete Raspberry Pi 400 kit). 46 | 47 | Link: https://www.raspberrypi.com/products/type-c-power-supply/ 48 | 49 | Cost: ~$10 50 | 51 | Total Cost for Required Hardware: ~$109-131 52 | 53 | --- 54 | 55 | ## Optional Hardware 56 | 57 | The following parts are optional but may enhance your experience: 58 | 59 | ### 10000 mAh Battery (for portable use) 60 | 61 | Link: https://www.amazon.com/gp/product/B07JYYRT7T 62 | 63 | Notes: This allows you to use Go Note Go away from a power outlet. You can also power Go Note Go directly from your car, laptop, or a standard outlet via USB if your use case allows Go Note Go to remain plugged in during use. 64 | 65 | Cost: ~$14-19 66 | 67 | ### Velcro 68 | 69 | Link: https://www.amazon.com/gp/product/B08P3MXLLD 70 | 71 | Notes: Useful for mounting the keyboard to different surfaces (e.g., car dashboard) or attaching the battery to the back of the keyboard. When applying the sticky side to a car dashboard, allow to dry overnight. 72 | 73 | Cost: ~$7 74 | 75 | ### USB Cables 76 | 77 | Links: 78 | * 3 ft USB - USB C cable: https://www.amazon.com/gp/product/B089DM4KDW 79 | * 6 in USB - USB C cable: https://www.amazon.com/gp/product/B012V56D2A 80 | 81 | Notes: 82 | * USB to USB-C cables for connecting the Pi to power sources 83 | * Short cables are useful for connecting peripherals while minimizing clutter 84 | 85 | Cost: ~$15.50 ($5.99 for 3 ft and $9.49 for 6 in) 86 | 87 | --- 88 | 89 | ## Other Tools for Setup 90 | 91 | These tools aren't Go Note Go specific, but may help you in the setup process: 92 | 93 | ### External Monitor (temporary, for initial setup) 94 | 95 | This can be useful for initial debugging, but is not required with the new setup process. 96 | 97 | ### USB Mouse (temporary, for initial setup) 98 | 99 | This can be helpful during initial configuration but is not required with the new setup process. -------------------------------------------------------------------------------- /installation.md: -------------------------------------------------------------------------------- 1 | ## Installation Instructions 2 | 3 | These instructions will guide you through setting up Go Note Go on a Raspberry Pi 400. 4 | 5 | 1. Download the latest image from GitHub Actions artifacts. 6 | 7 | 2. Flash the image onto an SD card. 8 | 9 | Example commands (macOS): 10 | ```bash 11 | diskutil unmountDisk /dev/disk4 12 | sudo dd bs=4M if=/Users/yourusername/Downloads/go-note-go.img of=/dev/rdisk4 conv=fsync status=progress 13 | ``` 14 | 15 | 3. Insert the SD card into the Raspberry Pi 400 and power it on. 16 | 17 | Give it a minute to boot. 18 | 19 | 4. Start the settings server. 20 | 21 | Type the following command and press Enter: 22 | ``` 23 | :server 24 | ``` 25 | 26 | This will start a WiFi hotspot called GoNoteGo-Wifi. 27 | 28 | 5. Connect to the GoNoteGo-Wifi hotspot. 29 | 30 | Connect from another device like a phone or computer. 31 | The password is: `swingset`. 32 | 33 | 6. Configure your Go Note Go. 34 | 35 | Navigate to: `192.168.4.1:8000`. 36 | 37 | Here you can configure: 38 | - WiFi networks to connect to 39 | - Where to upload your notes 40 | - Other settings 41 | 42 | Click Save when finished. 43 | 44 | 7. Verify internet connection. 45 | 46 | Run the following command on the Go Note Go: 47 | ``` 48 | :i 49 | ``` 50 | 51 | It should respond out loud with 'Yes' indicating it's connected to the internet. 52 | 53 | 8. Turn off the WiFi hotspot (optional). 54 | 55 | Run the following command: 56 | ``` 57 | :server stop 58 | ``` 59 | 60 | 9. That's it! Your Go Note Go is ready to use. Happy note-taking! 61 | 62 | If you're having any trouble getting set up, open a [new GitHub issue](https://github.com/dbieber/GoNoteGo/issues). -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "GoNoteGo" 3 | version = "1.0.0" 4 | description = "Go Note Go: A headless keyboard for taking notes on the go." 5 | readme = "README.md" 6 | 7 | requires-python = ">=3.7" 8 | # license = {file = "LICENSE.txt"} 9 | keywords = ["raspberry pi", "note taking", "writing", "no screen", "headless keyboard", "pkm", "camping"] 10 | authors = [ 11 | {name = "David Bieber", email = "david810@gmail.com"} 12 | ] 13 | maintainers = [ 14 | {name = "David Bieber", email = "david810@gmail.com"} 15 | ] 16 | 17 | classifiers = [ 18 | "Programming Language :: Python :: 3", 19 | ] 20 | 21 | dependencies = [ 22 | 'absl-py<=2.1.0', 23 | 'apscheduler<=3.10.4', 24 | 'dropbox<=12.0.2', 25 | 'fire<=0.7.0', 26 | 'flask<=3.0.3', 27 | 'keyboard<=0.13.5', 28 | 'numpy<=2.1.3', 29 | 'openai<=1.52.2', 30 | 'parsedatetime<=2.6', 31 | 'pydantic==2.9.2', # Pin to specific working version 32 | 'pydantic_core==2.23.4', # Pin to specific working version 33 | 'python-dateutil<=2.8.2', 34 | 'redis<=4.3.4', 35 | # selenium 4.0 breaks with arm geckodriver. 36 | 'selenium==3.141.0', 37 | 'setuptools-rust<=1.5.2', 38 | 'slack-sdk<=3.26.0', 39 | 'sounddevice<=0.4.5', 40 | 'soundfile', 41 | 'supervisor<=4.2.4', 42 | 'twython<=3.9.1', 43 | "urllib3==1.26.19", # For compatibility with selenium==3.141.0 44 | ] 45 | 46 | [project.optional-dependencies] 47 | dev = [] 48 | test = [ 49 | 'ruff', 50 | 'pytest', 51 | ] 52 | 53 | [project.urls] 54 | "Homepage" = "https://davidbieber.com/projects/go-note-go/" 55 | "Features List" = "https://davidbieber.com/snippets/2023-01-16-go-note-go-features/" 56 | "Bug Reports" = "https://github.com/dbieber/GoNoteGo/issues" 57 | "Funding" = "https://donate.stripe.com/4gwg2Y65WevpeWc8ww" 58 | "Say Thanks!" = "https://saythanks.io/to/dbieber" 59 | "Learn More" = "https://davidbieber.com/tags/go-note-go/" 60 | "Source" = "https://github.com/dbieber/GoNoteGo" 61 | 62 | [tool.ruff] 63 | line-length = 180 64 | lint.ignore = ["E722"] 65 | exclude = ["scratch"] 66 | 67 | [build-system] 68 | requires = ["setuptools>=43.0.0", "wheel"] 69 | build-backend = "setuptools.build_meta" 70 | -------------------------------------------------------------------------------- /scripts/install_settings.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the source and target file paths 4 | source_file="/boot/firmware/gonotego/secure_settings.py" 5 | target_dir="/home/pi/code/github/dbieber/GoNoteGo/gonotego/settings/" 6 | backup_file="/boot/firmware/gonotego/secure_settings.py.bak" 7 | 8 | # Check if the source file exists 9 | if [ -f "$source_file" ]; then 10 | # Copy the file to the target directory 11 | cp "$source_file" "$target_dir" 12 | 13 | # Rename the original file to a backup file 14 | mv "$source_file" "$backup_file" 15 | fi 16 | --------------------------------------------------------------------------------