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:
CRTkafa 2026-10-23 01:35:27 +08:00
parent 13b91dd423
commit 9610c9477e
14 changed files with 347 additions and 17 deletions

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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"]));
}

View file

@ -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})`}>

View file

@ -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.',

View file

@ -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.',

View file

@ -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>;

View 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];
}

View file

@ -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>
);
}

View file

@ -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>
);
})}

View file

@ -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 (

View file

@ -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 {

View file

@ -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);

View file

@ -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); }