diff --git a/Cargo.lock b/Cargo.lock index aefa4a7..61154ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -135,12 +144,24 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bitflags" version = "1.3.2" @@ -257,6 +278,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "dyn-clone" version = "1.0.17" @@ -272,6 +302,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -477,6 +520,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "http" version = "1.1.0" @@ -517,6 +566,12 @@ version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "1.5.0" @@ -630,6 +685,8 @@ dependencies = [ "chrono", "clap", "futures", + "log", + "pretty_env_logger", "prettyplease", "progenitor", "progenitor-client", @@ -640,6 +697,7 @@ dependencies = [ "syn", "tokio", "uuid", + "vcard4", ] [[package]] @@ -659,6 +717,17 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -714,6 +783,39 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "logos" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6b6e02facda28ca5fb8dbe4b152496ba3b1bd5a4b40bb2b1b2d8ad74e0f39b" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32eb6b5f26efacd015b000bfc562186472cd9b34bdba3f6b264e2a052676d10" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5d0c5463c911ef55624739fc353238b4e310f0144be1f875dc42fec6bfd5ec" +dependencies = [ + "logos-codegen", +] + [[package]] name = "memchr" version = "2.7.4" @@ -741,7 +843,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", @@ -765,6 +867,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -891,6 +999,22 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "prettyplease" version = "0.2.24" @@ -1000,7 +1124,7 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", "regex-automata", "regex-syntax", @@ -1012,7 +1136,7 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", "regex-syntax", ] @@ -1039,7 +1163,7 @@ version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1422,6 +1546,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.65" @@ -1442,6 +1575,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -1619,6 +1783,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1631,6 +1801,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.2" @@ -1658,6 +1838,22 @@ dependencies = [ "serde", ] +[[package]] +name = "vcard4" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92713aa20d2ffffdbc8e62cd3ebc14b448fc7d58b00f6976da8cef5419297101" +dependencies = [ + "aho-corasick 0.7.20", + "base64 0.21.7", + "logos", + "thiserror", + "time", + "unicode-segmentation", + "uriparse", + "zeroize", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1775,6 +1971,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -1921,3 +2126,17 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index e8ab2d3..a26b804 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,15 @@ anyhow = "1.0.91" chrono = { version = "0.4.38", features = ["serde"] } clap = { version = "4.5.20", features = ["derive"] } futures = "0.3.31" +log = "0.4.22" +pretty_env_logger = "0.5.0" progenitor-client = "0.8.0" regress = "0.10.1" reqwest = { version = "0.12.8", features = ["json", "stream"] } serde = { version = "1.0.213", features = ["derive"] } tokio = { version = "1.41.0", features = ["full"] } uuid = { version = "1.11.0", features = ["serde", "v4"] } +vcard4 = "0.5.2" [build-dependencies] prettyplease = "0.2.24" diff --git a/src/args.rs b/src/args.rs index b3b1c5a..13d2ea3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{command, Parser, Subcommand}; #[derive(Parser)] @@ -9,12 +11,20 @@ pub(crate) struct Opts { #[arg(short, long)] pub api_key: String, + #[arg(short, long)] + pub dry_run: bool, + #[command(subcommand)] pub command: Commands, } #[derive(Subcommand)] pub(crate) enum Commands { + /// People related commands + People { + #[command(subcommand)] + people_command: PeopleCommands, + }, /// Server related commands Server { #[command(subcommand)] @@ -22,6 +32,15 @@ pub(crate) enum Commands { }, } +#[derive(Subcommand)] +pub(crate) enum PeopleCommands { + /// Synchronises date of births from a vcard file + SyncDateOfBirths { + #[arg(short, long)] + vcard_file: PathBuf, + }, +} + #[derive(Subcommand)] pub(crate) enum ServerCommands { /// Fetches the version of the server diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 437a21e..6bca56d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1 +1,2 @@ pub mod server_version; +pub mod sync_date_of_birth; diff --git a/src/commands/sync_date_of_birth.rs b/src/commands/sync_date_of_birth.rs new file mode 100644 index 0000000..d022627 --- /dev/null +++ b/src/commands/sync_date_of_birth.rs @@ -0,0 +1,137 @@ +use std::{fs, path::PathBuf}; + +use anyhow::Result; +use chrono::{Datelike, NaiveDate}; +use log::*; +use uuid::Uuid; +use vcard4::{ + parse_loose, + property::{DateAndOrTime, DateTimeOrTextProperty}, +}; + +use crate::{ + types::{PersonResponseDto, PersonUpdateDto}, + Client, +}; + +pub async fn sync_date_of_birth( + client: &Client, + vcard_file: &PathBuf, + dry_run: bool, +) -> Result<()> { + let server_contacts = fetch_all_contacts(client).await?; + + let filtered_contacts: Vec<_> = server_contacts + .iter() + .filter(|p| !p.name.is_empty()) + .collect(); + + let contacts = fs::read_to_string(vcard_file)?; + let cards = parse_loose(contacts)?; + + let cards_with_bday: Vec<_> = cards.iter().filter(|c| c.bday.is_some()).collect(); + + info!( + "{} vcards with date of births, {} persons with names fetched from Immich", + cards_with_bday.len(), + filtered_contacts.len() + ); + + let mut updated_dobs = 0; + + for card in cards_with_bday { + let name = card.formatted_name.first().map(|text| text.value.clone()); + match name { + Some(formatted_name) => { + let contact = filtered_contacts.iter().find(|c| c.name == formatted_name); + match contact { + Some(c) => match card.bday.as_ref().unwrap() { + DateTimeOrTextProperty::DateTime(dt) => { + let bday = vcard_date_to_naive_date(dt.value.first().unwrap().clone())?; + if c.birth_date.as_ref().map_or(false, |bdate| bday == *bdate) { + debug!( + "{} already has the proper date of birth, skipping", + formatted_name + ); + } else if bday.year() > 0 { + update_person_bday(c, bday, client, dry_run).await?; + updated_dobs += 1; + } else { + debug!( + "{} has an incomplete date of birth, skipping", + formatted_name + ); + } + } + DateTimeOrTextProperty::Text(_) => todo!(), + }, + None => debug!("No Immich match for {}", formatted_name), + } + } + None => todo!(), + } + } + + info!("Updated {} persons", updated_dobs); + + Ok(()) +} + +fn vcard_date_to_naive_date(src: DateAndOrTime) -> Result { + match src { + DateAndOrTime::Date(date) => { + Ok( + NaiveDate::from_ymd_opt(date.year(), date.month() as u32, date.day().into()) + .expect(&format!("Unable to parse {}", date)), + ) + } + DateAndOrTime::DateTime(datetime) => Ok(NaiveDate::from_ymd_opt( + datetime.year(), + datetime.month() as u32, + datetime.day().into(), + ) + .expect(&format!("Unable to parse {}", datetime))), + DateAndOrTime::Time(_time) => todo!(), + } +} + +async fn update_person_bday( + person: &PersonResponseDto, + bday: NaiveDate, + client: &Client, + dry_run: bool, +) -> Result<()> { + let update = PersonUpdateDto { + feature_face_asset_id: None, + is_hidden: None, + name: None, + birth_date: Some(bday), + }; + + info!("Updating {}: {:?}", person.name, update); + + if !dry_run { + client + .update_person(&Uuid::parse_str(&person.id)?, &update) + .await?; + } + + Ok(()) +} + +async fn fetch_all_contacts(client: &Client) -> Result> { + let mut all_people = Vec::new(); + let mut page_number = 1; + let mut has_next_page = true; + + while has_next_page { + let fetched = client + .get_all_people(Some(page_number.into()), None, Some(true)) + .await?; + all_people.extend(fetched.people.clone()); + has_next_page = fetched.has_next_page.expect("Missing has_next_page"); + page_number += 1; + } + + Ok(all_people) +} diff --git a/src/main.rs b/src/main.rs index c1c2a3c..f167507 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,10 @@ include!(concat!(env!("OUT_DIR"), "/codegen.rs")); use crate::args::Commands; use anyhow::Result; -use args::ServerCommands; +use args::{PeopleCommands, ServerCommands}; use clap::Parser; use commands::server_version::server_version; +use commands::sync_date_of_birth::sync_date_of_birth; use reqwest::header; mod args; @@ -12,11 +13,18 @@ mod commands; #[tokio::main] async fn main() { + pretty_env_logger::init(); + let args = args::Opts::parse(); let client = get_client(&args.server_url, &args.api_key).unwrap(); let res = match &args.command { + Commands::People { people_command } => match people_command { + PeopleCommands::SyncDateOfBirths { vcard_file } => { + sync_date_of_birth(&client, vcard_file, args.dry_run).await + } + }, Commands::Server { server_command } => match server_command { ServerCommands::Version {} => server_version(&client).await, },