visit
git push
, synchronizing new content on other devices can be mapped to a git pull
.git clone
.
# Server machine
$ mkdir -p /home/user/notebook.git
$ cd /home/user/notebook.git
$ git init --bare
# Client machine
$ git clone user@remote-machine:/home/user/notebook.git
$ sudo apt install ruby-full
$ gem install madness
Take note of where the madness
executable was installed and create a new user systemd service file under ~/.config/systemd/user/madness.service
to manage the server on your repo folder:
[Unit]
Description=Serve Markdown content over HTML
After=network.target
[Service]
ExecStart=/home/user/.gem/ruby/version/bin/madness /path/to/the/notebook --port 9999
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
$ systemctl --user daemon-reload
$ systemctl --user start madness
$ systemctl --user enable madness
If everything went well you can head your browser to //host:9999
and you should see the Madness interface with your Markdown files.
Taking the case of Mosquitto, the installation and configuration is pretty straightforward. Install the mosquitto
package from your favorite package manager; the installation process should also create a configuration file under /etc/mosquitto/mosquitto.conf
. In the case of an SSL configuration with username and password, you would usually configure the following options:
# Usually 1883 for non-SSL connections, 8883 for SSL connections
port 8883
# SSL/TLS version
tls_version tlsv1.2
# Path to the certificate chain
cafile /etc/mosquitto/certs/chain.crt
# Path to the server certificate
certfile /etc/mosquitto/certs/server.crt
# Path to the server private key
keyfile /etc/mosquitto/certs/server.key
# Set to false to disable access without username and password
allow_anonymous false
# Password file, which contains username:password pairs
# You can create and manage a password file by following the
# instructions reported here:
# //mosquitto.org/documentation/authentication-methods/
password_file /etc/mosquitto/passwords.txt
If you don't need SSL encryption and authentication on your broker (which is ok if you are running the broker on a private network and accessing it from the outside over VPN), then you'll only need to set the port
option.
After you have configured the MQTT broker, you can start it and enable it via systemd
:
$ sudo systemctl start mosquitto
$ sudo systemctl enable mosquitto
You can then use an MQTT client like to connect to the broker and verify that everything is working.
notebook/sync
topic to tell the other clients that they should synchronize their copies of the repository.notebook/sync
, and the originator is different from the client itself (this is necessary in order to prevent "sync loops"), pull the latest changes from the remote repository.notebook/save
topic with a URL attached, the content of the associated web page will be parsed and saved to the notebook ("Save URL" feature).
# Install Redis
$ sudo apt install redis-server
# Start and enable the Redis server
$ sudo systemctl start redis-server
$ sudo systemctl enable redis-server
# Install Platypush
$ sudo pip install platypush
mqtt
( and ), used to subscribe to sync/save topics and dispatch messages to the broker.file.monitor
backend, used to monitor changes to local folders.pushbullet
or an alternative way to deliver notifications to other devices (such as telegram
, twilio
, gotify
, mailgun
). We'll use this to notify other clients when new content has been added.http.webpage
integration, used to scrape a web page's content to Markdown or PDF.
Start by creating a config.yaml
file with your integrations:
# The name of your client
device_id: my-client
mqtt:
host: your-mqtt-server
port: 1883
# Uncomment the lines below for SSL/user+password authentication
# port: 8883
# username: user
# password: pass
# tls_cafile: ~/path/to/ssl.crt
# tls_version: tlsv1.2
# Specify the topics you want to subscribe here
backend.mqtt:
listeners:
- topics:
- notebook/sync
# The configuration for the file monitor follows.
# This logic triggers FileSystemEvents whenever a change
# happens on the specified folder. We can use these events
# to build our sync logic
backend.file.monitor:
paths:
# Path to the folder where you have cloned the notebook
# git repo on your client
- path: /path/to/the/notebook
recursive: true
# Ignore changes on non-content sub-folders, such as .git or
# other configuration/cache folders
ignore_directories:
- .git
- .obsidian
$ platyvenv build -c config.yaml
Platypush virtual environment prepared under /home/user/.local/share/platypush/venv/my-client
Let's call this path $PREFIX
. Create a structure to store your scripts under $PREFIX/etc/platypush
(a copy of theconfig.yaml
file should already be there at this point). The structure will look like this:
$PREFIX
-> etc
-> platypush
-> config.yaml # Configuration file
-> scripts # Scripts folder
-> __init__.py # Empty file
-> notebook.py # Logic for notebook synchronization
Let's proceed with defining the core logic in notebook.py
:
import logging
import os
import re
from threading import RLock, Timer
from platypush.config import Config
from platypush.event.hook import hook
from platypush.message.event.file import FileSystemEvent
from platypush.message.event.mqtt import MQTTMessageEvent
from platypush.procedure import procedure
from platypush.utils import run
logger = logging.getLogger('notebook')
repo_path = '/path/to/your/git/repo'
sync_timer = None
sync_timer_lock = RLock()
def should_sync_notebook(event: MQTTMessageEvent) -> bool:
"""
Only synchronize the notebook if a sync request came from
a source other than ourselves - this is required to prevent
"sync loops", where a client receives its own sync message
and broadcasts sync requests again and again.
"""
return Config.get('device_id') != event.msg.get('origin')
def cancel_sync_timer():
"""
Utility function to cancel a pending synchronization timer.
"""
global sync_timer
with sync_timer_lock:
if sync_timer:
sync_timer.cancel()
sync_timer = None
def reset_sync_timer(path: str, seconds=15):
"""
Utility function to start a synchronization timer.
"""
global sync_timer
with sync_timer_lock:
cancel_sync_timer()
sync_timer = Timer(seconds, sync_notebook, (path,))
sync_timer.start()
@hook(MQTTMessageEvent, topic='notebook/sync')
def on_notebook_remote_update(event, **_):
"""
This hook is triggered when a message is received on the
notebook/sync MQTT topic. It triggers a sync between the
local and remote copies of the repository.
"""
if not should_sync_notebook(event):
return
sync_notebook(repo_path)
@hook(FileSystemEvent)
def on_notebook_local_update(event, **_):
"""
This hook is triggered when a change (i.e. file/directory
create/update/delete) is performed on the folder where the
repository is cloned. It starts a timer to synchronize the
local and remote repository copies.
"""
if not event.path.startswith(repo_path):
return
logger.info(f'Synchronizing repo path {repo_path}')
reset_sync_timer(repo_path)
@procedure
def sync_notebook(path: str, **_):
"""
This function holds the main synchronization logic.
It is declared through the @procedure decorator, so you can also
programmatically call it from your requests through e.g.
`procedure.notebook.sync_notebook`.
"""
# The timer lock ensures that only one thread at the time can
# synchronize the notebook
with sync_timer_lock:
# Cancel any previously awaiting timer
cancel_sync_timer()
logger.info(f'Synchronizing notebook - path: {path}')
cwd = os.getcwd()
os.chdir(path)
has_stashed_changes = False
try:
# Check if the local copy of the repo has changes
git_status = run('shell.exec', 'git status --porcelain').strip()
if git_status:
logger.info('The local copy has changes: synchronizing them to the repo')
# If we have modified/deleted files then we stash the local changes
# before pulling the remote changes to prevent conflicts
has_modifications = any(re.match(r'^\s*[MD]\s+', line) for line in git_status.split('\n'))
if has_modifications:
logger.info(run('shell.exec', 'git stash', ignore_errors=True))
has_stashed_changes = True
# Pull the latest changes from the repo
logger.info(run('shell.exec', 'git pull --rebase'))
if has_modifications:
# Un-stash the local changes
logger.info(run('shell.exec', 'git stash pop'))
# Add, commit and push the local changes
has_stashed_changes = False
device_id = Config.get('device_id')
logger.info(run('shell.exec', 'git add .'))
logger.info(run('shell.exec', f'git commit -a -m "Automatic sync triggered by {device_id}"'))
logger.info(run('shell.exec', 'git push origin main'))
# Notify other clients by pushing a message to the notebook/sync topic
# having this client ID as the origin. As an alternative, if you are using
# Gitlab to host your repo, you can also configure a webhook that is called
# upon push events and sends the same message to notebook/sync.
run('mqtt.publish', topic='notebook/sync', msg={'origin': Config.get('device_id')})
else:
# If we have no local changes, just pull the remote changes
logger.info(run('shell.exec', 'git pull'))
except Exception as e:
if has_stashed_changes:
logger.info(run('shell.exec', 'git stash pop'))
# In case of errors, retry in 5 minutes
reset_sync_timer(path, seconds=300)
raise e
finally:
os.chdir(cwd)
logger.info('Notebook synchronized')
$ platyvenv start my-client
Or create a systemd user service for it under ~/.config/systemd/user/platypush-notebook.service
:
$ cat <<EOF > ~/.config/systemd/user/platypush-notebook.service
[Unit]
Description=Platypush notebook automation
After=network.target
[Service]
ExecStart=/path/to/platyvenv start my-client
ExecStop=/path/to/platyvenv stop my-client
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
EOF
$ systemctl --user daemon-reload
$ systemctl --user start platypush-notebook
$ systemctl --user enable platypush-notebook
Luckily, it is possible to install and run Platypush on Android through Termux
, and the logic you have set up on your laptops and servers should also work flawlessly on Android. Termux allows you to run a Linux environment in user mode with no need for rooting your device.
First, install the Termux
app on your Android device. Optionally, you may also want to install the following apps:
Termux:API
: to programmatically access Android features (e.g., SMS texts, camera, GPS, battery level, etc.) from your scripts.Termux:Boot
: to start services such as Redis and Platypush at boot time without having to open the Termux app first (advised).Termux:Widget
: to add scripts (for example, to manually start Platypush or synchronize the notebook) on the home screen.Termux:GUI
: to add support for visual elements (such as dialogs and widgets for sharing content) to your scripts.
After installing Termux, open a new session, update the packages, install termux-services
(for services support) and enable SSH access (it's usually more handy to type commands on a physical keyboard than a phone screen):
$ pkg update
$ pkg install termux-services openssh
# Start and enable the SSH service
$ sv up sshd
$ sv-enable sshd
# Set a user password
$ passwd
A service that is enabled through sv-enable
will be started when a Termux session is first opened, but not at boot time unless Termux is started. If you want a service to be started a boot time, you need to install the Termux:Boot
app and then place the scripts you want to run at boot time inside the ~/.termux/boot
folder.
After starting sshd
and setting a password, you should be able to log in to your Android device over SSH:
$ ssh -p 8022 anyuser@android-device
The next step is to enable access for Termux to the internal storage (by default it can only access the app's own data folder). This can easily be done by running termux-setup-storage
and allowing storage access on the prompt. We may also want to disable battery optimization for Termux, so the services won't be killed in case of inactivity.
$ pkg install git redis python3
$ pip install platypush
If running the redis-server
command results in an error, then you may need to explicitly disable a warning for a COW bug for ARM64 architectures in the Redis configuration file. Simply add or uncomment the following line in /data/data/com.termux/files/usr/etc/redis.conf
:
ignore-warnings ARM64-COW-BUG
We then need to create a service for Redis, since it's not available by default. Termux doesn't use systemd to manage services, since that would require access to the PID 1, which is only available to the root user. Instead, it uses it own system of scripts that goes under the name of .
Services are installed under /data/data/com.termux/files/usr/var/service
. Just cd
to that directory and copy the available sshd
service to redis
:
$ cd /data/data/com.termux/files/usr/var/service
$ cp -r sshd redis
Then replace the content of the run
file in the service directory with this:
#!/data/data/com.termux/files/usr/bin/sh
exec redis-server 2>&1
$ sv up redis
$ sv-enable redis
Verify that you can access the /sdcard
folder (shared storage) after restarting Termux. If that's the case, we can now clone the notebook repo under /sdcard/notebook
:
$ git clone git-url /sdcard/notebook
repo_path
in the notebook.py
script needs to point to /sdcard/notebook
- if the notebook is cloned on the user's home directory then other apps won't be able to access it.
platyvenv start my-client > /path/to/logs/platypush.log 2>&1
Once everything is configured and you restart Termux, Platypush should automatically start in the background - you can check the status by running a tail
on the log file or through the ps
command. If you change a file in your notebook on either your Android device or your laptop, everything should now get up to date within a minute.
Finally, we can also leverage Termux:Shortcuts
to add a widget to the home screen to manually trigger the sync process - maybe because an update was received while the phone was off or the Platypush service was not running. Create a ~/.shortcuts
folder with a script inside named e.g. sync_notebook.sh
:
#!/data/data/com.termux/files/usr/bin/bash
cat <<EOF | python
from platypush.utils import run
run('mqtt.publish', topic='notebook/sync', msg={'origin': None})
EOF
This script leverages the platypush.utils.run
method to send a message to the notebook/sync
MQTT topic with no origin
to force all the subscribed clients to pull the latest updates from the remote server.
You can now browse to the widgets' menu of your Android device (usually it's done by long-pressing an empty area on the launcher), select Termux shortcut, and then select your newly created script. By clicking on the icon, you will force a sync across all the connected devices.
$ git clone git-url /path/to/nextcloud/data/user/files/Notes
And then set the repo_path
in notebook.py
to this directory.
Keep in mind, however, that local changes in the Notes
folder will not be synchronized to the NextCloud app until the next cron is executed. If you want the changes to be propagated as soon as they are pushed to the git repo, then you'll have to add an extra piece of logic to the script that synchronizes the notebook, in order to rescan the Notes
folder for changes. Also, Platypush will have to run with the same user that runs the NextCloud web server because of the requirements for executing the occ
script:
import logging
from platypush.utils import run
...
logger = logging.getLogger('notebook')
# Path to the NextCloud occ script
occ_path = '/srv/http/nextcloud/occ'
...
def sync_notebook(path: str, **_):
...
refresh_nextcloud()
def refresh_nextcloud():
logger.info(run('shell.exec', f'php {occ_path} files:scan --path=/nextcloud-user/files/Notes'))
logger.info(run('shell.exec', f'php {occ_path} files:cleanup'))
# The name of your client
device_id: my-client
mqtt:
host: your-mqtt-server
port: 1883
# Uncomment the lines below for SSL/user+password authentication
# port: 8883
# username: user
# password: pass
# tls_cafile: ~/path/to/ssl.crt
# tls_version: tlsv1.2
# Specify the topics you want to subscribe here
backend.mqtt:
listeners:
- topics:
- notebook/sync
# notebook/save will be used to send parsing requests
- notebook/save
# Monitor the local repository copy for changes
backend.file.monitor:
paths:
# Path to the folder where you have cloned the notebook
# git repo on your client
- path: /path/to/the/notebook
recursive: true
# Ignore changes on non-content sub-folders, such as .git or
# other configuration/cache folders
ignore_directories:
- .git
- .obsidian
# Enable the http.webpage integration for parsing web pages
http.webpage:
enabled: true
# We will use Pushbullet to send a link to all the connected devices
# with the URL of the newly saved link, but you can use any other
# services for delivering notifications and/or messages - such as
# Gotify, Twilio, Telegram or any email integration
backend.pushbullet:
token: my-token
device: my-client
pushbullet:
enabled: true
$ platyvenv build -c config.yaml
Make sure that at the end of the process, you have the node
and npm
executables installed - the http.webpage
integration uses the API to convert web pages to Markdown.
Then copy the previously created scripts
folder under <environment-base-dir>/etc/platypush/scripts
. We now want to add a new script (let's name it e.g. webpage.py
) that is in charge of subscribing to new messages on notebook/save
and using the http.webpage
integration to save its content in Markdown format in the repository folder. Once the parsed file is in the right directory, the previously created automation will take care of synchronizing it to the git repo.
import logging
import os
import re
import shutil
import tempfile
from datetime import datetime
from typing import Optional
from urllib.parse import quote
from platypush.event.hook import hook
from platypush.message.event.mqtt import MQTTMessageEvent
from platypush.procedure import procedure
from platypush.utils import run
logger = logging.getLogger('notebook')
repo_path = '/path/to/your/notebook/repo'
# Base URL for your Madness Markdown instance
markdown_base_url = '//my-host/'
@hook(MQTTMessageEvent, topic='notebook/save')
def on_notebook_url_save_request(event, **_):
"""
Subscribe to new messages on the notebook/save topic.
Such messages can contain either a URL to parse, or a
note to create - with specified content and title.
"""
url = event.msg.get('url')
content = event.msg.get('content')
title = event.msg.get('title')
save_link(url=url, content=content, title=title)
@procedure
def save_link(url: Optional[str] = None, title: Optional[str] = None, content: Optional[str] = None, **_):
assert url or content, 'Please specify either a URL or some Markdown content'
# Create a temporary file for the Markdown content
f = tempfile.NamedTemporaryFile(suffix='.md', delete=False)
if url:
logger.info(f'Parsing URL {url}')
# Parse the webpage to Markdown to the temporary file
response = run('http.webpage.simplify', url=url, outfile=f.name)
title = title or response.get('title')
# Sanitize title and filename
if not title:
title = f'Note created at {datetime.now()}'
title = title.replace('/', '-')
if content:
with open(f.name, 'w') as f:
f.write(content)
# Download the Markdown file to the repo
filename = re.sub(r'[^a-zA-Z0-9 \-_+,.]', '_', title) + '.md'
outfile = os.path.join(repo_path, filename)
shutil.move(f.name, outfile)
os.chmod(outfile, 0o660)
logger.info(f'URL {url} successfully downloaded to {outfile}')
# Send the URL
link_url = f'{markdown_base_url}/{quote(title)}'
run('pushbullet.send_note', title=title, url=link_url)
We now have a service that can listen for messages delivered on notebook/save
. If the message contains some Markdown content, it will directly save it to the notebook. If it contains a URL, it will use the http.webpage
integration to parse the web page and save it to the notebook. What we need now is a way to easily send messages to this channel while we are browsing the web. A common use-case is the one where you are reading an article on your browser (either on a computer or a mobile device), and you want to save it to your notebook to read it later through a mechanism similar to the familiar Share button. Let's break down this use-case in two:
If you are reading an article on your personal computer and you want to save it to your notebook (for example, to read it later on your mobile), then you can use the to create a simple action that sends your current tab to the notebook/save
MQTT channel.
Side note: the extension only works if the target Platypush machine has backend.http
(i.e., the webserver) enabled, as it is used to dispatch messages over the Platypush API. This wasn't required by the previous set up, but you can now select one of the devices to expose a web server by simply adding a backend.http
section to the configuration file and setting enabled: True
(by default, the web server will listen on the port 8008).
Then from the extension configuration panel, select your host -> Run Action. Wait for the autocomplete bar to populate (it may take a while the first time since it has to inspect all the methods in all the enabled packages) and then create a new mqtt.publish
action that sends a message with the current URL over the notebook/save
channel:
Click on the Save Action button at the bottom of the page, give your action a name and, optionally, an icon, a color, and a set of tags. You can also select a keybinding between Ctrl+Alt+0 and Ctrl+Alt+9 to automatically run your action without having to grab the mouse.
An easy way to share links to your notebook through an Android device is to leverage with the plugin and choose an app like that comes with a Tasker integration. You may then create a new AutoShare intent named, e.g., Save URL, create a Tasker task associated to it that uses the MQTT Client integration to send the message with the URL to the right MQTT topic. When you are browsing a web page that you'd like to save, then you simply click on the Share button and select AutoShare Command in the popup window, then select the action you have created.
Termux also provides a mechanism for , and we can easily create a sharing intent for the notebook by creating a script under ~/bin/termux-url-opener
. Make sure that the binary file is executable and that you have Termux:GUI
installed for support for visual widgets:
#!/data/data/com.termux/files/usr/bin/bash
arg="$1"
# termux-dialog-radio show a list of mutually exclusive options and returns
# the selection in JSON format. The options need to be provided over the -v
# argument and they are comma-separated
action=$(termux-dialog radio -t 'Select an option' -v 'Save URL,some,other,options' | jq -r '.text')
case "$action" in
'Save URL')
cat <<EOF | python
from platypush.utils import run
run('mqtt.publish', topic='notebook/save', msg={'url': '$arg'})
EOF
;;
# You can add some other actions here
esac
Now browse to a page that you want to save from your mobile device, tap the Share button, select Termux and select the Save URL option. Everything should work out of the box.
It's relatively easy to set up such automation with the building blocks we have put in place and the Platypush rss
integration. Add an rss
section to the configuration file of any of your clients with the http.webpage
integration. It will contain the RSS sources you want to subscribe to:
rss:
subscriptions:
- //source1.com/feed/rss
- //source2.com/feed/rss
- //source3.com/feed/rss
Then either rebuild the virtual environment (platyvenv build -c config.yaml
) or manually install the required dependency in the existing environment (pip install feedparser
).
The RSS integration will trigger a NewFeedEntryEvent
whenever an entry is added to an RSS feed you are subscribed to. We now want to create a logic that reacts to such events and does the following:
digests
folder on the notebook.
Create a new script under $PREFIX/etc/platypush/scripts
named e.g. digests.py
:
import logging
import pathlib
import os
import tempfile
from datetime import datetime
from multiprocessing import RLock
from platypush.cron import cron
from platypush.event.hook import hook
from platypush.message.event.rss import NewFeedEntryEvent
from platypush.utils import run
from .notebook import repo_path
logger = logging.getLogger('digest-generator')
# Path to a text file where you'll store the processing queue
# for the feed entries - one URL per line
queue_path = '/path/to/feeds/processing/queue'
# Lock to ensure consistency when writing to the queue
queue_path_lock = RLock()
# The digests path will be a subfolder of the repo_path
digests_path = f'{repo_path}/digests'
@hook(NewFeedEntryEvent)
def on_new_feed_entry(event, **_):
"""
Subscribe to new RSS feed entry events and add the
corresponding URLs to a processing queue.
"""
with queue_path_lock:
with open(queue_path, 'a') as f:
f.write(event.url + '\n')
@cron('0 4 * * *')
def digest_generation_cron(**_):
"""
This cronjob runs every day at 4AM local time.
It processes all the URLs in the queue, it generates a digest
with the parsed content and it saves it in the notebook folder.
"""
logger.info('Running digest generation cronjob')
with queue_path_lock:
try:
with open(queue_path, 'r') as f:
md_files = []
for url in f:
# Create a temporary file for the Markdown content
tmp = tempfile.NamedTemporaryFile(suffix='.md', delete=False)
logger.info(f'Parsing URL {url}')
# Parse the webpage to Markdown to the temporary file
response = run('http.webpage.simplify', url=url, outfile=tmp.name)
title = response.get('title', url)
md_files.append(tmp.name)
except FileNotFoundError:
pass
if not md_files:
logger.info('No URLs to process')
return
try:
pathlib.Path(digests_path).mkdir(parents=True, exist_ok=True)
digest_file = os.path.join(digests_path, f'{datetime.now()}_digest')
digest_content = f'# Digest generated on {datetime.now()}\n\n'
for md_file in md_files:
with open(md_file, 'r') as f:
digest_content += f.read() + '\n\n'
with open(digest_file, 'w') as f:
f.write(digest_content)
# Clean up the queue
os.unlink(queue_path)
finally:
for md_file in md_files:
os.unlink(md_file)
Now restart the Platypush service. On the first start after configuring the rss
integration it should trigger a bunch of NewFeedEntryEvent
with all the newly seen content from the subscribed feed.
platyvenv
command (in the previous articles, I mainly targeted manual installations). Just for you to know, a platydock
command is also available to create Docker containers on the fly from a configuration file, but given the hardware requirements or specific dependency chains that some integrations may require, the mileage of platydock
may vary.Termux:API
, it's relatively easy to use Platypush to set up automations that replace the need for paid (and closed-source) services like Tasker.http.webpage
integration to distill web pages into readable Markdown.termux-url-opener
mechanism).rss
integration to subscribe to feeds, and how to hook it to http.webpage
and cronjobs to generate periodic digests delivered to our notebook.
Also Published