increased security
Browse files- app/lib/data/repositories/card_repository.dart +10 -2
- app/lib/data/services/api_client.dart +20 -2
- app/lib/data/services/auth_service.dart +23 -3
- app/lib/data/services/local_ai/local_ai_service.dart +13 -1
- app/lib/data/services/local_store.dart +10 -1
- app/lib/ui/core/app_controller.dart +9 -5
- app/lib/ui/core/root_gate.dart +1 -13
- app/lib/ui/features/onboarding/views/login_screen.dart +8 -3
- app/lib/ui/features/onboarding/views/name_screen.dart +0 -192
- app/lib/ui/features/profile/views/profile_screen.dart +107 -6
- app/lib/ui/features/share/view_models/share_view_model.dart +6 -1
- app/test/fakes.dart +3 -1
- backend/app/api/auth_routes.py +28 -1
- backend/app/api/cards.py +22 -10
- backend/app/api/catalog.py +37 -17
- backend/app/api/collections.py +10 -6
- backend/app/api/concepts.py +7 -7
- backend/app/api/graph.py +10 -12
- backend/app/api/library_chat.py +1 -1
- backend/app/services/llm_library_chat.py +17 -10
- backend/app/store/db.py +153 -20
- backend/tests/test_api.py +25 -17
- backend/tests/test_isolation.py +123 -0
- backend/tests/test_merge.py +45 -0
- backend/tests/test_prefer_local.py +28 -0
app/lib/data/repositories/card_repository.dart
CHANGED
|
@@ -28,6 +28,14 @@ class CardRepository extends ChangeNotifier {
|
|
| 28 |
|
| 29 |
ApiClient get api => _api;
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
void updateBaseUrl(String url) {
|
| 32 |
_api.setBaseUrl(url);
|
| 33 |
notifyListeners();
|
|
@@ -41,9 +49,9 @@ class CardRepository extends ChangeNotifier {
|
|
| 41 |
|
| 42 |
/// Submit a shared URL. On network failure the URL is queued locally and the
|
| 43 |
/// caller is told it is pending (offline share queue, docs/06).
|
| 44 |
-
Future<CreateCardResult> share(String url) async {
|
| 45 |
try {
|
| 46 |
-
final result = await _api.createCard(url);
|
| 47 |
await flushPendingShares();
|
| 48 |
notifyListeners();
|
| 49 |
return result;
|
|
|
|
| 28 |
|
| 29 |
ApiClient get api => _api;
|
| 30 |
|
| 31 |
+
/// Developer toggle (persisted): route new cards through the on-device model
|
| 32 |
+
/// instead of the server LLM. Callers still gate on the model being ready.
|
| 33 |
+
bool get preferLocalModel => _store.preferLocalModel;
|
| 34 |
+
Future<void> setPreferLocalModel(bool value) async {
|
| 35 |
+
await _store.setPreferLocalModel(value);
|
| 36 |
+
notifyListeners();
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
void updateBaseUrl(String url) {
|
| 40 |
_api.setBaseUrl(url);
|
| 41 |
notifyListeners();
|
|
|
|
| 49 |
|
| 50 |
/// Submit a shared URL. On network failure the URL is queued locally and the
|
| 51 |
/// caller is told it is pending (offline share queue, docs/06).
|
| 52 |
+
Future<CreateCardResult> share(String url, {bool preferLocal = false}) async {
|
| 53 |
try {
|
| 54 |
+
final result = await _api.createCard(url, preferLocal: preferLocal);
|
| 55 |
await flushPendingShares();
|
| 56 |
notifyListeners();
|
| 57 |
return result;
|
app/lib/data/services/api_client.dart
CHANGED
|
@@ -233,13 +233,31 @@ class ApiClient {
|
|
| 233 |
return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
|
| 234 |
}
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
// ------------------------------------------------------------------------- //
|
| 237 |
// Cards
|
| 238 |
// ------------------------------------------------------------------------- //
|
| 239 |
|
| 240 |
-
Future<CreateCardResult> createCard(String url) async {
|
| 241 |
final resp = await _send(
|
| 242 |
-
(h) => _client.post(_uri('/cards'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
extra: const {'content-type': 'application/json'},
|
| 244 |
);
|
| 245 |
final json = _decodeMap(resp);
|
|
|
|
| 233 |
return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
|
| 234 |
}
|
| 235 |
|
| 236 |
+
/// Fold a guest (anonymous) account's data into the caller's account. The
|
| 237 |
+
/// request is authenticated as the destination (Google) uid; [guestToken] is
|
| 238 |
+
/// the guest's own ID token, captured before switching identities, and proves
|
| 239 |
+
/// ownership of the source. Returns the number of rows moved.
|
| 240 |
+
Future<int> mergeGuestLibrary(String guestToken) async {
|
| 241 |
+
final resp = await _send(
|
| 242 |
+
(h) => _client.post(_uri('/auth/merge'),
|
| 243 |
+
headers: h, body: jsonEncode({'guest_token': guestToken})),
|
| 244 |
+
extra: const {'content-type': 'application/json'},
|
| 245 |
+
);
|
| 246 |
+
return (_decodeMap(resp)['merged'] as num?)?.toInt() ?? 0;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
// ------------------------------------------------------------------------- //
|
| 250 |
// Cards
|
| 251 |
// ------------------------------------------------------------------------- //
|
| 252 |
|
| 253 |
+
Future<CreateCardResult> createCard(String url, {bool preferLocal = false}) async {
|
| 254 |
final resp = await _send(
|
| 255 |
+
(h) => _client.post(_uri('/cards'),
|
| 256 |
+
headers: h,
|
| 257 |
+
body: jsonEncode({
|
| 258 |
+
'url': url,
|
| 259 |
+
if (preferLocal) 'prefer_local': true,
|
| 260 |
+
})),
|
| 261 |
extra: const {'content-type': 'application/json'},
|
| 262 |
);
|
| 263 |
final json = _decodeMap(resp);
|
app/lib/data/services/auth_service.dart
CHANGED
|
@@ -24,7 +24,16 @@ abstract class AuthService {
|
|
| 24 |
Stream<AuthUser?> get userChanges;
|
| 25 |
AuthUser? get currentUser;
|
| 26 |
Future<AuthUser> signInAnonymously();
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
Future<String?> idToken({bool forceRefresh = false});
|
| 29 |
Future<void> signOut();
|
| 30 |
}
|
|
@@ -60,7 +69,9 @@ class FirebaseAuthService implements AuthService {
|
|
| 60 |
}
|
| 61 |
|
| 62 |
@override
|
| 63 |
-
Future<AuthUser> signInWithGoogle(
|
|
|
|
|
|
|
| 64 |
final account = await _google.signIn();
|
| 65 |
if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
|
| 66 |
final gAuth = await account.authentication;
|
|
@@ -75,8 +86,17 @@ class FirebaseAuthService implements AuthService {
|
|
| 75 |
cred = await current.linkWithCredential(credential); // uid preserved
|
| 76 |
} on fb.FirebaseAuthException catch (e) {
|
| 77 |
if (e.code != 'credential-already-in-use') rethrow;
|
| 78 |
-
// Google account already has a Cachy identity —
|
|
|
|
|
|
|
|
|
|
| 79 |
cred = await _auth.signInWithCredential(credential);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
} else {
|
| 82 |
cred = await _auth.signInWithCredential(credential);
|
|
|
|
| 24 |
Stream<AuthUser?> get userChanges;
|
| 25 |
AuthUser? get currentUser;
|
| 26 |
Future<AuthUser> signInAnonymously();
|
| 27 |
+
|
| 28 |
+
/// Sign in with Google. When the current session is an anonymous guest, the
|
| 29 |
+
/// uid is linked (data stays put). If that Google account already has its own
|
| 30 |
+
/// Cachy identity, linking is impossible, so we switch to it and — if
|
| 31 |
+
/// [mergeGuestData] is provided — hand back the guest's ID token so the
|
| 32 |
+
/// caller can fold the guest's server-side data into the account.
|
| 33 |
+
Future<AuthUser> signInWithGoogle({
|
| 34 |
+
Future<void> Function(String guestIdToken)? mergeGuestData,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
Future<String?> idToken({bool forceRefresh = false});
|
| 38 |
Future<void> signOut();
|
| 39 |
}
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
@override
|
| 72 |
+
Future<AuthUser> signInWithGoogle({
|
| 73 |
+
Future<void> Function(String guestIdToken)? mergeGuestData,
|
| 74 |
+
}) async {
|
| 75 |
final account = await _google.signIn();
|
| 76 |
if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
|
| 77 |
final gAuth = await account.authentication;
|
|
|
|
| 86 |
cred = await current.linkWithCredential(credential); // uid preserved
|
| 87 |
} on fb.FirebaseAuthException catch (e) {
|
| 88 |
if (e.code != 'credential-already-in-use') rethrow;
|
| 89 |
+
// Google account already has a Cachy identity — linking is impossible.
|
| 90 |
+
// Grab the guest's token before switching so its data can be merged,
|
| 91 |
+
// then sign into the existing account.
|
| 92 |
+
final guestToken = await current.getIdToken();
|
| 93 |
cred = await _auth.signInWithCredential(credential);
|
| 94 |
+
if (guestToken != null && guestToken.isNotEmpty && mergeGuestData != null) {
|
| 95 |
+
// Best-effort: a failed merge must not block sign-in.
|
| 96 |
+
try {
|
| 97 |
+
await mergeGuestData(guestToken);
|
| 98 |
+
} catch (_) {}
|
| 99 |
+
}
|
| 100 |
}
|
| 101 |
} else {
|
| 102 |
cred = await _auth.signInWithCredential(credential);
|
app/lib/data/services/local_ai/local_ai_service.dart
CHANGED
|
@@ -60,9 +60,20 @@ abstract class LocalAiService extends ChangeNotifier {
|
|
| 60 |
});
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
/// Prompt tuned for 1B models: short instruction, one few-shot example,
|
| 64 |
/// strict JSON-only suffix.
|
| 65 |
-
String buildStructurePrompt(String bundle)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
You turn a video's raw text into one JSON knowledge card. Reply with JSON only, no prose, no markdown fences.
|
| 67 |
|
| 68 |
Schema:
|
|
@@ -78,6 +89,7 @@ Input:
|
|
| 78 |
$bundle
|
| 79 |
|
| 80 |
Output (JSON only):''';
|
|
|
|
| 81 |
|
| 82 |
/// Parse + minimally validate model output into card JSON.
|
| 83 |
///
|
|
|
|
| 60 |
});
|
| 61 |
}
|
| 62 |
|
| 63 |
+
/// Char cap on the bundle fed to the on-device model. The Gemma session runs
|
| 64 |
+
/// with maxTokens: 2048 covering prompt + output; at ~4 chars/token the fixed
|
| 65 |
+
/// scaffold plus a full JSON card leaves room for roughly this much input. A fat
|
| 66 |
+
/// bundle (long transcript) would otherwise overflow the window and the model
|
| 67 |
+
/// throws — silently keeping the paragraph card it was meant to upgrade.
|
| 68 |
+
const kLocalAiBundleCharCap = 4000;
|
| 69 |
+
|
| 70 |
/// Prompt tuned for 1B models: short instruction, one few-shot example,
|
| 71 |
/// strict JSON-only suffix.
|
| 72 |
+
String buildStructurePrompt(String bundle) {
|
| 73 |
+
if (bundle.length > kLocalAiBundleCharCap) {
|
| 74 |
+
bundle = bundle.substring(0, kLocalAiBundleCharCap);
|
| 75 |
+
}
|
| 76 |
+
return '''
|
| 77 |
You turn a video's raw text into one JSON knowledge card. Reply with JSON only, no prose, no markdown fences.
|
| 78 |
|
| 79 |
Schema:
|
|
|
|
| 89 |
$bundle
|
| 90 |
|
| 91 |
Output (JSON only):''';
|
| 92 |
+
}
|
| 93 |
|
| 94 |
/// Parse + minimally validate model output into card JSON.
|
| 95 |
///
|
app/lib/data/services/local_store.dart
CHANGED
|
@@ -21,6 +21,7 @@ class LocalStore {
|
|
| 21 |
static const _apiBaseUrlKey = 'api_base_url';
|
| 22 |
static const _splitPaneFractionKey = 'split_pane_fraction';
|
| 23 |
static const _localAiEnabledKey = 'local_ai_enabled';
|
|
|
|
| 24 |
|
| 25 |
static Future<LocalStore> open() async =>
|
| 26 |
LocalStore(await SharedPreferences.getInstance());
|
|
@@ -47,8 +48,16 @@ class LocalStore {
|
|
| 47 |
bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
|
| 48 |
Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
/// Clears user identity and onboarding state, effectively signing the user
|
| 51 |
-
/// out and forcing them back through the
|
|
|
|
|
|
|
| 52 |
Future<void> clearUser() async {
|
| 53 |
await _prefs.remove(_userNameKey);
|
| 54 |
await _prefs.remove(_seenOnboardingKey);
|
|
|
|
| 21 |
static const _apiBaseUrlKey = 'api_base_url';
|
| 22 |
static const _splitPaneFractionKey = 'split_pane_fraction';
|
| 23 |
static const _localAiEnabledKey = 'local_ai_enabled';
|
| 24 |
+
static const _preferLocalModelKey = 'prefer_local_model';
|
| 25 |
|
| 26 |
static Future<LocalStore> open() async =>
|
| 27 |
LocalStore(await SharedPreferences.getInstance());
|
|
|
|
| 48 |
bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
|
| 49 |
Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
|
| 50 |
|
| 51 |
+
/// Developer toggle: route new cards through the on-device model instead of
|
| 52 |
+
/// the server LLM (backend skips structuring, phone structures the bundle).
|
| 53 |
+
bool get preferLocalModel => _prefs.getBool(_preferLocalModelKey) ?? false;
|
| 54 |
+
Future<void> setPreferLocalModel(bool v) =>
|
| 55 |
+
_prefs.setBool(_preferLocalModelKey, v);
|
| 56 |
+
|
| 57 |
/// Clears user identity and onboarding state, effectively signing the user
|
| 58 |
+
/// out and forcing them back through onboarding + the login gate on next
|
| 59 |
+
/// launch. The legacy name key is cleared too (it only lingers for users
|
| 60 |
+
/// upgrading from the old name-based build).
|
| 61 |
Future<void> clearUser() async {
|
| 62 |
await _prefs.remove(_userNameKey);
|
| 63 |
await _prefs.remove(_seenOnboardingKey);
|
app/lib/ui/core/app_controller.dart
CHANGED
|
@@ -31,11 +31,15 @@ class AppController extends ChangeNotifier {
|
|
| 31 |
/// signed out. Drives the login gate and the profile account section.
|
| 32 |
AuthUser? get authUser => _authUser;
|
| 33 |
|
| 34 |
-
/// True once the user has cleared onboarding
|
| 35 |
-
///
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
Future<void> continueAnonymously() => _auth.signInAnonymously();
|
| 40 |
|
| 41 |
ThemeMode _themeMode;
|
|
|
|
| 31 |
/// signed out. Drives the login gate and the profile account section.
|
| 32 |
AuthUser? get authUser => _authUser;
|
| 33 |
|
| 34 |
+
/// True once the user has cleared onboarding but has no Firebase identity
|
| 35 |
+
/// yet — the one moment [RootGate] shows the login screen. Identity is now
|
| 36 |
+
/// the Firebase uid (Google or anonymous); no name is collected up front.
|
| 37 |
+
bool get needsLogin => seenOnboarding && _authUser == null;
|
| 38 |
+
|
| 39 |
+
Future<void> signInWithGoogle({
|
| 40 |
+
Future<void> Function(String guestIdToken)? mergeGuestData,
|
| 41 |
+
}) =>
|
| 42 |
+
_auth.signInWithGoogle(mergeGuestData: mergeGuestData);
|
| 43 |
Future<void> continueAnonymously() => _auth.signInAnonymously();
|
| 44 |
|
| 45 |
ThemeMode _themeMode;
|
app/lib/ui/core/root_gate.dart
CHANGED
|
@@ -9,13 +9,12 @@ import 'package:provider/provider.dart';
|
|
| 9 |
import '../../data/repositories/card_repository.dart';
|
| 10 |
import '../../data/services/auth_service.dart';
|
| 11 |
import '../features/onboarding/views/login_screen.dart';
|
| 12 |
-
import '../features/onboarding/views/name_screen.dart';
|
| 13 |
import '../features/onboarding/views/onboarding_screen.dart';
|
| 14 |
import '../features/onboarding/views/splash_screen.dart';
|
| 15 |
import 'app_controller.dart';
|
| 16 |
import 'home_shell.dart';
|
| 17 |
|
| 18 |
-
enum _Phase { splash, onboarding,
|
| 19 |
|
| 20 |
class RootGate extends StatefulWidget {
|
| 21 |
const RootGate({super.key});
|
|
@@ -29,7 +28,6 @@ class _RootGateState extends State<RootGate> {
|
|
| 29 |
|
| 30 |
_Phase _afterSplash(AppController app) {
|
| 31 |
if (!app.seenOnboarding) return _Phase.onboarding;
|
| 32 |
-
if (!app.hasUserName) return _Phase.nameEntry;
|
| 33 |
if (app.needsLogin) return _Phase.login;
|
| 34 |
return _Phase.shell;
|
| 35 |
}
|
|
@@ -48,14 +46,6 @@ class _RootGateState extends State<RootGate> {
|
|
| 48 |
|
| 49 |
Future<void> _finishOnboarding() async {
|
| 50 |
await context.read<AppController>().completeOnboarding();
|
| 51 |
-
if (!mounted) return;
|
| 52 |
-
final app = context.read<AppController>();
|
| 53 |
-
setState(() => _phase = app.hasUserName
|
| 54 |
-
? (app.needsLogin ? _Phase.login : _Phase.shell)
|
| 55 |
-
: _Phase.nameEntry);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
void _finishNameEntry() {
|
| 59 |
if (!mounted) return;
|
| 60 |
final app = context.read<AppController>();
|
| 61 |
setState(() => _phase = app.needsLogin ? _Phase.login : _Phase.shell);
|
|
@@ -90,8 +80,6 @@ class _RootGateState extends State<RootGate> {
|
|
| 90 |
SplashScreen(key: const ValueKey('splash'), onDone: _finishSplash),
|
| 91 |
_Phase.onboarding =>
|
| 92 |
OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
|
| 93 |
-
_Phase.nameEntry =>
|
| 94 |
-
NameScreen(key: const ValueKey('nameEntry'), onDone: _finishNameEntry),
|
| 95 |
_Phase.login =>
|
| 96 |
LoginScreen(key: const ValueKey('login'), onDone: _finishLogin),
|
| 97 |
_Phase.shell => const HomeShell(key: ValueKey('shell')),
|
|
|
|
| 9 |
import '../../data/repositories/card_repository.dart';
|
| 10 |
import '../../data/services/auth_service.dart';
|
| 11 |
import '../features/onboarding/views/login_screen.dart';
|
|
|
|
| 12 |
import '../features/onboarding/views/onboarding_screen.dart';
|
| 13 |
import '../features/onboarding/views/splash_screen.dart';
|
| 14 |
import 'app_controller.dart';
|
| 15 |
import 'home_shell.dart';
|
| 16 |
|
| 17 |
+
enum _Phase { splash, onboarding, login, shell }
|
| 18 |
|
| 19 |
class RootGate extends StatefulWidget {
|
| 20 |
const RootGate({super.key});
|
|
|
|
| 28 |
|
| 29 |
_Phase _afterSplash(AppController app) {
|
| 30 |
if (!app.seenOnboarding) return _Phase.onboarding;
|
|
|
|
| 31 |
if (app.needsLogin) return _Phase.login;
|
| 32 |
return _Phase.shell;
|
| 33 |
}
|
|
|
|
| 46 |
|
| 47 |
Future<void> _finishOnboarding() async {
|
| 48 |
await context.read<AppController>().completeOnboarding();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
if (!mounted) return;
|
| 50 |
final app = context.read<AppController>();
|
| 51 |
setState(() => _phase = app.needsLogin ? _Phase.login : _Phase.shell);
|
|
|
|
| 80 |
SplashScreen(key: const ValueKey('splash'), onDone: _finishSplash),
|
| 81 |
_Phase.onboarding =>
|
| 82 |
OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
|
|
|
|
|
|
|
| 83 |
_Phase.login =>
|
| 84 |
LoginScreen(key: const ValueKey('login'), onDone: _finishLogin),
|
| 85 |
_Phase.shell => const HomeShell(key: ValueKey('shell')),
|
app/lib/ui/features/onboarding/views/login_screen.dart
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
-
/// Login gate, shown after onboarding
|
| 2 |
-
///
|
|
|
|
| 3 |
library;
|
| 4 |
|
| 5 |
import 'package:flutter/material.dart';
|
| 6 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 7 |
import 'package:provider/provider.dart';
|
| 8 |
|
|
|
|
| 9 |
import '../../../../data/services/auth_service.dart';
|
| 10 |
import '../../../core/brand.dart';
|
| 11 |
import '../../../core/widgets/responsive_center.dart';
|
|
@@ -85,7 +87,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|
| 85 |
onPressed: _busy
|
| 86 |
? null
|
| 87 |
: () => _run(() async {
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
}),
|
| 90 |
icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo,
|
| 91 |
size: 20),
|
|
|
|
| 1 |
+
/// Login gate, shown right after onboarding. Google is primary; "use without
|
| 2 |
+
/// login" runs anonymous auth and can be upgraded to Google later (the uid is
|
| 3 |
+
/// preserved via account linking, so guest data carries over).
|
| 4 |
library;
|
| 5 |
|
| 6 |
import 'package:flutter/material.dart';
|
| 7 |
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 8 |
import 'package:provider/provider.dart';
|
| 9 |
|
| 10 |
+
import '../../../../data/repositories/card_repository.dart';
|
| 11 |
import '../../../../data/services/auth_service.dart';
|
| 12 |
import '../../../core/brand.dart';
|
| 13 |
import '../../../core/widgets/responsive_center.dart';
|
|
|
|
| 87 |
onPressed: _busy
|
| 88 |
? null
|
| 89 |
: () => _run(() async {
|
| 90 |
+
final api = context.read<CardRepository>().api;
|
| 91 |
+
await auth.signInWithGoogle(
|
| 92 |
+
mergeGuestData: api.mergeGuestLibrary,
|
| 93 |
+
);
|
| 94 |
}),
|
| 95 |
icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo,
|
| 96 |
size: 20),
|
app/lib/ui/features/onboarding/views/name_screen.dart
DELETED
|
@@ -1,192 +0,0 @@
|
|
| 1 |
-
/// Name entry gate — shown once on first launch so the backend can isolate
|
| 2 |
-
/// each user's cards. The entered name becomes the X-Owner-Id header on every
|
| 3 |
-
/// API request (stored in LocalStore, read by ApiClient).
|
| 4 |
-
library;
|
| 5 |
-
|
| 6 |
-
import 'package:flutter/material.dart';
|
| 7 |
-
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
| 8 |
-
import 'package:provider/provider.dart';
|
| 9 |
-
|
| 10 |
-
import '../../../core/app_controller.dart';
|
| 11 |
-
import '../../../core/brand.dart';
|
| 12 |
-
import '../../../core/widgets/responsive_center.dart';
|
| 13 |
-
|
| 14 |
-
class NameScreen extends StatefulWidget {
|
| 15 |
-
const NameScreen({super.key, required this.onDone});
|
| 16 |
-
final VoidCallback onDone;
|
| 17 |
-
|
| 18 |
-
@override
|
| 19 |
-
State<NameScreen> createState() => _NameScreenState();
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
class _NameScreenState extends State<NameScreen> {
|
| 23 |
-
final _controller = TextEditingController();
|
| 24 |
-
bool _submitting = false;
|
| 25 |
-
|
| 26 |
-
@override
|
| 27 |
-
void dispose() {
|
| 28 |
-
_controller.dispose();
|
| 29 |
-
super.dispose();
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
Future<void> _submit() async {
|
| 33 |
-
final name = _controller.text.trim();
|
| 34 |
-
if (name.isEmpty) return;
|
| 35 |
-
setState(() => _submitting = true);
|
| 36 |
-
await context.read<AppController>().setUserName(name);
|
| 37 |
-
if (mounted) widget.onDone();
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
@override
|
| 41 |
-
Widget build(BuildContext context) {
|
| 42 |
-
final theme = Theme.of(context);
|
| 43 |
-
final scheme = theme.colorScheme;
|
| 44 |
-
|
| 45 |
-
return Scaffold(
|
| 46 |
-
backgroundColor: scheme.surface,
|
| 47 |
-
body: Container(
|
| 48 |
-
decoration: BoxDecoration(
|
| 49 |
-
gradient: RadialGradient(
|
| 50 |
-
center: const Alignment(0, -0.45),
|
| 51 |
-
radius: 1.3,
|
| 52 |
-
colors: [
|
| 53 |
-
scheme.primary.withValues(alpha: 0.12),
|
| 54 |
-
Colors.transparent,
|
| 55 |
-
],
|
| 56 |
-
),
|
| 57 |
-
),
|
| 58 |
-
child: SafeArea(
|
| 59 |
-
child: ResponsiveCenter(
|
| 60 |
-
child: Padding(
|
| 61 |
-
padding: const EdgeInsets.symmetric(horizontal: 28),
|
| 62 |
-
child: Column(
|
| 63 |
-
crossAxisAlignment: CrossAxisAlignment.start,
|
| 64 |
-
children: [
|
| 65 |
-
const SizedBox(height: 48),
|
| 66 |
-
Container(
|
| 67 |
-
width: 56,
|
| 68 |
-
height: 56,
|
| 69 |
-
decoration: BoxDecoration(
|
| 70 |
-
color: scheme.primary,
|
| 71 |
-
borderRadius: BorderRadius.circular(16),
|
| 72 |
-
boxShadow: [
|
| 73 |
-
BoxShadow(
|
| 74 |
-
color: scheme.primary.withValues(alpha: 0.4),
|
| 75 |
-
blurRadius: 16,
|
| 76 |
-
offset: const Offset(0, 4),
|
| 77 |
-
),
|
| 78 |
-
],
|
| 79 |
-
),
|
| 80 |
-
child: PhosphorIcon(
|
| 81 |
-
PhosphorIconsRegular.user,
|
| 82 |
-
color: scheme.onPrimary,
|
| 83 |
-
size: 28,
|
| 84 |
-
),
|
| 85 |
-
),
|
| 86 |
-
const SizedBox(height: 32),
|
| 87 |
-
RichText(
|
| 88 |
-
text: TextSpan(
|
| 89 |
-
style: theme.textTheme.displaySmall
|
| 90 |
-
?.copyWith(color: scheme.onSurface),
|
| 91 |
-
children: [
|
| 92 |
-
const TextSpan(text: "What's\nyour "),
|
| 93 |
-
TextSpan(
|
| 94 |
-
text: 'name?',
|
| 95 |
-
style: TextStyle(color: scheme.primary),
|
| 96 |
-
),
|
| 97 |
-
],
|
| 98 |
-
),
|
| 99 |
-
),
|
| 100 |
-
const SizedBox(height: 16),
|
| 101 |
-
Text(
|
| 102 |
-
'Your library stays private. Only you see your cards.',
|
| 103 |
-
style: theme.textTheme.bodyLarge?.copyWith(
|
| 104 |
-
color: scheme.onSurfaceVariant,
|
| 105 |
-
height: 1.5,
|
| 106 |
-
),
|
| 107 |
-
),
|
| 108 |
-
const SizedBox(height: 40),
|
| 109 |
-
TextField(
|
| 110 |
-
controller: _controller,
|
| 111 |
-
autofocus: true,
|
| 112 |
-
textCapitalization: TextCapitalization.words,
|
| 113 |
-
textInputAction: TextInputAction.done,
|
| 114 |
-
onSubmitted: (_) => _submit(),
|
| 115 |
-
style: theme.textTheme.titleLarge?.copyWith(
|
| 116 |
-
fontWeight: FontWeight.w600,
|
| 117 |
-
),
|
| 118 |
-
decoration: InputDecoration(
|
| 119 |
-
hintText: 'Your name',
|
| 120 |
-
filled: true,
|
| 121 |
-
fillColor: scheme.surfaceContainerLow,
|
| 122 |
-
border: OutlineInputBorder(
|
| 123 |
-
borderRadius: BorderRadius.circular(16),
|
| 124 |
-
borderSide: BorderSide(color: scheme.outlineVariant),
|
| 125 |
-
),
|
| 126 |
-
enabledBorder: OutlineInputBorder(
|
| 127 |
-
borderRadius: BorderRadius.circular(16),
|
| 128 |
-
borderSide: BorderSide(color: scheme.outlineVariant),
|
| 129 |
-
),
|
| 130 |
-
focusedBorder: OutlineInputBorder(
|
| 131 |
-
borderRadius: BorderRadius.circular(16),
|
| 132 |
-
borderSide: BorderSide(color: scheme.primary, width: 2),
|
| 133 |
-
),
|
| 134 |
-
contentPadding: const EdgeInsets.symmetric(
|
| 135 |
-
horizontal: 20,
|
| 136 |
-
vertical: 18,
|
| 137 |
-
),
|
| 138 |
-
),
|
| 139 |
-
),
|
| 140 |
-
const Spacer(),
|
| 141 |
-
ListenableBuilder(
|
| 142 |
-
listenable: _controller,
|
| 143 |
-
builder: (context, _) {
|
| 144 |
-
final ready = _controller.text.trim().isNotEmpty;
|
| 145 |
-
return FilledButton(
|
| 146 |
-
onPressed: (ready && !_submitting) ? _submit : null,
|
| 147 |
-
style: FilledButton.styleFrom(
|
| 148 |
-
minimumSize: const Size.fromHeight(56),
|
| 149 |
-
shape: RoundedRectangleBorder(
|
| 150 |
-
borderRadius: BorderRadius.circular(16),
|
| 151 |
-
),
|
| 152 |
-
),
|
| 153 |
-
child: _submitting
|
| 154 |
-
? const SizedBox(
|
| 155 |
-
width: 22,
|
| 156 |
-
height: 22,
|
| 157 |
-
child: CircularProgressIndicator(
|
| 158 |
-
strokeWidth: 2.5,
|
| 159 |
-
),
|
| 160 |
-
)
|
| 161 |
-
: Row(
|
| 162 |
-
mainAxisAlignment: MainAxisAlignment.center,
|
| 163 |
-
children: [
|
| 164 |
-
Text(
|
| 165 |
-
'Enter Cachy',
|
| 166 |
-
style: Brand.label(
|
| 167 |
-
size: 16,
|
| 168 |
-
color: scheme.onPrimary,
|
| 169 |
-
weight: FontWeight.w700,
|
| 170 |
-
),
|
| 171 |
-
),
|
| 172 |
-
const SizedBox(width: 8),
|
| 173 |
-
PhosphorIcon(
|
| 174 |
-
PhosphorIconsRegular.arrowRight,
|
| 175 |
-
size: 18,
|
| 176 |
-
color: scheme.onPrimary,
|
| 177 |
-
),
|
| 178 |
-
],
|
| 179 |
-
),
|
| 180 |
-
);
|
| 181 |
-
},
|
| 182 |
-
),
|
| 183 |
-
const SizedBox(height: 32),
|
| 184 |
-
],
|
| 185 |
-
),
|
| 186 |
-
),
|
| 187 |
-
),
|
| 188 |
-
),
|
| 189 |
-
),
|
| 190 |
-
);
|
| 191 |
-
}
|
| 192 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/lib/ui/features/profile/views/profile_screen.dart
CHANGED
|
@@ -326,14 +326,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|
| 326 |
_accountRow(theme, user)
|
| 327 |
else
|
| 328 |
_backupBanner(theme),
|
| 329 |
-
if (signedIn
|
| 330 |
-
(context.read<AppController>().userName ?? '').isNotEmpty)
|
| 331 |
_Tile(
|
| 332 |
icon: PhosphorIconsRegular.clockCounterClockwise,
|
| 333 |
title: 'Restore old library',
|
| 334 |
-
subtitle: '
|
| 335 |
-
onTap:
|
| 336 |
-
_offerClaim(context.read<AppController>().userName!),
|
| 337 |
),
|
| 338 |
_Tile(
|
| 339 |
icon: PhosphorIconsRegular.signOut,
|
|
@@ -434,9 +432,10 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|
| 434 |
Future<void> _signInAndMaybeClaim() async {
|
| 435 |
setState(() => _signingIn = true);
|
| 436 |
final app = context.read<AppController>();
|
|
|
|
| 437 |
final messenger = ScaffoldMessenger.of(context);
|
| 438 |
try {
|
| 439 |
-
await app.signInWithGoogle();
|
| 440 |
} catch (_) {
|
| 441 |
messenger.showSnackBar(
|
| 442 |
const SnackBar(content: Text("Couldn't sign in. Try again.")));
|
|
@@ -466,6 +465,54 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|
| 466 |
),
|
| 467 |
);
|
| 468 |
if (restore != true || !mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
final api = context.read<CardRepository>().api;
|
| 470 |
final messenger = ScaffoldMessenger.of(context);
|
| 471 |
try {
|
|
@@ -755,6 +802,10 @@ class _DeveloperScreenState extends State<_DeveloperScreen> {
|
|
| 755 |
subtitle: 'Clear the override and reconnect to the hosted Space.',
|
| 756 |
onTap: () => _setUrl(repo, ApiClient.defaultBaseUrl),
|
| 757 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
const SizedBox(height: 12),
|
| 759 |
Text(
|
| 760 |
'Changes take effect immediately and persist across launches.',
|
|
@@ -766,6 +817,56 @@ class _DeveloperScreenState extends State<_DeveloperScreen> {
|
|
| 766 |
);
|
| 767 |
}
|
| 768 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
void _setUrl(CardRepository repo, String url) {
|
| 770 |
repo.updateBaseUrl(url);
|
| 771 |
if (!mounted) return;
|
|
|
|
| 326 |
_accountRow(theme, user)
|
| 327 |
else
|
| 328 |
_backupBanner(theme),
|
| 329 |
+
if (signedIn)
|
|
|
|
| 330 |
_Tile(
|
| 331 |
icon: PhosphorIconsRegular.clockCounterClockwise,
|
| 332 |
title: 'Restore old library',
|
| 333 |
+
subtitle: 'Used Cachy before with a name? Bring those cards in.',
|
| 334 |
+
onTap: _promptRestoreByName,
|
|
|
|
| 335 |
),
|
| 336 |
_Tile(
|
| 337 |
icon: PhosphorIconsRegular.signOut,
|
|
|
|
| 432 |
Future<void> _signInAndMaybeClaim() async {
|
| 433 |
setState(() => _signingIn = true);
|
| 434 |
final app = context.read<AppController>();
|
| 435 |
+
final api = context.read<CardRepository>().api;
|
| 436 |
final messenger = ScaffoldMessenger.of(context);
|
| 437 |
try {
|
| 438 |
+
await app.signInWithGoogle(mergeGuestData: api.mergeGuestLibrary);
|
| 439 |
} catch (_) {
|
| 440 |
messenger.showSnackBar(
|
| 441 |
const SnackBar(content: Text("Couldn't sign in. Try again.")));
|
|
|
|
| 465 |
),
|
| 466 |
);
|
| 467 |
if (restore != true || !mounted) return;
|
| 468 |
+
await _runClaim(name);
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
/// Legacy rescue for the old name-keyed build: the user types the name they
|
| 472 |
+
/// saved under and those rows re-point onto this account. First claim wins.
|
| 473 |
+
Future<void> _promptRestoreByName() async {
|
| 474 |
+
final controller = TextEditingController();
|
| 475 |
+
final name = await showDialog<String>(
|
| 476 |
+
context: context,
|
| 477 |
+
builder: (ctx) => AlertDialog(
|
| 478 |
+
title: const Text('Restore old library'),
|
| 479 |
+
content: Column(
|
| 480 |
+
mainAxisSize: MainAxisSize.min,
|
| 481 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 482 |
+
children: [
|
| 483 |
+
const Text(
|
| 484 |
+
'Enter the name you used in the old version of Cachy. The cards '
|
| 485 |
+
'saved under it will move into this account.'),
|
| 486 |
+
const SizedBox(height: 16),
|
| 487 |
+
TextField(
|
| 488 |
+
controller: controller,
|
| 489 |
+
autofocus: true,
|
| 490 |
+
textCapitalization: TextCapitalization.words,
|
| 491 |
+
textInputAction: TextInputAction.done,
|
| 492 |
+
decoration: const InputDecoration(
|
| 493 |
+
hintText: 'Your old name',
|
| 494 |
+
border: OutlineInputBorder(),
|
| 495 |
+
),
|
| 496 |
+
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
| 497 |
+
),
|
| 498 |
+
],
|
| 499 |
+
),
|
| 500 |
+
actions: [
|
| 501 |
+
TextButton(
|
| 502 |
+
onPressed: () => Navigator.pop(ctx),
|
| 503 |
+
child: const Text('Cancel')),
|
| 504 |
+
FilledButton(
|
| 505 |
+
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
| 506 |
+
child: const Text('Restore')),
|
| 507 |
+
],
|
| 508 |
+
),
|
| 509 |
+
);
|
| 510 |
+
controller.dispose();
|
| 511 |
+
if (name == null || name.isEmpty || !mounted) return;
|
| 512 |
+
await _runClaim(name);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
Future<void> _runClaim(String name) async {
|
| 516 |
final api = context.read<CardRepository>().api;
|
| 517 |
final messenger = ScaffoldMessenger.of(context);
|
| 518 |
try {
|
|
|
|
| 802 |
subtitle: 'Clear the override and reconnect to the hosted Space.',
|
| 803 |
onTap: () => _setUrl(repo, ApiClient.defaultBaseUrl),
|
| 804 |
),
|
| 805 |
+
const SizedBox(height: 20),
|
| 806 |
+
Text('AI model', style: theme.textTheme.titleSmall),
|
| 807 |
+
const SizedBox(height: 8),
|
| 808 |
+
_localModelToggle(theme, repo),
|
| 809 |
const SizedBox(height: 12),
|
| 810 |
Text(
|
| 811 |
'Changes take effect immediately and persist across launches.',
|
|
|
|
| 817 |
);
|
| 818 |
}
|
| 819 |
|
| 820 |
+
Widget _localModelToggle(ThemeData theme, CardRepository repo) {
|
| 821 |
+
final scheme = theme.colorScheme;
|
| 822 |
+
LocalAiService? ai;
|
| 823 |
+
try {
|
| 824 |
+
ai = context.watch<LocalAiService>();
|
| 825 |
+
} catch (_) {
|
| 826 |
+
ai = null;
|
| 827 |
+
}
|
| 828 |
+
final ready = ai?.canStructure ?? false;
|
| 829 |
+
final on = repo.preferLocalModel;
|
| 830 |
+
return Material(
|
| 831 |
+
color: scheme.surfaceContainerLow,
|
| 832 |
+
borderRadius: BorderRadius.circular(14),
|
| 833 |
+
child: Column(
|
| 834 |
+
children: [
|
| 835 |
+
SwitchListTile(
|
| 836 |
+
shape:
|
| 837 |
+
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
| 838 |
+
secondary: const PhosphorIcon(PhosphorIconsRegular.cpu),
|
| 839 |
+
title: const Text('Use on-device model'),
|
| 840 |
+
subtitle: const Text(
|
| 841 |
+
'Structure new cards with the local model instead of the '
|
| 842 |
+
'server LLM.'),
|
| 843 |
+
value: on,
|
| 844 |
+
onChanged: (v) => repo.setPreferLocalModel(v),
|
| 845 |
+
),
|
| 846 |
+
if (on && !ready)
|
| 847 |
+
Padding(
|
| 848 |
+
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
| 849 |
+
child: Row(
|
| 850 |
+
children: [
|
| 851 |
+
PhosphorIcon(PhosphorIconsRegular.warning,
|
| 852 |
+
size: 16, color: scheme.error),
|
| 853 |
+
const SizedBox(width: 8),
|
| 854 |
+
Expanded(
|
| 855 |
+
child: Text(
|
| 856 |
+
'On-device model not ready — download and enable it in '
|
| 857 |
+
'Settings first. New cards use the server until then.',
|
| 858 |
+
style: theme.textTheme.bodySmall
|
| 859 |
+
?.copyWith(color: scheme.error),
|
| 860 |
+
),
|
| 861 |
+
),
|
| 862 |
+
],
|
| 863 |
+
),
|
| 864 |
+
),
|
| 865 |
+
],
|
| 866 |
+
),
|
| 867 |
+
);
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
void _setUrl(CardRepository repo, String url) {
|
| 871 |
repo.updateBaseUrl(url);
|
| 872 |
if (!mounted) return;
|
app/lib/ui/features/share/view_models/share_view_model.dart
CHANGED
|
@@ -65,7 +65,12 @@ class ShareViewModel extends ChangeNotifier {
|
|
| 65 |
notifyListeners();
|
| 66 |
|
| 67 |
try {
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
_cardId = result.cardId;
|
| 70 |
_quotaDegraded = result.quotaDegraded;
|
| 71 |
if (result.cached) {
|
|
|
|
| 65 |
notifyListeners();
|
| 66 |
|
| 67 |
try {
|
| 68 |
+
// Dev toggle: route through the on-device model, but only when it can
|
| 69 |
+
// actually structure — otherwise fall back to the server LLM so we never
|
| 70 |
+
// strand a card as a paragraph with no upgrade path.
|
| 71 |
+
final preferLocal =
|
| 72 |
+
_repository.preferLocalModel && (_localAi?.canStructure ?? false);
|
| 73 |
+
final result = await _repository.share(cleaned, preferLocal: preferLocal);
|
| 74 |
_cardId = result.cardId;
|
| 75 |
_quotaDegraded = result.quotaDegraded;
|
| 76 |
if (result.cached) {
|
app/test/fakes.dart
CHANGED
|
@@ -21,7 +21,9 @@ class FakeAuthService implements AuthService {
|
|
| 21 |
}
|
| 22 |
|
| 23 |
@override
|
| 24 |
-
Future<AuthUser> signInWithGoogle(
|
|
|
|
|
|
|
| 25 |
// Linking keeps the uid when the current user is anonymous.
|
| 26 |
final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
|
| 27 |
_user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
@override
|
| 24 |
+
Future<AuthUser> signInWithGoogle({
|
| 25 |
+
Future<void> Function(String guestIdToken)? mergeGuestData,
|
| 26 |
+
}) async {
|
| 27 |
// Linking keeps the uid when the current user is anonymous.
|
| 28 |
final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
|
| 29 |
_user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
|
backend/app/api/auth_routes.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
| 5 |
from fastapi import APIRouter, HTTPException
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
| 8 |
-
from app.auth import OwnerDep
|
| 9 |
from app.store import db
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
@@ -15,6 +15,10 @@ class ClaimRequest(BaseModel):
|
|
| 15 |
name: str
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
@router.post("/claim")
|
| 19 |
async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
|
| 20 |
"""Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
|
|
@@ -26,3 +30,26 @@ async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
|
|
| 26 |
if claimed is None:
|
| 27 |
raise HTTPException(status_code=409, detail="name already claimed")
|
| 28 |
return {"claimed": claimed}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from fastapi import APIRouter, HTTPException
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
| 8 |
+
from app.auth import OwnerDep, _verify
|
| 9 |
from app.store import db
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
| 15 |
name: str
|
| 16 |
|
| 17 |
|
| 18 |
+
class MergeRequest(BaseModel):
|
| 19 |
+
guest_token: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
@router.post("/claim")
|
| 23 |
async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
|
| 24 |
"""Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
|
|
|
|
| 30 |
if claimed is None:
|
| 31 |
raise HTTPException(status_code=409, detail="name already claimed")
|
| 32 |
return {"claimed": claimed}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/merge")
|
| 36 |
+
async def merge(req: MergeRequest, owner_id: OwnerDep) -> dict:
|
| 37 |
+
"""Fold a guest (anonymous) account's data into the caller's account.
|
| 38 |
+
|
| 39 |
+
The caller's Bearer token is the destination. `guest_token` is the guest's
|
| 40 |
+
own ID token, captured client-side just before it signed into an existing
|
| 41 |
+
Google identity — it proves ownership of the source account. Source must be
|
| 42 |
+
anonymous so this can't be used to siphon a real account's data."""
|
| 43 |
+
token = req.guest_token.strip()
|
| 44 |
+
if not token:
|
| 45 |
+
raise HTTPException(status_code=422, detail="guest_token required")
|
| 46 |
+
try:
|
| 47 |
+
decoded = _verify(token)
|
| 48 |
+
except Exception:
|
| 49 |
+
raise HTTPException(status_code=401, detail="invalid guest token")
|
| 50 |
+
if decoded.get("firebase", {}).get("sign_in_provider") != "anonymous":
|
| 51 |
+
raise HTTPException(status_code=403, detail="source must be a guest account")
|
| 52 |
+
from_uid = str(decoded["uid"])
|
| 53 |
+
async with db.session() as s:
|
| 54 |
+
moved = await db.merge_owner(s, from_uid=from_uid, to_uid=owner_id)
|
| 55 |
+
return {"merged": moved}
|
backend/app/api/cards.py
CHANGED
|
@@ -33,6 +33,10 @@ router = APIRouter(prefix="/cards", tags=["cards"])
|
|
| 33 |
|
| 34 |
class CreateCardRequest(BaseModel):
|
| 35 |
url: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
class CreateCardResponse(BaseModel):
|
|
@@ -129,6 +133,9 @@ async def create_card(
|
|
| 129 |
)
|
| 130 |
|
| 131 |
within = await quota.card_budget(owner_id, request)
|
|
|
|
|
|
|
|
|
|
| 132 |
card = db.CardRow(
|
| 133 |
source_url=url,
|
| 134 |
platform=_platform_for(url),
|
|
@@ -138,11 +145,11 @@ async def create_card(
|
|
| 138 |
)
|
| 139 |
session.add(card)
|
| 140 |
await session.flush() # assign card.id
|
| 141 |
-
job = db.JobRow(card_id=card.id, state=JobState.QUEUED.value, degraded=
|
| 142 |
session.add(job)
|
| 143 |
await session.commit()
|
| 144 |
return CreateCardResponse(
|
| 145 |
-
card_id=card.id, state=CardState.QUEUED, quota_degraded=
|
| 146 |
)
|
| 147 |
|
| 148 |
|
|
@@ -226,9 +233,9 @@ async def import_cards(
|
|
| 226 |
|
| 227 |
|
| 228 |
@router.get("/{card_id}", response_model=Card)
|
| 229 |
-
async def get_card(card_id: str) -> Card:
|
| 230 |
async with db.session() as session:
|
| 231 |
-
row = await db.get_card_row(session, card_id)
|
| 232 |
if row is None:
|
| 233 |
raise HTTPException(status_code=404, detail="card not found")
|
| 234 |
return row.to_card()
|
|
@@ -258,9 +265,11 @@ async def list_cards(
|
|
| 258 |
|
| 259 |
|
| 260 |
@router.patch("/{card_id}", response_model=Card)
|
| 261 |
-
async def patch_card(
|
|
|
|
|
|
|
| 262 |
async with db.session() as session:
|
| 263 |
-
row = await db.get_card_row(session, card_id)
|
| 264 |
if row is None:
|
| 265 |
raise HTTPException(status_code=404, detail="card not found")
|
| 266 |
if req.blocks is not None:
|
|
@@ -268,6 +277,9 @@ async def patch_card(card_id: str, req: PatchCardRequest) -> Card:
|
|
| 268 |
if req.action_items is not None:
|
| 269 |
row.action_items = req.action_items # follow toggle + per-item done state
|
| 270 |
if req.collection_id is not None:
|
|
|
|
|
|
|
|
|
|
| 271 |
row.collection_id = req.collection_id
|
| 272 |
await session.commit()
|
| 273 |
await session.refresh(row)
|
|
@@ -289,7 +301,7 @@ async def chat_card(
|
|
| 289 |
raise HTTPException(status_code=422, detail="last message must be from user")
|
| 290 |
|
| 291 |
async with db.session() as session:
|
| 292 |
-
row = await db.get_card_row(session, card_id)
|
| 293 |
if row is None:
|
| 294 |
raise HTTPException(status_code=404, detail="card not found")
|
| 295 |
if row.state != CardState.READY.value:
|
|
@@ -350,7 +362,7 @@ async def explore_rabbithole(
|
|
| 350 |
root = (req.root or topic).strip()
|
| 351 |
|
| 352 |
async with db.session() as session:
|
| 353 |
-
row = await db.get_card_row(session, card_id)
|
| 354 |
if row is None:
|
| 355 |
raise HTTPException(status_code=404, detail="card not found")
|
| 356 |
if row.state != CardState.READY.value:
|
|
@@ -492,9 +504,9 @@ async def upload_structure(card_id: str, payload: dict, owner_id: OwnerDep) -> C
|
|
| 492 |
|
| 493 |
|
| 494 |
@router.delete("/{card_id}")
|
| 495 |
-
async def delete_card(card_id: str) -> dict:
|
| 496 |
async with db.session() as session:
|
| 497 |
-
row = await db.get_card_row(session, card_id)
|
| 498 |
if row is None:
|
| 499 |
raise HTTPException(status_code=404, detail="card not found")
|
| 500 |
thumb = row.thumbnail
|
|
|
|
| 33 |
|
| 34 |
class CreateCardRequest(BaseModel):
|
| 35 |
url: str
|
| 36 |
+
# Dev/testing: skip server-side LLM structuring and hand the raw bundle to
|
| 37 |
+
# the owner's device so the on-device model structures the card instead.
|
| 38 |
+
# Reuses the quota-degrade path (paragraph fallback + stored bundle).
|
| 39 |
+
prefer_local: bool = False
|
| 40 |
|
| 41 |
|
| 42 |
class CreateCardResponse(BaseModel):
|
|
|
|
| 133 |
)
|
| 134 |
|
| 135 |
within = await quota.card_budget(owner_id, request)
|
| 136 |
+
# Degrade (skip server LLM, keep the bundle for on-device structuring)
|
| 137 |
+
# when past quota OR when the client explicitly prefers the local model.
|
| 138 |
+
degraded = (not within) or req.prefer_local
|
| 139 |
card = db.CardRow(
|
| 140 |
source_url=url,
|
| 141 |
platform=_platform_for(url),
|
|
|
|
| 145 |
)
|
| 146 |
session.add(card)
|
| 147 |
await session.flush() # assign card.id
|
| 148 |
+
job = db.JobRow(card_id=card.id, state=JobState.QUEUED.value, degraded=degraded)
|
| 149 |
session.add(job)
|
| 150 |
await session.commit()
|
| 151 |
return CreateCardResponse(
|
| 152 |
+
card_id=card.id, state=CardState.QUEUED, quota_degraded=degraded
|
| 153 |
)
|
| 154 |
|
| 155 |
|
|
|
|
| 233 |
|
| 234 |
|
| 235 |
@router.get("/{card_id}", response_model=Card)
|
| 236 |
+
async def get_card(card_id: str, owner_id: OwnerDep) -> Card:
|
| 237 |
async with db.session() as session:
|
| 238 |
+
row = await db.get_card_row(session, card_id, owner_id=owner_id)
|
| 239 |
if row is None:
|
| 240 |
raise HTTPException(status_code=404, detail="card not found")
|
| 241 |
return row.to_card()
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
@router.patch("/{card_id}", response_model=Card)
|
| 268 |
+
async def patch_card(
|
| 269 |
+
card_id: str, req: PatchCardRequest, owner_id: OwnerDep
|
| 270 |
+
) -> Card:
|
| 271 |
async with db.session() as session:
|
| 272 |
+
row = await db.get_card_row(session, card_id, owner_id=owner_id)
|
| 273 |
if row is None:
|
| 274 |
raise HTTPException(status_code=404, detail="card not found")
|
| 275 |
if req.blocks is not None:
|
|
|
|
| 277 |
if req.action_items is not None:
|
| 278 |
row.action_items = req.action_items # follow toggle + per-item done state
|
| 279 |
if req.collection_id is not None:
|
| 280 |
+
dest = await db.get_collection_row(session, req.collection_id)
|
| 281 |
+
if dest is None or dest.owner_id != owner_id:
|
| 282 |
+
raise HTTPException(status_code=404, detail="collection not found")
|
| 283 |
row.collection_id = req.collection_id
|
| 284 |
await session.commit()
|
| 285 |
await session.refresh(row)
|
|
|
|
| 301 |
raise HTTPException(status_code=422, detail="last message must be from user")
|
| 302 |
|
| 303 |
async with db.session() as session:
|
| 304 |
+
row = await db.get_card_row(session, card_id, owner_id=owner_id)
|
| 305 |
if row is None:
|
| 306 |
raise HTTPException(status_code=404, detail="card not found")
|
| 307 |
if row.state != CardState.READY.value:
|
|
|
|
| 362 |
root = (req.root or topic).strip()
|
| 363 |
|
| 364 |
async with db.session() as session:
|
| 365 |
+
row = await db.get_card_row(session, card_id, owner_id=owner_id)
|
| 366 |
if row is None:
|
| 367 |
raise HTTPException(status_code=404, detail="card not found")
|
| 368 |
if row.state != CardState.READY.value:
|
|
|
|
| 504 |
|
| 505 |
|
| 506 |
@router.delete("/{card_id}")
|
| 507 |
+
async def delete_card(card_id: str, owner_id: OwnerDep) -> dict:
|
| 508 |
async with db.session() as session:
|
| 509 |
+
row = await db.get_card_row(session, card_id, owner_id=owner_id)
|
| 510 |
if row is None:
|
| 511 |
raise HTTPException(status_code=404, detail="card not found")
|
| 512 |
thumb = row.thumbnail
|
backend/app/api/catalog.py
CHANGED
|
@@ -36,48 +36,64 @@ async def list_catalog(
|
|
| 36 |
stmt = select(db.ArtifactRow).order_by(db.ArtifactRow.created_at.desc())
|
| 37 |
if type is not None:
|
| 38 |
stmt = stmt.where(db.ArtifactRow.type == type.value)
|
| 39 |
-
if card_id is None:
|
| 40 |
-
stmt = stmt.where(db.ArtifactRow.saved.is_(True))
|
| 41 |
rows = (await session.execute(stmt)).scalars().all()
|
| 42 |
-
entries = [r.to_entry() for r in rows]
|
| 43 |
owner_cards = set(
|
| 44 |
(await session.execute(
|
| 45 |
select(db.CardRow.id).where(db.CardRow.owner_id == owner_id)
|
| 46 |
)).scalars().all()
|
| 47 |
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return entries[offset : offset + limit]
|
| 52 |
|
| 53 |
|
| 54 |
@router.get("/{artifact_id}", response_model=CatalogDetail)
|
| 55 |
-
async def get_catalog_entry(artifact_id: str) -> CatalogDetail:
|
| 56 |
async with db.session() as session:
|
| 57 |
row = await session.get(db.ArtifactRow, artifact_id)
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 60 |
entry = row.to_entry()
|
|
|
|
| 61 |
return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids)
|
| 62 |
|
| 63 |
|
| 64 |
@router.post("/{artifact_id}/save", response_model=CatalogEntry)
|
| 65 |
-
async def save_catalog_entry(artifact_id: str) -> CatalogEntry:
|
| 66 |
"""Add a referenced artifact to the catalog tab (long-press to save)."""
|
| 67 |
async with db.session() as session:
|
| 68 |
-
row = await db.set_artifact_saved(session, artifact_id, True)
|
| 69 |
if row is None:
|
| 70 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 71 |
invalidate_graph_cache()
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
@router.post("/{artifact_id}/fetch-info", response_model=CatalogEntry)
|
| 76 |
-
async def fetch_catalog_info(artifact_id: str) -> CatalogEntry:
|
| 77 |
"""Generate + persist an LLM detail for the artifact (Fetch info button)."""
|
| 78 |
async with db.session() as session:
|
| 79 |
row = await session.get(db.ArtifactRow, artifact_id)
|
| 80 |
-
if row is None
|
|
|
|
|
|
|
| 81 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 82 |
type_ = ArtifactType(row.type) if row.type else ArtifactType.OTHER
|
| 83 |
desc = await llm_catalog.describe_async(
|
|
@@ -87,16 +103,20 @@ async def fetch_catalog_info(artifact_id: str) -> CatalogEntry:
|
|
| 87 |
raise HTTPException(
|
| 88 |
status_code=502, detail="could not generate details right now"
|
| 89 |
)
|
| 90 |
-
row = await db.set_artifact_description(
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.delete("/{artifact_id}")
|
| 95 |
-
async def delete_catalog_entry(artifact_id: str) -> dict:
|
| 96 |
"""Remove an item from the catalog. Soft by default: the row stays so it still
|
| 97 |
backs per-card references — it just leaves the catalog tab (saved=False)."""
|
| 98 |
async with db.session() as session:
|
| 99 |
-
row = await db.set_artifact_saved(session, artifact_id, False)
|
| 100 |
if row is None:
|
| 101 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 102 |
await session.commit()
|
|
|
|
| 36 |
stmt = select(db.ArtifactRow).order_by(db.ArtifactRow.created_at.desc())
|
| 37 |
if type is not None:
|
| 38 |
stmt = stmt.where(db.ArtifactRow.type == type.value)
|
|
|
|
|
|
|
| 39 |
rows = (await session.execute(stmt)).scalars().all()
|
|
|
|
| 40 |
owner_cards = set(
|
| 41 |
(await session.execute(
|
| 42 |
select(db.CardRow.id).where(db.CardRow.owner_id == owner_id)
|
| 43 |
)).scalars().all()
|
| 44 |
)
|
| 45 |
+
saved_ids = await db.owner_saved_artifact_ids(session, owner_id)
|
| 46 |
+
entries: list[CatalogEntry] = []
|
| 47 |
+
for r in rows:
|
| 48 |
+
source = set(r.source_card_ids or [])
|
| 49 |
+
if not (source & owner_cards):
|
| 50 |
+
continue # not the caller's artifact
|
| 51 |
+
if card_id is None and r.id not in saved_ids:
|
| 52 |
+
continue # catalog tab shows only what this owner saved
|
| 53 |
+
if card_id is not None and card_id not in source:
|
| 54 |
+
continue # reference view for a specific card
|
| 55 |
+
entry = r.to_entry()
|
| 56 |
+
entry.saved = r.id in saved_ids
|
| 57 |
+
entries.append(entry)
|
| 58 |
return entries[offset : offset + limit]
|
| 59 |
|
| 60 |
|
| 61 |
@router.get("/{artifact_id}", response_model=CatalogDetail)
|
| 62 |
+
async def get_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> CatalogDetail:
|
| 63 |
async with db.session() as session:
|
| 64 |
row = await session.get(db.ArtifactRow, artifact_id)
|
| 65 |
+
# Membership implies ownership (it's only created after an ownership check),
|
| 66 |
+
# and the detail view is for catalog items — so gate on the saved membership.
|
| 67 |
+
if row is None or not await db.is_artifact_saved_by(
|
| 68 |
+
session, artifact_id, owner_id
|
| 69 |
+
):
|
| 70 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 71 |
entry = row.to_entry()
|
| 72 |
+
entry.saved = True
|
| 73 |
return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids)
|
| 74 |
|
| 75 |
|
| 76 |
@router.post("/{artifact_id}/save", response_model=CatalogEntry)
|
| 77 |
+
async def save_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> CatalogEntry:
|
| 78 |
"""Add a referenced artifact to the catalog tab (long-press to save)."""
|
| 79 |
async with db.session() as session:
|
| 80 |
+
row = await db.set_artifact_saved(session, artifact_id, True, owner_id=owner_id)
|
| 81 |
if row is None:
|
| 82 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 83 |
invalidate_graph_cache()
|
| 84 |
+
entry = row.to_entry()
|
| 85 |
+
entry.saved = True
|
| 86 |
+
return entry
|
| 87 |
|
| 88 |
|
| 89 |
@router.post("/{artifact_id}/fetch-info", response_model=CatalogEntry)
|
| 90 |
+
async def fetch_catalog_info(artifact_id: str, owner_id: OwnerDep) -> CatalogEntry:
|
| 91 |
"""Generate + persist an LLM detail for the artifact (Fetch info button)."""
|
| 92 |
async with db.session() as session:
|
| 93 |
row = await session.get(db.ArtifactRow, artifact_id)
|
| 94 |
+
if row is None or not await db.owner_owns_any_card(
|
| 95 |
+
session, owner_id, row.source_card_ids or []
|
| 96 |
+
):
|
| 97 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 98 |
type_ = ArtifactType(row.type) if row.type else ArtifactType.OTHER
|
| 99 |
desc = await llm_catalog.describe_async(
|
|
|
|
| 103 |
raise HTTPException(
|
| 104 |
status_code=502, detail="could not generate details right now"
|
| 105 |
)
|
| 106 |
+
row = await db.set_artifact_description(
|
| 107 |
+
session, artifact_id, desc, owner_id=owner_id
|
| 108 |
+
)
|
| 109 |
+
entry = row.to_entry()
|
| 110 |
+
entry.saved = await db.is_artifact_saved_by(session, artifact_id, owner_id)
|
| 111 |
+
return entry
|
| 112 |
|
| 113 |
|
| 114 |
@router.delete("/{artifact_id}")
|
| 115 |
+
async def delete_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> dict:
|
| 116 |
"""Remove an item from the catalog. Soft by default: the row stays so it still
|
| 117 |
backs per-card references — it just leaves the catalog tab (saved=False)."""
|
| 118 |
async with db.session() as session:
|
| 119 |
+
row = await db.set_artifact_saved(session, artifact_id, False, owner_id=owner_id)
|
| 120 |
if row is None:
|
| 121 |
raise HTTPException(status_code=404, detail="artifact not found")
|
| 122 |
await session.commit()
|
backend/app/api/collections.py
CHANGED
|
@@ -69,13 +69,13 @@ async def create_collection(
|
|
| 69 |
|
| 70 |
@router.patch("/{collection_id}", response_model=CollectionOut)
|
| 71 |
async def rename_collection(
|
| 72 |
-
collection_id: str, req: RenameCollectionRequest
|
| 73 |
) -> CollectionOut:
|
| 74 |
name = req.name.strip()
|
| 75 |
if not name:
|
| 76 |
raise HTTPException(status_code=422, detail="name is required")
|
| 77 |
async with db.session() as session:
|
| 78 |
-
row = await db.rename_collection(session, collection_id, name)
|
| 79 |
if row is None:
|
| 80 |
raise HTTPException(status_code=404, detail="collection not found")
|
| 81 |
pairs = await db.list_collections(session, owner_id=row.owner_id)
|
|
@@ -84,9 +84,9 @@ async def rename_collection(
|
|
| 84 |
|
| 85 |
|
| 86 |
@router.delete("/{collection_id}", response_class=Response, status_code=204)
|
| 87 |
-
async def delete_collection(collection_id: str) -> Response:
|
| 88 |
async with db.session() as session:
|
| 89 |
-
ok = await db.delete_collection(session, collection_id)
|
| 90 |
if not ok:
|
| 91 |
raise HTTPException(
|
| 92 |
status_code=404,
|
|
@@ -96,9 +96,13 @@ async def delete_collection(collection_id: str) -> Response:
|
|
| 96 |
|
| 97 |
|
| 98 |
@router.post("/cards/{card_id}/move", response_model=dict)
|
| 99 |
-
async def move_card(
|
|
|
|
|
|
|
| 100 |
async with db.session() as session:
|
| 101 |
-
row = await db.move_card_to_collection(
|
|
|
|
|
|
|
| 102 |
if row is None:
|
| 103 |
raise HTTPException(status_code=404, detail="card not found")
|
| 104 |
return {"card_id": card_id, "collection_id": row.collection_id}
|
|
|
|
| 69 |
|
| 70 |
@router.patch("/{collection_id}", response_model=CollectionOut)
|
| 71 |
async def rename_collection(
|
| 72 |
+
collection_id: str, req: RenameCollectionRequest, owner_id: OwnerDep
|
| 73 |
) -> CollectionOut:
|
| 74 |
name = req.name.strip()
|
| 75 |
if not name:
|
| 76 |
raise HTTPException(status_code=422, detail="name is required")
|
| 77 |
async with db.session() as session:
|
| 78 |
+
row = await db.rename_collection(session, collection_id, name, owner_id=owner_id)
|
| 79 |
if row is None:
|
| 80 |
raise HTTPException(status_code=404, detail="collection not found")
|
| 81 |
pairs = await db.list_collections(session, owner_id=row.owner_id)
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
@router.delete("/{collection_id}", response_class=Response, status_code=204)
|
| 87 |
+
async def delete_collection(collection_id: str, owner_id: OwnerDep) -> Response:
|
| 88 |
async with db.session() as session:
|
| 89 |
+
ok = await db.delete_collection(session, collection_id, owner_id=owner_id)
|
| 90 |
if not ok:
|
| 91 |
raise HTTPException(
|
| 92 |
status_code=404,
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
@router.post("/cards/{card_id}/move", response_model=dict)
|
| 99 |
+
async def move_card(
|
| 100 |
+
card_id: str, req: MoveCardRequest, owner_id: OwnerDep
|
| 101 |
+
) -> dict:
|
| 102 |
async with db.session() as session:
|
| 103 |
+
row = await db.move_card_to_collection(
|
| 104 |
+
session, card_id, req.collection_id, owner_id=owner_id
|
| 105 |
+
)
|
| 106 |
if row is None:
|
| 107 |
raise HTTPException(status_code=404, detail="card not found")
|
| 108 |
return {"card_id": card_id, "collection_id": row.collection_id}
|
backend/app/api/concepts.py
CHANGED
|
@@ -77,11 +77,13 @@ async def get_concept(
|
|
| 77 |
|
| 78 |
|
| 79 |
@router.post("/{concept_id}/define", response_model=ConceptEntry)
|
| 80 |
-
async def define_concept(concept_id: str) -> ConceptEntry:
|
| 81 |
"""Generate + persist an LLM definition for a concept."""
|
| 82 |
async with db.session() as session:
|
| 83 |
row = await session.get(db.ConceptRow, concept_id)
|
| 84 |
-
if row is None
|
|
|
|
|
|
|
| 85 |
raise HTTPException(status_code=404, detail="concept not found")
|
| 86 |
definition = await llm_concept.define_async(row.name)
|
| 87 |
if not definition:
|
|
@@ -94,12 +96,10 @@ async def define_concept(concept_id: str) -> ConceptEntry:
|
|
| 94 |
|
| 95 |
|
| 96 |
@router.delete("/{concept_id}")
|
| 97 |
-
async def delete_concept(concept_id: str) -> dict:
|
| 98 |
async with db.session() as session:
|
| 99 |
-
|
| 100 |
-
if
|
| 101 |
raise HTTPException(status_code=404, detail="concept not found")
|
| 102 |
-
await session.delete(row)
|
| 103 |
-
await session.commit()
|
| 104 |
invalidate_graph_cache()
|
| 105 |
return {"removed": concept_id}
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
@router.post("/{concept_id}/define", response_model=ConceptEntry)
|
| 80 |
+
async def define_concept(concept_id: str, owner_id: OwnerDep) -> ConceptEntry:
|
| 81 |
"""Generate + persist an LLM definition for a concept."""
|
| 82 |
async with db.session() as session:
|
| 83 |
row = await session.get(db.ConceptRow, concept_id)
|
| 84 |
+
if row is None or not await db.owner_owns_any_card(
|
| 85 |
+
session, owner_id, row.source_card_ids or []
|
| 86 |
+
):
|
| 87 |
raise HTTPException(status_code=404, detail="concept not found")
|
| 88 |
definition = await llm_concept.define_async(row.name)
|
| 89 |
if not definition:
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
@router.delete("/{concept_id}")
|
| 99 |
+
async def delete_concept(concept_id: str, owner_id: OwnerDep) -> dict:
|
| 100 |
async with db.session() as session:
|
| 101 |
+
ok = await db.remove_owner_from_concept(session, concept_id, owner_id)
|
| 102 |
+
if not ok:
|
| 103 |
raise HTTPException(status_code=404, detail="concept not found")
|
|
|
|
|
|
|
| 104 |
invalidate_graph_cache()
|
| 105 |
return {"removed": concept_id}
|
backend/app/api/graph.py
CHANGED
|
@@ -128,9 +128,9 @@ async def _data_fingerprint(owner_id: str | None) -> str:
|
|
| 128 |
card_agg = (await session.execute(card_stmt)).one()
|
| 129 |
art_agg = (
|
| 130 |
await session.execute(
|
| 131 |
-
select(
|
| 132 |
-
|
| 133 |
-
)
|
| 134 |
)
|
| 135 |
).one()
|
| 136 |
col_count = (
|
|
@@ -324,15 +324,13 @@ async def get_graph(
|
|
| 324 |
|
| 325 |
user_card_ids = {r.id for r in card_rows}
|
| 326 |
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
if bool(set(a.source_card_ids or []) & user_card_ids)
|
| 335 |
-
]
|
| 336 |
|
| 337 |
all_concept_rows = (await session.execute(select(db.ConceptRow))).scalars().all()
|
| 338 |
concept_rows = [
|
|
|
|
| 128 |
card_agg = (await session.execute(card_stmt)).one()
|
| 129 |
art_agg = (
|
| 130 |
await session.execute(
|
| 131 |
+
select(
|
| 132 |
+
func.count(), func.max(db.ArtifactSaveRow.created_at)
|
| 133 |
+
).where(db.ArtifactSaveRow.owner_id == owner_id)
|
| 134 |
)
|
| 135 |
).one()
|
| 136 |
col_count = (
|
|
|
|
| 324 |
|
| 325 |
user_card_ids = {r.id for r in card_rows}
|
| 326 |
|
| 327 |
+
saved_ids = await db.owner_saved_artifact_ids(session, owner_id)
|
| 328 |
+
artifact_rows = (
|
| 329 |
+
(await session.execute(
|
| 330 |
+
select(db.ArtifactRow).where(db.ArtifactRow.id.in_(saved_ids))
|
| 331 |
+
)).scalars().all()
|
| 332 |
+
if saved_ids else []
|
| 333 |
+
)
|
|
|
|
|
|
|
| 334 |
|
| 335 |
all_concept_rows = (await session.execute(select(db.ConceptRow))).scalars().all()
|
| 336 |
concept_rows = [
|
backend/app/api/library_chat.py
CHANGED
|
@@ -56,7 +56,7 @@ async def library_chat(
|
|
| 56 |
raise HTTPException(status_code=422, detail="last message must be from user")
|
| 57 |
|
| 58 |
history = [m.model_dump() for m in req.messages]
|
| 59 |
-
result = await llm_library_chat.answer(history)
|
| 60 |
if result is None:
|
| 61 |
raise HTTPException(
|
| 62 |
status_code=503, detail="chat is unavailable (no LLM backend configured)"
|
|
|
|
| 56 |
raise HTTPException(status_code=422, detail="last message must be from user")
|
| 57 |
|
| 58 |
history = [m.model_dump() for m in req.messages]
|
| 59 |
+
result = await llm_library_chat.answer(history, owner_id)
|
| 60 |
if result is None:
|
| 61 |
raise HTTPException(
|
| 62 |
status_code=503, detail="chat is unavailable (no LLM backend configured)"
|
backend/app/services/llm_library_chat.py
CHANGED
|
@@ -42,23 +42,26 @@ Use it sparingly. No other markdown (no headers, no links).
|
|
| 42 |
--- END CARDS ---"""
|
| 43 |
|
| 44 |
|
| 45 |
-
async def retrieve(question: str, limit: int = _TOP_K) -> list[Card]:
|
| 46 |
-
"""Top cards for a question
|
|
|
|
| 47 |
if embeddings.embeddings_enabled():
|
| 48 |
-
semantic = await _retrieve_semantic(question, limit)
|
| 49 |
if semantic:
|
| 50 |
return semantic
|
| 51 |
-
return await _retrieve_text(question, limit)
|
| 52 |
|
| 53 |
|
| 54 |
-
async def _retrieve_semantic(question: str, limit: int) -> list[Card]:
|
| 55 |
query_vec = embeddings.embed(question)
|
| 56 |
if not query_vec:
|
| 57 |
return []
|
| 58 |
async with db.session() as session:
|
| 59 |
rows = (
|
| 60 |
await session.execute(
|
| 61 |
-
select(db.CardRow)
|
|
|
|
|
|
|
| 62 |
)
|
| 63 |
).scalars().all()
|
| 64 |
scored = [
|
|
@@ -71,13 +74,14 @@ async def _retrieve_semantic(question: str, limit: int) -> list[Card]:
|
|
| 71 |
return [r.to_card() for _, r in scored[:limit]]
|
| 72 |
|
| 73 |
|
| 74 |
-
async def _retrieve_text(question: str, limit: int) -> list[Card]:
|
| 75 |
needle = f"%{question.lower()}%"
|
| 76 |
async with db.session() as session:
|
| 77 |
rows = (
|
| 78 |
await session.execute(
|
| 79 |
select(db.CardRow)
|
| 80 |
.where(db.CardRow.state == CardState.READY.value)
|
|
|
|
| 81 |
.where(
|
| 82 |
or_(
|
| 83 |
db.CardRow.one_liner.ilike(needle),
|
|
@@ -98,6 +102,7 @@ async def _retrieve_text(question: str, limit: int) -> list[Card]:
|
|
| 98 |
await session.execute(
|
| 99 |
select(db.CardRow)
|
| 100 |
.where(db.CardRow.state == CardState.READY.value)
|
|
|
|
| 101 |
.order_by(db.CardRow.created_at.desc())
|
| 102 |
.limit(limit)
|
| 103 |
)
|
|
@@ -126,10 +131,12 @@ def _latest_question(history: list[dict]) -> str:
|
|
| 126 |
return ""
|
| 127 |
|
| 128 |
|
| 129 |
-
async def answer(
|
|
|
|
|
|
|
| 130 |
"""Return (reply, source_cards) or None if no LLM backend is configured.
|
| 131 |
-
Retrieval is grounded on the latest user question."""
|
| 132 |
-
cards = await retrieve(_latest_question(history))
|
| 133 |
reply = await _generate(_context(cards), history)
|
| 134 |
if reply is None:
|
| 135 |
return None
|
|
|
|
| 42 |
--- END CARDS ---"""
|
| 43 |
|
| 44 |
|
| 45 |
+
async def retrieve(question: str, owner_id: str, limit: int = _TOP_K) -> list[Card]:
|
| 46 |
+
"""Top cards for a question, scoped to this owner: semantic when embeddings
|
| 47 |
+
are on, else full-text."""
|
| 48 |
if embeddings.embeddings_enabled():
|
| 49 |
+
semantic = await _retrieve_semantic(question, owner_id, limit)
|
| 50 |
if semantic:
|
| 51 |
return semantic
|
| 52 |
+
return await _retrieve_text(question, owner_id, limit)
|
| 53 |
|
| 54 |
|
| 55 |
+
async def _retrieve_semantic(question: str, owner_id: str, limit: int) -> list[Card]:
|
| 56 |
query_vec = embeddings.embed(question)
|
| 57 |
if not query_vec:
|
| 58 |
return []
|
| 59 |
async with db.session() as session:
|
| 60 |
rows = (
|
| 61 |
await session.execute(
|
| 62 |
+
select(db.CardRow)
|
| 63 |
+
.where(db.CardRow.state == CardState.READY.value)
|
| 64 |
+
.where(db.CardRow.owner_id == owner_id)
|
| 65 |
)
|
| 66 |
).scalars().all()
|
| 67 |
scored = [
|
|
|
|
| 74 |
return [r.to_card() for _, r in scored[:limit]]
|
| 75 |
|
| 76 |
|
| 77 |
+
async def _retrieve_text(question: str, owner_id: str, limit: int) -> list[Card]:
|
| 78 |
needle = f"%{question.lower()}%"
|
| 79 |
async with db.session() as session:
|
| 80 |
rows = (
|
| 81 |
await session.execute(
|
| 82 |
select(db.CardRow)
|
| 83 |
.where(db.CardRow.state == CardState.READY.value)
|
| 84 |
+
.where(db.CardRow.owner_id == owner_id)
|
| 85 |
.where(
|
| 86 |
or_(
|
| 87 |
db.CardRow.one_liner.ilike(needle),
|
|
|
|
| 102 |
await session.execute(
|
| 103 |
select(db.CardRow)
|
| 104 |
.where(db.CardRow.state == CardState.READY.value)
|
| 105 |
+
.where(db.CardRow.owner_id == owner_id)
|
| 106 |
.order_by(db.CardRow.created_at.desc())
|
| 107 |
.limit(limit)
|
| 108 |
)
|
|
|
|
| 131 |
return ""
|
| 132 |
|
| 133 |
|
| 134 |
+
async def answer(
|
| 135 |
+
history: list[dict], owner_id: str
|
| 136 |
+
) -> tuple[str, list[Card]] | None:
|
| 137 |
"""Return (reply, source_cards) or None if no LLM backend is configured.
|
| 138 |
+
Retrieval is grounded on the latest user question, scoped to this owner."""
|
| 139 |
+
cards = await retrieve(_latest_question(history), owner_id)
|
| 140 |
reply = await _generate(_context(cards), history)
|
| 141 |
if reply is None:
|
| 142 |
return None
|
backend/app/store/db.py
CHANGED
|
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
|
| 16 |
Integer,
|
| 17 |
String,
|
| 18 |
Text,
|
|
|
|
| 19 |
func,
|
| 20 |
inspect as sa_inspect,
|
| 21 |
select,
|
|
@@ -191,7 +192,9 @@ class ArtifactRow(Base):
|
|
| 191 |
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 192 |
thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 193 |
source_card_ids: Mapped[list] = mapped_column(JSON, default=list)
|
| 194 |
-
#
|
|
|
|
|
|
|
| 195 |
saved: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
| 196 |
# On-demand LLM detail ("what is this"), filled via the Fetch info action.
|
| 197 |
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
@@ -215,6 +218,18 @@ class ArtifactRow(Base):
|
|
| 215 |
)
|
| 216 |
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
class ConceptRow(Base):
|
| 219 |
"""A deduplicated concept node: one evergreen idea, many source cards.
|
| 220 |
Dedupe key is name_norm (no type axis). Mirrors ArtifactRow."""
|
|
@@ -373,6 +388,23 @@ async def claim_owner(db_session: AsyncSession, *, name: str, uid: str) -> int |
|
|
| 373 |
return total
|
| 374 |
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
# --------------------------------------------------------------------------- #
|
| 377 |
# Engine / session lifecycle
|
| 378 |
# --------------------------------------------------------------------------- #
|
|
@@ -509,8 +541,33 @@ def session() -> AsyncSession:
|
|
| 509 |
# Convenience queries used across the app
|
| 510 |
# --------------------------------------------------------------------------- #
|
| 511 |
|
| 512 |
-
async def get_card_row(
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
|
| 516 |
async def find_card_by_url(
|
|
@@ -536,10 +593,12 @@ async def upsert_artifact(
|
|
| 536 |
creator: str | None,
|
| 537 |
year: int | None,
|
| 538 |
thumbnail: str | None,
|
| 539 |
-
saved: bool = False,
|
| 540 |
) -> ArtifactRow:
|
| 541 |
"""Insert a catalog item or merge into the existing one (dedupe by type+title).
|
| 542 |
-
Appends card_id to source_card_ids and backfills a missing thumbnail.
|
|
|
|
|
|
|
|
|
|
| 543 |
norm = _norm_title(title)
|
| 544 |
res = await db.execute(
|
| 545 |
select(ArtifactRow).where(
|
|
@@ -556,12 +615,9 @@ async def upsert_artifact(
|
|
| 556 |
year=year,
|
| 557 |
thumbnail=thumbnail,
|
| 558 |
source_card_ids=[card_id],
|
| 559 |
-
saved=saved,
|
| 560 |
)
|
| 561 |
db.add(row)
|
| 562 |
else:
|
| 563 |
-
if saved:
|
| 564 |
-
row.saved = True
|
| 565 |
if card_id not in (row.source_card_ids or []):
|
| 566 |
row.source_card_ids = [*(row.source_card_ids or []), card_id]
|
| 567 |
flag_modified(row, "source_card_ids")
|
|
@@ -576,25 +632,53 @@ async def upsert_artifact(
|
|
| 576 |
|
| 577 |
|
| 578 |
async def set_artifact_saved(
|
| 579 |
-
db: AsyncSession, artifact_id: str, saved: bool
|
| 580 |
) -> ArtifactRow | None:
|
| 581 |
-
"""
|
| 582 |
-
|
|
|
|
| 583 |
row = await db.get(ArtifactRow, artifact_id)
|
| 584 |
-
if row is None
|
|
|
|
|
|
|
| 585 |
return None
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
await db.commit()
|
| 588 |
return row
|
| 589 |
|
| 590 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
async def set_artifact_description(
|
| 592 |
-
db: AsyncSession, artifact_id: str, description: str
|
|
|
|
| 593 |
) -> ArtifactRow | None:
|
| 594 |
"""Persist the on-demand LLM detail for an artifact (Fetch info)."""
|
| 595 |
row = await db.get(ArtifactRow, artifact_id)
|
| 596 |
if row is None:
|
| 597 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
row.description = description
|
| 599 |
await db.commit()
|
| 600 |
return row
|
|
@@ -631,6 +715,12 @@ async def get_or_create_collection(
|
|
| 631 |
return row
|
| 632 |
|
| 633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
async def list_collections(
|
| 635 |
db_session: AsyncSession, owner_id: str | None
|
| 636 |
) -> list[tuple[CollectionRow, int]]:
|
|
@@ -668,10 +758,11 @@ async def create_custom_collection(
|
|
| 668 |
|
| 669 |
|
| 670 |
async def rename_collection(
|
| 671 |
-
db_session: AsyncSession, collection_id: str, name: str
|
|
|
|
| 672 |
) -> CollectionRow | None:
|
| 673 |
row = await db_session.get(CollectionRow, collection_id)
|
| 674 |
-
if row is None:
|
| 675 |
return None
|
| 676 |
row.name = name.strip()
|
| 677 |
await db_session.commit()
|
|
@@ -679,12 +770,15 @@ async def rename_collection(
|
|
| 679 |
|
| 680 |
|
| 681 |
async def delete_collection(
|
| 682 |
-
db_session: AsyncSession, collection_id: str
|
| 683 |
) -> bool:
|
| 684 |
-
"""Delete a custom collection; returns False if not found
|
|
|
|
| 685 |
row = await db_session.get(CollectionRow, collection_id)
|
| 686 |
if row is None or not row.is_custom:
|
| 687 |
return False
|
|
|
|
|
|
|
| 688 |
# Detach cards from this collection before deleting.
|
| 689 |
from sqlalchemy import update as sa_update
|
| 690 |
await db_session.execute(
|
|
@@ -698,11 +792,16 @@ async def delete_collection(
|
|
| 698 |
|
| 699 |
|
| 700 |
async def move_card_to_collection(
|
| 701 |
-
db_session: AsyncSession, card_id: str, collection_id: str | None
|
|
|
|
| 702 |
) -> CardRow | None:
|
| 703 |
row = await db_session.get(CardRow, card_id)
|
| 704 |
-
if row is None:
|
| 705 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
row.collection_id = collection_id
|
| 707 |
await db_session.commit()
|
| 708 |
return row
|
|
@@ -778,6 +877,35 @@ async def set_concept_definition(
|
|
| 778 |
return row
|
| 779 |
|
| 780 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
async def cleanup_after_card_deletion(
|
| 782 |
db: AsyncSession, card_id: str
|
| 783 |
) -> None:
|
|
@@ -805,6 +933,11 @@ async def cleanup_after_card_deletion(
|
|
| 805 |
if card_id in current_ids:
|
| 806 |
new_ids = [c for c in current_ids if c != card_id]
|
| 807 |
if not new_ids:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
await db.delete(art)
|
| 809 |
else:
|
| 810 |
art.source_card_ids = new_ids
|
|
|
|
| 16 |
Integer,
|
| 17 |
String,
|
| 18 |
Text,
|
| 19 |
+
delete,
|
| 20 |
func,
|
| 21 |
inspect as sa_inspect,
|
| 22 |
select,
|
|
|
|
| 192 |
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 193 |
thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 194 |
source_card_ids: Mapped[list] = mapped_column(JSON, default=list)
|
| 195 |
+
# Deprecated: catalog-tab membership is now per-owner (see [ArtifactSaveRow]).
|
| 196 |
+
# Kept so old rows/DBs still load; no longer read for filtering. Always False.
|
| 197 |
+
# ponytail: leave the dead column, drop it in a later real migration.
|
| 198 |
saved: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
| 199 |
# On-demand LLM detail ("what is this"), filled via the Fetch info action.
|
| 200 |
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
| 218 |
)
|
| 219 |
|
| 220 |
|
| 221 |
+
class ArtifactSaveRow(Base):
|
| 222 |
+
"""Per-owner catalog membership: one row = this owner saved this artifact into
|
| 223 |
+
their catalog tab. Replaces the shared artifacts.saved flag so one user
|
| 224 |
+
saving/removing a catalog item never touches another user's catalog."""
|
| 225 |
+
|
| 226 |
+
__tablename__ = "artifact_saves"
|
| 227 |
+
|
| 228 |
+
owner_id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 229 |
+
artifact_id: Mapped[str] = mapped_column(String, primary_key=True, index=True)
|
| 230 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
class ConceptRow(Base):
|
| 234 |
"""A deduplicated concept node: one evergreen idea, many source cards.
|
| 235 |
Dedupe key is name_norm (no type axis). Mirrors ArtifactRow."""
|
|
|
|
| 388 |
return total
|
| 389 |
|
| 390 |
|
| 391 |
+
async def merge_owner(db_session: AsyncSession, *, from_uid: str, to_uid: str) -> int:
|
| 392 |
+
"""Fold a guest account's rows into another account: re-point everything
|
| 393 |
+
owned by `from_uid` to `to_uid`. Used when a guest signs into a Google
|
| 394 |
+
account that already has a Cachy identity, so linking isn't possible and the
|
| 395 |
+
guest's data would otherwise be orphaned. Returns rows moved."""
|
| 396 |
+
if from_uid == to_uid:
|
| 397 |
+
return 0
|
| 398 |
+
total = 0
|
| 399 |
+
for model in (CardRow, CollectionRow, ConversationRow, ConnectionRow):
|
| 400 |
+
res = await db_session.execute(
|
| 401 |
+
update(model).where(model.owner_id == from_uid).values(owner_id=to_uid)
|
| 402 |
+
)
|
| 403 |
+
total += res.rowcount or 0
|
| 404 |
+
await db_session.commit()
|
| 405 |
+
return total
|
| 406 |
+
|
| 407 |
+
|
| 408 |
# --------------------------------------------------------------------------- #
|
| 409 |
# Engine / session lifecycle
|
| 410 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 541 |
# Convenience queries used across the app
|
| 542 |
# --------------------------------------------------------------------------- #
|
| 543 |
|
| 544 |
+
async def get_card_row(
|
| 545 |
+
db: AsyncSession, card_id: str, owner_id: str | None = None
|
| 546 |
+
) -> CardRow | None:
|
| 547 |
+
"""Fetch a card by id. When `owner_id` is given, a card owned by anyone else
|
| 548 |
+
reads as absent (None) — so callers can 404 without leaking existence."""
|
| 549 |
+
row = await db.get(CardRow, card_id)
|
| 550 |
+
if row is None:
|
| 551 |
+
return None
|
| 552 |
+
if owner_id is not None and row.owner_id != owner_id:
|
| 553 |
+
return None
|
| 554 |
+
return row
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
async def owner_owns_any_card(
|
| 558 |
+
db: AsyncSession, owner_id: str, card_ids: list[str] | set[str]
|
| 559 |
+
) -> bool:
|
| 560 |
+
"""True when `owner_id` owns at least one of `card_ids`. Used to gate access
|
| 561 |
+
to shared artifact/concept rows, which are owned only through their source
|
| 562 |
+
cards (no owner_id column of their own)."""
|
| 563 |
+
if not card_ids:
|
| 564 |
+
return False
|
| 565 |
+
res = await db.execute(
|
| 566 |
+
select(CardRow.id)
|
| 567 |
+
.where(CardRow.owner_id == owner_id, CardRow.id.in_(list(card_ids)))
|
| 568 |
+
.limit(1)
|
| 569 |
+
)
|
| 570 |
+
return res.scalar_one_or_none() is not None
|
| 571 |
|
| 572 |
|
| 573 |
async def find_card_by_url(
|
|
|
|
| 593 |
creator: str | None,
|
| 594 |
year: int | None,
|
| 595 |
thumbnail: str | None,
|
|
|
|
| 596 |
) -> ArtifactRow:
|
| 597 |
"""Insert a catalog item or merge into the existing one (dedupe by type+title).
|
| 598 |
+
Appends card_id to source_card_ids and backfills a missing thumbnail.
|
| 599 |
+
|
| 600 |
+
Catalog-tab membership is per-owner (see [ArtifactSaveRow]); this only records
|
| 601 |
+
the shared reference, never who saved it."""
|
| 602 |
norm = _norm_title(title)
|
| 603 |
res = await db.execute(
|
| 604 |
select(ArtifactRow).where(
|
|
|
|
| 615 |
year=year,
|
| 616 |
thumbnail=thumbnail,
|
| 617 |
source_card_ids=[card_id],
|
|
|
|
| 618 |
)
|
| 619 |
db.add(row)
|
| 620 |
else:
|
|
|
|
|
|
|
| 621 |
if card_id not in (row.source_card_ids or []):
|
| 622 |
row.source_card_ids = [*(row.source_card_ids or []), card_id]
|
| 623 |
flag_modified(row, "source_card_ids")
|
|
|
|
| 632 |
|
| 633 |
|
| 634 |
async def set_artifact_saved(
|
| 635 |
+
db: AsyncSession, artifact_id: str, saved: bool, owner_id: str
|
| 636 |
) -> ArtifactRow | None:
|
| 637 |
+
"""Add/remove this owner's catalog membership for an artifact. The caller must
|
| 638 |
+
own one of the artifact's source cards, else the row reads as absent (None).
|
| 639 |
+
Unsaving keeps the shared row (it still backs per-card references)."""
|
| 640 |
row = await db.get(ArtifactRow, artifact_id)
|
| 641 |
+
if row is None or not await owner_owns_any_card(
|
| 642 |
+
db, owner_id, row.source_card_ids or []
|
| 643 |
+
):
|
| 644 |
return None
|
| 645 |
+
membership = await db.get(ArtifactSaveRow, (owner_id, artifact_id))
|
| 646 |
+
if saved and membership is None:
|
| 647 |
+
db.add(ArtifactSaveRow(owner_id=owner_id, artifact_id=artifact_id))
|
| 648 |
+
elif not saved and membership is not None:
|
| 649 |
+
await db.delete(membership)
|
| 650 |
await db.commit()
|
| 651 |
return row
|
| 652 |
|
| 653 |
|
| 654 |
+
async def owner_saved_artifact_ids(db: AsyncSession, owner_id: str) -> set[str]:
|
| 655 |
+
"""Artifact ids this owner has saved into their catalog tab."""
|
| 656 |
+
res = await db.execute(
|
| 657 |
+
select(ArtifactSaveRow.artifact_id).where(
|
| 658 |
+
ArtifactSaveRow.owner_id == owner_id
|
| 659 |
+
)
|
| 660 |
+
)
|
| 661 |
+
return set(res.scalars().all())
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
async def is_artifact_saved_by(
|
| 665 |
+
db: AsyncSession, artifact_id: str, owner_id: str
|
| 666 |
+
) -> bool:
|
| 667 |
+
return await db.get(ArtifactSaveRow, (owner_id, artifact_id)) is not None
|
| 668 |
+
|
| 669 |
+
|
| 670 |
async def set_artifact_description(
|
| 671 |
+
db: AsyncSession, artifact_id: str, description: str,
|
| 672 |
+
owner_id: str | None = None,
|
| 673 |
) -> ArtifactRow | None:
|
| 674 |
"""Persist the on-demand LLM detail for an artifact (Fetch info)."""
|
| 675 |
row = await db.get(ArtifactRow, artifact_id)
|
| 676 |
if row is None:
|
| 677 |
return None
|
| 678 |
+
if owner_id is not None and not await owner_owns_any_card(
|
| 679 |
+
db, owner_id, row.source_card_ids or []
|
| 680 |
+
):
|
| 681 |
+
return None
|
| 682 |
row.description = description
|
| 683 |
await db.commit()
|
| 684 |
return row
|
|
|
|
| 715 |
return row
|
| 716 |
|
| 717 |
|
| 718 |
+
async def get_collection_row(
|
| 719 |
+
db_session: AsyncSession, collection_id: str
|
| 720 |
+
) -> CollectionRow | None:
|
| 721 |
+
return await db_session.get(CollectionRow, collection_id)
|
| 722 |
+
|
| 723 |
+
|
| 724 |
async def list_collections(
|
| 725 |
db_session: AsyncSession, owner_id: str | None
|
| 726 |
) -> list[tuple[CollectionRow, int]]:
|
|
|
|
| 758 |
|
| 759 |
|
| 760 |
async def rename_collection(
|
| 761 |
+
db_session: AsyncSession, collection_id: str, name: str,
|
| 762 |
+
owner_id: str | None = None,
|
| 763 |
) -> CollectionRow | None:
|
| 764 |
row = await db_session.get(CollectionRow, collection_id)
|
| 765 |
+
if row is None or (owner_id is not None and row.owner_id != owner_id):
|
| 766 |
return None
|
| 767 |
row.name = name.strip()
|
| 768 |
await db_session.commit()
|
|
|
|
| 770 |
|
| 771 |
|
| 772 |
async def delete_collection(
|
| 773 |
+
db_session: AsyncSession, collection_id: str, owner_id: str | None = None
|
| 774 |
) -> bool:
|
| 775 |
+
"""Delete a custom collection; returns False if not found, not custom, or not
|
| 776 |
+
owned by `owner_id`."""
|
| 777 |
row = await db_session.get(CollectionRow, collection_id)
|
| 778 |
if row is None or not row.is_custom:
|
| 779 |
return False
|
| 780 |
+
if owner_id is not None and row.owner_id != owner_id:
|
| 781 |
+
return False
|
| 782 |
# Detach cards from this collection before deleting.
|
| 783 |
from sqlalchemy import update as sa_update
|
| 784 |
await db_session.execute(
|
|
|
|
| 792 |
|
| 793 |
|
| 794 |
async def move_card_to_collection(
|
| 795 |
+
db_session: AsyncSession, card_id: str, collection_id: str | None,
|
| 796 |
+
owner_id: str | None = None,
|
| 797 |
) -> CardRow | None:
|
| 798 |
row = await db_session.get(CardRow, card_id)
|
| 799 |
+
if row is None or (owner_id is not None and row.owner_id != owner_id):
|
| 800 |
return None
|
| 801 |
+
if collection_id is not None and owner_id is not None:
|
| 802 |
+
dest = await db_session.get(CollectionRow, collection_id)
|
| 803 |
+
if dest is None or dest.owner_id != owner_id:
|
| 804 |
+
return None
|
| 805 |
row.collection_id = collection_id
|
| 806 |
await db_session.commit()
|
| 807 |
return row
|
|
|
|
| 877 |
return row
|
| 878 |
|
| 879 |
|
| 880 |
+
async def remove_owner_from_concept(
|
| 881 |
+
db: AsyncSession, concept_id: str, owner_id: str
|
| 882 |
+
) -> bool:
|
| 883 |
+
"""Detach `owner_id`'s cards from a shared concept. If no source cards remain
|
| 884 |
+
(from anyone), delete the row. Returns False when the concept doesn't exist
|
| 885 |
+
or the caller owns none of its source cards (so nothing was removed)."""
|
| 886 |
+
row = await db.get(ConceptRow, concept_id)
|
| 887 |
+
if row is None:
|
| 888 |
+
return False
|
| 889 |
+
current = list(row.source_card_ids or [])
|
| 890 |
+
if not await owner_owns_any_card(db, owner_id, current):
|
| 891 |
+
return False
|
| 892 |
+
mine = set(
|
| 893 |
+
(await db.execute(
|
| 894 |
+
select(CardRow.id).where(
|
| 895 |
+
CardRow.owner_id == owner_id, CardRow.id.in_(current)
|
| 896 |
+
)
|
| 897 |
+
)).scalars().all()
|
| 898 |
+
)
|
| 899 |
+
remaining = [c for c in current if c not in mine]
|
| 900 |
+
if remaining:
|
| 901 |
+
row.source_card_ids = remaining
|
| 902 |
+
flag_modified(row, "source_card_ids")
|
| 903 |
+
else:
|
| 904 |
+
await db.delete(row)
|
| 905 |
+
await db.commit()
|
| 906 |
+
return True
|
| 907 |
+
|
| 908 |
+
|
| 909 |
async def cleanup_after_card_deletion(
|
| 910 |
db: AsyncSession, card_id: str
|
| 911 |
) -> None:
|
|
|
|
| 933 |
if card_id in current_ids:
|
| 934 |
new_ids = [c for c in current_ids if c != card_id]
|
| 935 |
if not new_ids:
|
| 936 |
+
await db.execute(
|
| 937 |
+
delete(ArtifactSaveRow).where(
|
| 938 |
+
ArtifactSaveRow.artifact_id == art.id
|
| 939 |
+
)
|
| 940 |
+
)
|
| 941 |
await db.delete(art)
|
| 942 |
else:
|
| 943 |
art.source_card_ids = new_ids
|
backend/tests/test_api.py
CHANGED
|
@@ -89,18 +89,20 @@ async def test_catalog_upsert_dedupes_and_lists(client, database):
|
|
| 89 |
for cid in ("c1", "c2", "c3")
|
| 90 |
])
|
| 91 |
await s.commit()
|
| 92 |
-
await database.upsert_artifact(
|
| 93 |
s, card_id="c1", type_="book", title="Atomic Habits",
|
| 94 |
-
creator="James Clear", year=2018, thumbnail="http://x/a.jpg",
|
| 95 |
)
|
| 96 |
-
await database.upsert_artifact(
|
| 97 |
s, card_id="c2", type_="book", title="atomic habits",
|
| 98 |
-
creator=None, year=None, thumbnail=None,
|
| 99 |
)
|
| 100 |
-
await database.upsert_artifact(
|
| 101 |
s, card_id="c3", type_="movie", title="Inception",
|
| 102 |
-
creator="Nolan", year=2010, thumbnail=None,
|
| 103 |
)
|
|
|
|
|
|
|
| 104 |
|
| 105 |
r = await client.get("/catalog")
|
| 106 |
entries = r.json()
|
|
@@ -114,11 +116,12 @@ async def test_catalog_upsert_dedupes_and_lists(client, database):
|
|
| 114 |
assert [e["title"] for e in r2.json()] == ["Inception"]
|
| 115 |
|
| 116 |
|
| 117 |
-
async def _make_ready_card(database) -> str:
|
| 118 |
async with database.session() as s:
|
| 119 |
row = database.CardRow(
|
| 120 |
source_url="https://instagram.com/reel/chat",
|
| 121 |
state="ready",
|
|
|
|
| 122 |
content_type="recipe",
|
| 123 |
one_liner="Easy pancakes",
|
| 124 |
tldr="Mix, pour, flip.",
|
|
@@ -188,7 +191,7 @@ async def test_chat_returns_reply_when_backend_answers(client, database, monkeyp
|
|
| 188 |
async def test_chat_persists_and_restores_per_owner(client, database, monkeypatch):
|
| 189 |
"""A chat turn is saved for the owner that made it, restorable via GET, and
|
| 190 |
invisible to a different owner."""
|
| 191 |
-
card_id = await _make_ready_card(database)
|
| 192 |
monkeypatch.setattr(
|
| 193 |
"app.api.cards.llm_chat.answer", lambda card, history: "Use 1 cup."
|
| 194 |
)
|
|
@@ -217,7 +220,7 @@ async def test_chat_persists_and_restores_per_owner(client, database, monkeypatc
|
|
| 217 |
async def test_rabbithole_persists_trail_and_restores(client, database, monkeypatch):
|
| 218 |
"""Successive dives build a persisted trail keyed by the root topic, and a
|
| 219 |
branch taken after jumping back replaces the abandoned deeper steps."""
|
| 220 |
-
card_id = await _make_ready_card(database)
|
| 221 |
|
| 222 |
async def fake_explore(card, topic, trail):
|
| 223 |
return {"explanation": f"About {topic}.", "threads": [f"{topic} deeper"]}
|
|
@@ -273,7 +276,7 @@ async def test_rabbithole_persists_trail_and_restores(client, database, monkeypa
|
|
| 273 |
|
| 274 |
|
| 275 |
async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch):
|
| 276 |
-
async def fake_answer(history):
|
| 277 |
return ("You saved 2 recipes.", [])
|
| 278 |
|
| 279 |
monkeypatch.setattr(
|
|
@@ -298,11 +301,16 @@ async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch)
|
|
| 298 |
|
| 299 |
async def test_catalog_detail_and_delete(client, database):
|
| 300 |
async with database.session() as s:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
row = await database.upsert_artifact(
|
| 302 |
s, card_id="c1", type_="podcast", title="Lex Fridman",
|
| 303 |
-
creator=None, year=None, thumbnail=None,
|
| 304 |
)
|
| 305 |
artifact_id = row.id
|
|
|
|
| 306 |
|
| 307 |
g = await client.get(f"/catalog/{artifact_id}")
|
| 308 |
assert g.status_code == 200
|
|
@@ -374,7 +382,7 @@ async def test_library_chat_503_without_llm_backend(client):
|
|
| 374 |
|
| 375 |
|
| 376 |
async def test_library_chat_returns_reply(client, monkeypatch):
|
| 377 |
-
async def fake_answer(history):
|
| 378 |
return ("You saved 3 recipes.", [])
|
| 379 |
|
| 380 |
monkeypatch.setattr("app.api.library_chat.llm_library_chat.answer", fake_answer)
|
|
@@ -417,8 +425,8 @@ async def test_search_mode_text_works(client):
|
|
| 417 |
async def test_delete_card_cascades_cleanup(client, database):
|
| 418 |
from sqlalchemy import select
|
| 419 |
async with database.session() as s:
|
| 420 |
-
c1 = database.CardRow(id="del-1", source_url="http://x/1", state="ready")
|
| 421 |
-
c2 = database.CardRow(id="del-2", source_url="http://x/2", state="ready")
|
| 422 |
s.add_all([c1, c2])
|
| 423 |
await s.commit()
|
| 424 |
|
|
@@ -429,9 +437,9 @@ async def test_delete_card_cascades_cleanup(client, database):
|
|
| 429 |
await database.upsert_concept(s, name="Idea Shared", card_id="del-1")
|
| 430 |
await database.upsert_concept(s, name="Idea Shared", card_id="del-2")
|
| 431 |
|
| 432 |
-
await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Solo", creator=None, year=None, thumbnail=None
|
| 433 |
-
await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None
|
| 434 |
-
await database.upsert_artifact(s, card_id="del-2", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None
|
| 435 |
|
| 436 |
res = await client.delete("/cards/del-1")
|
| 437 |
assert res.status_code == 200
|
|
|
|
| 89 |
for cid in ("c1", "c2", "c3")
|
| 90 |
])
|
| 91 |
await s.commit()
|
| 92 |
+
a1 = await database.upsert_artifact(
|
| 93 |
s, card_id="c1", type_="book", title="Atomic Habits",
|
| 94 |
+
creator="James Clear", year=2018, thumbnail="http://x/a.jpg",
|
| 95 |
)
|
| 96 |
+
a2 = await database.upsert_artifact(
|
| 97 |
s, card_id="c2", type_="book", title="atomic habits",
|
| 98 |
+
creator=None, year=None, thumbnail=None,
|
| 99 |
)
|
| 100 |
+
a3 = await database.upsert_artifact(
|
| 101 |
s, card_id="c3", type_="movie", title="Inception",
|
| 102 |
+
creator="Nolan", year=2010, thumbnail=None,
|
| 103 |
)
|
| 104 |
+
for aid in {a1.id, a2.id, a3.id}:
|
| 105 |
+
await database.set_artifact_saved(s, aid, True, "test-user")
|
| 106 |
|
| 107 |
r = await client.get("/catalog")
|
| 108 |
entries = r.json()
|
|
|
|
| 116 |
assert [e["title"] for e in r2.json()] == ["Inception"]
|
| 117 |
|
| 118 |
|
| 119 |
+
async def _make_ready_card(database, owner_id: str = "test-user") -> str:
|
| 120 |
async with database.session() as s:
|
| 121 |
row = database.CardRow(
|
| 122 |
source_url="https://instagram.com/reel/chat",
|
| 123 |
state="ready",
|
| 124 |
+
owner_id=owner_id,
|
| 125 |
content_type="recipe",
|
| 126 |
one_liner="Easy pancakes",
|
| 127 |
tldr="Mix, pour, flip.",
|
|
|
|
| 191 |
async def test_chat_persists_and_restores_per_owner(client, database, monkeypatch):
|
| 192 |
"""A chat turn is saved for the owner that made it, restorable via GET, and
|
| 193 |
invisible to a different owner."""
|
| 194 |
+
card_id = await _make_ready_card(database, owner_id="alice")
|
| 195 |
monkeypatch.setattr(
|
| 196 |
"app.api.cards.llm_chat.answer", lambda card, history: "Use 1 cup."
|
| 197 |
)
|
|
|
|
| 220 |
async def test_rabbithole_persists_trail_and_restores(client, database, monkeypatch):
|
| 221 |
"""Successive dives build a persisted trail keyed by the root topic, and a
|
| 222 |
branch taken after jumping back replaces the abandoned deeper steps."""
|
| 223 |
+
card_id = await _make_ready_card(database, owner_id="alice")
|
| 224 |
|
| 225 |
async def fake_explore(card, topic, trail):
|
| 226 |
return {"explanation": f"About {topic}.", "threads": [f"{topic} deeper"]}
|
|
|
|
| 276 |
|
| 277 |
|
| 278 |
async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch):
|
| 279 |
+
async def fake_answer(history, owner_id):
|
| 280 |
return ("You saved 2 recipes.", [])
|
| 281 |
|
| 282 |
monkeypatch.setattr(
|
|
|
|
| 301 |
|
| 302 |
async def test_catalog_detail_and_delete(client, database):
|
| 303 |
async with database.session() as s:
|
| 304 |
+
s.add(database.CardRow(
|
| 305 |
+
id="c1", source_url="http://cat/c1", state="ready", owner_id="test-user",
|
| 306 |
+
))
|
| 307 |
+
await s.commit()
|
| 308 |
row = await database.upsert_artifact(
|
| 309 |
s, card_id="c1", type_="podcast", title="Lex Fridman",
|
| 310 |
+
creator=None, year=None, thumbnail=None,
|
| 311 |
)
|
| 312 |
artifact_id = row.id
|
| 313 |
+
await database.set_artifact_saved(s, artifact_id, True, "test-user")
|
| 314 |
|
| 315 |
g = await client.get(f"/catalog/{artifact_id}")
|
| 316 |
assert g.status_code == 200
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
async def test_library_chat_returns_reply(client, monkeypatch):
|
| 385 |
+
async def fake_answer(history, owner_id):
|
| 386 |
return ("You saved 3 recipes.", [])
|
| 387 |
|
| 388 |
monkeypatch.setattr("app.api.library_chat.llm_library_chat.answer", fake_answer)
|
|
|
|
| 425 |
async def test_delete_card_cascades_cleanup(client, database):
|
| 426 |
from sqlalchemy import select
|
| 427 |
async with database.session() as s:
|
| 428 |
+
c1 = database.CardRow(id="del-1", source_url="http://x/1", state="ready", owner_id="test-user")
|
| 429 |
+
c2 = database.CardRow(id="del-2", source_url="http://x/2", state="ready", owner_id="test-user")
|
| 430 |
s.add_all([c1, c2])
|
| 431 |
await s.commit()
|
| 432 |
|
|
|
|
| 437 |
await database.upsert_concept(s, name="Idea Shared", card_id="del-1")
|
| 438 |
await database.upsert_concept(s, name="Idea Shared", card_id="del-2")
|
| 439 |
|
| 440 |
+
await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Solo", creator=None, year=None, thumbnail=None)
|
| 441 |
+
await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None)
|
| 442 |
+
await database.upsert_artifact(s, card_id="del-2", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None)
|
| 443 |
|
| 444 |
res = await client.delete("/cards/del-1")
|
| 445 |
assert res.status_code == 200
|
backend/tests/test_isolation.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-owner isolation: a card/collection/artifact/concept owned by one user
|
| 2 |
+
is invisible and immutable to another. Locks the owner-scoping guards on the
|
| 3 |
+
by-id routes (get/patch/delete card, collections, catalog, concepts)."""
|
| 4 |
+
|
| 5 |
+
from app.store import db
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _card(database, cid: str, owner: str) -> None:
|
| 9 |
+
async with database.session() as s:
|
| 10 |
+
s.add(database.CardRow(
|
| 11 |
+
id=cid, source_url=f"http://iso/{cid}", state="ready", owner_id=owner,
|
| 12 |
+
one_liner="secret", tldr="secret body",
|
| 13 |
+
))
|
| 14 |
+
await s.commit()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _h(owner: str) -> dict:
|
| 18 |
+
return {"x-owner-id": owner}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def test_get_patch_delete_card_denied_to_other_owner(client, database):
|
| 22 |
+
await _card(database, "iso-1", "alice")
|
| 23 |
+
|
| 24 |
+
assert (await client.get("/cards/iso-1", headers=_h("bob"))).status_code == 404
|
| 25 |
+
assert (await client.patch(
|
| 26 |
+
"/cards/iso-1", headers=_h("bob"), json={"blocks": []}
|
| 27 |
+
)).status_code == 404
|
| 28 |
+
assert (await client.delete("/cards/iso-1", headers=_h("bob"))).status_code == 404
|
| 29 |
+
|
| 30 |
+
# Owner still gets it, and it survived bob's delete attempt.
|
| 31 |
+
assert (await client.get("/cards/iso-1", headers=_h("alice"))).status_code == 200
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def test_collection_mutations_denied_to_other_owner(client, database):
|
| 35 |
+
await _card(database, "iso-2", "alice")
|
| 36 |
+
created = await client.post(
|
| 37 |
+
"/collections", headers=_h("alice"), json={"name": "Alice folder"}
|
| 38 |
+
)
|
| 39 |
+
col_id = created.json()["id"]
|
| 40 |
+
|
| 41 |
+
assert (await client.patch(
|
| 42 |
+
f"/collections/{col_id}", headers=_h("bob"), json={"name": "hijack"}
|
| 43 |
+
)).status_code == 404
|
| 44 |
+
assert (await client.delete(
|
| 45 |
+
f"/collections/{col_id}", headers=_h("bob")
|
| 46 |
+
)).status_code == 404
|
| 47 |
+
# Bob can't move alice's card either.
|
| 48 |
+
assert (await client.post(
|
| 49 |
+
"/collections/cards/iso-2/move", headers=_h("bob"),
|
| 50 |
+
json={"collection_id": col_id},
|
| 51 |
+
)).status_code == 404
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def test_catalog_and_concept_by_id_denied_to_other_owner(client, database):
|
| 55 |
+
await _card(database, "iso-3", "alice")
|
| 56 |
+
async with database.session() as s:
|
| 57 |
+
art = await db.upsert_artifact(
|
| 58 |
+
s, card_id="iso-3", type_="book", title="Alice Book",
|
| 59 |
+
creator=None, year=None, thumbnail=None,
|
| 60 |
+
)
|
| 61 |
+
await db.set_artifact_saved(s, art.id, True, "alice")
|
| 62 |
+
con = await db.upsert_concept(s, card_id="iso-3", name="alice concept")
|
| 63 |
+
art_id, con_id = art.id, con.id
|
| 64 |
+
|
| 65 |
+
assert (await client.get(
|
| 66 |
+
f"/catalog/{art_id}", headers=_h("bob")
|
| 67 |
+
)).status_code == 404
|
| 68 |
+
assert (await client.delete(
|
| 69 |
+
f"/catalog/{art_id}", headers=_h("bob")
|
| 70 |
+
)).status_code == 404
|
| 71 |
+
assert (await client.post(
|
| 72 |
+
f"/concepts/{con_id}/define", headers=_h("bob")
|
| 73 |
+
)).status_code == 404
|
| 74 |
+
assert (await client.delete(
|
| 75 |
+
f"/concepts/{con_id}", headers=_h("bob")
|
| 76 |
+
)).status_code == 404
|
| 77 |
+
|
| 78 |
+
# Owner still reaches the catalog entry.
|
| 79 |
+
assert (await client.get(
|
| 80 |
+
f"/catalog/{art_id}", headers=_h("alice")
|
| 81 |
+
)).status_code == 200
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_catalog_saved_state_is_per_owner(client, database):
|
| 85 |
+
"""A single shared artifact (referenced by two owners' cards) has independent
|
| 86 |
+
catalog membership per owner — one saving/removing never affects the other."""
|
| 87 |
+
await _card(database, "shared-a", "alice")
|
| 88 |
+
await _card(database, "shared-b", "bob")
|
| 89 |
+
async with database.session() as s:
|
| 90 |
+
# Same title+type from both cards dedupes into ONE shared artifact row.
|
| 91 |
+
await db.upsert_artifact(
|
| 92 |
+
s, card_id="shared-a", type_="book", title="Shared Book",
|
| 93 |
+
creator=None, year=None, thumbnail=None,
|
| 94 |
+
)
|
| 95 |
+
art = await db.upsert_artifact(
|
| 96 |
+
s, card_id="shared-b", type_="book", title="Shared Book",
|
| 97 |
+
creator=None, year=None, thumbnail=None,
|
| 98 |
+
)
|
| 99 |
+
art_id = art.id
|
| 100 |
+
|
| 101 |
+
# Alice saves it; her catalog shows it, bob's does not.
|
| 102 |
+
assert (await client.post(
|
| 103 |
+
f"/catalog/{art_id}/save", headers=_h("alice")
|
| 104 |
+
)).status_code == 200
|
| 105 |
+
alice_titles = [e["title"] for e in (
|
| 106 |
+
await client.get("/catalog", headers=_h("alice"))).json()]
|
| 107 |
+
bob_titles = [e["title"] for e in (
|
| 108 |
+
await client.get("/catalog", headers=_h("bob"))).json()]
|
| 109 |
+
assert alice_titles == ["Shared Book"]
|
| 110 |
+
assert bob_titles == []
|
| 111 |
+
|
| 112 |
+
# Bob saves it too — independent membership.
|
| 113 |
+
assert (await client.post(
|
| 114 |
+
f"/catalog/{art_id}/save", headers=_h("bob")
|
| 115 |
+
)).status_code == 200
|
| 116 |
+
|
| 117 |
+
# Alice removes it; bob's catalog is untouched.
|
| 118 |
+
assert (await client.delete(
|
| 119 |
+
f"/catalog/{art_id}", headers=_h("alice")
|
| 120 |
+
)).status_code == 200
|
| 121 |
+
assert (await client.get("/catalog", headers=_h("alice"))).json() == []
|
| 122 |
+
assert [e["title"] for e in (
|
| 123 |
+
await client.get("/catalog", headers=_h("bob"))).json()] == ["Shared Book"]
|
backend/tests/test_merge.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guest (anonymous) account data folding into an existing account."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
|
| 5 |
+
from app.api import auth_routes
|
| 6 |
+
from app.auth import get_owner
|
| 7 |
+
from app.main import app
|
| 8 |
+
from app.store import db
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def _seed_card(owner_id: str) -> None:
|
| 12 |
+
async with db.session() as s:
|
| 13 |
+
s.add(db.CardRow(owner_id=owner_id, source_url=f"https://x/{owner_id}", state="ready"))
|
| 14 |
+
await s.commit()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def test_merge_folds_guest_rows_into_account(client, monkeypatch) -> None:
|
| 18 |
+
await _seed_card("guest-uid")
|
| 19 |
+
|
| 20 |
+
# Caller (Bearer) is the destination Google account.
|
| 21 |
+
app.dependency_overrides[get_owner] = lambda: "google-uid"
|
| 22 |
+
# guest_token verifies to an anonymous guest uid.
|
| 23 |
+
monkeypatch.setattr(
|
| 24 |
+
auth_routes,
|
| 25 |
+
"_verify",
|
| 26 |
+
lambda _t: {"uid": "guest-uid", "firebase": {"sign_in_provider": "anonymous"}},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
resp = await client.post("/auth/merge", json={"guest_token": "guest-jwt"})
|
| 30 |
+
assert resp.status_code == 200 and resp.json()["merged"] >= 1
|
| 31 |
+
|
| 32 |
+
async with db.session() as s:
|
| 33 |
+
cards = (await s.execute(select(db.CardRow))).scalars().all()
|
| 34 |
+
assert cards and all(c.owner_id == "google-uid" for c in cards)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def test_merge_rejects_non_anonymous_source(client, monkeypatch) -> None:
|
| 38 |
+
app.dependency_overrides[get_owner] = lambda: "google-uid"
|
| 39 |
+
monkeypatch.setattr(
|
| 40 |
+
auth_routes,
|
| 41 |
+
"_verify",
|
| 42 |
+
lambda _t: {"uid": "other-real-uid", "firebase": {"sign_in_provider": "google.com"}},
|
| 43 |
+
)
|
| 44 |
+
resp = await client.post("/auth/merge", json={"guest_token": "real-jwt"})
|
| 45 |
+
assert resp.status_code == 403
|
backend/tests/test_prefer_local.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dev toggle: prefer_local forces on-device structuring by degrading the job
|
| 2 |
+
(skip server LLM, keep the bundle for the phone) even when within quota."""
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
|
| 6 |
+
from app.store import db
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def test_prefer_local_degrades_job_within_quota(client) -> None:
|
| 10 |
+
r = await client.post(
|
| 11 |
+
"/cards", json={"url": "https://example.com/local", "prefer_local": True}
|
| 12 |
+
)
|
| 13 |
+
assert r.status_code == 200
|
| 14 |
+
assert r.json()["quota_degraded"] is True
|
| 15 |
+
|
| 16 |
+
async with db.session() as s:
|
| 17 |
+
jobs = (await s.execute(select(db.JobRow))).scalars().all()
|
| 18 |
+
assert len(jobs) == 1 and jobs[0].degraded is True
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def test_default_uses_server_llm(client) -> None:
|
| 22 |
+
r = await client.post("/cards", json={"url": "https://example.com/server"})
|
| 23 |
+
assert r.status_code == 200
|
| 24 |
+
assert r.json()["quota_degraded"] is False
|
| 25 |
+
|
| 26 |
+
async with db.session() as s:
|
| 27 |
+
jobs = (await s.execute(select(db.JobRow))).scalars().all()
|
| 28 |
+
assert len(jobs) == 1 and jobs[0].degraded is False
|