more command options and api fixes
All checks were successful
Build legacy Nix package on Ubuntu / build (push) Successful in 10m21s
All checks were successful
Build legacy Nix package on Ubuntu / build (push) Successful in 10m21s
This commit is contained in:
parent
4e6248cd0d
commit
824218ad73
5 changed files with 323 additions and 123 deletions
|
@ -7,6 +7,8 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: install sudo
|
||||
run: apt update && apt install sudo
|
||||
- uses: actions/checkout@v2
|
||||
- uses: https://github.com/cachix/install-nix-action@v26
|
||||
- name: Building package
|
||||
|
|
136
src/client.rs
136
src/client.rs
|
@ -1,13 +1,13 @@
|
|||
use crate::*;
|
||||
use reqwest::{
|
||||
header::{HeaderMap, HeaderValue},
|
||||
Client, Method, Request, Url,
|
||||
header::{HeaderMap, HeaderValue},
|
||||
};
|
||||
use serde::{de::Visitor, Serializer};
|
||||
use serde::{Serializer, de::Visitor};
|
||||
use serde_json::Value;
|
||||
use std::{borrow::Borrow, collections::HashMap};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum RecordType {
|
||||
A,
|
||||
AAAA,
|
||||
|
@ -24,52 +24,56 @@ pub enum RecordType {
|
|||
DS,
|
||||
CAA,
|
||||
}
|
||||
/*
|
||||
impl Serialize for RecordType {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(match self {
|
||||
RecordType::A => "A",
|
||||
RecordType::AAAA => "AAAA",
|
||||
RecordType::NS => "NS",
|
||||
RecordType::MX => "MX",
|
||||
RecordType::CNAME => "CNAME",
|
||||
RecordType::RP => "RP",
|
||||
RecordType::TXT => "TXT",
|
||||
RecordType::SOA => "SOA",
|
||||
RecordType::HINFO => "HINFO",
|
||||
RecordType::SRV => "SRV",
|
||||
RecordType::DANE => "DANE",
|
||||
RecordType::TLSA => "TLSA",
|
||||
RecordType::DS => "DS",
|
||||
RecordType::CAA => "CAA",
|
||||
|
||||
impl ToString for RecordType {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
RecordType::A => "A".to_string(),
|
||||
RecordType::AAAA => "AAAA".to_string(),
|
||||
RecordType::NS => "NS".to_string(),
|
||||
RecordType::MX => "MX".to_string(),
|
||||
RecordType::CNAME => "CNAME".to_string(),
|
||||
RecordType::RP => "RP".to_string(),
|
||||
RecordType::TXT => "TXT".to_string(),
|
||||
RecordType::SOA => "SOA".to_string(),
|
||||
RecordType::HINFO => "HINFO".to_string(),
|
||||
RecordType::SRV => "SRV".to_string(),
|
||||
RecordType::DANE => "DANE".to_string(),
|
||||
RecordType::TLSA => "TLSA".to_string(),
|
||||
RecordType::DS => "DS".to_string(),
|
||||
RecordType::CAA => "CAA".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for RecordType {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Ok(match value {
|
||||
"A" => RecordType::A,
|
||||
"AAAA" => RecordType::AAAA,
|
||||
"NS" => RecordType::NS,
|
||||
"MX" => RecordType::MX,
|
||||
"CNAME" => RecordType::CNAME,
|
||||
"RP" => RecordType::RP,
|
||||
"TXT" => RecordType::TXT,
|
||||
"SOA" => RecordType::SOA,
|
||||
"HINFO" => RecordType::HINFO,
|
||||
"SRV" => RecordType::SRV,
|
||||
"DANE" => RecordType::DANE,
|
||||
"TLSA" => RecordType::TLSA,
|
||||
"DS" => RecordType::DS,
|
||||
"CAA" => RecordType::CAA,
|
||||
_ => return Err(""),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for RecordType {
|
||||
type Value = &'static str;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(formatter, "a dns record string")
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct _RecordQuery {
|
||||
records: Vec<RecordPayload>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RecordType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de> {
|
||||
deserializer.deserialize_str(RecordType)
|
||||
}
|
||||
fn deserialize_in_place<D>(deserializer: D, place: &mut Self) -> Result<(), D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>, {
|
||||
deserializer.deserialize_str()
|
||||
}
|
||||
}
|
||||
*/
|
||||
pub struct HetznerDNSAPIClient {
|
||||
token: String,
|
||||
host: Url,
|
||||
|
@ -112,9 +116,18 @@ impl HetznerDNSAPIClient {
|
|||
HeaderValue::from_str(self.token.as_str()).unwrap(),
|
||||
);
|
||||
if let Some(payload) = payload {
|
||||
*req.body_mut() = Some(serde_json::to_string(&payload).map_err(|e| { println!("body encoding error: {}",e); ()} )?.into());
|
||||
*req.body_mut() = Some(
|
||||
serde_json::to_string(&payload)
|
||||
.map_err(|e| {
|
||||
println!("body encoding error: {}", e);
|
||||
()
|
||||
})?
|
||||
.into(),
|
||||
);
|
||||
println!("{:#?}", serde_json::to_string(&payload));
|
||||
}
|
||||
let t = self.client
|
||||
let t = self
|
||||
.client
|
||||
.execute(req)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
@ -122,10 +135,20 @@ impl HetznerDNSAPIClient {
|
|||
()
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|e| { println!("request error: {}", e); ()})?
|
||||
.text().await
|
||||
.map_err(|e| { println!("request decoding error: {}", e); ()})?;
|
||||
Ok(serde_json::from_str::<T>(&t).map_err(|e| {println!("json response parsing error: {}",e); ()})?)
|
||||
.map_err(|e| {
|
||||
println!("request error: {}", e);
|
||||
()
|
||||
})?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("request decoding error: {}", e);
|
||||
()
|
||||
})?;
|
||||
Ok(serde_json::from_str::<T>(&t).map_err(|e| {
|
||||
println!("json response parsing error: {}", e);
|
||||
()
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn get_zones<'a>(
|
||||
|
@ -134,8 +157,9 @@ impl HetznerDNSAPIClient {
|
|||
page: Option<u32>,
|
||||
per_page: Option<u32>,
|
||||
search_name: Option<&'a str>,
|
||||
) -> Result<ZoneResult, ()> {
|
||||
self.api_call(
|
||||
) -> Result<Vec<Zone>, ()> {
|
||||
let result: ZoneResult = self
|
||||
.api_call(
|
||||
"zones",
|
||||
Method::GET,
|
||||
Some(&[
|
||||
|
@ -146,7 +170,8 @@ impl HetznerDNSAPIClient {
|
|||
]),
|
||||
None::<String>,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok(result.zones)
|
||||
}
|
||||
|
||||
pub async fn create_zone(&self, name: String, ttl: Option<u64>) -> Result<Zone, ()> {
|
||||
|
@ -285,14 +310,17 @@ impl HetznerDNSAPIClient {
|
|||
"records/bulk",
|
||||
Method::POST,
|
||||
None::<[(&str, &str); 0]>,
|
||||
Some(payloads),
|
||||
Some(_RecordQuery { records: payloads }),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
Ok(result.records)
|
||||
}
|
||||
|
||||
pub async fn update_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> {
|
||||
pub async fn update_records(
|
||||
&self,
|
||||
payloads: Vec<(String, RecordPayload)>,
|
||||
) -> Result<Vec<Record>, ()> {
|
||||
let result: RecordsResult = self
|
||||
.api_call(
|
||||
"records/bulk",
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
#![allow(dead_code, unused)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
|
||||
pub mod models;
|
||||
pub mod client;
|
||||
|
||||
pub mod models;
|
||||
|
|
216
src/main.rs
216
src/main.rs
|
@ -1,12 +1,12 @@
|
|||
#![allow(dead_code, unused)]
|
||||
use core::panic;
|
||||
use std::ops::Sub;
|
||||
use crate::client::*;
|
||||
use crate::models::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use core::panic;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::Sub;
|
||||
|
||||
mod models;
|
||||
mod client;
|
||||
mod models;
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
enum Mode {
|
||||
|
@ -35,27 +35,18 @@ struct ZoneContext {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordProto<'a> {
|
||||
r#type: RecordType,
|
||||
name: &'a str,
|
||||
value: &'a str,
|
||||
ttl: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordContext<'a> {
|
||||
struct RecordContext {
|
||||
all: bool,
|
||||
zone: &'a str,
|
||||
records: Vec<RecordProto<'a>>,
|
||||
records: Vec<RecordPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Context<'a> {
|
||||
struct Context {
|
||||
mode: Mode,
|
||||
submode: SubMode,
|
||||
token: Option<String>,
|
||||
zone_context: ZoneContext,
|
||||
record_context: RecordContext<'a>,
|
||||
record_context: RecordContext,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
|
@ -72,8 +63,13 @@ async fn main() {
|
|||
},
|
||||
record_context: RecordContext {
|
||||
all: false,
|
||||
zone: "",
|
||||
records: vec![],
|
||||
records: vec![RecordPayload {
|
||||
zone_id: String::new(),
|
||||
r#type: RecordType::A,
|
||||
name: String::new(),
|
||||
value: String::new(),
|
||||
ttl: 0,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let mut _continue = false;
|
||||
|
@ -135,7 +131,32 @@ async fn main() {
|
|||
if arg.starts_with("-") {
|
||||
match arg.as_str() {
|
||||
"--all" => ctx.record_context.all = true,
|
||||
_ => todo!()
|
||||
"--zone" => {
|
||||
ctx.record_context.records[0].zone_id =
|
||||
std::env::args().nth(idx + 1).unwrap()
|
||||
}
|
||||
"--name" => {
|
||||
ctx.record_context.records[0].name =
|
||||
std::env::args().nth(idx + 1).unwrap()
|
||||
}
|
||||
"--value" => {
|
||||
ctx.record_context.records[0].value =
|
||||
std::env::args().nth(idx + 1).unwrap()
|
||||
}
|
||||
"--type" => {
|
||||
ctx.record_context.records[0].r#type = RecordType::try_from(
|
||||
std::env::args().nth(idx + 1).unwrap().as_str(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
"--ttl" => {
|
||||
ctx.record_context.records[0].ttl = std::env::args()
|
||||
.nth(idx + 1)
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap_or(86400)
|
||||
}
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -160,6 +181,68 @@ async fn main() {
|
|||
"{:#?}",
|
||||
client.get_zones(None, None, None, None).await.unwrap()
|
||||
);
|
||||
} else if !ctx.zone_context.zone.is_empty() {
|
||||
for name in ctx.zone_context.zone.split(",") {
|
||||
if let Ok(zones) =
|
||||
client.get_zones(Some(name), None, None, None).await
|
||||
{
|
||||
zones.into_iter().for_each(|z| println!("{:#?}", z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SubMode::Create => {
|
||||
if !ctx.zone_context.name.is_empty() {
|
||||
if let Ok(zone) = client
|
||||
.create_zone(ctx.zone_context.name, Some(ctx.zone_context.ttl))
|
||||
.await
|
||||
{
|
||||
println!("{:#?}", zone);
|
||||
}
|
||||
}
|
||||
if !ctx.zone_context.zone.is_empty() {
|
||||
eprintln!(
|
||||
"Ignoring additional --zone value - use update/u to update an existing zone"
|
||||
);
|
||||
}
|
||||
}
|
||||
SubMode::Update => {
|
||||
if !ctx.zone_context.zone.is_empty() && !ctx.zone_context.name.is_empty() {
|
||||
if let Ok(zones) = client
|
||||
.get_zones(Some(ctx.zone_context.zone.as_str()), None, None, None)
|
||||
.await
|
||||
{
|
||||
if let Ok(zone) = client
|
||||
.update_zone(
|
||||
zones.into_iter().next().unwrap().id,
|
||||
ctx.zone_context.name,
|
||||
Some(ctx.zone_context.ttl),
|
||||
)
|
||||
.await
|
||||
{
|
||||
println!("{:#?}", zone);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Unable to fetch zone {}", ctx.zone_context.zone);
|
||||
}
|
||||
} else {
|
||||
eprintln!("--zone and --name are required for updating!");
|
||||
}
|
||||
}
|
||||
SubMode::Delete => {
|
||||
if !ctx.zone_context.zone.is_empty() {
|
||||
if let Ok(zones) = client
|
||||
.get_zones(Some(ctx.zone_context.zone.as_str()), None, None, None)
|
||||
.await
|
||||
{
|
||||
if client
|
||||
.delete_zone(zones.into_iter().next().unwrap().id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
eprintln!("Successfully deleted {}", ctx.zone_context.zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
@ -170,10 +253,101 @@ async fn main() {
|
|||
SubMode::Get => {
|
||||
if ctx.record_context.all {
|
||||
println!("{:#?}", client.get_records(None, None, None).await.unwrap());
|
||||
} else if !ctx.record_context.records.is_empty() {
|
||||
for zone in ctx
|
||||
.record_context
|
||||
.records
|
||||
.into_iter()
|
||||
.map(|r| r.zone_id)
|
||||
.filter(|z| !z.is_empty())
|
||||
{
|
||||
let zone = client
|
||||
.get_zones(Some(zone.as_str()), None, None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
let mut records =
|
||||
client.get_records(None, None, Some(zone.id)).await.unwrap();
|
||||
println!("{:#?}", records);
|
||||
}
|
||||
}
|
||||
_ => { todo!() }
|
||||
}
|
||||
SubMode::Create => {
|
||||
if !ctx.record_context.records.is_empty() {
|
||||
let zone = &client
|
||||
.get_zones(
|
||||
Some(ctx.record_context.records[0].zone_id.as_str()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.id;
|
||||
ctx.record_context.records[0].zone_id = zone.to_string();
|
||||
println!("{:#?}", ctx.record_context.records);
|
||||
if let Ok(record) =
|
||||
client.create_records(ctx.record_context.records).await
|
||||
{
|
||||
println!("{:#?}", record);
|
||||
}
|
||||
}
|
||||
}
|
||||
SubMode::Update => {
|
||||
if !ctx.record_context.records.is_empty() {
|
||||
let mut records = vec![];
|
||||
let mut updated_records = vec![];
|
||||
let mut records_iter = ctx.record_context.records.into_iter();
|
||||
for zone in records_iter
|
||||
.clone()
|
||||
.map(|r| r.zone_id)
|
||||
.filter(|z| !z.is_empty())
|
||||
{
|
||||
let zone = client
|
||||
.get_zones(Some(zone.as_str()), None, None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
records.extend(
|
||||
client.get_records(None, None, Some(zone.id)).await.unwrap(),
|
||||
);
|
||||
}
|
||||
for old_record in records {
|
||||
if let Some(mut new_record) =
|
||||
records_iter.find(|r| r.name == old_record.name)
|
||||
{
|
||||
let mut old_record = old_record;
|
||||
updated_records.push((
|
||||
old_record.id,
|
||||
RecordPayload {
|
||||
zone_id: old_record.zone_id,
|
||||
r#type: old_record.r#type,
|
||||
name: old_record.name,
|
||||
value: new_record.value,
|
||||
ttl: new_record.ttl,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if updated_records.len() > 0 {
|
||||
let records = client.update_records(updated_records).await.unwrap();
|
||||
eprintln!("Updated {} records", records.len());
|
||||
} else {
|
||||
eprintln!(
|
||||
"No records found that require updating. Did you mean to create/c the records instead?"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SubMode::Delete => {}
|
||||
_ => {
|
||||
todo!()
|
||||
}
|
||||
},
|
||||
Mode::PrimaryServer => {
|
||||
todo!()
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::client::RecordType;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Borrow;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TxtVerification {
|
||||
pub name: String,
|
||||
pub token: String
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -13,12 +14,12 @@ pub struct Pagination {
|
|||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
pub last_page: u32,
|
||||
pub total_entries: u32
|
||||
pub total_entries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Meta {
|
||||
pub pagination: Pagination
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -42,59 +43,56 @@ pub struct Zone {
|
|||
//pub verified: Option<DateTime<Utc>>,
|
||||
pub records_count: u32,
|
||||
pub is_secondary_dns: bool,
|
||||
pub txt_verification: TxtVerification
|
||||
pub txt_verification: TxtVerification,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ZoneResult {
|
||||
pub zones: Vec<Zone>,
|
||||
pub meta: Meta
|
||||
pub meta: Meta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordPayload {
|
||||
zone_id: String,
|
||||
r#type: RecordType,
|
||||
name: String,
|
||||
value: String,
|
||||
ttl: u64
|
||||
pub zone_id: String,
|
||||
pub r#type: RecordType,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub ttl: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Record {
|
||||
id: String,
|
||||
pub id: String,
|
||||
#[serde(with = "hetzner_date")]
|
||||
created: DateTime<Utc>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(with = "hetzner_date")]
|
||||
modified: DateTime<Utc>,
|
||||
zone_id: String,
|
||||
r#type: String,
|
||||
name: String,
|
||||
value: String,
|
||||
ttl: Option<u64>
|
||||
pub modified: DateTime<Utc>,
|
||||
pub zone_id: String,
|
||||
pub r#type: RecordType,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub ttl: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RecordResult {
|
||||
pub record: Record
|
||||
pub record: Record,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RecordsResult {
|
||||
pub records: Vec<Record>
|
||||
pub records: Vec<Record>,
|
||||
}
|
||||
|
||||
mod hetzner_date {
|
||||
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||
use serde::{self, Deserialize, Serializer, Deserializer};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
// 2025-01-06 02:18:34.674 +0000 UTC
|
||||
const FORMAT: &str = "%F %T.%-f";
|
||||
|
||||
pub fn serialize<S>(
|
||||
date: &DateTime<Utc>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
|
@ -102,14 +100,13 @@ mod hetzner_date {
|
|||
serializer.serialize_str(&s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<DateTime<Utc>, D::Error>
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let dt = NaiveDateTime::parse_from_str(&s.split(" +").next().unwrap(), FORMAT).map_err(serde::de::Error::custom)?;
|
||||
let dt = NaiveDateTime::parse_from_str(&s.split(" +").next().unwrap(), FORMAT)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue