mirror of
https://github.com/SeCherkasov/util-linux-cal.git
synced 2026-03-30 07:51:46 +03:00
Initial commit: Rust calendar utility
This commit is contained in:
14
plugins/holiday_highlighter/Cargo.toml
Normal file
14
plugins/holiday_highlighter/Cargo.toml
Normal 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"
|
||||
98
plugins/holiday_highlighter/README.md
Normal file
98
plugins/holiday_highlighter/README.md
Normal 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 |
|
||||
98
plugins/holiday_highlighter/README_ru.md
Normal file
98
plugins/holiday_highlighter/README_ru.md
Normal 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` | Резервная локаль для определения страны |
|
||||
214
plugins/holiday_highlighter/src/lib.rs
Normal file
214
plugins/holiday_highlighter/src/lib.rs
Normal 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()
|
||||
}
|
||||
56
plugins/holiday_highlighter/tests/integration_tests.rs
Normal file
56
plugins/holiday_highlighter/tests/integration_tests.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user