diff --git a/backend/config/categories.toml.example b/backend/config/categories.toml.example index ed679ef..109b819 100644 --- a/backend/config/categories.toml.example +++ b/backend/config/categories.toml.example @@ -6,6 +6,10 @@ # file under ~/.superbrain-server/config/categories.toml is safe from deploys. [taxonomy] +# false = only the [[categories]] list below is the taxonomy. Built-in +# defaults (product, places, food, software, book, …) are not used for +# classification and must not appear as client filter/edit chips. +# true = merge built-in defaults with any [[categories]] you add. use_default_categories = false allow_multiple_categories = false fallback_category = "Other" diff --git a/superbrain-app/src/constants/categories.ts b/superbrain-app/src/constants/categories.ts index 3f718e0..9f18dae 100644 --- a/superbrain-app/src/constants/categories.ts +++ b/superbrain-app/src/constants/categories.ts @@ -1,13 +1,12 @@ -export const DEFAULT_CATEGORIES = [ - { id: 'all', name: 'All', icon: 'star', count: 0 }, - // Operator taxonomy (config-driven on server) - { id: 'sysadmin', name: 'Sysadmin', icon: 'terminal', count: 0 }, - { id: 'science', name: 'Science', icon: 'flask', count: 0 }, - { id: 'technology', name: 'Technology', icon: 'hardware-chip', count: 0 }, - { id: 'history', name: 'History', icon: 'hourglass', count: 0 }, - { id: 'humanities', name: 'Humanities', icon: 'library', count: 0 }, - { id: 'politics', name: 'Politics', icon: 'newspaper', count: 0 }, - // Legacy built-in names (kept until migrated / for older posts) +/** Chip shown for the unfiltered feed. */ +export const ALL_CATEGORY = { id: 'all', name: 'All', icon: 'star', count: 0 }; + +/** + * Built-in mainline categories (product/places/food/…). + * Used when GET /taxonomy is unavailable, or when the server sets + * use_default_categories=true. Matching upstream default chip set. + */ +export const BUILTIN_DEFAULT_CATEGORIES = [ { id: 'product', name: 'Product', icon: 'cube', count: 0 }, { id: 'places', name: 'Places', icon: 'location', count: 0 }, { id: 'food', name: 'Food', icon: 'restaurant', count: 0 }, @@ -20,6 +19,12 @@ export const DEFAULT_CATEGORIES = [ { id: 'other', name: 'Other', icon: 'pricetag', count: 0 }, ]; +/** + * Default chip set for servers without config-driven taxonomy (upstream path). + * Custom taxonomies replace this via GET /taxonomy when present. + */ +export const DEFAULT_CATEGORIES = [ALL_CATEGORY, ...BUILTIN_DEFAULT_CATEGORIES]; + export const CATEGORY_ICONS: Record = { 'all': 'star', 'sysadmin': 'terminal-outline', diff --git a/superbrain-app/src/screens/HomeScreen.tsx b/superbrain-app/src/screens/HomeScreen.tsx index fa1d7f6..ce7ae28 100644 --- a/superbrain-app/src/screens/HomeScreen.tsx +++ b/superbrain-app/src/screens/HomeScreen.tsx @@ -40,7 +40,12 @@ import { RootStackParamList } from '../../App'; import CustomToast from '../components/CustomToast'; import BottomNav from '../components/BottomNav'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; -import { DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { ALL_CATEGORY, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { + TaxonomyPayload, + isTaxonomyApiActive, + usesStrictCustomTaxonomy, +} from '../services/taxonomySupport'; type NavigationProp = NativeStackNavigationProp; @@ -71,6 +76,8 @@ const HomeScreen = () => { const [onboardingStep, setOnboardingStep] = useState(0); const [categories, setCategories] = useState(DEFAULT_CATEGORIES); const lastFocusRefreshRef = useRef(0); + /** Caches the GET /taxonomy result within a single loadPosts call. */ + const taxonomyRef = useRef(undefined); useEffect(() => { const bootstrap = async () => { @@ -208,21 +215,32 @@ const HomeScreen = () => { apiService.getTaxonomy().catch(() => null), ]); - // Prefer configured taxonomy order when available; fall back to app defaults. - const seed = - taxonomy && taxonomy.categories.length > 0 - ? taxonomy.categories.map(c => ({ - id: c.id, - name: c.name, - icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline', - count: 0, - })) - : DEFAULT_CATEGORIES.filter(c => c.id !== 'all').map(c => ({ ...c, count: 0 })); + // No GET /taxonomy (upstream today) → keep full built-in pill set + counts. + // Taxonomy present + use_default_categories=false → configured chips only. + // Taxonomy present + defaults on → seed from server taxonomy list. + const taxonomyActive = isTaxonomyApiActive(taxonomy); + const strictCustom = usesStrictCustomTaxonomy(taxonomy); + + let seed: Array<{ id: string; name: string; icon: string; count: number }>; + if (taxonomyActive) { + seed = taxonomy!.categories.map(c => ({ + id: c.id, + name: c.name, + icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline', + count: 0, + })); + } else { + seed = DEFAULT_CATEGORIES.filter(c => c.id !== 'all').map(c => ({ ...c, count: 0 })); + } const mergedById = new Map(seed.map(c => [c.id.toLowerCase(), { ...c }])); + const configuredIds = new Set(mergedById.keys()); for (const c of cats || []) { const id = c.id.toLowerCase(); + if (strictCustom && !configuredIds.has(id)) { + continue; + } const existing = mergedById.get(id); mergedById.set(id, { id, @@ -232,15 +250,12 @@ const HomeScreen = () => { }); } - // Hide legacy zero-count defaults when a configured taxonomy is active. - let merged = Array.from(mergedById.values()); - if (taxonomy && taxonomy.categories.length > 0) { - const configured = new Set(taxonomy.categories.map(c => c.id.toLowerCase())); - merged = merged.filter(c => configured.has(c.id.toLowerCase()) || c.count > 0); - } - - const totalCount = merged.reduce((sum, c) => sum + c.count, 0); - setCategories([{ id: 'all', name: 'All', icon: 'star', count: totalCount }, ...merged]); + const merged = Array.from(mergedById.values()); + // Upstream totals from visible chips; strict taxonomy still counts all posts in All. + const totalCount = strictCustom + ? (cats || []).reduce((sum, c) => sum + (c.count || 0), 0) + : merged.reduce((sum, c) => sum + c.count, 0); + setCategories([{ ...ALL_CATEGORY, count: totalCount }, ...merged]); } catch (e) { console.warn('Failed to load categories, using defaults:', e); } @@ -277,6 +292,8 @@ const HomeScreen = () => { }; const loadPosts = async (forceRefresh: boolean = false) => { + // Reset taxonomy cache for this load cycle; re-fetched lazily below. + taxonomyRef.current = undefined; try { // Reconcile: if a post was in the failed list AND still stuck in analyzing, clean it up. const failedList = await postsCache.getFailedPosts(); @@ -310,9 +327,22 @@ const HomeScreen = () => { setPosts(merged); setLoading(false); - // If not forcing refresh and no analyzing posts, we're done + // Upstream path: return early when local data is enough. + // Taxonomy-aware servers: always continue so migrations reach the device. + // Defer the taxonomy check until after we confirm connectivity (below) + // to avoid a 404 round-trip on upstream servers that lack GET /taxonomy. if (!forceRefresh && analyzingShortcodes.length === 0) { - return; + const isOnlineForGate = await apiService.testConnection().catch(() => false); + if (!isOnlineForGate) { + return; + } + const taxonomy = await apiService.getTaxonomy().catch(() => null); + if (!isTaxonomyApiActive(taxonomy)) { + return; + } + // taxonomy is active — fall through to background sync below. + // Stash result so syncIfNeeded and post-sync reload can reuse it. + taxonomyRef.current = taxonomy; } } else { // No local data at all — show loading spinner @@ -320,7 +350,7 @@ const HomeScreen = () => { } // ── Step 2: Background sync with backend ── - // This runs non-blocking: UI is already showing local data (if any) + // UI may already be showing local data; refresh if sync changes anything. const isOnline = await apiService.testConnection().catch(() => false); if (isOnline) { @@ -328,11 +358,20 @@ const HomeScreen = () => { await postsCache.flushPendingPostMutations(); await postsCache.flushPendingAnalyses(); - // Delta or full sync depending on DB state - const dataChanged = await syncService.syncIfNeeded(); + // forceFull / taxonomy_version resync only activate when GET /taxonomy exists + // (see syncService); without it this matches upstream delta-only sync. + // Reuse any taxonomy payload already fetched above to avoid a redundant call. + const taxonomy = taxonomyRef.current !== undefined + ? taxonomyRef.current + : await apiService.getTaxonomy().catch(() => null); + const dataChanged = await syncService.syncIfNeeded(forceRefresh, taxonomy); // If sync brought new data, re-read from local DB and update UI if (dataChanged || forceRefresh) { + // Category chip reload is taxonomy-aware; skip extra work on mainline. + if (isTaxonomyApiActive(taxonomy)) { + await loadCategories(); + } const freshPosts = await localDb.getAllPosts(); if (freshPosts.length > 0) { // Clear analyzing state for posts that now exist in the synced data diff --git a/superbrain-app/src/screens/PostDetailScreen.tsx b/superbrain-app/src/screens/PostDetailScreen.tsx index b17a68a..d678f34 100644 --- a/superbrain-app/src/screens/PostDetailScreen.tsx +++ b/superbrain-app/src/screens/PostDetailScreen.tsx @@ -13,15 +13,19 @@ import { collectionsService } from '../services/collections'; import { Collection } from '../types'; import { schedulePostWatchLaterNotification, sendImmediateWatchLaterNotification, sendImmediateSavedNotification } from '../services/notificationService'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; -import { DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { BUILTIN_DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { isTaxonomyApiActive } from '../services/taxonomySupport'; type Props = NativeStackScreenProps; type CategoryOption = { id: string; name: string; icon: string }; -const FALLBACK_CATEGORIES: CategoryOption[] = DEFAULT_CATEGORIES - .filter(c => c.id !== 'all') - .map(c => ({ id: c.id, name: c.name, icon: c.icon })); +/** Upstream / no-taxonomy edit list (built-in mainline categories). */ +const FALLBACK_CATEGORIES: CategoryOption[] = BUILTIN_DEFAULT_CATEGORIES.map(c => ({ + id: c.id, + name: c.name, + icon: c.icon, +})); const PostDetailScreen = ({ route, navigation }: Props) => { const { post } = route.params; @@ -56,21 +60,10 @@ const PostDetailScreen = ({ route, navigation }: Props) => { try { const taxonomy = await apiService.getTaxonomy(); if (cancelled) return; - if (taxonomy && taxonomy.categories.length > 0) { + // Only replace the built-in picker when the server exposes taxonomy. + if (isTaxonomyApiActive(taxonomy)) { setCategoryOptions( - taxonomy.categories.map(c => ({ - id: c.id, - name: c.name, - icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag', - })) - ); - return; - } - const cats = await apiService.getCategories(); - if (cancelled) return; - if (cats && cats.length > 0) { - setCategoryOptions( - cats.map(c => ({ + taxonomy!.categories.map(c => ({ id: c.id, name: c.name, icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag', @@ -78,7 +71,7 @@ const PostDetailScreen = ({ route, navigation }: Props) => { ); } } catch { - // Keep FALLBACK_CATEGORIES + // Keep FALLBACK_CATEGORIES (upstream path) } })(); return () => { diff --git a/superbrain-app/src/services/api.ts b/superbrain-app/src/services/api.ts index 240c1f5..3b013f1 100644 --- a/superbrain-app/src/services/api.ts +++ b/superbrain-app/src/services/api.ts @@ -419,6 +419,7 @@ class ApiService { allow_multiple_categories: boolean; fallback_category: string; use_default_categories: boolean; + taxonomy_version: string; } | null> { try { const headers = await this.getHeaders(); @@ -429,6 +430,7 @@ class ApiService { allow_multiple_categories: boolean; fallback_category: string; use_default_categories: boolean; + taxonomy_version?: string; }>( `${baseUrl}/taxonomy`, { headers, timeout: DEFAULT_TIMEOUT } @@ -439,9 +441,13 @@ class ApiService { allow_multiple_categories: !!response.data.allow_multiple_categories, fallback_category: response.data.fallback_category || 'Other', use_default_categories: !!response.data.use_default_categories, + taxonomy_version: response.data.taxonomy_version || '', }; - } catch (error) { - console.error('Error fetching taxonomy:', error); + } catch (error: any) { + // 404 = upstream / pre-taxonomy servers — silent by design + if (error?.response?.status !== 404) { + console.error('Error fetching taxonomy:', error); + } return null; } } diff --git a/superbrain-app/src/services/localDb.ts b/superbrain-app/src/services/localDb.ts index a9c39b5..c20d4bf 100644 --- a/superbrain-app/src/services/localDb.ts +++ b/superbrain-app/src/services/localDb.ts @@ -230,6 +230,23 @@ async function setLastSyncTime(isoTimestamp: string): Promise { ); } +async function getSyncMeta(key: string): Promise { + const db = await getDb(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT value FROM sync_meta WHERE key = ?', + [key] + ); + return row?.value ?? null; +} + +async function setSyncMeta(key: string, value: string): Promise { + const db = await getDb(); + await db.runAsync( + 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)', + [key, value] + ); +} + // ─── Utilities ───────────────────────────────────────────────────── /** Drop all data — used for hard reset. */ @@ -258,6 +275,8 @@ const localDb = { updatePost, getLastSyncTime, setLastSyncTime, + getSyncMeta, + setSyncMeta, clearAll, isEmpty, }; diff --git a/superbrain-app/src/services/syncService.ts b/superbrain-app/src/services/syncService.ts index edca8b6..8195d02 100644 --- a/superbrain-app/src/services/syncService.ts +++ b/superbrain-app/src/services/syncService.ts @@ -10,6 +10,7 @@ import localDb from './localDb'; import apiService from './api'; import { Post } from '../types'; +import { TaxonomyPayload, isTaxonomyApiActive } from './taxonomySupport'; const BATCH_SIZE = 200; // posts per batch during full sync @@ -107,14 +108,51 @@ async function deltaSync(): Promise { /** * Decides whether to do a full or delta sync. * - Empty local DB → full sync + * - forceFull → full sync only when GET /taxonomy is active (taxonomy-aware + * servers / pull-to-refresh after migrations). Without taxonomy, forceFull + * is ignored so behavior matches upstream mainline (delta only). + * - taxonomy_version change → full sync (no-op when endpoint absent) * - Has data → delta sync + * + * Accepts an optional pre-fetched taxonomy payload so callers that already + * fetched GET /taxonomy do not trigger a redundant round-trip. * Returns true if any data changed. */ -async function syncIfNeeded(): Promise { +async function syncIfNeeded( + forceFull: boolean = false, + prefetchedTaxonomy?: TaxonomyPayload | null, +): Promise { try { const empty = await localDb.isEmpty(); - if (empty) { + let taxonomyChanged = false; + let taxonomyVersion = ''; + let taxonomyActive = false; + try { + const taxonomy = prefetchedTaxonomy !== undefined + ? prefetchedTaxonomy + : await apiService.getTaxonomy(); + taxonomyActive = isTaxonomyApiActive(taxonomy); + taxonomyVersion = taxonomyActive ? (taxonomy?.taxonomy_version || '') : ''; + if (taxonomyVersion) { + const localVersion = await localDb.getSyncMeta('taxonomy_version'); + taxonomyChanged = localVersion !== taxonomyVersion; + if (taxonomyChanged) { + console.log( + `[Sync] Taxonomy version changed (${localVersion || 'none'} → ${taxonomyVersion}); forcing full sync` + ); + } + } + } catch { + /* offline / taxonomy endpoint unavailable — upstream path */ + } + + const effectiveForceFull = forceFull && taxonomyActive; + + if (empty || effectiveForceFull || taxonomyChanged) { const count = await fullSync(); + if (taxonomyVersion) { + await localDb.setSyncMeta('taxonomy_version', taxonomyVersion); + } return count > 0; } else { const changes = await deltaSync(); diff --git a/superbrain-app/src/services/taxonomySupport.ts b/superbrain-app/src/services/taxonomySupport.ts new file mode 100644 index 0000000..6b41adf --- /dev/null +++ b/superbrain-app/src/services/taxonomySupport.ts @@ -0,0 +1,33 @@ +/** + * Helpers for optional server-driven category taxonomy (`GET /taxonomy`). + * + * When the endpoint is absent (upstream mainline today), clients must keep + * historical APK behavior. New chip/sync rules activate only when the server + * returns a successful taxonomy payload with at least one category. + */ + +export type TaxonomyPayload = { + categories: Array<{ id: string; name: string; precedence?: number; guidance?: string }>; + allow_multiple_categories: boolean; + fallback_category: string; + use_default_categories: boolean; + taxonomy_version: string; +}; + +/** True when the backend exposes a usable config-driven taxonomy. */ +export function isTaxonomyApiActive( + taxonomy: TaxonomyPayload | null | undefined +): boolean { + return !!(taxonomy && Array.isArray(taxonomy.categories) && taxonomy.categories.length > 0); +} + +/** + * Strict custom taxonomy: hide built-in product/places/food chips and any + * labels outside the configured list. Only when the server explicitly sets + * use_default_categories=false. + */ +export function usesStrictCustomTaxonomy( + taxonomy: TaxonomyPayload | null | undefined +): boolean { + return isTaxonomyApiActive(taxonomy) && taxonomy!.use_default_categories === false; +}