AnyBot/src/app/vault_ui.rs

843 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 設定 → 保險庫: 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() },
]
}