AnyBot/src/app/vault_ui.rs

843 lines
46 KiB
Rust
Raw Normal View History

// 設定 → 保險庫: logins, cards and contact info the bots can fill for the person (never seeing
// passwords, card numbers or security codes). Layout follows the original's vault pane:
// 「新項目」 menu (登入資訊 / 卡 / 聯絡資訊 · 匯入), tabs with counts, 搜尋保險庫, rows with a
// tile, title and subtitle, per-type empty states, and a footer note.
use super::*;
use super::sidebar_ui::shorten_width;
use crate::icons::Icon;
use crate::vault_store::{self as store, Address, ImportPlan, Item, Kind, RowStatus};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum VaultTab {
#[default]
All,
Login,
Card,
Contact,
}
#[derive(Default, Clone)]
pub struct LoginForm {
pub id: Option<String>,
pub sites: Vec<String>,
pub username: String,
pub password: String,
pub notes: String,
pub err: String,
}
#[derive(Default, Clone)]
pub struct CardForm {
pub id: Option<String>,
pub number: String,
pub expiry: String,
pub csc: String,
pub show_csc: bool,
pub holder: String,
pub nickname: String,
/// "" = 無帳單地址, "new" = typed below, else a contact's id.
pub billing_from: String,
pub billing: Address,
pub notes: String,
pub locked: String,
pub err: String,
}
#[derive(Default, Clone)]
pub struct ContactForm {
pub id: Option<String>,
pub label: String,
pub name: String,
pub address: Address,
pub phones: Vec<String>,
pub emails: Vec<String>,
pub notes: String,
pub err: String,
}
#[derive(Default)]
pub struct ImportState {
pub picking: Option<Arc<Mutex<Option<Vec<PathBuf>>>>>,
pub file: String,
pub plan: Option<ImportPlan>,
pub err: String,
/// Rows still to save (indexes into plan.rows), and how many there were.
pub queue: Vec<usize>,
pub total: usize,
pub stop: bool,
pub done: Option<(usize, usize, Vec<(String, String)>)>,
}
pub enum VaultDialog {
ViewLogin(String),
Login(LoginForm),
Card(CardForm),
Contact(ContactForm),
Remove(String, String, Kind),
Import(ImportState),
}
#[derive(Default)]
pub struct VaultUi {
pub tab: VaultTab,
pub search: String,
pub items: Option<Result<Vec<Item>, String>>,
pub dialog: Option<VaultDialog>,
pub note: String,
}
fn kind_label(k: Kind) -> &'static str {
match k {
Kind::Login => "登入資訊",
Kind::Card => "卡片",
Kind::Contact => "聯絡資訊",
}
}
fn kind_icon(k: Kind) -> Icon {
match k {
Kind::Login => Icon::Key,
Kind::Card => Icon::CreditCard,
Kind::Contact => Icon::Person,
}
}
impl App {
fn vault_items(&mut self) -> Vec<Item> {
if self.vault.items.is_none() {
self.vault.items = Some(if self.demo { Ok(demo_items()) } else { store::list() });
}
match self.vault.items.as_ref() {
Some(Ok(v)) => v.clone(),
_ => vec![],
}
}
fn vault_refresh(&mut self) {
self.vault.items = None;
}
pub(super) fn vault_page(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
let items = self.vault_items();
// Header: title and 「新項目」.
ui.horizontal(|ui| {
ui.label(RichText::new(t("保險庫")).strong().size(FONT_XL));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let open = self.vault.dialog.is_some();
let b = ui.add_enabled(!open, primary_button("新項目", p));
egui::Popup::menu(&b).show(|ui| {
ui.set_min_width(menu_ui::MENU_W);
ui.spacing_mut().item_spacing.y = 0.0;
if menu_ui::item(ui, p, Icon::Key, "登入資訊").clicked() {
self.vault.dialog = Some(VaultDialog::Login(LoginForm { sites: vec![String::new()], ..Default::default() }));
ui.close();
}
if menu_ui::item(ui, p, Icon::CreditCard, "卡").clicked() {
self.vault.dialog = Some(VaultDialog::Card(CardForm::default()));
ui.close();
}
if menu_ui::item(ui, p, Icon::Person, "聯絡資訊").clicked() {
self.vault.dialog = Some(VaultDialog::Contact(ContactForm { phones: vec![String::new()], emails: vec![String::new()], ..Default::default() }));
ui.close();
}
menu_ui::sep(ui, p);
if menu_ui::item(ui, p, Icon::FileText, "匯入").clicked() {
self.vault.dialog = Some(VaultDialog::Import(ImportState::default()));
ui.close();
}
});
});
});
hint(ui, "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫", p);
ui.add_space(10.0);
if let Some(Err(e)) = &self.vault.items {
crate::icons::label(ui, Icon::Alert, &format!("{}:{e}", t("讀不到保險庫")), 13.0, p.danger);
return;
}
// Tabs with counts, and the search box.
let count = |k: Option<Kind>| items.iter().filter(|i| k.map(|k| i.kind == k).unwrap_or(true)).count();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (tab, label, k) in [(VaultTab::All, "全部", None), (VaultTab::Login, "登入資訊", Some(Kind::Login)), (VaultTab::Card, "卡片", Some(Kind::Card)), (VaultTab::Contact, "聯絡資訊", Some(Kind::Contact))] {
let on = self.vault.tab == tab;
let text = format!("{} {}", t(label), count(k));
let b = egui::Button::new(RichText::new(text).size(FONT_SM).color(if on { p.text } else { p.text2 })).fill(if on { p.active } else { Color32::TRANSPARENT }).corner_radius(8).min_size(vec2(0.0, 28.0));
if ui.add(b).clicked() {
self.vault.tab = tab;
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(8, 4)).show(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)), Icon::Search, p.text3);
let resp = ui.add(egui::TextEdit::singleline(&mut self.vault.search).hint_text(t("搜尋保險庫")).frame(Frame::NONE).desired_width(150.0));
if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
self.vault.search.clear();
}
if !self.vault.search.is_empty() && ui.add(egui::Button::new(RichText::new("×").color(p.text3)).frame(false)).on_hover_text(t("清除搜尋")).clicked() {
self.vault.search.clear();
}
});
});
});
});
ui.add_space(8.0);
let q = self.vault.search.trim().to_lowercase();
let want = |i: &&Item| match self.vault.tab {
VaultTab::All => true,
VaultTab::Login => i.kind == Kind::Login,
VaultTab::Card => i.kind == Kind::Card,
VaultTab::Contact => i.kind == Kind::Contact,
};
let shown: Vec<&Item> = items.iter().filter(want).filter(|i| q.is_empty() || i.haystack().contains(&q)).collect();
let mut open = None;
setting_card(ui, p, |ui| {
if shown.is_empty() {
let text = if !q.is_empty() {
format!("找不到與「{}」相符的結果", self.vault.search.trim())
} else {
match self.vault.tab {
VaultTab::All => "尚未儲存任何內容。透過新項目新增登入資訊、卡片或聯絡資訊。",
VaultTab::Login => "尚無登入資訊。透過新項目新增一筆,或從 CSV 匯入。",
VaultTab::Card => "尚無已儲存的卡片。透過新項目新增一張。",
VaultTab::Contact => "還沒有聯絡資訊。透過新項目新增。",
}
.to_string()
};
ui.add_space(6.0);
ui.label(RichText::new(t(&text)).color(p.text3).size(FONT_SM));
ui.add_space(6.0);
return;
}
// While searching 「全部」, results are grouped by kind.
let grouped = !q.is_empty() && self.vault.tab == VaultTab::All;
let mut last_kind = None;
for it in shown {
if grouped && last_kind != Some(it.kind) {
ui.add_space(4.0);
ui.label(RichText::new(t(kind_label(it.kind))).size(FONT_XS).color(p.text3));
last_kind = Some(it.kind);
}
if self.vault_row(ui, it, p).clicked() {
open = Some(it.clone());
}
}
});
if let Some(it) = open {
self.vault.dialog = Some(match it.kind {
Kind::Login => VaultDialog::ViewLogin(it.id.clone()),
Kind::Card => VaultDialog::Card(CardForm {
id: Some(it.id.clone()),
expiry: format!("{:02}/{:02}", it.exp_month, it.exp_year % 100),
holder: it.holder.clone(),
nickname: it.nickname.clone(),
billing_from: if it.billing.is_empty() { String::new() } else { "new".into() },
billing: it.billing.clone(),
notes: it.notes.clone(),
locked: format!("{} •••• {}", it.brand, it.last4),
..Default::default()
}),
Kind::Contact => VaultDialog::Contact(ContactForm {
id: Some(it.id.clone()),
label: it.title.clone(),
name: it.name.clone(),
address: it.address.clone(),
phones: if it.phones.is_empty() { vec![String::new()] } else { it.phones.clone() },
emails: if it.emails.is_empty() { vec![String::new()] } else { it.emails.clone() },
notes: it.notes.clone(),
..Default::default()
}),
});
}
ui.add_space(8.0);
let has_cards = items.iter().any(|i| i.kind == Kind::Card);
hint(
ui,
if has_cards { "Bot 絕對看不到已儲存卡片的卡號或安全碼。它可以看到登入資訊和聯絡資訊,以便替你填寫。" } else { "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫" },
p,
);
hint(ui, "每次填入前都會先問你。資料加密存在這台電腦上,金鑰放在系統鑰匙圈。登入時保險庫沒有的網站,也會找 macOS 鑰匙圈和 1Password(裝了 op 指令並登入時)。", p);
if !self.vault.note.is_empty() {
ui.label(RichText::new(&self.vault.note).size(FONT_XS).color(p.text2));
}
}
fn vault_row(&self, ui: &mut egui::Ui, it: &Item, p: Palette) -> egui::Response {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 48.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 10, p.hover);
}
let tile = egui::Rect::from_min_size(rect.left_center() + vec2(6.0, -16.0), vec2(32.0, 32.0));
ui.painter().rect_filled(tile, 8, p.active);
crate::icons::paint(ui.painter(), tile.shrink(8.0), kind_icon(it.kind), p.text2);
let title = shorten_width(ui, &it.display_title(), rect.width() - 90.0, FONT_SM);
let sub = if it.subtitle().is_empty() && !it.notes.is_empty() { format!("{}{}", t("提示:"), it.notes) } else { it.subtitle() };
let sub = shorten_width(ui, &sub, rect.width() - 90.0, FONT_XS);
let x = tile.right() + 12.0;
ui.painter().text(egui::pos2(x, rect.center().y - 9.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(FONT_SM), p.text);
ui.painter().text(egui::pos2(x, rect.center().y + 10.0), egui::Align2::LEFT_CENTER, sub, egui::FontId::proportional(FONT_XS), p.text3);
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(14.0, 14.0)), Icon::ChevronRight, p.text3);
r.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The open vault dialog, if any (called every frame from the settings window).
pub(super) fn vault_dialogs(&mut self, ctx: &egui::Context) {
let p = self.pal;
let Some(d) = self.vault.dialog.take() else { return };
let items = self.vault_items();
let mut next: Option<VaultDialog> = None;
let mut saved = false;
match d {
VaultDialog::ViewLogin(id) => {
let Some(it) = items.iter().find(|i| i.id == id).cloned() else { return };
let title = it.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_else(|| it.display_title());
let mut keep = true;
let close = dialog(ctx, "vault-view", &title, 440.0, true, p, |ui| {
dialog_body(ui, "vault-view-body", dialog_max_height(ctx) - 120.0, |ui| {
field_label(ui, "網站", p);
ui.horizontal(|ui| {
ui.label(it.sites.join("、"));
if let Some(first) = it.sites.first() {
if ui.add(egui::Button::new(RichText::new(t("開啟")).size(FONT_XS)).corner_radius(6)).on_hover_text(format!("{} {}", t("開啟"), first)).clicked() {
let url = if first.contains("://") { first.clone() } else { format!("https://{first}") };
let _ = crate::preview::system_open(&url).spawn();
}
}
});
if !it.username.is_empty() {
field_label(ui, "使用者名稱", p);
ui.label(&it.username);
}
field_label(ui, "密碼", p);
ui.label("••••••••••••••").on_hover_text(t("已儲存的密碼,已隱藏"));
if !it.notes.is_empty() {
field_label(ui, "提示", p);
ui.label(&it.notes);
}
});
dialog_footer(ui, p, |ui| {
if ui.add(danger_button("移除", p)).clicked() {
next = Some(VaultDialog::Remove(it.id.clone(), it.display_title(), Kind::Login));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(primary_button("完成", p)).clicked() {
keep = false;
}
if ui.add(secondary_button("編輯")).clicked() {
next = Some(VaultDialog::Login(LoginForm { id: Some(it.id.clone()), sites: it.sites.clone(), username: it.username.clone(), notes: it.notes.clone(), ..Default::default() }));
}
});
});
});
if next.is_none() && keep && !close {
next = Some(VaultDialog::ViewLogin(id));
}
}
VaultDialog::Login(mut f) => {
let title = match &f.id {
Some(_) => format!("{} {} {}", t("編輯"), f.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_default(), t("的登入資訊")),
None => t("新登入資訊"),
};
let mut keep = true;
let close = dialog(ctx, "vault-login", &title, 480.0, false, p, |ui| {
dialog_body(ui, "vault-login-body", dialog_max_height(ctx) - 120.0, |ui| {
field_label(ui, "網站", p);
let mut drop = None;
let n = f.sites.len();
for (i, s) in f.sites.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(s).hint_text("example.com").desired_width(ui.available_width() - if n > 1 { 34.0 } else { 0.0 }));
if n > 1 && ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() {
drop = Some(i);
}
});
let d = store::registrable_domain(&store::host_of(s));
if !d.is_empty() {
ui.label(RichText::new(format!("在 {d} 及其子網域上填入")).size(FONT_XS).color(p.text3));
}
}
if let Some(i) = drop {
f.sites.remove(i);
}
if f.sites.len() < store::MAX_SITES && ui.add(egui::Button::new(RichText::new(t("新增網站")).size(FONT_XS)).frame(false)).clicked() {
f.sites.push(String::new());
}
field_label(ui, "使用者名稱或電子郵件(選填)", p);
ui.add(egui::TextEdit::singleline(&mut f.username).hint_text("name@example.com").desired_width(f32::INFINITY));
field_label(ui, "密碼", p);
ui.add(egui::TextEdit::singleline(&mut f.password).password(true).hint_text(if f.id.is_some() { "••••••••••••••(留空就不變)" } else { "" }).desired_width(f32::INFINITY));
field_label(ui, "提示(選填)", p);
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
if f.id.is_none() {
let host = f.sites.first().map(|s| store::host_of(s)).unwrap_or_default();
hint(ui, if host.is_empty() { t("Bot 可以使用此登入資訊替你登入") } else { format!("Bot 可以使用此登入資訊替你登入 {host}") }, p);
}
if !f.err.is_empty() {
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
}
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let ok = f.sites.iter().any(|s| !s.trim().is_empty()) && (f.id.is_some() || !f.password.is_empty());
if ui.add_enabled(ok, primary_button("儲存登入資訊", p)).clicked() {
let r = if self.demo { Ok(String::new()) } else { store::put_login(store::LoginInput { id: f.id.clone(), sites: f.sites.clone(), username: f.username.clone(), password: f.password.clone(), notes: f.notes.clone() }) };
match r {
Ok(_) => {
keep = false;
saved = true;
}
Err(e) => f.err = e,
}
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if keep && !close {
next = Some(VaultDialog::Login(f));
}
}
VaultDialog::Card(mut f) => {
let editing = f.id.is_some();
let title = if editing { format!("{} {}", t("編輯"), f.locked) } else { t("新卡片") };
let contacts: Vec<Item> = items.iter().filter(|i| i.kind == Kind::Contact && !i.address.is_empty()).cloned().collect();
let mut keep = true;
let close = dialog(ctx, "vault-card", &title, 480.0, false, p, |ui| {
dialog_body(ui, "vault-card-body", dialog_max_height(ctx) - 120.0, |ui| {
field_label(ui, "卡號", p);
if editing {
ui.horizontal(|ui| {
crate::icons::label(ui, Icon::Lock, &f.locked, 13.0, p.text2);
ui.label(RichText::new(t("已鎖定")).size(FONT_XS).color(p.text3));
});
ui.label(RichText::new(t("若要變更卡號,請移除此卡片後重新新增")).size(FONT_XS).color(p.text3));
} else {
ui.add(egui::TextEdit::singleline(&mut f.number).hint_text("1234 5678 9012 3456").desired_width(f32::INFINITY));
let d = store::digits(&f.number);
if d.len() >= 12 {
let (msg, col) = if store::luhn_ok(&d) { (store::card_brand(&d).to_string(), p.text3) } else { (t("請輸入有效的卡號"), p.danger) };
ui.label(RichText::new(msg).size(FONT_XS).color(col));
}
}
ui.horizontal(|ui| {
ui.vertical(|ui| {
field_label(ui, "有效期限", p);
ui.add(egui::TextEdit::singleline(&mut f.expiry).hint_text(t("月 / 年")).desired_width(110.0));
});
if !editing {
ui.vertical(|ui| {
field_label(ui, "安全碼(選填)", p);
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.csc).password(!f.show_csc).hint_text(t("3 或 4 位數字")).desired_width(110.0));
let icon = if f.show_csc { Icon::EyeOff } else { Icon::Eye };
if crate::icons::button(ui, icon, 16.0, p.text2, p.hover).on_hover_text(t(if f.show_csc { "隱藏安全碼" } else { "顯示安全碼" })).clicked() {
f.show_csc = !f.show_csc;
}
});
});
}
});
field_label(ui, "持卡人姓名(選填)", p);
ui.add(egui::TextEdit::singleline(&mut f.holder).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY));
field_label(ui, "暱稱(選填)", p);
ui.add(egui::TextEdit::singleline(&mut f.nickname).hint_text(t("例如:旅行")).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY));
field_label(ui, "帳單地址", p);
let current = if f.billing_from.is_empty() { t("無帳單地址") } else if f.billing_from == "new" { t("輸入地址") } else { contacts.iter().find(|c| c.id == f.billing_from).map(|c| c.display_title()).unwrap_or_default() };
egui::ComboBox::from_id_salt("vault-billing").selected_text(current).width(240.0).show_ui(ui, |ui| {
ui.selectable_value(&mut f.billing_from, String::new(), t("無帳單地址"));
for c in &contacts {
if ui.selectable_label(f.billing_from == c.id, format!("{} · {}", c.display_title(), c.address.one_line())).clicked() {
f.billing_from = c.id.clone();
f.billing = c.address.clone();
}
}
if ui.selectable_label(f.billing_from == "new", t("新增帳單地址")).clicked() {
f.billing_from = "new".into();
}
});
if f.billing_from == "new" {
address_fields(ui, &mut f.billing, p);
}
field_label(ui, "提示(選填)", p);
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
hint(ui, "Bot 永遠不會看到卡號和安全碼。每次付款填卡前都會問你。", p);
if !f.err.is_empty() {
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
}
});
dialog_footer(ui, p, |ui| {
if let Some(id) = f.id.clone() {
if ui.add(danger_button("移除", p)).clicked() {
next = Some(VaultDialog::Remove(id, f.locked.clone(), Kind::Card));
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(primary_button("儲存卡片", p)).clicked() {
let billing = if f.billing_from.is_empty() { Address::default() } else { f.billing.clone() };
let r = if self.demo {
Ok(String::new())
} else {
store::put_card(store::CardInput { id: f.id.clone(), number: f.number.clone(), expiry: f.expiry.clone(), csc: f.csc.clone(), holder: f.holder.clone(), nickname: f.nickname.clone(), billing, notes: f.notes.clone() })
};
match r {
Ok(_) => {
keep = false;
saved = true;
}
Err(e) => f.err = e,
}
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if next.is_none() && keep && !close {
next = Some(VaultDialog::Card(f));
}
}
VaultDialog::Contact(mut f) => {
let title = if f.id.is_some() { t("編輯聯絡資訊") } else { t("新增聯絡資訊") };
let mut keep = true;
let close = dialog(ctx, "vault-contact", &title, 500.0, false, p, |ui| {
dialog_body(ui, "vault-contact-body", dialog_max_height(ctx) - 120.0, |ui| {
field_label(ui, "姓名", p);
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(f32::INFINITY));
address_fields(ui, &mut f.address, p);
rows_editor(ui, "手機", "新增手機號碼", &mut f.phones, store::valid_phone, "請輸入有效的電話號碼", p);
rows_editor(ui, "電子郵件地址", "新增電子郵件", &mut f.emails, store::valid_email, "請輸入有效的電子郵件地址", p);
field_label(ui, "標籤(選填)", p);
ui.add(egui::TextEdit::singleline(&mut f.label).hint_text(t("例如:住家、公司")).desired_width(f32::INFINITY));
field_label(ui, "提示(選填)", p);
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
if !f.err.is_empty() {
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
}
});
dialog_footer(ui, p, |ui| {
if let Some(id) = f.id.clone() {
if ui.add(danger_button("移除", p)).clicked() {
let name = if f.label.trim().is_empty() { f.name.clone() } else { f.label.clone() };
next = Some(VaultDialog::Remove(id, name, Kind::Contact));
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(primary_button("儲存聯絡資訊", p)).clicked() {
let r = if self.demo {
Ok(String::new())
} else {
store::put_contact(store::ContactInput { id: f.id.clone(), label: f.label.clone(), name: f.name.clone(), address: f.address.clone(), phones: f.phones.clone(), emails: f.emails.clone(), notes: f.notes.clone() })
};
match r {
Ok(_) => {
keep = false;
saved = true;
}
Err(e) => f.err = e,
}
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if next.is_none() && keep && !close {
next = Some(VaultDialog::Contact(f));
}
}
VaultDialog::Remove(id, name, kind) => {
let (q, body) = match kind {
Kind::Login => (t("要移除這筆登入資訊嗎?"), format!("Bot 將無法再用它登入 {name}")),
Kind::Card => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用這張卡片付款")),
Kind::Contact => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用此聯絡資訊")),
};
let mut keep = true;
let close = dialog(ctx, "vault-remove", &q, 420.0, true, p, |ui| {
dialog_body(ui, "vault-remove-body", 200.0, |ui| {
ui.label(body);
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(danger_button("移除", p)).clicked() {
if !self.demo {
if let Err(e) = store::remove(&id) {
self.vault.note = format!("{}:{e}", t("無法移除。請重試。"));
}
}
keep = false;
saved = true;
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if keep && !close {
next = Some(VaultDialog::Remove(id, name, kind));
}
}
VaultDialog::Import(mut st) => {
if self.vault_import(ctx, &mut st) {
next = Some(VaultDialog::Import(st));
} else {
// Only when it closed: marking saved every frame reloaded the whole
// vault list on every repaint while the import dialog was open.
saved = true;
}
}
}
self.vault.dialog = next;
if saved {
self.vault_refresh();
}
}
/// 從 CSV 匯入. Returns false when the dialog should close.
fn vault_import(&mut self, ctx: &egui::Context, st: &mut ImportState) -> bool {
let p = self.pal;
// A file was picked (the picker runs off the window's thread).
if let Some(slot) = st.picking.clone() {
if let Some(files) = slot.lock().unwrap().take() {
st.picking = None;
if let Some(f) = files.into_iter().next() {
st.file = f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
st.err.clear();
st.plan = None;
match std::fs::metadata(&f).map(|m| m.len()) {
Ok(n) if n > store::MAX_IMPORT_BYTES => st.err = t("該檔案太大。請選擇小於 5 MB 的 CSV 檔案。"),
Ok(_) => match std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()) {
Ok(text) => match store::plan_import(&text) {
Ok(plan) => st.plan = Some(plan),
Err(e) => st.err = e,
},
Err(_) => st.err = t("無法讀取該檔案。請再試一次"),
},
Err(_) => st.err = t("無法讀取該檔案。請再試一次"),
}
}
} else {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
// Saving: a few rows per frame so the progress bar moves and 停止匯入 works.
if !st.queue.is_empty() {
if st.stop {
let left = st.queue.len();
st.queue.clear();
if let Some(d) = st.done.as_mut() {
d.2.push((String::new(), format!("匯入在所有項目儲存完成前就已停止({left} 個沒有匯入)")));
}
} else if let Some(plan) = st.plan.as_ref() {
let take: Vec<usize> = st.queue.drain(..st.queue.len().min(20)).collect();
for i in take {
let row = &plan.rows[i];
let d = st.done.get_or_insert((0, 0, vec![]));
match if self.demo { Ok(()) } else { store::import_row(row) } {
Ok(()) if row.kind == Kind::Card => d.1 += 1,
Ok(()) => d.0 += 1,
Err(e) => d.2.push((row.label.clone(), e)),
}
}
ctx.request_repaint();
}
}
let mut keep = true;
let close = dialog(ctx, "vault-import", "從 CSV 匯入", 560.0, false, p, |ui| {
dialog_body(ui, "vault-import-body", dialog_max_height(ctx) - 140.0, |ui| {
let importing = !st.queue.is_empty();
if let (Some((logins, cards, fails)), false) = (&st.done, importing) {
// Summary.
let line = if *cards > 0 { format!("已匯入 {logins} 筆登入資訊和 {cards} 張卡片") } else { format!("已匯入 {logins} 筆登入資訊") };
crate::icons::label(ui, Icon::CircleCheck, &line, 14.0, p.text);
if let Some(plan) = &st.plan {
let dup = plan.count(|s| *s == RowStatus::AlreadySaved);
if dup > 0 {
ui.label(RichText::new(format!("已略過保險庫中已有的 {dup} 個項目")).size(FONT_SM).color(p.text2));
}
}
if !fails.is_empty() {
ui.label(RichText::new(format!("{} 個項目無法匯入", fails.len())).size(FONT_SM).color(p.danger));
for (name, e) in fails.iter().take(30) {
ui.label(RichText::new(if name.is_empty() { e.clone() } else { format!("{name}:{e}") }).size(FONT_XS).color(p.text3));
}
}
return;
}
if importing {
let done = st.total - st.queue.len();
ui.label(format!("正在匯入第 {done} 個,共 {} 個…", st.total));
ui.add(egui::ProgressBar::new(done as f32 / st.total.max(1) as f32));
return;
}
match &st.plan {
None => {
ui.label(t("從 1Password、Chrome、Edge、Safari、Apple Passwords、Firefox、Bitwarden 或 LastPass 匯出的 CSV 檔案匯入登入資訊"));
ui.label(RichText::new(t("從 LastPass 匯出的檔案會一併匯入卡片")).size(FONT_XS).color(p.text3));
ui.label(RichText::new(t("檔案只會在這部裝置上讀取,絕不會上傳")).size(FONT_XS).color(p.text3));
if st.picking.is_some() {
ui.label(RichText::new(t("正在等你選檔案…")).size(FONT_XS).color(p.text3));
}
}
Some(plan) => {
crate::icons::label(ui, Icon::FileText, &format!("{}:{}", t(plan.format.banner()), st.file), 13.0, p.text2);
ui.add_space(4.0);
let ready = plan.count(|s| *s == RowStatus::Ready);
let dup = plan.count(|s| *s == RowStatus::AlreadySaved);
let bad = plan.count(|s| matches!(s, RowStatus::Error(_)));
let skip = plan.count(|s| matches!(s, RowStatus::Skipped(_)));
ui.label(RichText::new(format!("{ready} 個項目已就緒 · {dup} 個項目已在你的保險庫中 · {bad} 個項目有錯誤 · 已略過 {skip} 個項目")).size(FONT_SM));
ui.add_space(6.0);
ui.label(RichText::new(t("此檔案中的項目")).size(FONT_XS).color(p.text3));
for r in plan.rows.iter().take(300) {
ui.horizontal(|ui| {
let (rr, _) = ui.allocate_exact_size(vec2(16.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui.painter(), rr.shrink(2.0), kind_icon(r.kind), p.text3);
ui.label(RichText::new(&r.label).size(FONT_SM));
ui.label(RichText::new(&r.secondary).size(FONT_XS).color(p.text3));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (s, c) = match &r.status {
RowStatus::Ready => (t("已就緒"), p.text3),
RowStatus::AlreadySaved => (t("已在你的保險庫中"), p.text3),
RowStatus::Error(e) => (t(e), p.danger),
RowStatus::Skipped(e) => (t(e), p.text3),
};
ui.label(RichText::new(s).size(FONT_XS).color(c));
});
});
}
if plan.rows.len() > 300 {
ui.label(RichText::new(format!("…還有 {} 列", plan.rows.len() - 300)).size(FONT_XS).color(p.text3));
}
}
}
if !st.err.is_empty() {
ui.label(RichText::new(&st.err).size(FONT_XS).color(p.danger));
}
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !st.queue.is_empty() {
if ui.add(secondary_button("停止匯入")).clicked() {
st.stop = true;
}
return;
}
if st.done.is_some() {
if ui.add(primary_button("完成", p)).clicked() {
keep = false;
}
return;
}
let ready: Vec<usize> = st.plan.as_ref().map(|pl| pl.rows.iter().enumerate().filter(|(_, r)| r.status == RowStatus::Ready).map(|(i, _)| i).collect()).unwrap_or_default();
if st.plan.is_some() && ui.add_enabled(!ready.is_empty(), primary_button(&format!("匯入 {} 個項目", ready.len()), p)).clicked() {
st.total = ready.len();
st.queue = ready;
st.done = Some((0, 0, vec![]));
}
let pick = if st.plan.is_some() { "選擇其他檔案" } else { "選擇 CSV 檔案" };
if ui.add_enabled(st.picking.is_none(), if st.plan.is_some() { secondary_button(pick) } else { primary_button(pick, p) }).clicked() {
let slot = Arc::new(Mutex::new(None));
st.picking = Some(slot.clone());
std::thread::spawn(move || {
let files = crate::attach::pick_files();
*slot.lock().unwrap() = Some(files);
});
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
keep && !close
}
}
/// The six address fields.
fn address_fields(ui: &mut egui::Ui, a: &mut Address, p: Palette) {
field_label(ui, "街道地址", p);
ui.add(egui::TextEdit::singleline(&mut a.line1).desired_width(f32::INFINITY));
field_label(ui, "公寓、套房或單位(選填)", p);
ui.add(egui::TextEdit::singleline(&mut a.line2).desired_width(f32::INFINITY));
ui.horizontal(|ui| {
let w = (ui.available_width() - 8.0) / 2.0;
ui.vertical(|ui| {
field_label(ui, "城市", p);
ui.add(egui::TextEdit::singleline(&mut a.city).desired_width(w));
});
ui.vertical(|ui| {
field_label(ui, "州/省/地區", p);
ui.add(egui::TextEdit::singleline(&mut a.region).desired_width(w));
});
});
ui.horizontal(|ui| {
let w = (ui.available_width() - 8.0) / 2.0;
ui.vertical(|ui| {
field_label(ui, "郵遞區號", p);
ui.add(egui::TextEdit::singleline(&mut a.postal).desired_width(w));
});
ui.vertical(|ui| {
field_label(ui, "國家/地區", p);
ui.add(egui::TextEdit::singleline(&mut a.country).desired_width(w));
});
});
}
/// Phone or email rows: the first is the main one; others can become it or be removed.
fn rows_editor(ui: &mut egui::Ui, label: &str, add: &str, rows: &mut Vec<String>, valid: fn(&str) -> bool, bad: &str, p: Palette) {
if rows.is_empty() {
rows.push(String::new());
}
let mut drop = None;
let mut main = None;
for i in 0..rows.len() {
field_label(ui, &if i == 0 { t(label) } else { format!("{} {}", t(label), i + 1) }, p);
ui.horizontal(|ui| {
let extra = if i > 0 { 110.0 } else { 0.0 };
ui.add(egui::TextEdit::singleline(&mut rows[i]).desired_width(ui.available_width() - extra));
if i > 0 {
if ui.add(egui::Button::new(RichText::new(t("設為主要")).size(FONT_XS)).frame(false)).clicked() {
main = Some(i);
}
if ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() {
drop = Some(i);
}
}
});
let v = rows[i].trim();
if !v.is_empty() && !valid(v) {
ui.label(RichText::new(t(bad)).size(FONT_XS).color(p.danger));
} else if !v.is_empty() && rows[..i].iter().any(|r| r.trim() == v) {
ui.label(RichText::new(t("上方已列出")).size(FONT_XS).color(p.text3));
}
}
if let Some(i) = main {
let v = rows.remove(i);
rows.insert(0, v);
}
if let Some(i) = drop {
rows.remove(i);
}
if rows.len() < store::MAX_ROWS && ui.add(egui::Button::new(RichText::new(t(add)).size(FONT_XS)).frame(false)).clicked() {
rows.push(String::new());
}
}
/// Screenshot mode: a few made-up items.
fn demo_items() -> Vec<Item> {
vec![
Item { id: "d1".into(), kind: Kind::Login, title: "github.com".into(), sites: vec!["github.com".into()], username: "me@example.com".into(), notes: "工作用的帳號".into(), ..Default::default() },
Item { id: "d2".into(), kind: Kind::Login, title: "momoshop.com.tw".into(), sites: vec!["momoshop.com.tw".into()], username: "0912xxx678".into(), ..Default::default() },
Item { id: "d3".into(), kind: Kind::Card, title: "Visa".into(), brand: "Visa".into(), last4: "4242".into(), nickname: "旅行".into(), exp_month: 8, exp_year: 2029, ..Default::default() },
Item { id: "d4".into(), kind: Kind::Contact, title: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), country: "台灣".into(), ..Default::default() }, phones: vec!["0912-345-678".into()], ..Default::default() },
]
}