// 設定 → 保險庫: 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, pub sites: Vec, pub username: String, pub password: String, pub notes: String, pub err: String, } #[derive(Default, Clone)] pub struct CardForm { pub id: Option, 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, pub label: String, pub name: String, pub address: Address, pub phones: Vec, pub emails: Vec, pub notes: String, pub err: String, } #[derive(Default)] pub struct ImportState { pub picking: Option>>>>, pub file: String, pub plan: Option, pub err: String, /// Rows still to save (indexes into plan.rows), and how many there were. pub queue: Vec, 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, String>>, pub dialog: Option, 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 { 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| 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 = 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 = 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)); } 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 = 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 = 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, 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 { 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() }, ] }