Desktop: per-store ignore (hide only the App Store or Google Play side), N ignored show toggle in sidebar and portfolio, Unignore in the app controls; ignore flags sync with the shared app list
This commit is contained in:
parent
13b91dd423
commit
9610c9477e
14 changed files with 347 additions and 17 deletions
|
|
@ -73,7 +73,8 @@ pub fn sync_machine(s: &AppState) -> Res<()> {
|
|||
false
|
||||
}
|
||||
};
|
||||
if report.changed() || folders {
|
||||
let ignored = registry::pull_ignored(&mut st, &s.registry);
|
||||
if report.changed() || folders || ignored {
|
||||
persist(s, &st)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1206,6 +1207,10 @@ pub fn overrides_set(window: WebviewWindow, state: State<'_, AppState>, override
|
|||
let mut st = saved(&state)?;
|
||||
st.overrides = overrides.clone();
|
||||
persist(&state, &st)?;
|
||||
// apps the shared list knows carry their ignore flags there (the CLI's watch and portfolio reads honour them)
|
||||
if let Err(e) = registry::push_ignored(&st, &state.registry) {
|
||||
crate::logs::host("warn", &format!("ignored apps not written to the app list: {e}"));
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,16 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
|
|||
// the store-only entries the CLI wrote stay as they are
|
||||
let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty()));
|
||||
let mut doc = serde_json::json!({ "version": 1, "projects": entries });
|
||||
// the folders' `ignored` flags (written by the CLI or by push_ignored) stay with their folder
|
||||
let old = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
|
||||
if let (Some(old), Some(list)) = (old.as_ref().and_then(|o| o.get("projects")).and_then(Value::as_array), doc.get_mut("projects").and_then(Value::as_array_mut)) {
|
||||
for e in list {
|
||||
let ig = e.get("path").and_then(Value::as_str).and_then(|path| old.iter().find(|o| o.get("path").and_then(Value::as_str) == Some(path))).and_then(|o| o.get("ignored")).cloned();
|
||||
if let (Some(ig), Some(o)) = (ig, e.as_object_mut()) {
|
||||
o.insert("ignored".into(), ig);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = keep {
|
||||
doc["storeApps"] = s;
|
||||
}
|
||||
|
|
@ -217,3 +227,139 @@ pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), Str
|
|||
}
|
||||
write(file, &list)
|
||||
}
|
||||
|
||||
// ---- ignored apps: the registry's `ignored` flags (cli/lib/registry.ts) <-> the app's ignore keys ----
|
||||
// projects: "ignored": ["apple", "play"] (both = the whole app) <-> "project:<id>" (both) / "project:<id>:ios" / "project:<id>:android"
|
||||
// storeApps: "ignored": ["apple"] <-> "app:ios:<account id>:<app id>" (android for play)
|
||||
// The registry is the truth for the apps it lists: `pull_ignored` (start, list shown) replaces the app's keys for those
|
||||
// apps with the registry's flags, `push_ignored` (the owner ignores or restores in the app) writes them back. Apps
|
||||
// that are not in the registry (listed by a store account only) keep their keys in the app's state alone.
|
||||
const SIDES: [&str; 2] = ["apple", "play"];
|
||||
|
||||
fn word_of(side: &str) -> &'static str {
|
||||
if side == "apple" { "ios" } else { "android" }
|
||||
}
|
||||
|
||||
fn sides_in(v: &Value) -> Vec<String> {
|
||||
let have: Vec<&str> = v.get("ignored").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
SIDES.iter().filter(|s| have.contains(s)).map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
fn is_project_key_of(k: &str, id: &str) -> bool {
|
||||
k == format!("project:{id}") || k.starts_with(&format!("project:{id}:"))
|
||||
}
|
||||
|
||||
/// The ignore keys of one folder for its ignored store sides (pure).
|
||||
pub fn project_keys(id: &str, sides: &[String]) -> Vec<String> {
|
||||
if SIDES.iter().all(|s| sides.iter().any(|x| x == s)) {
|
||||
return vec![format!("project:{id}")];
|
||||
}
|
||||
sides.iter().filter(|s| SIDES.contains(&s.as_str())).map(|s| format!("project:{id}:{}", word_of(s))).collect()
|
||||
}
|
||||
|
||||
/// The ignored store sides of a folder, from the app's ignore keys (pure).
|
||||
pub fn project_sides(keys: &[String], id: &str) -> Vec<String> {
|
||||
let mut on = [false, false];
|
||||
for k in keys {
|
||||
if *k == format!("project:{id}") {
|
||||
on = [true, true];
|
||||
} else if *k == format!("project:{id}:ios") {
|
||||
on[0] = true;
|
||||
} else if *k == format!("project:{id}:android") {
|
||||
on[1] = true;
|
||||
}
|
||||
}
|
||||
SIDES.iter().enumerate().filter(|(i, _)| on[*i]).map(|(_, s)| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Whether an ignore key "app:<ios|android>:<account>:<app id>" is this store app (pure).
|
||||
pub fn app_key_matches(k: &str, store: &str, app_id: &str) -> bool {
|
||||
let Some(rest) = k.strip_prefix("app:") else { return false };
|
||||
rest.split(':').next() == Some(word_of(store)) && rest.rsplit(':').next() == Some(app_id)
|
||||
}
|
||||
|
||||
/// The account (id) that reads a store-only entry: the one with its credential profile, else the only account of that store.
|
||||
pub fn account_for(accounts: &[crate::accounts::Account], store: &str, profile: Option<&str>) -> Option<String> {
|
||||
use crate::accounts::AccountKind;
|
||||
let kind = if store == "apple" { AccountKind::Apple } else { AccountKind::Play };
|
||||
let of_kind: Vec<_> = accounts.iter().filter(|a| a.kind == kind).collect();
|
||||
match profile {
|
||||
Some(p) => of_kind.iter().find(|a| a.profile.as_deref() == Some(p)).map(|a| a.id.clone()),
|
||||
None => (of_kind.len() == 1).then(|| of_kind[0].id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_doc(file: &Path) -> Option<Value> {
|
||||
serde_json::from_str::<Value>(&fs::read_to_string(file).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Registry flags -> the app's ignore keys. True when the keys changed.
|
||||
pub fn pull_ignored(state: &mut State, file: &Path) -> bool {
|
||||
let Some(doc) = parse_doc(file) else { return false };
|
||||
let before = state.overrides.ignored.clone();
|
||||
let mut keys = before.clone();
|
||||
for p in doc.get("projects").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(path) = p.get("path").and_then(Value::as_str) else { continue };
|
||||
let Some(id) = state.projects.iter().find(|x| x.path == path).map(|x| x.id.clone()) else { continue };
|
||||
keys.retain(|k| !is_project_key_of(k, &id));
|
||||
keys.extend(project_keys(&id, &sides_in(p)));
|
||||
}
|
||||
for a in doc.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str), a.get("appId").and_then(Value::as_str)) else { continue };
|
||||
let Some(acct) = account_for(&state.accounts, store, a.get("account").and_then(Value::as_str)) else { continue };
|
||||
keys.retain(|k| !app_key_matches(k, store, app_id));
|
||||
if sides_in(a).iter().any(|s| s == store) {
|
||||
keys.push(format!("app:{}:{acct}:{app_id}", word_of(store)));
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
keys.retain(|k| seen.insert(k.clone()));
|
||||
if keys == before {
|
||||
return false;
|
||||
}
|
||||
state.overrides.ignored = keys;
|
||||
true
|
||||
}
|
||||
|
||||
/// The app's ignore keys -> registry flags (only the apps the registry lists). Writes when something changed.
|
||||
pub fn push_ignored(state: &State, file: &Path) -> Result<bool, String> {
|
||||
let Some(mut doc) = parse_doc(file) else { return Ok(false) };
|
||||
let keys = &state.overrides.ignored;
|
||||
let mut changed = false;
|
||||
let mut set = |entry: &mut Value, sides: Vec<String>| {
|
||||
if sides_in(entry) == sides {
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
if let Some(o) = entry.as_object_mut() {
|
||||
if sides.is_empty() {
|
||||
o.remove("ignored");
|
||||
} else {
|
||||
o.insert("ignored".into(), serde_json::json!(sides));
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(list) = doc.get_mut("projects").and_then(Value::as_array_mut) {
|
||||
for p in list {
|
||||
let Some(id) = p.get("path").and_then(Value::as_str).and_then(|path| state.projects.iter().find(|x| x.path == path)).map(|x| x.id.clone()) else { continue };
|
||||
set(p, project_sides(keys, &id));
|
||||
}
|
||||
}
|
||||
if let Some(list) = doc.get_mut("storeApps").and_then(Value::as_array_mut) {
|
||||
for a in list {
|
||||
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str).map(str::to_string), a.get("appId").and_then(Value::as_str).map(str::to_string)) else { continue };
|
||||
if account_for(&state.accounts, &store, a.get("account").and_then(Value::as_str)).is_none() {
|
||||
continue;
|
||||
}
|
||||
let on = keys.iter().any(|k| app_key_matches(k, &store, &app_id));
|
||||
set(a, if on { vec![store] } else { vec![] });
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n";
|
||||
let tmp = file.with_extension(format!("tmp-{}", std::process::id()));
|
||||
fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||
fs::rename(&tmp, file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,3 +207,40 @@ fn a_folder_that_pins_another_storebound_says_so() {
|
|||
assert!(!pin_differs(spec, "0.8.0"), "{spec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("projects.json");
|
||||
// as `storebound apps ignore` wrote it: one folder fully, one store side of another, one store-only app
|
||||
let (one, two) = (dir.path().join("one").to_string_lossy().to_string(), dir.path().join("two").to_string_lossy().to_string());
|
||||
let doc = json!({ "version": 1, "projects": [
|
||||
{ "id": "aaaaaaaaaaaaaaaa", "path": one, "name": "One", "added": "t", "ignored": ["apple", "play"] },
|
||||
{ "id": "bbbbbbbbbbbbbbbb", "path": two, "name": "Two", "added": "t", "ignored": ["play"] }],
|
||||
"storeApps": [{ "store": "apple", "appId": "6400000999", "account": "teamA", "name": "Demo", "ignored": ["apple"] }] });
|
||||
std::fs::write(&file, doc.to_string()).unwrap();
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", &one, None), project("bbbbbbbbbbbbbbbb", &two, None)], ..State::default() };
|
||||
st.accounts = vec![account("acct1", "Team A", AccountKind::Apple, Some("teamA"))];
|
||||
st.overrides.ignored = vec!["account:zzz".into(), "app:ios:other:6400000999".into()];
|
||||
assert!(registry::pull_ignored(&mut st, &file));
|
||||
assert!(st.overrides.ignored.contains(&"account:zzz".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"project:aaaaaaaaaaaaaaaa".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"project:bbbbbbbbbbbbbbbb:android".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"app:ios:acct1:6400000999".to_string()));
|
||||
assert!(!st.overrides.ignored.contains(&"app:ios:other:6400000999".to_string()));
|
||||
assert!(!registry::pull_ignored(&mut st, &file), "second pull changes nothing");
|
||||
// the owner restores one folder's play side and ignores the other's apple side in the app
|
||||
st.overrides.ignored.retain(|k| k != "project:bbbbbbbbbbbbbbbb:android" && k != "app:ios:acct1:6400000999");
|
||||
st.overrides.ignored.push("project:bbbbbbbbbbbbbbbb:ios".into());
|
||||
assert!(registry::push_ignored(&st, &file).unwrap());
|
||||
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(doc["projects"][0]["ignored"], json!(["apple", "play"]));
|
||||
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
||||
assert!(doc["storeApps"][0].get("ignored").is_none());
|
||||
assert!(!registry::push_ignored(&st, &file).unwrap());
|
||||
// writing the folder list keeps the flags of the folders that stay
|
||||
let list = registry::read(&file).unwrap();
|
||||
registry::write(&file, &list).unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue