Compare commits

..

7 Commits

Author SHA1 Message Date
azykov@mail.ru 6d132ee3fb radicale birthday calendars
radicale webhooks
2026-05-07 11:38:10 +03:00
azykov@mail.ru 8e23b06d69 moved radicale config to apps 2026-05-07 11:28:29 +03:00
azykov@mail.ru 765777bf61 joplin 2026-04-23 12:28:51 +03:00
azykov@mail.ru af3f61ed24 bunkerm 2026-04-23 10:15:43 +03:00
azykov@mail.ru 34bf7e85fd immich 2026-04-23 10:15:16 +03:00
azykov@mail.ru 5ed8d8213f syncthing 2026-04-22 23:12:51 +03:00
azykov@mail.ru f863db5156 tidyquest 2026-04-22 23:05:18 +03:00
29 changed files with 922 additions and 4 deletions

21
bunkerm/compose.yaml Normal file
View File

@ -0,0 +1,21 @@
services:
bunkerm:
image: bunkeriot/bunkerm:latest
container_name: bunkerm
restart: always
ports:
- "1883:1900"
volumes:
- /docker/data/bunkerm/mosquitto_data:/var/lib/mosquitto
- ./config/mosquitto:/etc/mosquitto
- /docker/data/bunkerm/data:/data
- /docker/data/bunkerm/history:/var/lib/history
environment:
- HISTORY_MAX_MESSAGES=50000
- HISTORY_MAX_AGE_DAYS=7
networks:
- caddy_default
networks:
caddy_default:
external: true

View File

@ -0,0 +1,18 @@
# MQTT listener on port 1900
listener 1900
per_listener_settings false
allow_anonymous false
# HTTP listener for Dynamic Security Plugin on port 8080
listener 8080
#password_file /etc/mosquitto/passwd
password_file /etc/mosquitto/mosquitto_passwd
# Dynamic Security Plugin configuration
plugin /usr/lib/mosquitto_dynamic_security.so
plugin_opt_config_file /var/lib/mosquitto/dynamic-security.json
log_dest file /var/log/mosquitto/mosquitto.log
log_type all
log_timestamp true
persistence true
persistence_location /var/lib/mosquitto/
persistence_file mosquitto.db

View File

@ -111,3 +111,38 @@ files.aggtaa.com {
output file /var/log/caddy/files.aggtaa.com.log output file /var/log/caddy/files.aggtaa.com.log
} }
} }
photo.aggtaa.com {
reverse_proxy immich_server:2283
log {
output file /var/log/caddy/photo.aggtaa.com.log
}
}
mqtt.aggtaa.com {
forward_auth auth:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
reverse_proxy bunkerm:2000
log {
output file /var/log/caddy/mqtt.aggtaa.com.log
}
}
quest.aggtaa.com {
reverse_proxy tidyquest:3000
log {
output file /var/log/caddy/quest.aggtaa.com.log
}
}
notes.aggtaa.com {
reverse_proxy joplin_app:22300
log {
output file /var/log/caddy/notes.aggtaa.com.log
}
}

22
immich/.env Normal file
View File

@ -0,0 +1,22 @@
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
# The location where your uploaded files are stored
UPLOAD_LOCATION=/storage/large/immich/uploads
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=/storage/large/immich/db
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
TZ=Europe/Moscow
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=nUKz7KUS7x8mjQYsqBuyKVQpsw7Y1zYB
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

91
immich/compose.yaml Normal file
View File

@ -0,0 +1,91 @@
#
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
#
# Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
name: immich
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
networks:
- net
- caddy_default
immich-machine-learning:
container_name: immich_machine_learning
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
# Example tag: ${IMMICH_VERSION:-release}-cuda
# image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-openvino
extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
file: hwaccel.ml.yml
service: openvino # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
cpus: 1
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
healthcheck:
disable: false
networks:
- net
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always
networks:
- net
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
networks:
- net
volumes:
model-cache:
networks:
net:
internal: true
caddy_default:
external: true

57
immich/hwaccel.ml.yml Normal file
View File

@ -0,0 +1,57 @@
# Configurations for hardware-accelerated machine learning
# If using Unraid or another platform that doesn't allow multiple Compose files,
# you can inline the config for a backend by copying its contents
# into the immich-machine-learning service in the docker-compose.yml file.
# See https://docs.immich.app/features/ml-hardware-acceleration for info on usage.
services:
armnn:
devices:
- /dev/mali0:/dev/mali0
volumes:
- /lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro # Mali firmware for your chipset (not always required depending on the driver)
- /usr/lib/libmali.so:/usr/lib/libmali.so:ro # Mali driver for your chipset (always required)
rknn:
security_opt:
- systempaths=unconfined
- apparmor=unconfined
devices:
- /dev/dri:/dev/dri
cpu: {}
cuda:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities:
- gpu
rocm:
group_add:
- video
devices:
- /dev/dri:/dev/dri
- /dev/kfd:/dev/kfd
openvino:
device_cgroup_rules:
- 'c 189:* rmw'
devices:
- /dev/dri:/dev/dri
volumes:
- /dev/bus/usb:/dev/bus/usb
openvino-wsl:
devices:
- /dev/dri:/dev/dri
- /dev/dxg:/dev/dxg
volumes:
- /dev/bus/usb:/dev/bus/usb
- /usr/lib/wsl:/usr/lib/wsl

59
joplin/compose.yaml Normal file
View File

@ -0,0 +1,59 @@
# This is a sample docker-compose file that can be used to run Joplin Server
# along with a PostgreSQL server.
#
# Update the following fields in the stanza below:
#
# POSTGRES_USER
# POSTGRES_PASSWORD
# APP_BASE_URL
#
# APP_BASE_URL: This is the base public URL where the service will be running.
# - If Joplin Server needs to be accessible over the internet, configure APP_BASE_URL as follows: https://example.com/joplin.
# - If Joplin Server does not need to be accessible over the internet, set the APP_BASE_URL to your server's hostname.
# For Example: http://[hostname]:22300. The base URL can include the port.
# APP_PORT: The local port on which the Docker container will listen.
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.
# - If Joplin Server does not need to be accessible over the internet, the port can be mapped to 22300.
services:
app:
image: joplin/server:latest
container_name: joplin_app
restart: always
depends_on:
- db
ports:
- "22300:22300"
environment:
- APP_PORT=22300
- APP_BASE_URL=https://notes.aggtaa.com
- DB_CLIENT=pg
- POSTGRES_DATABASE=joplin
- POSTGRES_USER=joplin
- POSTGRES_PASSWORD=VZa8MBSuJHkzcGoowmJH1gzO8fCOU3zY
# - POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_HOST=db
networks:
- net
- caddy_default
db:
image: postgres:16
container_name: joplin_db
restart: always
volumes:
- /docker/data/joplin/db:/var/lib/postgresql/data
environment:
- POSTGRES_DB=joplin
- POSTGRES_USER=joplin
- POSTGRES_PASSWORD=VZa8MBSuJHkzcGoowmJH1gzO8fCOU3zY
networks:
- net
networks:
net:
internal: true
caddy_default:
external: true

View File

@ -1,5 +1,5 @@
services: services:
docker-radicale: radicale:
image: tomsquest/docker-radicale image: tomsquest/docker-radicale
container_name: radicale container_name: radicale
restart: always restart: always
@ -10,12 +10,36 @@ services:
- TAKE_FILE_OWNERSHIP=false - TAKE_FILE_OWNERSHIP=false
volumes: volumes:
- /docker/data/radicale/data:/data - /docker/data/radicale/data:/data
- /docker/data/radicale/config:/config:ro - ./config:/config:ro
networks: networks:
- caddy_default - caddy_default
- net
ui:
image: ghcr.io/nagimov/agendav-docker:latest
container_name: radicale_ui
restart: always
ports:
- 19999:8080
user: "root:root"
environment:
- AGENDAV_SERVER_NAME=127.0.0.1
- AGENDAV_TITLE=
- AGENDAV_FOOTER=
- AGENDAV_CALDAV_SERVER=http://radicale:5232/%u
- AGENDAV_CALDAV_PUBLIC_URL=https://c.aggtaa.com
- AGENDAV_TIMEZONE=Europe/Moscow
- AGENDAV_WEEKSTART=1 # monday
- AGENDAV_LANG=en
- AGENDAV_LOG_DIR=/tmp/
# - AGENDAV_ENVIRONMENT=dev
networks:
- caddy_default
- net
networks: networks:
caddy_default: caddy_default:
external: true external: true
net:
internal: true
# also see https://github.com/iBigQ/radicale-birthday-calendar # also see https://github.com/iBigQ/radicale-birthday-calendar

163
radicale/config/config Normal file
View File

@ -0,0 +1,163 @@
# -*- mode: conf -*-
# vim:ft=cfg
# Config file for Radicale - A simple calendar server
#
# Place it into /etc/radicale/config (global)
# or ~/.config/radicale/config (user)
#
# The current values are the default ones
[server]
# CalDAV server hostnames separated by a comma
# IPv4 syntax: address:port
# IPv6 syntax: [address]:port
# For example: 0.0.0.0:9999, [::]:9999
#hosts = 127.0.0.1:5232
hosts = 0.0.0.0:5232
# Daemon flag
#daemon = False
# File storing the PID in daemon mode
#pid =
# Max parallel connections
#max_connections = 20
# Max size of request body (bytes)
#max_content_length = 100000000
# Socket timeout (seconds)
#timeout = 30
# SSL flag, enable HTTPS protocol
ssl = False
# SSL certificate path
# certificate = /etc/ssl/certs/ssl-cert-snakeoil.pem
# SSL private key
# key = /etc/ssl/private/ssl-cert-snakeoil.key
# CA certificate for validating clients. This can be used to secure
# TCP traffic between Radicale and a reverse proxy
#certificate_authority =
# SSL Protocol used. See python's ssl module for available values
#protocol = PROTOCOL_TLSv1_2
# Available ciphers. See python's ssl module for available ciphers
#ciphers =
# Reverse DNS to resolve client address in logs
#dns_lookup = True
# Message displayed in the client when a password is needed
#realm = Radicale - Password Required
[encoding]
# Encoding for responding requests
#request = utf-8
# Encoding for storing local collections
#stock = utf-8
[auth]
# Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user
#type = remote_user
type = htpasswd
# Htpasswd filename
htpasswd_filename = /config/users
# Htpasswd encryption method
# Value: plain | sha1 | ssha | crypt | bcrypt | md5
# Only bcrypt can be considered secure.
# bcrypt and md5 require the passlib library to be installed.
htpasswd_encryption = bcrypt
# Incorrect authentication delay (seconds)
#delay = 1
[rights]
# Rights backend
# Value: none | authenticated | owner_only | owner_write | from_file
type = owner_only
# File for rights management from_file
# file = /config/rights
[storage]
# Storage backend
# Value: multifilesystem
type = multifilesystem
# Folder for storing local collections, created if not present
filesystem_folder = /data/collections
# Lock the storage. Never start multiple instances of Radicale or edit the
# storage externally while Radicale is running if disabled.
# filesystem_locking = True
# Sync all changes to disk during requests. (This can impair performance.)
# Disabling it increases the risk of data loss, when the system crashes or
# power fails!
# filesystem_fsync = True
# Delete sync token that are older (seconds)
#max_sync_token_age = 2592000
# Close the lock file when no more clients are waiting.
# This option is not very useful in general, but on Windows files that are
# opened cannot be deleted.
#filesystem_close_lock_file = False
# Command that is run after changes to storage
# Example: ([ -d .git ] || git init) && ([ -e .gitignore ] || printf '.Radicale.cache\n.Radicale.lock\n.Radicale.tmp-*\n' > .gitignore) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s)
# hook = (/bin/sh /webhooks/notify.sh "%(user)s" "%(path)s" "%(request)s" || true)
[web]
# Web interface backend
# Value: none | internal
#type = internal
[logging]
# Logging configuration file
# If no config is given, simple information is printed on the standard output
# For more information about the syntax of the configuration file, see:
# http://docs.python.org/library/logging.config.html
# config = /config/logging
# Set the default logging level to debug
#debug = False
# Store all environment variables (including those set in the shell)
#full_environment = False
# Don't include passwords in logs
#mask_passwords = True
[headers]
# Additional HTTP headers
Access-Control-Allow-Origin = *
Access-Control-Allow-Methods: GET, POST, OPTIONS, PROPFIND, PROPPATCH, REPORT, PUT, MOVE, DELETE, LOCK, UNLOCK
Access-Control-Allow-Headers: User-Agent, Authorization, Content-type, Depth, If-match, If-None-Match, Lock-Token, Timeout, Destination, Overwrite, Prefer, X-client, X-Requested-With
Access-Control-Expose-Headers: Etag, Preference-Applied

11
radicale/config/rights Executable file
View File

@ -0,0 +1,11 @@
# read access to root collection for all users
[all-read-root-collection]
user = .+
collection =
permission = r
# write access to own files for each user
[owner-write]
user: .+
collection: ^%(login)s(/.+)?$
permission: rw

2
radicale/config/users Executable file
View File

@ -0,0 +1,2 @@
anton:$2y$05$4e0H5UK4Q5p7.FNiY36NTuCYJWXPp4M5HOTLFL/HUP58EaCyNRwGa
jintara:$2y$05$ZXu4ILSNwUKgRh2vQdoB3elQ52/KA3NznMIEX3q5y1reXszN3PW7m

17
radicale/test.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# USER="jintara"
# PATH_="/data/collections/collection-root/jintara/bda0aa8d-8db8-1362-ee05-64d9cf7c8271/560b559b-130a-490e-acf8-1aa4e5ac4752.vcf" # дарья кухно
# PATH_="/data/collections/collection-root/jintara/bda0aa8d-8db8-1362-ee05-64d9cf7c8271/8fb3b96b-0b08-4606-95e9-fec553f8dc5e.vcf" # ольга кузина
USER="anton"
#PATH_="/data/collections/collection-root/anton/c9cc103a-bb2f-cde2-0138-25126ce8ab3f/708ac282-53b5-4044-9352-55883fcc6013.vcf" # test birthday
PATH_="/data/collections/collection-root/anton/c9cc103a-bb2f-cde2-0138-25126ce8ab3f/3f38ee57-74c8-4c65-99c7-ca6159e8bd7a.vcf" # Александр Кухно
REQUEST="PUT"
curl -s -X POST http://localhost:19000/hooks/on-change \
-H "Content-Type: application/json" \
-H "X-Secret: RUCVDCATrwtYlCT680FSW4IT1PrvkLB9" \
-d "{\"user\":\"$USER\",\"path\":\"$PATH_\",\"request\":\"$REQUEST\"}"

View File

@ -0,0 +1,29 @@
- id: on-change
execute-command: /usr/local/bin/python3
command-working-directory: /etc/webhook/scripts
include-command-output-in-response: true
pass-arguments-to-command:
- source: string
name: "/etc/webhook/scripts/on-change.py"
- source: payload
name: user
- source: payload
name: path
- source: payload
name: request
trigger-rule:
match:
type: value
value: RUCVDCATrwtYlCT680FSW4IT1PrvkLB9
parameter:
source: header
name: X-Secret
- id: rescan
execute-command: /usr/local/bin/python3
command-working-directory: /etc/webhook/scripts
include-command-output-in-response: true
pass-arguments-to-command:
- source: string
name: "/etc/webhook/scripts/rescan.py"
- source: url
name: user

View File

@ -0,0 +1,15 @@
#!/bin/sh
USER="$1"
PATH_="$2"
REQUEST="$3"
# Only fire on actual writes
case "$REQUEST" in
PUT|DELETE) ;;
*) exit 0 ;;
esac
curl -s -X POST $WEBHOOKS_URI/on-change \
-H "Content-Type: application/json" \
-H "X-Secret: RUCVDCATrwtYlCT680FSW4IT1PrvkLB9" \
-d "{\"user\":\"$USER\",\"path\":\"$PATH_\",\"request\":\"$REQUEST\"}"

View File

@ -0,0 +1,15 @@
calendars:
anton:
base:
dir: /data/collections/collection-root/anton
dates_target: 7c3bf078-e287-ed95-f9f6-016fc84663df
rescan:
- path: c9cc103a-bb2f-cde2-0138-25126ce8ab3f
jintara:
base:
dir: /data/collections/collection-root/jintara
dates_target: ebbbb557-5ce9-60cf-7a58-f25ace327913
rescan:
- path: bda0aa8d-8db8-1362-ee05-64d9cf7c8271

View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
from pprint import pprint
import vobject
from .types import Event
from .utils import parse_date
def read_contact_date_field(card, target: list[Event], field_name: str, target_field_name: str):
if field_name in card.contents:
for idx, component in enumerate(card.contents[field_name]): # datetime.date or string depending on format
field_id = f"{field_name}-{idx}"
omit_year = component.params.get("X-APPLE-OMIT-YEAR")
dt = parse_date(field_id, target_field_name, component.value, omit_year[0] if omit_year else False)
target.append(dt)
def read_contact_abdate_field(card, target: list[Event]):
grouped_fields = {}
field_name = "x-abdate"
field_name_2 = "x-ablabel"
if field_name in card.contents:
for component in card.contents[field_name]: # datetime.date or string depending on format
if component.group not in grouped_fields:
grouped_fields[component.group] = {}
grouped_fields[component.group]["value"] = component.value
if field_name_2 in card.contents:
for component in card.contents[field_name_2]: # datetime.date or string depending on format
if component.group not in grouped_fields:
grouped_fields[component.group] = {}
grouped_fields[component.group]["name"] = component.value
# if len(grouped_fields):
# pprint(card)
for group in grouped_fields:
f = grouped_fields[group]
# pprint(group)
# pprint(f)
if "name" in f and "value" in f:
target.append(parse_date(group, f["name"], f["value"]))
def read_contact(path: str) -> tuple[vobject.vCard, list[Event]]:
# print("---")
# with open(path, 'r') as file:
# print(file.read())
# print("---")
with open(path) as f:
card = vobject.readOne(f)
result = []
read_contact_date_field(card, result, "bday", "birthday")
read_contact_date_field(card, result, "anniversary", "anniversary")
read_contact_date_field(card, result, "x-anniversary", "anniversary")
# result["x-abdate"] = read_contact_date_field(card, "x-abdate") # need specific parsing. also see X-ABDATE;X-ABLABEL
read_contact_date_field(card, result, "x-anniversary", "anniversary")
read_contact_date_field(card, result, "x-evolution-anniversary", "anniversary") # also see X-EVOLUTION-SPOUSE
read_contact_abdate_field(card, result) # also see X-EVOLUTION-SPOUSE
return card, result

View File

@ -0,0 +1,41 @@
import vobject
def get_contact_name(card: vobject.vCard) -> str:
"""Extract best available display name from a vCard, never raises."""
# 1. FN — formatted name, most reliable
try:
fn = card.contents["fn"][0].value.strip()
if fn:
return fn
except (KeyError, IndexError, AttributeError):
pass
# 2. N — structured name: last, first, middle, prefix, suffix
try:
n = card.contents["n"][0].value
parts = [n.prefix, n.given, n.additional, n.family, n.suffix]
name = " ".join(p for p in parts if p and p.strip())
if name.strip():
return name.strip()
except (KeyError, IndexError, AttributeError):
pass
# 3. ORG — organization name as last resort
try:
org = card.contents["org"][0].value
# org can be a list ["Company", "Department"] or a string
if isinstance(org, list):
org = " / ".join(p for p in org if p and p.strip())
if org and org.strip():
return org.strip()
except (KeyError, IndexError, AttributeError):
pass
# 4. EMAIL
try:
return card.contents["email"][0].value.strip()
except (KeyError, IndexError, AttributeError):
pass
return "Unknown"

View File

@ -0,0 +1,27 @@
import vobject
from datetime import date, datetime
from pathlib import Path
from .types import Event
def create_event(name: str, base_id: str, eventDate: Event) -> tuple[str, str]:
year = eventDate.year or datetime.now().year
dt = date(year, eventDate.month, eventDate.day)
id = base_id
cal = vobject.iCalendar()
event = cal.add('vevent')
event.add('uid').value = id
event.add('summary').value = f"{name}, {eventDate.name}"
event.add('description').value = (f"{eventDate.year}-" if eventDate.year is not None else "") + f"{eventDate.month:02}-{eventDate.day:02}"
event.add('dtstart').value = dt
event.add('rrule').value = 'FREQ=YEARLY'
return id, cal.serialize()
def write_calendar_event(prefix: str, event: Event, dir: Path):
evtid, ics = create_event(prefix, f"{id}-{event.id}", event)
with open(dir / f"{evtid}.ics", 'w') as f:
f.write(ics)

View File

@ -0,0 +1,8 @@
from typing import NamedTuple, Optional
class Event(NamedTuple):
id: str
name: str
day: int
month: int
year: Optional[int] = None

View File

@ -0,0 +1,24 @@
import re
from datetime import date, datetime
from .types import Event
def parse_date(id: str, name: str, input: str, apple_omit_year: str | None = None) -> Event:
if isinstance(input, date):
return Event(id, name, input.day, input.month, input.year)
match = re.match(r"^(-|NaN)-(\d{2})-?(\d{2})$", input) # no-year format: --MMDD or --MM-DD. Also NaN-MM-DD spotted
if match:
return Event(id, name, int(match.group(3)), int(match.group(2)))
for fmt in ("%Y-%m-%d", "%Y%m%d"):
try:
dt = datetime.strptime(input, fmt).date()
if apple_omit_year == f"{dt.year}":
return Event(id, name, dt.day, dt.month)
else:
return Event(id, name, dt.day, dt.month, dt.year)
except ValueError:
continue
return None # cannot parse

View File

@ -0,0 +1,61 @@
#!/usr/bin/env python3
from pprint import pprint
import sys
import yaml
from box import Box
from pathlib import Path
from contactdates import calendar, addressbook, addressbook_utils
def load_config():
CONFIG_PATH = Path(__file__).parent / "config.yaml"
with open(CONFIG_PATH) as f:
return Box(yaml.safe_load(f))
def handle_contact_change(action: str, path: str, user: str):
config = load_config()
base_path = Path(config.calendars[user].base.dir)
target_calendar = config.calendars[user].dates_target
dir = base_path / target_calendar
print(f"Reading contact {path}")
card, events = addressbook.read_contact(path)
name = addressbook_utils.get_contact_name(card)
id = card.contents["uid"][0].value.strip()
print(f"Writing contact \"{name}\" {len(events)} events to {dir}")
for event in events:
evtid, evt = calendar.create_event(name, f"{id}-{event.id}", event)
print(f"Writing contact \"{name}\" event {evtid}")
with open(dir / f"{evtid}.ics", 'w') as f:
f.write(evt)
def handle_change():
user = sys.argv[1] if len(sys.argv) > 1 else "?"
path = sys.argv[2] if len(sys.argv) > 2 else "?"
request = sys.argv[3] if len(sys.argv) > 3 else "?"
# Distinguish contacts (CardDAV) vs calendar (CalDAV) by path
if ".vcf" in path:
kind = "CONTACT"
elif ".ics" in path:
kind = "EVENT"
else:
kind = "COLLECTION"
print(f"{request} {user} {kind} {path}")
if kind == "CONTACT":
handle_contact_change(request, path, user)
def main():
handle_change()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
from pprint import pprint
import sys
import yaml
import glob
from box import Box
from pathlib import Path
from contactdates import calendar, addressbook, addressbook_utils
def load_config():
CONFIG_PATH = Path(__file__).parent / "config.yaml"
with open(CONFIG_PATH) as f:
return Box(yaml.safe_load(f))
def process_contact(file: Path, target_dir: Path, user: str):
# print(f"Reading {user} contact {file.name}")
card, events = addressbook.read_contact(str(file))
name = addressbook_utils.get_contact_name(card)
id = card.contents["uid"][0].value.strip()
if len(events):
print(f"Reading {user} contact {file.name}")
# pprint(card)
print(f"Writing contact \"{name}\" {len(events)} events to {target_dir}")
for file_to_delete in target_dir.glob(f"{id}-*.ics"):
print(f"Deleting old contact event {file_to_delete.name}")
file_to_delete.unlink()
for event in events:
evtid, evt = calendar.create_event(name, f"{id}-{event.id}", event)
print(f"Writing contact \"{name}\" event {evtid}")
with open(target_dir / f"{evtid}.ics", 'w') as f:
f.write(evt)
def handle_rescan(user: str):
print(f"Rescanning {user} addressbooks")
config = load_config()
user_config = config.calendars[user]
pprint(user_config)
base_path = Path(config.calendars[user].base.dir)
target_calendar = base_path / config.calendars[user].dates_target
addressbooks = user_config.rescan
for ab in addressbooks:
dir = base_path / ab.path
print(f"Rescanning {user} addressbook {dir}")
files = dir.glob("*.vcf")
for file in files:
process_contact(file, target_calendar, user)
def main():
user = sys.argv[1] if len(sys.argv) > 1 else "?"
handle_rescan(user)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,6 @@
FROM python:3.13-alpine
COPY --from=almir/webhook /usr/local/bin/webhook /usr/local/bin/webhook
USER root
RUN pip install --no-cache-dir pyyaml python-box vobject
EXPOSE 9000
ENTRYPOINT ["webhook"]

1
syncthing/.env Normal file
View File

@ -0,0 +1 @@
# VARIABLE=value #comment

22
syncthing/compose.yaml Normal file
View File

@ -0,0 +1,22 @@
services:
syncthing:
image: syncthing/syncthing
container_name: syncthing
restart: always
hostname: docker1
ports:
- 8384:8384
volumes:
- /docker/data/syncthing/data:/var/syncthing
healthcheck:
test: curl -fkLsS -m 2 127.0.0.1:8384/rest/noauth/health | grep -o --color=never
OK || exit 1
interval: 1m
timeout: 10s
retries: 3
networks:
- caddy_default
networks:
caddy_default:
external: true

1
tidyquest/.env Normal file
View File

@ -0,0 +1 @@
# VARIABLE=value #comment

18
tidyquest/compose.yaml Normal file
View File

@ -0,0 +1,18 @@
services:
tidyquest:
image: mellowfox/tidyquest:latest
container_name: tidyquest
restart: always
ports:
- 3020:3000
environment:
- NODE_ENV=production
- JWT_SECRET=YMuTbK7kAw1Iv6qVp1465eLxSQ4w6fTQ
- TZ=Europe/Moscow
volumes:
- /docker/data/tidyquest/data:/app/data
networks:
- caddy_default
networks:
caddy_default:
external: true