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"]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { AppFolders } from './screens/AppFolders';
|
|||
import { AppView } from './screens/AppView';
|
||||
import { Dependencies } from './screens/Dependencies';
|
||||
import { Ignored } from './screens/Ignored';
|
||||
import { useShowIgnored } from './lib/show-ignored';
|
||||
import { Onboarding } from './screens/Onboarding';
|
||||
import { Portfolio } from './screens/Portfolio';
|
||||
import { Settings } from './screens/Settings';
|
||||
|
|
@ -47,6 +48,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
|
||||
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
||||
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const listed = showIgnored ? [...visible, ...ignoredApps] : visible;
|
||||
const toggleGroup = (id: string) => setCollapsed(c => {
|
||||
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
|
||||
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||
|
|
@ -142,7 +146,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
||||
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
|
||||
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
|
||||
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
||||
|
||||
const appItem = (a: UnifiedApp) => {
|
||||
|
|
@ -152,10 +156,12 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
|
||||
return (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
<button className={`nav nav-app${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{tone && <span className={`ds-sdot ${tone}`} role="img" aria-label={tone === 'bad' ? t('pf.blocker') : tone === 'warn' ? t('pf.warning') : t('pf.note')} />}</span>
|
||||
<span className="grow">{a.name}</span>
|
||||
{a.ignored && <span className="so-dot" title={t('ignored.rowHint')}><Icon name="eyeOff" /></span>}
|
||||
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
|
||||
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
||||
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
|
||||
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
|
||||
|
|
@ -196,7 +202,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{!visible.length && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
|
||||
<div className="side-label"><span>{t('nav.workspace')}</span></div>
|
||||
{WORKSPACE.map((s, i) => (
|
||||
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>
|
||||
|
|
|
|||
|
|
@ -737,6 +737,16 @@ export const en = {
|
|||
'app.unpair': 'Unpair',
|
||||
'app.ignore': 'Ignore',
|
||||
'app.ignoreHint': 'Leave this app out of the portfolio and store reads (Ignored lists it to restore)',
|
||||
'app.unignore': 'Stop ignoring',
|
||||
'app.unignoreHint': 'Bring this app back into the portfolio and store reads',
|
||||
'app.ignoreSide': 'Ignore {store}',
|
||||
'app.unignoreSide': 'Stop ignoring {store}',
|
||||
'app.ignoreSideHint': 'Leave only the {store} side of this app out of reads and notifications. Storebound only: the store itself is untouched',
|
||||
'app.unignoreSideHint': 'Read the {store} side of this app again',
|
||||
'ignored.show': '{count} ignored · show',
|
||||
'ignored.hide': '{count} ignored · hide',
|
||||
'ignored.rowHint': 'Ignored: not read, not watched',
|
||||
'ignored.sideHint': 'Ignored on {stores}',
|
||||
'app.studioHint': 'Open the studio',
|
||||
'app.storeOnlyTitle': 'Store only.',
|
||||
'app.storeOnlyText': 'This app is on your store accounts but has no app folder: releases, reviews, health and TestFlight are shown read-only. Link its folder, or start one, to manage listings, products and checks.',
|
||||
|
|
|
|||
|
|
@ -738,6 +738,16 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'app.pick': 'Bir uygulama seçin',
|
||||
'app.unpair': 'Eşleşmeyi ayır',
|
||||
'app.ignore': 'Yok say',
|
||||
'app.unignore': 'Yok saymayı bırak',
|
||||
'app.unignoreHint': 'Bu uygulamayı portföye ve mağaza okumalarına geri al',
|
||||
'app.ignoreSide': '{store} tarafını yok say',
|
||||
'app.unignoreSide': '{store} tarafını geri al',
|
||||
'app.ignoreSideHint': 'Uygulamanın yalnızca {store} tarafını okumalardan ve bildirimlerden çıkarır. Yalnızca storebound: mağazaya dokunulmaz',
|
||||
'app.unignoreSideHint': 'Uygulamanın {store} tarafını yeniden oku',
|
||||
'ignored.show': '{count} yok sayılan · göster',
|
||||
'ignored.hide': '{count} yok sayılan · gizle',
|
||||
'ignored.rowHint': 'Yok sayılıyor: okunmaz, izlenmez',
|
||||
'ignored.sideHint': '{stores} tarafı yok sayılıyor',
|
||||
'app.ignoreHint': 'Bu uygulamayı portföy ve mağaza okumalarının dışında bırak (Yok sayılanlar ekranından geri getirilir)',
|
||||
'app.studioHint': 'Stüdyoyu aç',
|
||||
'app.storeOnlyTitle': 'Yalnızca mağaza.',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { UnifiedApp as CoreApp } from '../../../../cli/lib/identity';
|
|||
|
||||
export {
|
||||
buildApps, type BuildInput, NO_OVERRIDES, normId, normName, type Overrides, type Pair, pairCandidates, type Pairing, refKey, type StoreRef, storeTarget,
|
||||
withIgnored, withLink, withPair, withStudio, withUnpair,
|
||||
withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
|
||||
} from '../../../../cli/lib/identity';
|
||||
|
||||
export type UnifiedApp = CoreApp<Project>;
|
||||
|
|
|
|||
25
apps/desktop/src/lib/show-ignored.ts
Normal file
25
apps/desktop/src/lib/show-ignored.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// "N ignored · show": whether the sidebar and the portfolio list the ignored apps (dimmed, with Restore). A per-viewer
|
||||
// convenience kept in localStorage (a private window or blocked storage just means hidden), shared by every list.
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const KEY = 'storebound.showIgnored';
|
||||
const EVT = 'storebound:show-ignored';
|
||||
|
||||
export function readShowIgnored(): boolean {
|
||||
try { return localStorage.getItem(KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
export function setShowIgnored(on: boolean): void {
|
||||
try { localStorage.setItem(KEY, on ? '1' : '0'); } catch { /* private mode */ }
|
||||
window.dispatchEvent(new Event(EVT));
|
||||
}
|
||||
|
||||
export function useShowIgnored(): [boolean, (on: boolean) => void] {
|
||||
const [on, set] = useState(readShowIgnored);
|
||||
useEffect(() => {
|
||||
const f = () => set(readShowIgnored());
|
||||
window.addEventListener(EVT, f);
|
||||
return () => window.removeEventListener(EVT, f);
|
||||
}, []);
|
||||
return [on, setShowIgnored];
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import { useState } from 'react';
|
||||
import { api, errorText } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withStudio, withUnpair } from '../lib/identity';
|
||||
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair } from '../lib/identity';
|
||||
import { attention, storeNow } from '../lib/model';
|
||||
import { Hero, nextStep, Tabs } from '../ui/ds';
|
||||
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
|
||||
|
|
@ -41,6 +41,7 @@ function AppControls({ app }: { app: UnifiedApp }) {
|
|||
const nav = useNav();
|
||||
const cands = pairCandidates(app, d.apps);
|
||||
const canUnpair = !app.project && !!app.ios && !!app.android;
|
||||
const sides = (['ios', 'android'] as const).filter(k => app[k] || app.ignoredStores.includes(k));
|
||||
return (
|
||||
<div className="app-controls">
|
||||
<span className="small muted row" style={{ gap: 6 }}><Icon name="link" className="ic13" />{t(PAIRING[app.pairing])}</span>
|
||||
|
|
@ -62,7 +63,15 @@ function AppControls({ app }: { app: UnifiedApp }) {
|
|||
</select></label>
|
||||
)}
|
||||
{canUnpair && <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withUnpair(o, app.ios!.key, app.android!.key)).then(() => nav.go({ screen: 'portfolio' }))}><Icon name="unlink" />{t('app.unpair')}</button>}
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withIgnored(o, app.ignoreKeys, true)).then(() => nav.go({ screen: 'portfolio' }))} title={t('app.ignoreHint')}><Icon name="eyeOff" />{t('app.ignore')}</button>
|
||||
{sides.length === 2 && sides.map(k => {
|
||||
const off = app.ignored || app.ignoredStores.includes(k);
|
||||
const store = k === 'ios' ? 'App Store' : 'Google Play';
|
||||
return <button key={k} className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withSideIgnored(o, app, k, !off))}
|
||||
title={off ? t('app.unignoreSideHint', { store }) : t('app.ignoreSideHint', { store })}><Icon name={off ? 'eye' : 'eyeOff'} />{off ? t('app.unignoreSide', { store }) : t('app.ignoreSide', { store })}</button>;
|
||||
})}
|
||||
{app.ignored
|
||||
? <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withIgnored(o, app.ignoreKeys, false))} title={t('app.unignoreHint')}><Icon name="eye" />{t('app.unignore')}</button>
|
||||
: <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withIgnored(o, app.ignoreKeys, true)).then(() => nav.go({ screen: 'portfolio' }))} title={t('app.ignoreHint')}><Icon name="eyeOff" />{t('app.ignore')}</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them
|
||||
// back (nothing on the stores changes either way).
|
||||
import { t } from '../i18n';
|
||||
import { withIgnored } from '../lib/identity';
|
||||
import { withIgnored, withSideIgnored } from '../lib/identity';
|
||||
import { useNav } from '../nav';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
|
|
@ -12,7 +12,7 @@ import '../styles/folders.css';
|
|||
export function Ignored() {
|
||||
const d = useData();
|
||||
const nav = useNav();
|
||||
const apps = d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const apps = d.apps.filter(a => a.ignored || a.ignoredStores.length > 0).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`));
|
||||
const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] }));
|
||||
const empty = !apps.length && !accounts.length;
|
||||
|
|
@ -37,7 +37,8 @@ export function Ignored() {
|
|||
{a.android && <span className="row" style={{ gap: 4 }}><StoreGlyph store="android" className="ic13" /><span className="mono-id">{a.android.app.id}</span></span>}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))}><Icon name="eye" />{t('ignored.restore')}</button>
|
||||
{a.ignoredStores.map(k => <button key={k} className="btn btn-sm" onClick={() => d.updateOverrides(o => withSideIgnored(o, a, k, false))}><Icon name="eye" />{t('app.unignoreSide', { store: k === 'ios' ? 'App Store' : 'Google Play' })}</button>)}
|
||||
{a.ignored && <button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))}><Icon name="eye" />{t('ignored.restore')}</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
|||
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, pct, share } from '../lib/format';
|
||||
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity';
|
||||
import { useShowIgnored } from '../lib/show-ignored';
|
||||
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { type AppTab, useNav } from '../nav';
|
||||
import type { IconName } from '../ui/icons';
|
||||
|
|
@ -282,6 +283,8 @@ export function Portfolio() {
|
|||
const [query, setQuery] = useState('');
|
||||
const setView = (v: Partial<View>) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; });
|
||||
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]);
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const [round, setRound] = useState(0);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const health = useCachedHealth(visible.map(a => a.target), round);
|
||||
|
|
@ -427,6 +430,18 @@ export function Portfolio() {
|
|||
<Seg value={view.layout} onChange={layout => setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
|
||||
</div>
|
||||
|
||||
{ignoredApps.length > 0 && (
|
||||
<div className="row small" style={{ gap: 8, flexWrap: 'wrap' }} data-testid="ignored-strip">
|
||||
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
|
||||
{showIgnored && ignoredApps.map(a => (
|
||||
<span key={a.key} className="row muted" style={{ gap: 4 }}>
|
||||
<button className="link" onClick={() => open(a)}>{a.name}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.layout === 'cards' && (rows.length ? grouped.map(g => {
|
||||
const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ export function classifyRead(result: unknown, error: unknown, task: ReadTask<unk
|
|||
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
|
||||
if (!a) return { ios: false, android: false };
|
||||
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
|
||||
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
|
||||
// a store side the owner ignored is not read (reviews, health, status of that store)
|
||||
return { ios: !a.ignoredStores.includes('ios') && (!!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl)), android: !a.ignoredStores.includes('android') && (!!a.android || !!pl?.android || (!!a.project?.playAccount && !pl)) };
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { describe, expect, test } from 'bun:test';
|
|||
import type { Account, Project, ProjectSummary, StoreApp } from '../src/api';
|
||||
import {
|
||||
buildApps, filterSort, groupByStudio, type ListRow, NO_OVERRIDES, normId, normName, type Overrides, pairCandidates, storeTarget, studioCounts,
|
||||
withIgnored, withLink, withPair, withStudio, withUnpair,
|
||||
withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
|
||||
} from '../src/lib/identity';
|
||||
import { crashCrossed, newLowReviews, releaseChanges } from '../src/lib/notify';
|
||||
|
||||
|
|
@ -73,6 +73,37 @@ describe('identity pairing', () => {
|
|||
expect([linked.ios?.app.id, linked.android?.app.id, linked.pairing]).toEqual(['102', 'com.example.orbit', 'folder']);
|
||||
expect(apps.some(a => a.name === 'Orbit Hop' && !a.project)).toBe(false);
|
||||
});
|
||||
test('ignore one store side: a folder hides only that store, both sides is the whole app, restore splits it', () => {
|
||||
const base = byName(build(), 'Prism Puddle');
|
||||
expect([base.ios?.app.id, base.android?.app.id, base.ignored, base.ignoredStores]).toEqual(['101', 'com.example.prism', false, []]);
|
||||
const noIos = withSideIgnored(NO_OVERRIDES, base, 'ios', true);
|
||||
expect(noIos.ignored).toEqual(['project:p1:ios']);
|
||||
const half = byName(build(noIos), 'Prism Puddle');
|
||||
expect([half.ios, half.android?.app.id, half.ignored, half.ignoredStores]).toEqual([null, 'com.example.prism', false, ['ios']]);
|
||||
expect(half.sideKeys.ios).toBe('project:p1:ios');
|
||||
// the hidden iOS app does not come back as a store-only app of its own
|
||||
expect(build(noIos).filter(a => a.ios?.app.id === '101')).toHaveLength(0);
|
||||
const both = withSideIgnored(noIos, half, 'android', true);
|
||||
expect(both.ignored).toEqual(['project:p1']);
|
||||
const whole = byName(build(both), 'Prism Puddle');
|
||||
expect([whole.ignored, whole.ignoredStores]).toEqual([true, []]);
|
||||
const back = withSideIgnored(both, whole, 'ios', false);
|
||||
expect(back.ignored.sort()).toEqual(['project:p1:android']);
|
||||
expect(byName(build(back), 'Prism Puddle').ignoredStores).toEqual(['android']);
|
||||
// restoring the whole app clears every key of it
|
||||
expect(withIgnored(both, whole.ignoreKeys, false).ignored).toEqual([]);
|
||||
});
|
||||
test('ignore one side of a paired store-only app; ignoring both sides ignores the app', () => {
|
||||
const orbit = byName(build(), 'Orbit Hop');
|
||||
expect(orbit.ios && orbit.android).toBeTruthy();
|
||||
const o = withSideIgnored(NO_OVERRIDES, orbit, 'android', true);
|
||||
const half = byName(build(o), 'Orbit Hop');
|
||||
expect([half.ios?.app.id, half.android, half.ignored, half.ignoredStores]).toEqual(['102', null, false, ['android']]);
|
||||
const all = withSideIgnored(o, half, 'ios', true);
|
||||
const full = byName(build(all), 'Orbit Hop');
|
||||
expect(full.ignored).toBe(true);
|
||||
expect(full.ios && full.android).toBeTruthy();
|
||||
});
|
||||
test('ignore: an app (all its store keys) or a whole account; studio moves', () => {
|
||||
const tycoon = byName(build(), 'Pocket Tycoon');
|
||||
const o = withIgnored(NO_OVERRIDES, tycoon.ignoreKeys, true);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ export interface UnifiedApp<P extends IdProject = IdProject> {
|
|||
pairing: Pairing;
|
||||
/** The keys an "ignore" puts on the list ("project:<id>" or "app:<store key>"). */
|
||||
ignoreKeys: string[];
|
||||
/** Ignored entirely (every store side it has). */
|
||||
ignored: boolean;
|
||||
/** Store sides left out while the other side stays (a half-ignored app): their ref is dropped from ios / android. */
|
||||
ignoredStores: ('ios' | 'android')[];
|
||||
/** The ignore key of each side (null: no such side), for the per-store toggles. */
|
||||
sideKeys: { ios: string | null; android: string | null };
|
||||
/** Store icon (data URL) when the folder has none. */
|
||||
storeIcon: string | null;
|
||||
}
|
||||
|
|
@ -115,9 +120,17 @@ export function buildApps<P extends IdProject>(i: BuildInput<P>): UnifiedApp<P>[
|
|||
const studio = studioOverride.get(`project:${p.id}`)
|
||||
?? (p.appleAccount && accountStudio.get(p.appleAccount)) ?? (p.playAccount && accountStudio.get(p.playAccount))
|
||||
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
|
||||
// ignored: the whole folder, or every store side it has; one side alone hides just that store (project:<id>:ios | :android)
|
||||
const whole = ignored.has(`project:${p.id}`);
|
||||
const sideOff = { ios: whole || ignored.has(`project:${p.id}:ios`), android: whole || ignored.has(`project:${p.id}:android`) };
|
||||
const has = { ios: !!ios || !!s?.apple || !!p.appleAccount, android: !!android || !!s?.play || !!p.playAccount };
|
||||
const present = (['ios', 'android'] as const).filter(k => has[k]);
|
||||
const allOff = whole || (present.length > 0 && present.every(k => sideOff[k]));
|
||||
const hidden = allOff ? [] : present.filter(k => sideOff[k]);
|
||||
out.push({
|
||||
key: p.id, target: p.id, name: p.name, project: p, ios, android, studio, pairing: 'folder',
|
||||
ignoreKeys: [`project:${p.id}`], ignored: ignored.has(`project:${p.id}`), storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
||||
key: p.id, target: p.id, name: p.name, project: p, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing: 'folder',
|
||||
ignoreKeys: [`project:${p.id}`, `project:${p.id}:ios`, `project:${p.id}:android`], ignored: allOff, ignoredStores: hidden, sideKeys: { ios: `project:${p.id}:ios`, android: `project:${p.id}:android` },
|
||||
storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -147,9 +160,14 @@ export function buildApps<P extends IdProject>(i: BuildInput<P>): UnifiedApp<P>[
|
|||
const keys = [ios, android].filter((r): r is StoreRef => !!r).map(r => r.key);
|
||||
const studio = keys.map(k => studioOverride.get(k)).find(Boolean)
|
||||
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
|
||||
// a paired app is ignored when every side is; one ignored side alone hides just that store
|
||||
const off = (r: StoreRef | null) => !!r && ignored.has(`app:${r.key}`);
|
||||
const allOff = [ios, android].filter(Boolean).length > 0 && [ios, android].every(r => !r || off(r));
|
||||
const hidden = allOff ? [] : (['ios', 'android'] as const).filter(k => off(k === 'ios' ? ios : android));
|
||||
out.push({
|
||||
key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios, android, studio, pairing,
|
||||
ignoreKeys: keys.map(k => `app:${k}`), ignored: keys.some(k => ignored.has(`app:${k}`)), storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
||||
key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing,
|
||||
ignoreKeys: keys.map(k => `app:${k}`), ignored: allOff, ignoredStores: hidden, sideKeys: { ios: ios ? `app:${ios.key}` : null, android: android ? `app:${android.key}` : null },
|
||||
storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
||||
});
|
||||
};
|
||||
for (const [a, b, how] of pairs) storeOnly(a, b, how);
|
||||
|
|
@ -180,6 +198,21 @@ export function withUnpair(o: Overrides, ios: string, android: string): Override
|
|||
return { ...o, pairs: o.pairs.filter(p => !samePair(p, ios, android)), unpairs: [...o.unpairs.filter(p => !samePair(p, ios, android)), { ios, android }] };
|
||||
}
|
||||
|
||||
/** Overrides after ignoring (or restoring) ONE store side of an app. A whole-folder key is split into its sides first. */
|
||||
export function withSideIgnored(o: Overrides, app: UnifiedApp, side: 'ios' | 'android', on: boolean): Overrides {
|
||||
const key = app.sideKeys[side];
|
||||
if (!key) return o;
|
||||
const set = new Set(o.ignored);
|
||||
if (app.project) {
|
||||
const whole = `project:${app.project.id}`;
|
||||
if (set.delete(whole)) { set.add(app.sideKeys.ios!); set.add(app.sideKeys.android!); }
|
||||
// both sides ignored is the same as the whole folder
|
||||
if (on) set.add(key); else set.delete(key);
|
||||
if (set.has(app.sideKeys.ios!) && set.has(app.sideKeys.android!)) { set.delete(app.sideKeys.ios!); set.delete(app.sideKeys.android!); set.add(whole); }
|
||||
} else if (on) set.add(key); else set.delete(key);
|
||||
return { ...o, ignored: [...set] };
|
||||
}
|
||||
|
||||
export function withIgnored(o: Overrides, keys: string[], on: boolean): Overrides {
|
||||
const set = new Set(o.ignored);
|
||||
for (const k of keys) { if (on) set.add(k); else set.delete(k); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue