Initial commit: Rust calendar utility

This commit is contained in:
2026-02-19 06:57:09 +03:00
commit 56a8b34710
19 changed files with 4755 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "holiday_highlighter"
version = "0.1.0"
edition = "2024"
description = "Holiday highlighting plugin for cal using isdayoff.ru API"
[lib]
crate-type = ["cdylib", "rlib"]
name = "holiday_highlighter"
[dependencies]
chrono = { version = "0.4.43", features = ["unstable-locales"] }
ureq = "2.12"
libc = "0.2"

View File

@@ -0,0 +1,98 @@
# Holiday Highlighter Plugin
Plugin for `cal` that adds holiday highlighting via isdayoff.ru API.
## Features
- **Automatic country detection** from system locale
- **Data caching** to reduce API requests
- **Multiple country support**: Russia, Belarus, Kazakhstan, USA, Uzbekistan, Turkey, Latvia
- **Day type data**: working days, weekends, shortened days, official holidays
## Building
```bash
# Build entire workspace
cargo build --release --workspace
# Plugin only
cargo build --release -p holiday_highlighter
```
## Installation
### User-local
```bash
mkdir -p ~/.local/lib/cal/plugins
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
```
### System-wide
```bash
sudo mkdir -p /usr/lib/cal/plugins
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
```
## Usage
```bash
# Highlight holidays for current country
cal -H
# Year with holidays
cal -y -H
# Three months with holidays
cal -3 -H
```
## Supported countries
| Code | Country | Auto-detect locales |
|------|---------|---------------------|
| RU | Russia | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
| BY | Belarus | be_BY, ru_BY |
| KZ | Kazakhstan | kk_KZ, ru_KZ |
| US | USA | en_US, en |
| UZ | Uzbekistan | uz_UZ, ru_UZ |
| TR | Turkey | tr_TR |
| LV | Latvia | lv_LV, ru_LV |
## API
The plugin uses [isdayoff.ru API](https://isdayoff.ru/):
### Monthly request
```
GET https://isdayoff.ru/api/getdata?year=2026&month=01&pre=1
```
### Yearly request
```
GET https://isdayoff.ru/api/getdata?year=2026&pre=1
```
Parameter `pre=1` includes pre-holiday shortened days information.
## Data format
Each character in the response represents a day of the month:
| Character | Day type | Description |
|-----------|----------|-------------|
| `0` | Working | Regular working day |
| `1` | Weekend | Saturday or Sunday |
| `2` | Shortened | Pre-holiday shortened day |
| `8` | Holiday | Official public holiday |
## Environment variables
| Variable | Description |
|----------|-------------|
| `LC_ALL` | Priority locale for country detection |
| `LC_TIME` | Locale for country detection |
| `LANG` | Fallback locale for country detection |

View File

@@ -0,0 +1,98 @@
# Holiday Highlighter Plugin
Плагин для `cal`, добавляющий подсветку официальных праздников через API isdayoff.ru.
## Возможности
- **Автоматическое определение страны** по системной локали
- **Кэширование данных** для уменьшения количества запросов к API
- **Поддержка нескольких стран**: Россия, Беларусь, Казахстан, США, Узбекистан, Турция, Латвия
- **Данные о типах дней**: рабочие, выходные, сокращённые, официальные праздники
## Сборка
```bash
# Сборка всего workspace
cargo build --release --workspace
# Только плагин
cargo build --release -p holiday_highlighter
```
## Установка
### Локально для пользователя
```bash
mkdir -p ~/.local/lib/cal/plugins
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
```
### Системно
```bash
sudo mkdir -p /usr/lib/cal/plugins
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
```
## Использование
```bash
# Подсветка праздников для текущей страны
cal -H
# Год с праздниками
cal -y -H
# Три месяца с праздниками
cal -3 -H
```
## Поддерживаемые страны
| Код | Страна | Локали для автоопределения |
|-----|--------|----------------------------|
| RU | Россия | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
| BY | Беларусь | be_BY, ru_BY |
| KZ | Казахстан | kk_KZ, ru_KZ |
| US | США | en_US, en |
| UZ | Узбекистан | uz_UZ, ru_UZ |
| TR | Турция | tr_TR |
| LV | Латвия | lv_LV, ru_LV |
## API
Плагин использует [isdayoff.ru API](https://isdayoff.ru/):
### Запрос за месяц
```
GET https://isdayoff.ru/api/getdata?year=2026&month=01&pre=1
```
### Запрос за год
```
GET https://isdayoff.ru/api/getdata?year=2026&pre=1
```
Параметр `pre=1` включает информацию о предпраздничных сокращённых днях.
## Формат данных
Каждый символ в ответе представляет день месяца:
| Символ | Тип дня | Описание |
|--------|---------|----------|
| `0` | Рабочий | Обычный рабочий день |
| `1` | Выходной | Суббота или воскресенье |
| `2` | Сокращённый | Предпраздничный день |
| `8` | Праздник | Официальный государственный праздник |
## Переменные окружения
| Переменная | Описание |
|------------|----------|
| `LC_ALL` | Приоритетная локаль для определения страны |
| `LC_TIME` | Локаль для определения страны |
| `LANG` | Резервная локаль для определения страны |

View File

@@ -0,0 +1,214 @@
//! Holiday Highlighter Plugin for cal.
//!
//! Fetches holiday data from isdayoff.ru API for multiple countries.
use libc::{c_char, c_int};
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::sync::{LazyLock, Mutex};
pub const PLUGIN_NAME: &str = env!("CARGO_PKG_NAME");
pub const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const API_URL_YEAR: &str = "https://isdayoff.ru/api/getdata";
const API_URL_MONTH: &str = "https://isdayoff.ru/api/getdata";
/// Supported countries with their locale mappings.
const SUPPORTED_COUNTRIES: &[(&str, &[&str])] = &[
("RU", &["ru_RU", "ru_BY", "ru_KZ", "ru_UZ", "ru_LV"]),
("BY", &["be_BY", "ru_BY"]),
("KZ", &["kk_KZ", "ru_KZ"]),
("US", &["en_US", "en"]),
("UZ", &["uz_UZ", "ru_UZ"]),
("TR", &["tr_TR"]),
("LV", &["lv_LV", "ru_LV"]),
];
type CacheKey = (i32, u32, String);
static CACHE: LazyLock<Mutex<HashMap<CacheKey, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Initialize the plugin (optional, cache is lazily initialized).
#[unsafe(no_mangle)]
pub extern "C" fn plugin_init() {}
/// Get plugin name (do not free returned pointer).
#[unsafe(no_mangle)]
pub extern "C" fn plugin_get_name() -> *const c_char {
static NAME: LazyLock<CString> = LazyLock::new(|| CString::new(PLUGIN_NAME).unwrap());
NAME.as_ptr()
}
/// Get plugin version (do not free returned pointer).
#[unsafe(no_mangle)]
pub extern "C" fn plugin_get_version() -> *const c_char {
static VERSION: LazyLock<CString> = LazyLock::new(|| CString::new(PLUGIN_VERSION).unwrap());
VERSION.as_ptr()
}
/// Get holiday data for a specific month.
///
/// Returns string where each character represents a day:
/// - '0' = working day, '1' = weekend, '2' = shortened, '8' = public holiday
///
/// # Safety
/// `country_code` must be a valid null-terminated C string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn plugin_get_holidays(
year: c_int,
month: c_int,
country_code: *const c_char,
) -> *mut c_char {
let country = unsafe { parse_country(country_code) };
let key = (year, month as u32, country.clone());
if let Some(data) = CACHE.lock().unwrap().get(&key) {
return CString::new(data.as_str()).unwrap().into_raw();
}
let data = fetch_holidays(year, month as u32, &country).unwrap_or_default();
CACHE.lock().unwrap().insert(key, data.clone());
CString::new(data).unwrap().into_raw()
}
unsafe fn parse_country(country_code: *const c_char) -> String {
unsafe {
CStr::from_ptr(country_code)
.to_str()
.unwrap_or("RU")
.to_string()
}
}
/// Free memory allocated by plugin_get_holidays.
///
/// # Safety
/// `ptr` must be returned by `plugin_get_holidays`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn plugin_free_holidays(ptr: *mut c_char) {
if !ptr.is_null() {
let _ = unsafe { CString::from_raw(ptr) };
}
}
/// Get country code from system locale.
#[unsafe(no_mangle)]
pub extern "C" fn plugin_get_country_from_locale() -> *mut c_char {
let country = get_country_from_locale();
CString::new(country).unwrap().into_raw()
}
/// Free memory allocated by plugin_get_country_from_locale.
///
/// # Safety
/// `ptr` must be returned by `plugin_get_country_from_locale`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn plugin_free_country(ptr: *mut c_char) {
if !ptr.is_null() {
let _ = unsafe { CString::from_raw(ptr) };
}
}
/// Check if a specific day is a holiday.
///
/// Returns: 0=working, 1=weekend, 2=shortened, 8=public, -1=error
///
/// # Safety
/// `country_code` must be a valid null-terminated C string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn plugin_is_holiday(
year: c_int,
month: c_int,
day: c_int,
country_code: *const c_char,
) -> c_int {
let country = unsafe { parse_country(country_code) };
let holidays = fetch_holidays(year, month as u32, &country);
match holidays {
Some(data) => {
let day_idx = (day - 1) as usize;
if day_idx < data.len() {
data.chars()
.nth(day_idx)
.and_then(|c| c.to_digit(10).map(|d| d as c_int))
.unwrap_or(-1)
} else {
-1
}
}
None => -1,
}
}
/// Get holiday data for entire year.
///
/// # Safety
/// `country_code` must be a valid null-terminated C string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn plugin_get_year_holidays(
year: c_int,
country_code: *const c_char,
) -> *mut c_char {
let country = unsafe { parse_country(country_code) };
let data = fetch_holidays_year(year, &country).unwrap_or_default();
CString::new(data).unwrap().into_raw()
}
/// Fetch holiday data for a month from isdayoff.ru API.
fn fetch_holidays(year: i32, month: u32, _country: &str) -> Option<String> {
let url = format!("{}?year={}&month={:02}&pre=1", API_URL_MONTH, year, month);
match ureq::get(&url).call() {
Ok(response) => response.into_string().ok(),
Err(_) => None,
}
}
/// Fetch holiday data for entire year from isdayoff.ru API.
pub fn fetch_holidays_year(year: i32, _country: &str) -> Option<String> {
let url = format!("{}?year={}&pre=1", API_URL_YEAR, year);
match ureq::get(&url).call() {
Ok(response) => response.into_string().ok(),
Err(_) => None,
}
}
/// Determine country code from system locale.
pub fn get_country_from_locale() -> String {
let locale = std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_TIME"))
.or_else(|_| std::env::var("LANG"))
.unwrap_or_else(|_| "en_US.UTF-8".to_string());
let locale_name = locale
.split('.')
.next()
.unwrap_or(&locale)
.split('@')
.next()
.unwrap_or(&locale);
// Match against supported countries
for (country, locales) in SUPPORTED_COUNTRIES {
for &supported_locale in *locales {
if locale_name == supported_locale {
return country.to_string();
}
}
}
// Extract country code from locale (e.g., "en_US" -> "US")
if let Some(underscore_pos) = locale_name.find('_') {
let country_code = &locale_name[underscore_pos + 1..];
for (country, _) in SUPPORTED_COUNTRIES {
if *country == country_code {
return country_code.to_string();
}
}
}
"RU".to_string()
}

View File

@@ -0,0 +1,56 @@
//! Integration tests for holiday_highlighter plugin.
use holiday_highlighter::{
PLUGIN_NAME, PLUGIN_VERSION, get_country_from_locale, plugin_get_name, plugin_get_version,
};
use std::ffi::CStr;
#[test]
fn test_get_country_from_locale_ru() {
unsafe {
std::env::set_var("LC_ALL", "ru_RU.UTF-8");
}
assert_eq!(get_country_from_locale(), "RU");
}
#[test]
fn test_get_country_from_locale_us() {
unsafe {
std::env::set_var("LC_ALL", "en_US.UTF-8");
}
assert_eq!(get_country_from_locale(), "US");
}
#[test]
fn test_get_country_from_locale_by() {
unsafe {
std::env::set_var("LC_ALL", "be_BY.UTF-8");
}
assert_eq!(get_country_from_locale(), "BY");
}
#[test]
fn test_get_country_from_locale_fallback() {
unsafe {
std::env::set_var("LC_ALL", "");
std::env::set_var("LC_TIME", "");
std::env::set_var("LANG", "");
}
assert_eq!(get_country_from_locale(), "RU");
}
#[test]
fn test_plugin_metadata_from_cargo() {
assert_eq!(PLUGIN_NAME, "holiday_highlighter");
assert_eq!(PLUGIN_VERSION, "0.1.0");
}
#[test]
fn test_plugin_get_name_version() {
unsafe {
let name = CStr::from_ptr(plugin_get_name()).to_str().unwrap();
let version = CStr::from_ptr(plugin_get_version()).to_str().unwrap();
assert_eq!(name, "holiday_highlighter");
assert_eq!(version, "0.1.0");
}
}