Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/config/categories.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 15 additions & 10 deletions superbrain-app/src/constants/categories.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -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<string, string> = {
'all': 'star',
'sysadmin': 'terminal-outline',
Expand Down
89 changes: 64 additions & 25 deletions superbrain-app/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootStackParamList>;

Expand Down Expand Up @@ -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<TaxonomyPayload | null | undefined>(undefined);

useEffect(() => {
const bootstrap = async () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -310,29 +327,51 @@ 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
setLoading(true);
}

// ── 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) {
// Flush any offline mutations first
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
Expand Down
31 changes: 12 additions & 19 deletions superbrain-app/src/screens/PostDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootStackParamList, 'PostDetail'>;

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;
Expand Down Expand Up @@ -56,29 +60,18 @@ 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',
}))
);
}
} catch {
// Keep FALLBACK_CATEGORIES
// Keep FALLBACK_CATEGORIES (upstream path)
}
})();
return () => {
Expand Down
10 changes: 8 additions & 2 deletions superbrain-app/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 }
Expand All @@ -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;
}
}
Expand Down
19 changes: 19 additions & 0 deletions superbrain-app/src/services/localDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,23 @@ async function setLastSyncTime(isoTimestamp: string): Promise<void> {
);
}

async function getSyncMeta(key: string): Promise<string | null> {
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<void> {
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. */
Expand Down Expand Up @@ -258,6 +275,8 @@ const localDb = {
updatePost,
getLastSyncTime,
setLastSyncTime,
getSyncMeta,
setSyncMeta,
clearAll,
isEmpty,
};
Expand Down
42 changes: 40 additions & 2 deletions superbrain-app/src/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -107,14 +108,51 @@ async function deltaSync(): Promise<number> {
/**
* 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<boolean> {
async function syncIfNeeded(
forceFull: boolean = false,
prefetchedTaxonomy?: TaxonomyPayload | null,
): Promise<boolean> {
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();
Expand Down
Loading