use chrono::{DateTime, Local, TimeZone};
use directories::ProjectDirs;
use once_cell::sync::Lazy;
use rusqlite::{params, Connection, Result};
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
const CHARSET: &str = "23456789abcdefghijkmnpqrstuvwxyz";
static INSTANCE: Lazy<Mutex<Option<TimeId>>> = Lazy::new(|| Mutex::new(None));
#[derive(Debug)]
pub struct TimeId {
charset: Vec<char>,
#[allow(dead_code)]
db_path: PathBuf,
conn: Connection,
}
impl TimeId {
fn default_db_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "nicklawman", "timeid") {
let db_dir = proj_dirs.data_local_dir();
db_dir.join("timeid.sqlite")
} else {
PathBuf::from(home::home_dir().unwrap()).join(".timeid.sqlite")
}
}
fn init(db_path: Option<&str>) -> Result<Self> {
let db_path = db_path
.map(PathBuf::from)
.unwrap_or_else(Self::default_db_path);
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
}
let conn = Connection::open(&db_path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS used_ids (
id TEXT PRIMARY KEY,
memo TEXT DEFAULT '',
tag TEXT DEFAULT ''
)",
[],
)?;
Ok(Self {
charset: CHARSET.chars().collect(),
db_path,
conn,
})
}
fn global() -> Result<MutexGuard<'static, Option<TimeId>>> {
let mut guard = INSTANCE.lock().unwrap();
if guard.is_none() {
*guard = Some(Self::init(None)?);
}
Ok(guard)
}
pub fn gen() -> Result<String> {
let mut guard = Self::global()?;
let instance = guard.as_mut().unwrap();
instance.gen_inner("", "")
}
pub fn parse(uid: &str) -> String {
let guard = INSTANCE.lock().unwrap();
let instance = guard
.as_ref()
.expect("TimeId must be initialized before calling parse()");
instance.parse_inner(uid)
}
pub fn gen_inner(&mut self, memo: &str, tag: &str) -> Result<String> {
let mut timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut uid = self.encode_timestamp(timestamp);
while self.exists(&uid)? {
timestamp += 1;
uid = self.encode_timestamp(timestamp);
}
self.store(&uid, memo, tag)?;
Ok(uid)
}
pub fn reset(db_path: Option<&str>) -> Result<()> {
let path = db_path
.map(PathBuf::from)
.unwrap_or_else(Self::default_db_path);
if path.exists() {
fs::remove_file(&path)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
}
let new_instance = Some(Self::init(Some(path.to_str().unwrap()))?);
let mut guard = INSTANCE.lock().unwrap();
*guard = new_instance;
Ok(())
}
pub fn status() -> Result<usize> {
let guard = Self::global()?;
let instance = guard.as_ref().unwrap();
let count: usize = instance
.conn
.query_row("SELECT COUNT(*) FROM used_ids", [], |row| row.get(0))?;
Ok(count)
}
pub fn parse_inner(&self, uid: &str) -> String {
let timestamp = self.decode_timestamp(uid);
let datetime: DateTime<Local> = Local.timestamp_opt(timestamp as i64, 0).unwrap();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
}
fn encode_base(&self, mut num: u64) -> String {
if num == 0 {
return self.charset[0].to_string();
}
let base = self.charset.len() as u64;
let mut encoded = String::new();
while num > 0 {
let rem = (num % base) as usize;
encoded.insert(0, self.charset[rem]);
num /= base;
}
encoded
}
fn decode_base(&self, uid: &str) -> u64 {
let base = self.charset.len() as u64;
uid.chars().fold(0, |acc, c| {
acc * base + self.charset.iter().position(|&x| x == c).unwrap() as u64
})
}
fn encode_timestamp(&self, timestamp: u64) -> String {
self.encode_base(timestamp * self.charset.len() as u64)
}
fn decode_timestamp(&self, uid: &str) -> u64 {
self.decode_base(uid) / self.charset.len() as u64
}
fn exists(&self, uid: &str) -> Result<bool> {
let mut stmt = self.conn.prepare("SELECT id FROM used_ids WHERE id = ?1")?;
let mut rows = stmt.query(params![uid])?;
Ok(rows.next()?.is_some())
}
fn store(&self, uid: &str, memo: &str, tag: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO used_ids (id, memo, tag) VALUES (?1, ?2, ?3)",
params![uid, memo, tag],
)?;
Ok(())
}
}