setCookie is fire-and-forget, so the import's count was what WebKit was handed, not what it kept. A probe from inside the page reads back the other end: pairing against Arc gave `names=tz,cids,frontend_lang` for aputure.odoo.com, and `tz` exists only in Arc's store — so the import demonstrably landed. The sites still ask for sign-in. That is the far end refusing the session, not a broken import, and the two are now distinguishable instead of being guessed at.
630 lines
20 KiB
Rust
630 lines
20 KiB
Rust
//! The command surface. The shell owns "which app is active"; Rust owns the
|
|
//! webviews and the file on disk.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
|
|
use tauri::{AppHandle, Manager, State};
|
|
use url::Url;
|
|
|
|
use crate::config::{self, App, Config, Group};
|
|
use crate::webviews;
|
|
|
|
/// Stage rect in logical pixels: x, y, width, height.
|
|
pub type Stage = (f64, f64, f64, f64);
|
|
|
|
pub struct AppState {
|
|
pub dir: PathBuf,
|
|
pub config: Mutex<Config>,
|
|
pub active: Mutex<Option<String>>,
|
|
pub stage: Mutex<Stage>,
|
|
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
|
pub booted: Mutex<bool>,
|
|
/// What the last page probe reported, for the notification diagnostic.
|
|
pub diag: Mutex<String>,
|
|
/// The last notification a page raised, and what macOS did with it.
|
|
pub last_notification: Mutex<String>,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn cfg(&self) -> Config {
|
|
self.config.lock().unwrap().clone()
|
|
}
|
|
|
|
fn persist(&self) -> Result<(), String> {
|
|
config::save(&self.dir, &self.config.lock().unwrap())
|
|
}
|
|
|
|
/// Records a selector chosen by right-clicking it in the page.
|
|
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
|
|
{
|
|
let mut cfg = self.config.lock().unwrap();
|
|
let app = cfg
|
|
.apps
|
|
.iter_mut()
|
|
.find(|a| a.id == app_id)
|
|
.ok_or_else(|| format!("no app {app_id}"))?;
|
|
if app.hidden.iter().any(|s| s == selector) {
|
|
return Ok(());
|
|
}
|
|
app.hidden.push(selector.to_string());
|
|
}
|
|
self.persist()
|
|
}
|
|
|
|
/// Every host the import is allowed to bring cookies across for: the
|
|
/// configured apps, plus the sign-in hosts that vouch for them.
|
|
fn importable_hosts(&self) -> Vec<String> {
|
|
let cfg = self.config.lock().unwrap();
|
|
let mut hosts: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
|
|
hosts.extend(crate::routing::identity_providers().iter().map(|s| s.to_string()));
|
|
hosts
|
|
}
|
|
}
|
|
|
|
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
|
let dir = handle
|
|
.path()
|
|
.app_config_dir()
|
|
.map_err(|e| format!("no config directory: {e}"))?;
|
|
let config = config::load(&dir)?;
|
|
Ok(AppState {
|
|
dir,
|
|
config: Mutex::new(config),
|
|
active: Mutex::new(None),
|
|
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
|
booted: Mutex::new(false),
|
|
diag: Mutex::new(String::new()),
|
|
last_notification: Mutex::new(String::new()),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_config(state: State<'_, AppState>) -> Config {
|
|
state.cfg()
|
|
}
|
|
|
|
/// Records the rect the shell has left for an app, and resizes the one showing.
|
|
#[tauri::command]
|
|
pub fn set_stage(
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) {
|
|
let stage = (x, y, width.max(0.0), height.max(0.0));
|
|
*state.stage.lock().unwrap() = stage;
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::set_stage(&app, active.as_deref(), stage);
|
|
}
|
|
|
|
/// Creates every app's webview: the active one first, the rest staggered.
|
|
///
|
|
/// Called by the shell once it has measured the stage, so the views are born
|
|
/// at the right size instead of being built against a guess and corrected.
|
|
#[tauri::command]
|
|
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
|
{
|
|
let mut booted = state.booted.lock().unwrap();
|
|
if *booted {
|
|
return Ok(());
|
|
}
|
|
*booted = true;
|
|
}
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
let active = state.active.lock().unwrap().clone();
|
|
|
|
let first = active
|
|
.clone()
|
|
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
|
|
|
|
if let Some(id) = &first {
|
|
if let Some(a) = cfg.app(id) {
|
|
webviews::create(&app, a, &cfg, stage)?;
|
|
*state.active.lock().unwrap() = Some(id.clone());
|
|
webviews::show_only(&app, Some(id), &cfg, stage);
|
|
}
|
|
}
|
|
|
|
// The rest follow with a gap, so launching does not fire a dozen
|
|
// simultaneous page loads at the network and the CPU.
|
|
let rest: Vec<App> = cfg
|
|
.ordered()
|
|
.into_iter()
|
|
.filter(|a| Some(&a.id) != first.as_ref())
|
|
.cloned()
|
|
.collect();
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
for a in rest {
|
|
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
|
let state = app.state::<AppState>();
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
|
|
eprintln!("could not create webview for {}: {e}", a.name);
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
*state.active.lock().unwrap() = Some(app_id.clone());
|
|
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
|
}
|
|
|
|
/// Hides every app, so a dialog is not painted over by a native view.
|
|
#[tauri::command]
|
|
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
|
webviews::hide_all(&app, &state.cfg());
|
|
}
|
|
|
|
/// Puts the active app back after a dialog closes.
|
|
#[tauri::command]
|
|
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
let url = Url::parse(&url).map_err(|e| e.to_string())?;
|
|
wv.navigate(url).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Back, forward and reload, run inside the app's own page.
|
|
#[tauri::command]
|
|
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
let script = if delta == 0 {
|
|
"location.reload()".to_string()
|
|
} else {
|
|
format!("history.go({delta})")
|
|
};
|
|
wv.eval(&script).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
|
|
app.get_webview(&webviews::label_for(&app_id))
|
|
.and_then(|wv| wv.url().ok())
|
|
.map(|u| u.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_external(url: String) -> Result<(), String> {
|
|
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
|
|
}
|
|
|
|
// ---------------------------------------------------------------- apps
|
|
|
|
#[tauri::command]
|
|
pub fn add_app(
|
|
name: String,
|
|
url: String,
|
|
group_id: Option<String>,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
let url = config::normalize_url(&url);
|
|
let scope = config::default_scope(&url)
|
|
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
|
|
|
|
let new = {
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let order = cfg
|
|
.apps
|
|
.iter()
|
|
.filter(|a| a.group_id == group_id)
|
|
.map(|a| a.order)
|
|
.max()
|
|
.map_or(0, |m| m + 1);
|
|
let new = App {
|
|
id: config::new_id(),
|
|
name: name.trim().to_string(),
|
|
url,
|
|
scope: vec![scope],
|
|
group_id,
|
|
user_agent: None,
|
|
hidden: Vec::new(),
|
|
order,
|
|
};
|
|
cfg.apps.push(new.clone());
|
|
new
|
|
};
|
|
state.persist()?;
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if *state.booted.lock().unwrap() {
|
|
webviews::create(&app, &new, &cfg, stage)?;
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
|
|
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
|
|
/// since none of the three can be altered on a live one.
|
|
#[tauri::command]
|
|
pub fn update_app(
|
|
updated: App,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
let rebuild = {
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let existing = cfg
|
|
.apps
|
|
.iter_mut()
|
|
.find(|a| a.id == updated.id)
|
|
.ok_or_else(|| format!("no app {}", updated.id))?;
|
|
let rebuild = existing.url != updated.url
|
|
|| existing.scope != updated.scope
|
|
|| existing.user_agent != updated.user_agent;
|
|
*existing = updated.clone();
|
|
rebuild
|
|
};
|
|
state.persist()?;
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if rebuild && *state.booted.lock().unwrap() {
|
|
webviews::destroy(&app, &updated.id);
|
|
webviews::create(&app, &updated, &cfg, stage)?;
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_app(
|
|
app_id: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
cfg.apps.retain(|a| a.id != app_id);
|
|
}
|
|
state.persist()?;
|
|
webviews::destroy(&app, &app_id);
|
|
|
|
let mut active = state.active.lock().unwrap();
|
|
if active.as_deref() == Some(app_id.as_str()) {
|
|
*active = None;
|
|
}
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
/// Rewrites group membership and position in one go, for a drag.
|
|
#[tauri::command]
|
|
pub fn reorder_apps(
|
|
ordering: Vec<(String, Option<String>, i32)>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
for (id, group_id, order) in ordering {
|
|
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
|
|
a.group_id = group_id;
|
|
a.order = order;
|
|
}
|
|
}
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
// -------------------------------------------------------------- groups
|
|
|
|
#[tauri::command]
|
|
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
|
|
cfg.groups.push(Group {
|
|
id: config::new_id(),
|
|
name: name.trim().to_string(),
|
|
collapsed: false,
|
|
order,
|
|
});
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let g = cfg
|
|
.groups
|
|
.iter_mut()
|
|
.find(|g| g.id == updated.id)
|
|
.ok_or_else(|| format!("no group {}", updated.id))?;
|
|
*g = updated;
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
|
|
/// never be a way to lose the things inside it by accident.
|
|
#[tauri::command]
|
|
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
cfg.groups.retain(|g| g.id != group_id);
|
|
for a in cfg.apps.iter_mut() {
|
|
if a.group_id.as_deref() == Some(group_id.as_str()) {
|
|
a.group_id = None;
|
|
}
|
|
}
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
// ------------------------------------------------------------ settings
|
|
|
|
#[tauri::command]
|
|
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
|
|
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
|
|
state.persist()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
|
|
state.config.lock().unwrap().settings.theme = theme;
|
|
state.persist()
|
|
}
|
|
|
|
// ------------------------------------------------------- hidden elements
|
|
|
|
/// Replaces an app's hidden selectors and re-applies them without a reload.
|
|
#[tauri::command]
|
|
pub fn set_hidden(
|
|
app_id: String,
|
|
hidden: Vec<String>,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let target = cfg
|
|
.apps
|
|
.iter_mut()
|
|
.find(|a| a.id == app_id)
|
|
.ok_or_else(|| format!("no app {app_id}"))?;
|
|
target.hidden = hidden.clone();
|
|
}
|
|
state.persist()?;
|
|
webviews::push_hidden(&app, &app_id, &hidden);
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
/// Starts the in-page element picker, for reaching something a right-click
|
|
/// cannot land on cleanly.
|
|
#[tauri::command]
|
|
pub fn pick_hidden(app_id: String, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
wv.eval("window.__workAppPick && window.__workAppPick()")
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Permission state, and what a notification raised straight from Rust does.
|
|
///
|
|
/// Splits the chain in two: if this succeeds and the page test does not, the
|
|
/// shim is at fault; if this fails, macOS never granted permission.
|
|
#[tauri::command]
|
|
pub fn notification_status(app: AppHandle) -> String {
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
let state = match app.notification().permission_state() {
|
|
Ok(s) => format!("{s:?}"),
|
|
Err(e) => format!("unknown ({e})"),
|
|
};
|
|
let raised = match app
|
|
.notification()
|
|
.builder()
|
|
.title("Work")
|
|
.body("Notifications are working.")
|
|
.show()
|
|
{
|
|
Ok(()) => "raised".to_string(),
|
|
Err(e) => format!("failed: {e}"),
|
|
};
|
|
let app_state = app.state::<AppState>();
|
|
let last = app_state.last_notification.lock().unwrap().clone();
|
|
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
|
format!("permission: {state} · direct: {raised} · from page: {from_page}")
|
|
}
|
|
|
|
/// Asks macOS for notification permission, once, at startup.
|
|
pub fn ensure_notification_permission(app: &AppHandle) {
|
|
use tauri_plugin_notification::NotificationExt;
|
|
let granted = matches!(
|
|
app.notification().permission_state(),
|
|
Ok(tauri_plugin_notification::PermissionState::Granted)
|
|
);
|
|
if !granted {
|
|
if let Err(e) = app.notification().request_permission() {
|
|
eprintln!("notification permission was refused: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fires a notification the way a page would.
|
|
///
|
|
/// Deliberately routed through the injected shim rather than raised directly:
|
|
/// the thing worth testing is the whole chain — page API, sentinel, Rust, and
|
|
/// macOS — not whether this process can show a notification.
|
|
#[tauri::command]
|
|
pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
// The probe reports what it found before raising anything, so a
|
|
// notification that never appears still says why.
|
|
wv.eval(
|
|
r#"(function () {
|
|
var kind = typeof window.Notification;
|
|
var shim = !!(window.Notification && window.Notification.__work);
|
|
var err = '';
|
|
try {
|
|
new Notification('Test notification',
|
|
{ body: 'If you can see this, pages can reach you.' });
|
|
} catch (e) { err = String(e); }
|
|
if (window.__workAppSend) {
|
|
window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err });
|
|
}
|
|
})();"#,
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Asks the page which cookies it can actually see.
|
|
///
|
|
/// `setCookie` is fire-and-forget, so the import's count is what was handed to
|
|
/// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are
|
|
/// invisible to script by design, so the answer is a floor, not a total — but a
|
|
/// zero here means the injection never landed at all.
|
|
#[tauri::command]
|
|
pub fn probe_cookies(app_id: String, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
wv.eval(
|
|
r#"(function () {
|
|
var names = document.cookie
|
|
? document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); })
|
|
: [];
|
|
if (window.__workAppSend) {
|
|
window.__workAppSend('diag', {
|
|
host: location.hostname,
|
|
visible: String(names.length),
|
|
names: names.slice(0, 12).join(',')
|
|
});
|
|
}
|
|
})();"#,
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// What the last cookie probe saw.
|
|
#[tauri::command]
|
|
pub fn cookie_probe(app: AppHandle) -> String {
|
|
let diag = app.state::<AppState>().diag.lock().unwrap().clone();
|
|
if diag.is_empty() { "no answer from the page".into() } else { diag }
|
|
}
|
|
|
|
// ------------------------------------------------------- browser pairing
|
|
|
|
#[tauri::command]
|
|
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
|
|
crate::cookies::list()
|
|
}
|
|
|
|
/// Imports the paired browser's cookies for the configured hosts.
|
|
///
|
|
/// Everything outside those hosts is dropped before anything is written: this
|
|
/// reaches into a browser's whole cookie store, and it must come back with the
|
|
/// sessions for the tools on the list and nothing else.
|
|
#[tauri::command]
|
|
pub fn pair_browser(
|
|
browser: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<crate::cookies::PairResult, String> {
|
|
let all = crate::cookies::read_all(&browser)?;
|
|
let scanned = all.len();
|
|
let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts());
|
|
|
|
let mut domains: Vec<String> = wanted.iter().map(|c| c.domain.clone()).collect();
|
|
domains.sort();
|
|
domains.dedup();
|
|
|
|
let mut warnings = Vec::new();
|
|
if wanted.is_empty() {
|
|
warnings.push(format!(
|
|
"Read {scanned} cookies, none for the apps on your list. \
|
|
Sign in to them in that browser first."
|
|
));
|
|
}
|
|
|
|
let imported = crate::cookies::inject::install(&app, wanted)?;
|
|
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
cfg.settings.paired_browser = Some(browser);
|
|
cfg.settings.last_paired_at = Some(now_iso());
|
|
}
|
|
state.persist()?;
|
|
|
|
Ok(crate::cookies::PairResult {
|
|
imported,
|
|
domains: domains.len(),
|
|
domain_names: domains,
|
|
warnings,
|
|
})
|
|
}
|
|
|
|
/// A timestamp for "last paired", without pulling in a date library for it.
|
|
fn now_iso() -> String {
|
|
let secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let days = secs / 86_400;
|
|
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
|
let (y, mo, d) = civil_from_days(days as i64);
|
|
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
|
}
|
|
|
|
/// Howard Hinnant's days-to-civil-date algorithm.
|
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
let z = z + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z.rem_euclid(146_097);
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn civil_from_days_matches_known_dates() {
|
|
// Cross-checked against Python:
|
|
// date(1970,1,1) + timedelta(days=n)
|
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
|
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
|
assert_eq!(civil_from_days(20_697), (2026, 9, 1));
|
|
assert_eq!(civil_from_days(20_698), (2026, 9, 2));
|
|
}
|
|
}
|