Vatxzz commited on
Commit
01f3cb0
·
1 Parent(s): 6ef9395

Phase 1 done

Browse files
Files changed (45) hide show
  1. app/android/app/src/main/AndroidManifest.xml +10 -0
  2. app/ios/Runner/Info.plist +2 -0
  3. app/lib/data/repositories/card_repository.dart +14 -0
  4. app/lib/data/services/api_client.dart +36 -0
  5. app/lib/domain/models/artifact.dart +115 -0
  6. app/lib/main.dart +2 -2
  7. app/lib/ui/core/home_shell.dart +46 -0
  8. app/lib/ui/features/catalog/view_models/catalog_view_model.dart +91 -0
  9. app/lib/ui/features/catalog/views/catalog_screen.dart +320 -0
  10. app/lib/ui/features/library/view_models/library_view_model.dart +59 -0
  11. app/lib/ui/features/library/views/library_screen.dart +131 -32
  12. app/lib/ui/features/reader/services/card_actions.dart +263 -0
  13. app/lib/ui/features/reader/view_models/chat_view_model.dart +62 -0
  14. app/lib/ui/features/reader/views/chat_screen.dart +213 -0
  15. app/lib/ui/features/reader/views/primary_action_bar.dart +131 -32
  16. app/lib/ui/features/reader/views/reader_screen.dart +1 -3
  17. app/pubspec.lock +113 -1
  18. app/pubspec.yaml +6 -0
  19. app/test/artifact_test.dart +45 -0
  20. backend/app/api/cards.py +41 -9
  21. backend/app/api/catalog.py +57 -0
  22. backend/app/main.py +2 -1
  23. backend/app/models/artifact.py +60 -0
  24. backend/app/models/card.py +1 -1
  25. backend/app/pipeline/extraction.py +35 -0
  26. backend/app/pipeline/ingestion/article.py +85 -0
  27. backend/app/pipeline/ingestion/downloader.py +80 -8
  28. backend/app/pipeline/ingestion/resolvers.py +643 -42
  29. backend/app/pipeline/ingestion/source.py +34 -0
  30. backend/app/pipeline/structuring.py +62 -8
  31. backend/app/pipeline/worker.py +45 -27
  32. backend/app/services/artifact_images.py +103 -0
  33. backend/app/services/llm_chat.py +139 -0
  34. backend/app/store/db.py +80 -0
  35. backend/pyproject.toml +2 -0
  36. backend/test_carousel_out.mp4 +0 -0
  37. backend/tests/test_api.py +119 -1
  38. backend/tests/test_article.py +111 -0
  39. backend/tests/test_pipeline.py +31 -0
  40. backend/tests/test_structuring.py +31 -0
  41. docs/02-ingestion.md +35 -0
  42. docs/04-structuring-and-schema.md +12 -2
  43. docs/09-features.md +16 -4
  44. docs/12-catalog-and-artifacts.md +90 -0
  45. docs/13-actions-and-chat.md +67 -0
app/android/app/src/main/AndroidManifest.xml CHANGED
@@ -48,5 +48,15 @@
48
  <action android:name="android.intent.action.PROCESS_TEXT"/>
49
  <data android:mimeType="text/plain"/>
50
  </intent>
 
 
 
 
 
 
 
 
 
 
51
  </queries>
52
  </manifest>
 
48
  <action android:name="android.intent.action.PROCESS_TEXT"/>
49
  <data android:mimeType="text/plain"/>
50
  </intent>
51
+ <!-- url_launcher: open Maps (save-place action) via an https link. -->
52
+ <intent>
53
+ <action android:name="android.intent.action.VIEW"/>
54
+ <data android:scheme="https"/>
55
+ </intent>
56
+ <!-- add_2_calendar: insert a reminder/session into a calendar app. -->
57
+ <intent>
58
+ <action android:name="android.intent.action.INSERT"/>
59
+ <data android:mimeType="vnd.android.cursor.dir/event"/>
60
+ </intent>
61
  </queries>
62
  </manifest>
app/ios/Runner/Info.plist CHANGED
@@ -2,6 +2,8 @@
2
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
  <plist version="1.0">
4
  <dict>
 
 
5
  <key>CADisableMinimumFrameDurationOnPhone</key>
6
  <true/>
7
  <key>CFBundleDevelopmentRegion</key>
 
2
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
  <plist version="1.0">
4
  <dict>
5
+ <key>NSCalendarsUsageDescription</key>
6
+ <string>Cachy adds reminders and sessions from your cards to your calendar.</string>
7
  <key>CADisableMinimumFrameDurationOnPhone</key>
8
  <true/>
9
  <key>CFBundleDevelopmentRegion</key>
app/lib/data/repositories/card_repository.dart CHANGED
@@ -5,6 +5,7 @@ library;
5
 
6
  import 'dart:convert';
7
 
 
8
  import '../../domain/models/card.dart';
9
  import '../../domain/models/enums.dart';
10
  import '../../domain/models/pipeline_event.dart';
@@ -101,6 +102,19 @@ class CardRepository {
101
 
102
  Future<List<Card>> search(String query) => _api.search(query);
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  /// Reconstruct the raw JSON for caching. Uses preserved `rawBlocks` so block
105
  /// state round-trips losslessly.
106
  Map<String, dynamic> _rawOf(Card card) => {
 
5
 
6
  import 'dart:convert';
7
 
8
+ import '../../domain/models/artifact.dart';
9
  import '../../domain/models/card.dart';
10
  import '../../domain/models/enums.dart';
11
  import '../../domain/models/pipeline_event.dart';
 
102
 
103
  Future<List<Card>> search(String query) => _api.search(query);
104
 
105
+ /// Grounded chat over one card (docs/13). Network-only; the conversation is
106
+ /// held in the view model and replayed each turn.
107
+ Future<String> chat(String cardId, List<Map<String, String>> messages) =>
108
+ _api.chat(cardId, messages);
109
+
110
+ /// The aggregated artifact catalog (docs/12). Network-only for now — these
111
+ /// are remote-thumbnail entries with no offline-render requirement.
112
+ Future<List<CatalogEntry>> catalog({ArtifactType? type}) =>
113
+ _api.listCatalog(type: type);
114
+
115
+ Future<void> deleteCatalogEntry(String artifactId) =>
116
+ _api.deleteCatalogEntry(artifactId);
117
+
118
  /// Reconstruct the raw JSON for caching. Uses preserved `rawBlocks` so block
119
  /// state round-trips losslessly.
120
  Map<String, dynamic> _rawOf(Card card) => {
app/lib/data/services/api_client.dart CHANGED
@@ -7,6 +7,7 @@ import 'dart:convert';
7
 
8
  import 'package:http/http.dart' as http;
9
 
 
10
  import '../../domain/models/card.dart';
11
  import '../../domain/models/enums.dart';
12
  import '../../domain/models/pipeline_event.dart';
@@ -121,6 +122,41 @@ class ApiClient {
121
  return _decodeList(resp).map(Card.fromJson).toList();
122
  }
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  // ------------------------------------------------------------------------- //
125
  // SSE pipeline stream — GET /cards/{id}/stream
126
  // ------------------------------------------------------------------------- //
 
7
 
8
  import 'package:http/http.dart' as http;
9
 
10
+ import '../../domain/models/artifact.dart';
11
  import '../../domain/models/card.dart';
12
  import '../../domain/models/enums.dart';
13
  import '../../domain/models/pipeline_event.dart';
 
122
  return _decodeList(resp).map(Card.fromJson).toList();
123
  }
124
 
125
+ /// Grounded Q&A over one card (docs/13). Stateless: send the full history each
126
+ /// turn as [{'role','content'}]; returns the assistant's reply text.
127
+ Future<String> chat(String cardId, List<Map<String, String>> messages) async {
128
+ final resp = await _client.post(
129
+ _uri('/cards/$cardId/chat'),
130
+ headers: const {'content-type': 'application/json'},
131
+ body: jsonEncode({'messages': messages}),
132
+ );
133
+ return (_decodeMap(resp)['reply'] as String?) ?? '';
134
+ }
135
+
136
+ // ------------------------------------------------------------------------- //
137
+ // Catalog — referenced artifacts aggregated across cards (docs/12)
138
+ // ------------------------------------------------------------------------- //
139
+
140
+ Future<List<CatalogEntry>> listCatalog({
141
+ ArtifactType? type,
142
+ int limit = 200,
143
+ int offset = 0,
144
+ }) async {
145
+ final resp = await _client.get(_uri('/catalog', {
146
+ 'type': ?type?.wire,
147
+ 'limit': limit,
148
+ 'offset': offset,
149
+ }));
150
+ return _decodeList(resp).map(CatalogEntry.fromJson).toList();
151
+ }
152
+
153
+ Future<void> deleteCatalogEntry(String artifactId) async {
154
+ final resp = await _client.delete(_uri('/catalog/$artifactId'));
155
+ if (resp.statusCode >= 400) {
156
+ throw ApiException(resp.statusCode, resp.body);
157
+ }
158
+ }
159
+
160
  // ------------------------------------------------------------------------- //
161
  // SSE pipeline stream — GET /cards/{id}/stream
162
  // ------------------------------------------------------------------------- //
app/lib/domain/models/artifact.dart ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Catalog artifact: a real-world thing a video references — book, movie,
2
+ /// podcast, product, place, etc. Mirrors the backend catalog contract
3
+ /// (docs/12, backend models/artifact.py). Parsing is tolerant: unknown type
4
+ /// degrades to [ArtifactType.other] rather than throwing.
5
+ library;
6
+
7
+ enum ArtifactType {
8
+ book,
9
+ movie,
10
+ tvShow,
11
+ podcast,
12
+ music,
13
+ product,
14
+ place,
15
+ app,
16
+ other;
17
+
18
+ static ArtifactType fromWire(String? value) {
19
+ switch (value) {
20
+ case 'book':
21
+ return ArtifactType.book;
22
+ case 'movie':
23
+ return ArtifactType.movie;
24
+ case 'tv_show':
25
+ return ArtifactType.tvShow;
26
+ case 'podcast':
27
+ return ArtifactType.podcast;
28
+ case 'music':
29
+ return ArtifactType.music;
30
+ case 'product':
31
+ return ArtifactType.product;
32
+ case 'place':
33
+ return ArtifactType.place;
34
+ case 'app':
35
+ return ArtifactType.app;
36
+ default:
37
+ return ArtifactType.other;
38
+ }
39
+ }
40
+
41
+ String get wire {
42
+ switch (this) {
43
+ case ArtifactType.tvShow:
44
+ return 'tv_show';
45
+ default:
46
+ return name;
47
+ }
48
+ }
49
+
50
+ /// Plural label used as a catalog section header.
51
+ String get sectionLabel {
52
+ switch (this) {
53
+ case ArtifactType.book:
54
+ return 'Books';
55
+ case ArtifactType.movie:
56
+ return 'Movies';
57
+ case ArtifactType.tvShow:
58
+ return 'TV Shows';
59
+ case ArtifactType.podcast:
60
+ return 'Podcasts';
61
+ case ArtifactType.music:
62
+ return 'Music';
63
+ case ArtifactType.product:
64
+ return 'Products';
65
+ case ArtifactType.place:
66
+ return 'Places';
67
+ case ArtifactType.app:
68
+ return 'Apps';
69
+ case ArtifactType.other:
70
+ return 'Other';
71
+ }
72
+ }
73
+ }
74
+
75
+ class CatalogEntry {
76
+ const CatalogEntry({
77
+ required this.id,
78
+ this.type = ArtifactType.other,
79
+ required this.title,
80
+ this.creator,
81
+ this.year,
82
+ this.thumbnail,
83
+ this.sourceCardIds = const [],
84
+ });
85
+
86
+ final String id;
87
+ final ArtifactType type;
88
+ final String title;
89
+ final String? creator;
90
+ final int? year;
91
+ final String? thumbnail;
92
+ final List<String> sourceCardIds;
93
+
94
+ /// "James Clear · 2018", "2018", or "" — the dimmed subtitle line.
95
+ String get subtitle {
96
+ final parts = <String>[
97
+ if (creator != null && creator!.isNotEmpty) creator!,
98
+ if (year != null) '$year',
99
+ ];
100
+ return parts.join(' · ');
101
+ }
102
+
103
+ factory CatalogEntry.fromJson(Map<String, dynamic> json) => CatalogEntry(
104
+ id: (json['id'] as String?) ?? '',
105
+ type: ArtifactType.fromWire(json['type'] as String?),
106
+ title: (json['title'] as String?) ?? '',
107
+ creator: json['creator'] as String?,
108
+ year: (json['year'] as num?)?.toInt(),
109
+ thumbnail: json['thumbnail'] as String?,
110
+ sourceCardIds: (json['source_card_ids'] as List?)
111
+ ?.map((e) => e.toString())
112
+ .toList() ??
113
+ const [],
114
+ );
115
+ }
app/lib/main.dart CHANGED
@@ -12,8 +12,8 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart';
12
  import 'data/repositories/card_repository.dart';
13
  import 'data/services/api_client.dart';
14
  import 'data/services/local_store.dart';
 
15
  import 'ui/core/theme.dart';
16
- import 'ui/features/library/views/library_screen.dart';
17
  import 'ui/features/share/views/share_screen.dart';
18
 
19
  Future<void> main() async {
@@ -100,7 +100,7 @@ class _CachyAppState extends State<CachyApp> {
100
  theme: AppTheme.light(),
101
  darkTheme: AppTheme.dark(),
102
  themeMode: ThemeMode.system,
103
- home: const LibraryScreen(),
104
  ),
105
  );
106
  }
 
12
  import 'data/repositories/card_repository.dart';
13
  import 'data/services/api_client.dart';
14
  import 'data/services/local_store.dart';
15
+ import 'ui/core/home_shell.dart';
16
  import 'ui/core/theme.dart';
 
17
  import 'ui/features/share/views/share_screen.dart';
18
 
19
  Future<void> main() async {
 
100
  theme: AppTheme.light(),
101
  darkTheme: AppTheme.dark(),
102
  themeMode: ThemeMode.system,
103
+ home: const HomeShell(),
104
  ),
105
  );
106
  }
app/lib/ui/core/home_shell.dart ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Root navigation shell: the two top-level spaces — Library (your cards) and
2
+ /// Catalog (artifacts referenced across them, docs/12). An IndexedStack keeps
3
+ /// each tab's state alive across switches.
4
+ library;
5
+
6
+ import 'package:flutter/material.dart';
7
+
8
+ import '../features/catalog/views/catalog_screen.dart';
9
+ import '../features/library/views/library_screen.dart';
10
+
11
+ class HomeShell extends StatefulWidget {
12
+ const HomeShell({super.key});
13
+
14
+ @override
15
+ State<HomeShell> createState() => _HomeShellState();
16
+ }
17
+
18
+ class _HomeShellState extends State<HomeShell> {
19
+ int _index = 0;
20
+
21
+ @override
22
+ Widget build(BuildContext context) {
23
+ return Scaffold(
24
+ body: IndexedStack(
25
+ index: _index,
26
+ children: const [LibraryScreen(), CatalogScreen()],
27
+ ),
28
+ bottomNavigationBar: NavigationBar(
29
+ selectedIndex: _index,
30
+ onDestinationSelected: (i) => setState(() => _index = i),
31
+ destinations: const [
32
+ NavigationDestination(
33
+ icon: Icon(Icons.video_library_outlined),
34
+ selectedIcon: Icon(Icons.video_library_rounded),
35
+ label: 'Library',
36
+ ),
37
+ NavigationDestination(
38
+ icon: Icon(Icons.auto_stories_outlined),
39
+ selectedIcon: Icon(Icons.auto_stories_rounded),
40
+ label: 'Catalog',
41
+ ),
42
+ ],
43
+ ),
44
+ );
45
+ }
46
+ }
app/lib/ui/features/catalog/view_models/catalog_view_model.dart ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Catalog state (MVVM). Loads aggregated artifacts from the repository, groups
2
+ /// them by type for sectioned display, and supports a type filter + delete.
3
+ library;
4
+
5
+ import 'package:flutter/foundation.dart';
6
+
7
+ import '../../../../data/repositories/card_repository.dart';
8
+ import '../../../../domain/models/artifact.dart';
9
+
10
+ enum CatalogStatus { idle, loading, ready, error, empty }
11
+
12
+ /// One display section: a type and its entries.
13
+ class CatalogSection {
14
+ const CatalogSection(this.type, this.entries);
15
+ final ArtifactType type;
16
+ final List<CatalogEntry> entries;
17
+ }
18
+
19
+ class CatalogViewModel extends ChangeNotifier {
20
+ CatalogViewModel({required CardRepository repository})
21
+ : _repository = repository;
22
+
23
+ final CardRepository _repository;
24
+
25
+ CatalogStatus _status = CatalogStatus.idle;
26
+ CatalogStatus get status => _status;
27
+
28
+ List<CatalogEntry> _entries = const [];
29
+
30
+ ArtifactType? _filter;
31
+ ArtifactType? get filter => _filter;
32
+
33
+ String? _error;
34
+ String? get error => _error;
35
+
36
+ /// The type filters that actually have entries, in catalog order — so the
37
+ /// filter bar never offers an empty category.
38
+ List<ArtifactType> get availableTypes {
39
+ final seen = <ArtifactType>{for (final e in _entries) e.type};
40
+ return ArtifactType.values.where(seen.contains).toList();
41
+ }
42
+
43
+ /// Entries grouped into sections (respecting the active filter).
44
+ List<CatalogSection> get sections {
45
+ final visible = _filter == null
46
+ ? _entries
47
+ : _entries.where((e) => e.type == _filter).toList();
48
+ final out = <CatalogSection>[];
49
+ for (final type in ArtifactType.values) {
50
+ final group = visible.where((e) => e.type == type).toList();
51
+ if (group.isNotEmpty) out.add(CatalogSection(type, group));
52
+ }
53
+ return out;
54
+ }
55
+
56
+ Future<void> load({bool showSpinner = true}) async {
57
+ if (showSpinner) {
58
+ _status = CatalogStatus.loading;
59
+ notifyListeners();
60
+ }
61
+ try {
62
+ _entries = await _repository.catalog();
63
+ _status =
64
+ _entries.isEmpty ? CatalogStatus.empty : CatalogStatus.ready;
65
+ _error = null;
66
+ } catch (e) {
67
+ _error = '$e';
68
+ _status = _entries.isEmpty ? CatalogStatus.error : CatalogStatus.ready;
69
+ }
70
+ notifyListeners();
71
+ }
72
+
73
+ Future<void> refresh() => load(showSpinner: false);
74
+
75
+ void setFilter(ArtifactType? type) {
76
+ if (_filter == type) return;
77
+ _filter = type;
78
+ notifyListeners();
79
+ }
80
+
81
+ Future<void> delete(String artifactId) async {
82
+ _entries = _entries.where((e) => e.id != artifactId).toList();
83
+ if (_entries.isEmpty) _status = CatalogStatus.empty;
84
+ notifyListeners();
85
+ try {
86
+ await _repository.deleteCatalogEntry(artifactId);
87
+ } catch (_) {
88
+ await load(showSpinner: false); // resync on failure
89
+ }
90
+ }
91
+ }
app/lib/ui/features/catalog/views/catalog_screen.dart ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The catalog: a browsable wall of artifact covers (docs/12) — every book,
2
+ /// movie, podcast, product, and place referenced across the user's cards,
3
+ /// deduplicated and grouped by type. Thumbnails are remote (free image APIs)
4
+ /// and degrade to a typed placeholder when absent or unreachable.
5
+ library;
6
+
7
+ import 'package:flutter/material.dart';
8
+ import 'package:provider/provider.dart';
9
+
10
+ import '../../../../data/repositories/card_repository.dart';
11
+ import '../../../../domain/models/artifact.dart';
12
+ import '../../../core/theme.dart';
13
+ import '../view_models/catalog_view_model.dart';
14
+
15
+ class CatalogScreen extends StatelessWidget {
16
+ const CatalogScreen({super.key});
17
+
18
+ @override
19
+ Widget build(BuildContext context) {
20
+ return ChangeNotifierProvider(
21
+ create: (ctx) =>
22
+ CatalogViewModel(repository: ctx.read<CardRepository>())..load(),
23
+ child: const _CatalogView(),
24
+ );
25
+ }
26
+ }
27
+
28
+ class _CatalogView extends StatelessWidget {
29
+ const _CatalogView();
30
+
31
+ @override
32
+ Widget build(BuildContext context) {
33
+ final vm = context.watch<CatalogViewModel>();
34
+ return Scaffold(
35
+ appBar: AppBar(
36
+ title: const Text('Catalog'),
37
+ bottom: vm.availableTypes.isEmpty
38
+ ? null
39
+ : PreferredSize(
40
+ preferredSize: const Size.fromHeight(50),
41
+ child: _FilterBar(
42
+ types: vm.availableTypes,
43
+ selected: vm.filter,
44
+ onSelect: vm.setFilter,
45
+ ),
46
+ ),
47
+ ),
48
+ body: RefreshIndicator(
49
+ onRefresh: vm.refresh,
50
+ child: _body(context, vm),
51
+ ),
52
+ );
53
+ }
54
+
55
+ Widget _body(BuildContext context, CatalogViewModel vm) {
56
+ switch (vm.status) {
57
+ case CatalogStatus.loading:
58
+ return const Center(child: CircularProgressIndicator());
59
+ case CatalogStatus.error:
60
+ return _Message(
61
+ icon: Icons.wifi_off_rounded,
62
+ title: "Can't reach the backend",
63
+ subtitle: vm.error ?? '',
64
+ action: FilledButton(onPressed: vm.load, child: const Text('Retry')),
65
+ );
66
+ case CatalogStatus.empty:
67
+ return const _Message(
68
+ icon: Icons.auto_stories_outlined,
69
+ title: 'Nothing catalogued yet',
70
+ subtitle:
71
+ 'Books, movies, podcasts and places mentioned in your cards '
72
+ 'show up here automatically.',
73
+ );
74
+ case CatalogStatus.idle:
75
+ case CatalogStatus.ready:
76
+ final sections = vm.sections;
77
+ return ListView.builder(
78
+ padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
79
+ physics: const AlwaysScrollableScrollPhysics(),
80
+ itemCount: sections.length,
81
+ itemBuilder: (ctx, i) => _Section(section: sections[i], vm: vm),
82
+ );
83
+ }
84
+ }
85
+ }
86
+
87
+ class _Section extends StatelessWidget {
88
+ const _Section({required this.section, required this.vm});
89
+ final CatalogSection section;
90
+ final CatalogViewModel vm;
91
+
92
+ @override
93
+ Widget build(BuildContext context) {
94
+ final theme = Theme.of(context);
95
+ return Column(
96
+ crossAxisAlignment: CrossAxisAlignment.start,
97
+ children: [
98
+ Padding(
99
+ padding: const EdgeInsets.only(top: 16, bottom: 10),
100
+ child: Text(
101
+ section.type.sectionLabel,
102
+ style: theme.textTheme.titleMedium
103
+ ?.copyWith(fontWeight: FontWeight.w700),
104
+ ),
105
+ ),
106
+ GridView.builder(
107
+ shrinkWrap: true,
108
+ physics: const NeverScrollableScrollPhysics(),
109
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
110
+ crossAxisCount: 3,
111
+ mainAxisSpacing: 14,
112
+ crossAxisSpacing: 14,
113
+ childAspectRatio: 0.58,
114
+ ),
115
+ itemCount: section.entries.length,
116
+ itemBuilder: (ctx, i) => _ArtifactTile(
117
+ entry: section.entries[i],
118
+ onDelete: () => vm.delete(section.entries[i].id),
119
+ ),
120
+ ),
121
+ ],
122
+ );
123
+ }
124
+ }
125
+
126
+ class _ArtifactTile extends StatelessWidget {
127
+ const _ArtifactTile({required this.entry, required this.onDelete});
128
+ final CatalogEntry entry;
129
+ final VoidCallback onDelete;
130
+
131
+ @override
132
+ Widget build(BuildContext context) {
133
+ final theme = Theme.of(context);
134
+ return GestureDetector(
135
+ onLongPress: () => _confirmDelete(context),
136
+ child: Column(
137
+ crossAxisAlignment: CrossAxisAlignment.start,
138
+ children: [
139
+ AspectRatio(
140
+ aspectRatio: 0.72,
141
+ child: ClipRRect(
142
+ borderRadius: BorderRadius.circular(12),
143
+ child: _Cover(entry: entry),
144
+ ),
145
+ ),
146
+ const SizedBox(height: 6),
147
+ Text(
148
+ entry.title,
149
+ maxLines: 2,
150
+ overflow: TextOverflow.ellipsis,
151
+ style: theme.textTheme.bodySmall
152
+ ?.copyWith(fontWeight: FontWeight.w600, height: 1.2),
153
+ ),
154
+ if (entry.subtitle.isNotEmpty)
155
+ Text(
156
+ entry.subtitle,
157
+ maxLines: 1,
158
+ overflow: TextOverflow.ellipsis,
159
+ style: theme.textTheme.labelSmall
160
+ ?.copyWith(color: theme.colorScheme.onSurfaceVariant),
161
+ ),
162
+ ],
163
+ ),
164
+ );
165
+ }
166
+
167
+ Future<void> _confirmDelete(BuildContext context) async {
168
+ final ok = await showDialog<bool>(
169
+ context: context,
170
+ builder: (ctx) => AlertDialog(
171
+ title: const Text('Remove from catalog?'),
172
+ content: Text('“${entry.title}” will be removed from the catalog.'),
173
+ actions: [
174
+ TextButton(
175
+ onPressed: () => Navigator.pop(ctx, false),
176
+ child: const Text('Cancel'),
177
+ ),
178
+ FilledButton(
179
+ onPressed: () => Navigator.pop(ctx, true),
180
+ child: const Text('Remove'),
181
+ ),
182
+ ],
183
+ ),
184
+ );
185
+ if (ok == true) onDelete();
186
+ }
187
+ }
188
+
189
+ /// The cover image with a typed placeholder fallback. `Image.network` errors
190
+ /// (offline, dead hotlink, 404) degrade to the placeholder, never crash.
191
+ class _Cover extends StatelessWidget {
192
+ const _Cover({required this.entry});
193
+ final CatalogEntry entry;
194
+
195
+ @override
196
+ Widget build(BuildContext context) {
197
+ final thumb = entry.thumbnail;
198
+ if (thumb == null || thumb.isEmpty) return _Placeholder(type: entry.type);
199
+ return Image.network(
200
+ thumb,
201
+ fit: BoxFit.cover,
202
+ errorBuilder: (context, error, stack) => _Placeholder(type: entry.type),
203
+ loadingBuilder: (ctx, child, progress) =>
204
+ progress == null ? child : _Placeholder(type: entry.type),
205
+ );
206
+ }
207
+ }
208
+
209
+ class _Placeholder extends StatelessWidget {
210
+ const _Placeholder({required this.type});
211
+ final ArtifactType type;
212
+
213
+ static const _icons = {
214
+ ArtifactType.book: Icons.menu_book_rounded,
215
+ ArtifactType.movie: Icons.movie_rounded,
216
+ ArtifactType.tvShow: Icons.tv_rounded,
217
+ ArtifactType.podcast: Icons.podcasts_rounded,
218
+ ArtifactType.music: Icons.music_note_rounded,
219
+ ArtifactType.product: Icons.shopping_bag_rounded,
220
+ ArtifactType.place: Icons.place_rounded,
221
+ ArtifactType.app: Icons.apps_rounded,
222
+ ArtifactType.other: Icons.category_rounded,
223
+ };
224
+
225
+ @override
226
+ Widget build(BuildContext context) {
227
+ final theme = Theme.of(context);
228
+ return ColoredBox(
229
+ color: theme.colorScheme.surfaceContainerHighest,
230
+ child: Center(
231
+ child: Icon(
232
+ _icons[type] ?? Icons.category_rounded,
233
+ size: 32,
234
+ color: theme.colorScheme.onSurfaceVariant,
235
+ ),
236
+ ),
237
+ );
238
+ }
239
+ }
240
+
241
+ class _FilterBar extends StatelessWidget {
242
+ const _FilterBar({
243
+ required this.types,
244
+ required this.selected,
245
+ required this.onSelect,
246
+ });
247
+ final List<ArtifactType> types;
248
+ final ArtifactType? selected;
249
+ final ValueChanged<ArtifactType?> onSelect;
250
+
251
+ @override
252
+ Widget build(BuildContext context) {
253
+ return SizedBox(
254
+ height: 50,
255
+ child: ListView(
256
+ scrollDirection: Axis.horizontal,
257
+ padding: const EdgeInsets.symmetric(horizontal: Insets.page),
258
+ children: [
259
+ Padding(
260
+ padding: const EdgeInsets.only(right: 8),
261
+ child: ChoiceChip(
262
+ label: const Text('All'),
263
+ selected: selected == null,
264
+ onSelected: (_) => onSelect(null),
265
+ ),
266
+ ),
267
+ for (final type in types)
268
+ Padding(
269
+ padding: const EdgeInsets.only(right: 8),
270
+ child: ChoiceChip(
271
+ label: Text(type.sectionLabel),
272
+ selected: selected == type,
273
+ onSelected: (_) => onSelect(type),
274
+ ),
275
+ ),
276
+ ],
277
+ ),
278
+ );
279
+ }
280
+ }
281
+
282
+ class _Message extends StatelessWidget {
283
+ const _Message({
284
+ required this.icon,
285
+ required this.title,
286
+ required this.subtitle,
287
+ this.action,
288
+ });
289
+ final IconData icon;
290
+ final String title;
291
+ final String subtitle;
292
+ final Widget? action;
293
+
294
+ @override
295
+ Widget build(BuildContext context) {
296
+ final theme = Theme.of(context);
297
+ return ListView(
298
+ physics: const AlwaysScrollableScrollPhysics(),
299
+ children: [
300
+ SizedBox(height: MediaQuery.of(context).size.height * 0.22),
301
+ Icon(icon, size: 52, color: theme.colorScheme.outline),
302
+ const SizedBox(height: 16),
303
+ Text(title,
304
+ textAlign: TextAlign.center, style: theme.textTheme.titleLarge),
305
+ const SizedBox(height: 8),
306
+ Padding(
307
+ padding: const EdgeInsets.symmetric(horizontal: 48),
308
+ child: Text(subtitle,
309
+ textAlign: TextAlign.center,
310
+ style: theme.textTheme.bodyMedium
311
+ ?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
312
+ ),
313
+ if (action != null) ...[
314
+ const SizedBox(height: 20),
315
+ Center(child: action!),
316
+ ],
317
+ ],
318
+ );
319
+ }
320
+ }
app/lib/ui/features/library/view_models/library_view_model.dart CHANGED
@@ -2,6 +2,8 @@
2
  /// pull-to-refresh, filtering by state, and delete. Falls back to cache offline.
3
  library;
4
 
 
 
5
  import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
@@ -31,6 +33,63 @@ class LibraryViewModel extends ChangeNotifier {
31
  bool _offline = false;
32
  bool get offline => _offline;
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  Future<void> load({bool showSpinner = true}) async {
35
  if (showSpinner) {
36
  _status = LibraryStatus.loading;
 
2
  /// pull-to-refresh, filtering by state, and delete. Falls back to cache offline.
3
  library;
4
 
5
+ import 'dart:async';
6
+
7
  import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
 
33
  bool _offline = false;
34
  bool get offline => _offline;
35
 
36
+ // --- search (full-text, /search endpoint) ------------------------------ //
37
+ String _query = '';
38
+ String get query => _query;
39
+ bool get searching => _query.trim().isNotEmpty;
40
+
41
+ List<Card> _results = const [];
42
+ List<Card> get results => List.unmodifiable(_results);
43
+
44
+ bool _searchBusy = false;
45
+ bool get searchBusy => _searchBusy;
46
+
47
+ Timer? _debounce;
48
+
49
+ /// Cards to render: search hits when a query is active, else the library.
50
+ List<Card> get visibleCards => searching ? _results : cards;
51
+
52
+ void setQuery(String value) {
53
+ _query = value;
54
+ notifyListeners();
55
+ _debounce?.cancel();
56
+ if (value.trim().isEmpty) {
57
+ _results = const [];
58
+ _searchBusy = false;
59
+ notifyListeners();
60
+ return;
61
+ }
62
+ _searchBusy = true;
63
+ notifyListeners();
64
+ _debounce = Timer(const Duration(milliseconds: 300), () => _runSearch(value));
65
+ }
66
+
67
+ void clearSearch() {
68
+ _debounce?.cancel();
69
+ _query = '';
70
+ _results = const [];
71
+ _searchBusy = false;
72
+ notifyListeners();
73
+ }
74
+
75
+ Future<void> _runSearch(String value) async {
76
+ try {
77
+ final hits = await _repository.search(value.trim());
78
+ if (value != _query) return; // a newer keystroke superseded this one
79
+ _results = hits;
80
+ } catch (_) {
81
+ _results = const [];
82
+ }
83
+ _searchBusy = false;
84
+ notifyListeners();
85
+ }
86
+
87
+ @override
88
+ void dispose() {
89
+ _debounce?.cancel();
90
+ super.dispose();
91
+ }
92
+
93
  Future<void> load({bool showSpinner = true}) async {
94
  if (showSpinner) {
95
  _status = LibraryStatus.loading;
app/lib/ui/features/library/views/library_screen.dart CHANGED
@@ -49,17 +49,27 @@ class _LibraryView extends StatelessWidget {
49
  ),
50
  ],
51
  bottom: PreferredSize(
52
- preferredSize: const Size.fromHeight(50),
53
- child: _FilterBar(
54
- selected: vm.filter,
55
- onSelect: vm.setFilter,
 
 
 
 
 
 
 
 
 
 
56
  ),
57
  ),
58
  ),
59
  floatingActionButton: FloatingActionButton.extended(
60
  onPressed: () => _openPaste(context),
61
  icon: const Icon(Icons.add_link_rounded),
62
- label: const Text('Add reel'),
63
  ),
64
  body: RefreshIndicator(
65
  onRefresh: vm.refresh,
@@ -69,6 +79,21 @@ class _LibraryView extends StatelessWidget {
69
  }
70
 
71
  Widget _body(BuildContext context, LibraryViewModel vm, api) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  switch (vm.status) {
73
  case LibraryStatus.loading:
74
  return const Center(child: CircularProgressIndicator());
@@ -83,36 +108,40 @@ class _LibraryView extends StatelessWidget {
83
  return const _Message(
84
  icon: Icons.video_library_outlined,
85
  title: 'No cards yet',
86
- subtitle: 'Share a reel or paste a link to make your first card.',
87
  );
88
  case LibraryStatus.idle:
89
  case LibraryStatus.ready:
90
- return GridView.builder(
91
- padding: const EdgeInsets.fromLTRB(
92
- Insets.page, 12, Insets.page, 96),
93
- physics: const AlwaysScrollableScrollPhysics(),
94
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
95
- crossAxisCount: 2,
96
- mainAxisSpacing: 14,
97
- crossAxisSpacing: 14,
98
- childAspectRatio: 0.72,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ),
100
- itemCount: vm.cards.length,
101
- itemBuilder: (ctx, i) {
102
- final card = vm.cards[i];
103
- return CardTile(
104
- card: card,
105
- api: api,
106
- onTap: () => Navigator.of(ctx).push(
107
- MaterialPageRoute(
108
- builder: (_) => ReaderScreen(cardId: card.cardId),
109
- ),
110
- ),
111
- onDelete: () => vm.delete(card.cardId),
112
- );
113
- },
114
  );
115
- }
 
116
  }
117
 
118
  Future<void> _openPaste(BuildContext context) async {
@@ -131,15 +160,19 @@ class _LibraryView extends StatelessWidget {
131
  mainAxisSize: MainAxisSize.min,
132
  crossAxisAlignment: CrossAxisAlignment.start,
133
  children: [
134
- Text('Paste a reel link',
135
  style: Theme.of(ctx).textTheme.titleLarge),
 
 
 
 
136
  const SizedBox(height: 14),
137
  TextField(
138
  controller: controller,
139
  autofocus: true,
140
  keyboardType: TextInputType.url,
141
  decoration: const InputDecoration(
142
- hintText: 'https://instagram.com/reel/…',
143
  border: OutlineInputBorder(),
144
  ),
145
  onSubmitted: (v) => Navigator.pop(ctx, v),
@@ -162,6 +195,72 @@ class _LibraryView extends StatelessWidget {
162
  }
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  class _FilterBar extends StatelessWidget {
166
  const _FilterBar({required this.selected, required this.onSelect});
167
  final CardState? selected;
 
49
  ),
50
  ],
51
  bottom: PreferredSize(
52
+ preferredSize: const Size.fromHeight(108),
53
+ child: Column(
54
+ children: [
55
+ _SearchField(
56
+ query: vm.query,
57
+ busy: vm.searchBusy,
58
+ onChanged: vm.setQuery,
59
+ onClear: vm.clearSearch,
60
+ ),
61
+ _FilterBar(
62
+ selected: vm.filter,
63
+ onSelect: vm.setFilter,
64
+ ),
65
+ ],
66
  ),
67
  ),
68
  ),
69
  floatingActionButton: FloatingActionButton.extended(
70
  onPressed: () => _openPaste(context),
71
  icon: const Icon(Icons.add_link_rounded),
72
+ label: const Text('Add link'),
73
  ),
74
  body: RefreshIndicator(
75
  onRefresh: vm.refresh,
 
79
  }
80
 
81
  Widget _body(BuildContext context, LibraryViewModel vm, api) {
82
+ // An active search overrides the normal library/status views.
83
+ if (vm.searching) {
84
+ if (vm.searchBusy && vm.results.isEmpty) {
85
+ return const Center(child: CircularProgressIndicator());
86
+ }
87
+ if (vm.results.isEmpty) {
88
+ return _Message(
89
+ icon: Icons.search_off_rounded,
90
+ title: 'No matches',
91
+ subtitle: 'Nothing in your library matches “${vm.query.trim()}”.',
92
+ );
93
+ }
94
+ return _grid(context, vm.results, vm, api);
95
+ }
96
+
97
  switch (vm.status) {
98
  case LibraryStatus.loading:
99
  return const Center(child: CircularProgressIndicator());
 
108
  return const _Message(
109
  icon: Icons.video_library_outlined,
110
  title: 'No cards yet',
111
+ subtitle: 'Share or paste any link a reel, article, or post.',
112
  );
113
  case LibraryStatus.idle:
114
  case LibraryStatus.ready:
115
+ return _grid(context, vm.cards, vm, api);
116
+ }
117
+ }
118
+
119
+ Widget _grid(
120
+ BuildContext context, List cards, LibraryViewModel vm, api) {
121
+ return GridView.builder(
122
+ padding: const EdgeInsets.fromLTRB(Insets.page, 12, Insets.page, 96),
123
+ physics: const AlwaysScrollableScrollPhysics(),
124
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
125
+ crossAxisCount: 2,
126
+ mainAxisSpacing: 14,
127
+ crossAxisSpacing: 14,
128
+ childAspectRatio: 0.72,
129
+ ),
130
+ itemCount: cards.length,
131
+ itemBuilder: (ctx, i) {
132
+ final card = cards[i];
133
+ return CardTile(
134
+ card: card,
135
+ api: api,
136
+ onTap: () => Navigator.of(ctx).push(
137
+ MaterialPageRoute(
138
+ builder: (_) => ReaderScreen(cardId: card.cardId),
139
+ ),
140
  ),
141
+ onDelete: () => vm.delete(card.cardId),
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  );
143
+ },
144
+ );
145
  }
146
 
147
  Future<void> _openPaste(BuildContext context) async {
 
160
  mainAxisSize: MainAxisSize.min,
161
  crossAxisAlignment: CrossAxisAlignment.start,
162
  children: [
163
+ Text('Paste a link',
164
  style: Theme.of(ctx).textTheme.titleLarge),
165
+ const SizedBox(height: 6),
166
+ Text('Reel, article, post, or page — any source.',
167
+ style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
168
+ color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
169
  const SizedBox(height: 14),
170
  TextField(
171
  controller: controller,
172
  autofocus: true,
173
  keyboardType: TextInputType.url,
174
  decoration: const InputDecoration(
175
+ hintText: 'https://…',
176
  border: OutlineInputBorder(),
177
  ),
178
  onSubmitted: (v) => Navigator.pop(ctx, v),
 
195
  }
196
  }
197
 
198
+ class _SearchField extends StatefulWidget {
199
+ const _SearchField({
200
+ required this.query,
201
+ required this.busy,
202
+ required this.onChanged,
203
+ required this.onClear,
204
+ });
205
+ final String query;
206
+ final bool busy;
207
+ final ValueChanged<String> onChanged;
208
+ final VoidCallback onClear;
209
+
210
+ @override
211
+ State<_SearchField> createState() => _SearchFieldState();
212
+ }
213
+
214
+ class _SearchFieldState extends State<_SearchField> {
215
+ late final TextEditingController _controller =
216
+ TextEditingController(text: widget.query);
217
+
218
+ @override
219
+ void dispose() {
220
+ _controller.dispose();
221
+ super.dispose();
222
+ }
223
+
224
+ @override
225
+ Widget build(BuildContext context) {
226
+ return Padding(
227
+ padding: const EdgeInsets.fromLTRB(Insets.page, 4, Insets.page, 4),
228
+ child: TextField(
229
+ controller: _controller,
230
+ textInputAction: TextInputAction.search,
231
+ onChanged: (v) {
232
+ setState(() {}); // refresh the clear-button affordance
233
+ widget.onChanged(v);
234
+ },
235
+ decoration: InputDecoration(
236
+ isDense: true,
237
+ hintText: 'Search your cards',
238
+ prefixIcon: widget.busy
239
+ ? const Padding(
240
+ padding: EdgeInsets.all(12),
241
+ child: SizedBox(
242
+ width: 18,
243
+ height: 18,
244
+ child: CircularProgressIndicator(strokeWidth: 2),
245
+ ),
246
+ )
247
+ : const Icon(Icons.search_rounded),
248
+ suffixIcon: _controller.text.isEmpty
249
+ ? null
250
+ : IconButton(
251
+ icon: const Icon(Icons.close_rounded),
252
+ onPressed: () {
253
+ _controller.clear();
254
+ widget.onClear();
255
+ },
256
+ ),
257
+ border: const OutlineInputBorder(),
258
+ ),
259
+ ),
260
+ );
261
+ }
262
+ }
263
+
264
  class _FilterBar extends StatelessWidget {
265
  const _FilterBar({required this.selected, required this.onSelect});
266
  final CardState? selected;
app/lib/ui/features/reader/services/card_actions.dart ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The action layer (docs/13): turns a card into things the device can do.
2
+ /// Actions are **content-aware** — derived from the blocks a card actually has,
3
+ /// not just its content_type — plus a common set offered on every card. Every
4
+ /// payload is built client-side from existing blocks, so nothing here needs a
5
+ /// schema change. All handlers are best-effort and never crash the reader.
6
+ library;
7
+
8
+ import 'package:add_2_calendar/add_2_calendar.dart';
9
+ import 'package:flutter/material.dart' hide Card;
10
+ import 'package:flutter/services.dart';
11
+ import 'package:share_plus/share_plus.dart';
12
+ import 'package:url_launcher/url_launcher.dart';
13
+
14
+ import '../../../../domain/models/block.dart';
15
+ import '../../../../domain/models/card.dart';
16
+
17
+ /// Outcome of an action so the UI can show the right message.
18
+ enum ActionResult { done, copied, empty, failed }
19
+
20
+ /// Every side-effect action the action layer can perform. (Chat/"Ask" is a
21
+ /// navigation concern handled by the reader, not a side-effect, so it's not here.)
22
+ enum CardActionType {
23
+ copy,
24
+ share,
25
+ openOriginal,
26
+ addToCalendar,
27
+ shoppingList,
28
+ openMaps,
29
+ openLinks,
30
+ }
31
+
32
+ /// A presentable action: what to label it and which icon to show.
33
+ class CardActionSpec {
34
+ const CardActionSpec(this.type, this.label, this.icon);
35
+ final CardActionType type;
36
+ final String label;
37
+ final IconData icon;
38
+ }
39
+
40
+ class CardActions {
41
+ const CardActions();
42
+
43
+ /// The ordered, content-aware action set for a card. Common actions first,
44
+ /// then ones unlocked by the blocks this card actually contains.
45
+ List<CardActionSpec> available(Card card) {
46
+ final out = <CardActionSpec>[
47
+ const CardActionSpec(CardActionType.copy, 'Copy', Icons.copy_rounded),
48
+ const CardActionSpec(CardActionType.share, 'Share', Icons.ios_share_rounded),
49
+ const CardActionSpec(
50
+ CardActionType.addToCalendar, 'Add to calendar', Icons.event_rounded),
51
+ ];
52
+ if (_hasPlace(card)) {
53
+ out.add(const CardActionSpec(
54
+ CardActionType.openMaps, 'Open in Maps', Icons.map_rounded));
55
+ }
56
+ if (_hasListItems(card)) {
57
+ out.add(const CardActionSpec(CardActionType.shoppingList, 'Shopping list',
58
+ Icons.add_shopping_cart_rounded));
59
+ }
60
+ if (_hasLinks(card)) {
61
+ out.add(const CardActionSpec(
62
+ CardActionType.openLinks, 'Open links', Icons.link_rounded));
63
+ }
64
+ if (card.source.url.isNotEmpty) {
65
+ out.add(const CardActionSpec(CardActionType.openOriginal, 'Open original',
66
+ Icons.play_circle_outline_rounded));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /// Map the server-derived primary action to a concrete handler so the big
72
+ /// primary button runs through the same dispatch as the secondary set.
73
+ CardActionType? primaryType(Card card) {
74
+ switch (card.primaryAction.kind.name) {
75
+ case 'export':
76
+ return CardActionType.share;
77
+ case 'shoppingList':
78
+ return CardActionType.shoppingList;
79
+ case 'savePlace':
80
+ return CardActionType.openMaps;
81
+ case 'reminder':
82
+ case 'schedule':
83
+ return CardActionType.addToCalendar;
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+
89
+ Future<ActionResult> perform(Card card, CardActionType type) async {
90
+ try {
91
+ switch (type) {
92
+ case CardActionType.copy:
93
+ return _copy(card);
94
+ case CardActionType.share:
95
+ return _share(_cardToMarkdown(card), card.base.oneLiner);
96
+ case CardActionType.openOriginal:
97
+ return _launch(card.source.url);
98
+ case CardActionType.addToCalendar:
99
+ return _addToCalendar(card);
100
+ case CardActionType.shoppingList:
101
+ return _shareShoppingList(card);
102
+ case CardActionType.openMaps:
103
+ return _openInMaps(card);
104
+ case CardActionType.openLinks:
105
+ return _openLinks(card);
106
+ }
107
+ } catch (_) {
108
+ return ActionResult.failed;
109
+ }
110
+ }
111
+
112
+ // --- block predicates -------------------------------------------------- //
113
+
114
+ bool _hasPlace(Card card) =>
115
+ card.blocks.any((b) => b is MapBlock && b.places.isNotEmpty);
116
+
117
+ bool _hasListItems(Card card) => card.blocks.any(
118
+ (b) => b is ChecklistBlock || b is BulletListBlock);
119
+
120
+ bool _hasLinks(Card card) => card.blocks.any((b) => b is LinkBlock);
121
+
122
+ // --- handlers ---------------------------------------------------------- //
123
+
124
+ Future<ActionResult> _copy(Card card) async {
125
+ final text = _cardToMarkdown(card);
126
+ if (text.trim().isEmpty) return ActionResult.empty;
127
+ await Clipboard.setData(ClipboardData(text: text));
128
+ return ActionResult.copied;
129
+ }
130
+
131
+ Future<ActionResult> _share(String text, String subject) async {
132
+ if (text.trim().isEmpty) return ActionResult.empty;
133
+ await Share.share(text, subject: subject.isEmpty ? 'Cachy card' : subject);
134
+ return ActionResult.done;
135
+ }
136
+
137
+ Future<ActionResult> _launch(String url) async {
138
+ if (url.trim().isEmpty) return ActionResult.empty;
139
+ final ok = await launchUrl(Uri.parse(url),
140
+ mode: LaunchMode.externalApplication);
141
+ return ok ? ActionResult.done : ActionResult.failed;
142
+ }
143
+
144
+ Future<ActionResult> _shareShoppingList(Card card) async {
145
+ final items = <String>[];
146
+ for (final b in card.blocks) {
147
+ if (b is ChecklistBlock) {
148
+ items.addAll(b.items.map((i) => i.text));
149
+ } else if (b is BulletListBlock) {
150
+ items.addAll(b.items);
151
+ }
152
+ }
153
+ final clean = items.where((i) => i.trim().isNotEmpty).toList();
154
+ if (clean.isEmpty) return ActionResult.empty;
155
+ final title = card.base.oneLiner.isEmpty ? 'Shopping list' : card.base.oneLiner;
156
+ final body = '$title\n\n${clean.map((i) => '- [ ] $i').join('\n')}';
157
+ return _share(body, title);
158
+ }
159
+
160
+ Future<ActionResult> _openInMaps(Card card) async {
161
+ Place? place;
162
+ for (final b in card.blocks) {
163
+ if (b is MapBlock && b.places.isNotEmpty) {
164
+ place = b.places.first;
165
+ break;
166
+ }
167
+ }
168
+ final query = place?.name ?? card.base.oneLiner;
169
+ if (query.trim().isEmpty) return ActionResult.empty;
170
+ final Uri uri;
171
+ if (place?.lat != null && place?.lng != null) {
172
+ uri = Uri.parse(
173
+ 'https://www.google.com/maps/search/?api=1&query=${place!.lat},${place.lng}',
174
+ );
175
+ } else {
176
+ uri = Uri.parse(
177
+ 'https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}',
178
+ );
179
+ }
180
+ final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
181
+ return ok ? ActionResult.done : ActionResult.failed;
182
+ }
183
+
184
+ Future<ActionResult> _openLinks(Card card) async {
185
+ final urls = card.blocks
186
+ .whereType<LinkBlock>()
187
+ .map((b) => b.url)
188
+ .where((u) => u.trim().isNotEmpty)
189
+ .toList();
190
+ if (urls.isEmpty) return ActionResult.empty;
191
+ // Open the first link; the rest stay visible as tappable link blocks.
192
+ return _launch(urls.first);
193
+ }
194
+
195
+ Future<ActionResult> _addToCalendar(Card card) async {
196
+ final title = card.base.oneLiner.isEmpty ? 'Cachy reminder' : card.base.oneLiner;
197
+ final start = DateTime.now().add(const Duration(days: 1));
198
+ final desc = [card.base.tldr, card.source.url]
199
+ .where((s) => s.trim().isNotEmpty)
200
+ .join('\n\n');
201
+ final event = Event(
202
+ title: title,
203
+ description: desc,
204
+ startDate: start,
205
+ endDate: start.add(const Duration(hours: 1)),
206
+ );
207
+ final ok = await Add2Calendar.addEvent2Cal(event);
208
+ return ok ? ActionResult.done : ActionResult.failed;
209
+ }
210
+
211
+ // --- markdown serialization (copy / share) ----------------------------- //
212
+
213
+ String _cardToMarkdown(Card card) {
214
+ final out = <String>[];
215
+ if (card.base.oneLiner.isNotEmpty) out.add('# ${card.base.oneLiner}');
216
+ if (card.base.tldr.isNotEmpty) out.add('> ${card.base.tldr}');
217
+ for (final b in card.blocks) {
218
+ out.add(_blockToMarkdown(b));
219
+ }
220
+ if (card.source.url.isNotEmpty) {
221
+ out.add('---\nSource: ${card.source.url}');
222
+ }
223
+ return out.where((s) => s.trim().isNotEmpty).join('\n\n');
224
+ }
225
+
226
+ String _blockToMarkdown(Block b) {
227
+ switch (b) {
228
+ case HeadingBlock(:final text, :final level):
229
+ return '${'#' * level.clamp(1, 6)} $text';
230
+ case ParagraphBlock(:final text):
231
+ return text;
232
+ case BulletListBlock(:final items):
233
+ return items.map((i) => '- $i').join('\n');
234
+ case StepListBlock(:final steps):
235
+ return steps
236
+ .asMap()
237
+ .entries
238
+ .map((e) => '${e.key + 1}. ${e.value.text}')
239
+ .join('\n');
240
+ case KeyValueBlock(:final pairs):
241
+ return pairs.map((p) => '**${p.key}:** ${p.value}').join('\n');
242
+ case ChecklistBlock(:final items):
243
+ return items
244
+ .map((i) => '- [${i.checked ? 'x' : ' '}] ${i.text}')
245
+ .join('\n');
246
+ case CalloutBlock(:final text):
247
+ return '> $text';
248
+ case LinkBlock(:final url, :final label):
249
+ return '[${label ?? url}]($url)';
250
+ case MapBlock(:final places):
251
+ return places
252
+ .map((p) => '- ${p.name}${p.note.isEmpty ? '' : ' — ${p.note}'}')
253
+ .join('\n');
254
+ case TableBlock(:final headers, :final rows):
255
+ final head = '| ${headers.join(' | ')} |';
256
+ final sep = '| ${headers.map((_) => '---').join(' | ')} |';
257
+ final body = rows.map((r) => '| ${r.join(' | ')} |').join('\n');
258
+ return '$head\n$sep\n$body';
259
+ case UnknownBlock(:final text):
260
+ return text ?? '';
261
+ }
262
+ }
263
+ }
app/lib/ui/features/reader/view_models/chat_view_model.dart ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Chat Q&A over a single card (docs/13). Stateless on the server: this view
2
+ /// model holds the conversation and replays the whole history on every turn.
3
+ library;
4
+
5
+ import 'package:flutter/foundation.dart';
6
+
7
+ import '../../../../data/repositories/card_repository.dart';
8
+ import '../../../../data/services/api_client.dart';
9
+
10
+ class ChatMessage {
11
+ const ChatMessage({required this.role, required this.content});
12
+ final String role; // "user" | "assistant"
13
+ final String content;
14
+
15
+ bool get isUser => role == 'user';
16
+ Map<String, String> toWire() => {'role': role, 'content': content};
17
+ }
18
+
19
+ class ChatViewModel extends ChangeNotifier {
20
+ ChatViewModel({required CardRepository repository, required this.cardId})
21
+ : _repository = repository;
22
+
23
+ final CardRepository _repository;
24
+ final String cardId;
25
+
26
+ final List<ChatMessage> _messages = [];
27
+ List<ChatMessage> get messages => List.unmodifiable(_messages);
28
+
29
+ bool _busy = false;
30
+ bool get busy => _busy;
31
+
32
+ String? _error;
33
+ String? get error => _error;
34
+
35
+ bool get isEmpty => _messages.isEmpty;
36
+
37
+ Future<void> send(String text) async {
38
+ final trimmed = text.trim();
39
+ if (trimmed.isEmpty || _busy) return;
40
+
41
+ _messages.add(ChatMessage(role: 'user', content: trimmed));
42
+ _busy = true;
43
+ _error = null;
44
+ notifyListeners();
45
+
46
+ try {
47
+ final reply = await _repository.chat(
48
+ cardId,
49
+ _messages.map((m) => m.toWire()).toList(),
50
+ );
51
+ _messages.add(ChatMessage(role: 'assistant', content: reply));
52
+ } on ApiException catch (e) {
53
+ _error = e.statusCode == 503
54
+ ? 'Chat is unavailable — no AI backend is configured.'
55
+ : "Couldn't get an answer. Try again.";
56
+ } catch (_) {
57
+ _error = "Couldn't reach the backend.";
58
+ }
59
+ _busy = false;
60
+ notifyListeners();
61
+ }
62
+ }
app/lib/ui/features/reader/views/chat_screen.dart ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Ask-the-card screen (docs/13): a grounded chat over one card's content.
2
+ /// Answers come from the backend LLM using only the card as context, so the
3
+ /// header carries the standard "AI-generated, may contain errors" note.
4
+ library;
5
+
6
+ import 'package:flutter/material.dart';
7
+ import 'package:provider/provider.dart';
8
+
9
+ import '../../../../data/repositories/card_repository.dart';
10
+ import '../view_models/chat_view_model.dart';
11
+
12
+ class ChatScreen extends StatelessWidget {
13
+ const ChatScreen({super.key, required this.cardId, required this.title});
14
+ final String cardId;
15
+ final String title;
16
+
17
+ @override
18
+ Widget build(BuildContext context) {
19
+ return ChangeNotifierProvider(
20
+ create: (ctx) => ChatViewModel(
21
+ repository: ctx.read<CardRepository>(),
22
+ cardId: cardId,
23
+ ),
24
+ child: _ChatView(title: title),
25
+ );
26
+ }
27
+ }
28
+
29
+ class _ChatView extends StatefulWidget {
30
+ const _ChatView({required this.title});
31
+ final String title;
32
+
33
+ @override
34
+ State<_ChatView> createState() => _ChatViewState();
35
+ }
36
+
37
+ class _ChatViewState extends State<_ChatView> {
38
+ final _controller = TextEditingController();
39
+ final _scroll = ScrollController();
40
+
41
+ @override
42
+ void dispose() {
43
+ _controller.dispose();
44
+ _scroll.dispose();
45
+ super.dispose();
46
+ }
47
+
48
+ void _send(ChatViewModel vm) {
49
+ final text = _controller.text;
50
+ _controller.clear();
51
+ vm.send(text).then((_) => _scrollToEnd());
52
+ _scrollToEnd();
53
+ }
54
+
55
+ void _scrollToEnd() {
56
+ WidgetsBinding.instance.addPostFrameCallback((_) {
57
+ if (_scroll.hasClients) {
58
+ _scroll.animateTo(
59
+ _scroll.position.maxScrollExtent,
60
+ duration: const Duration(milliseconds: 200),
61
+ curve: Curves.easeOut,
62
+ );
63
+ }
64
+ });
65
+ }
66
+
67
+ @override
68
+ Widget build(BuildContext context) {
69
+ final vm = context.watch<ChatViewModel>();
70
+ final theme = Theme.of(context);
71
+
72
+ return Scaffold(
73
+ appBar: AppBar(
74
+ title: Column(
75
+ crossAxisAlignment: CrossAxisAlignment.start,
76
+ children: [
77
+ Text(widget.title.isEmpty ? 'Ask this card' : widget.title,
78
+ maxLines: 1, overflow: TextOverflow.ellipsis),
79
+ Text(
80
+ 'AI-generated · may contain errors',
81
+ style: theme.textTheme.labelSmall
82
+ ?.copyWith(color: theme.colorScheme.onSurfaceVariant),
83
+ ),
84
+ ],
85
+ ),
86
+ ),
87
+ body: Column(
88
+ children: [
89
+ Expanded(child: _messages(context, vm)),
90
+ if (vm.error != null)
91
+ Padding(
92
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
93
+ child: Text(vm.error!,
94
+ style: TextStyle(color: theme.colorScheme.error)),
95
+ ),
96
+ _composer(context, vm),
97
+ ],
98
+ ),
99
+ );
100
+ }
101
+
102
+ Widget _messages(BuildContext context, ChatViewModel vm) {
103
+ if (vm.isEmpty) {
104
+ return Center(
105
+ child: Padding(
106
+ padding: const EdgeInsets.all(32),
107
+ child: Text(
108
+ 'Ask anything about this card — ingredients, steps, the gist…',
109
+ textAlign: TextAlign.center,
110
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
111
+ color: Theme.of(context).colorScheme.onSurfaceVariant),
112
+ ),
113
+ ),
114
+ );
115
+ }
116
+ return ListView.builder(
117
+ controller: _scroll,
118
+ padding: const EdgeInsets.all(16),
119
+ itemCount: vm.messages.length + (vm.busy ? 1 : 0),
120
+ itemBuilder: (ctx, i) {
121
+ if (i >= vm.messages.length) return const _TypingBubble();
122
+ return _Bubble(message: vm.messages[i]);
123
+ },
124
+ );
125
+ }
126
+
127
+ Widget _composer(BuildContext context, ChatViewModel vm) {
128
+ return SafeArea(
129
+ top: false,
130
+ child: Padding(
131
+ padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
132
+ child: Row(
133
+ children: [
134
+ Expanded(
135
+ child: TextField(
136
+ controller: _controller,
137
+ minLines: 1,
138
+ maxLines: 4,
139
+ textInputAction: TextInputAction.send,
140
+ onSubmitted: (_) => _send(vm),
141
+ decoration: const InputDecoration(
142
+ hintText: 'Ask about this card',
143
+ border: OutlineInputBorder(),
144
+ isDense: true,
145
+ ),
146
+ ),
147
+ ),
148
+ const SizedBox(width: 8),
149
+ IconButton.filled(
150
+ onPressed: vm.busy ? null : () => _send(vm),
151
+ icon: const Icon(Icons.send_rounded),
152
+ ),
153
+ ],
154
+ ),
155
+ ),
156
+ );
157
+ }
158
+ }
159
+
160
+ class _Bubble extends StatelessWidget {
161
+ const _Bubble({required this.message});
162
+ final ChatMessage message;
163
+
164
+ @override
165
+ Widget build(BuildContext context) {
166
+ final scheme = Theme.of(context).colorScheme;
167
+ final isUser = message.isUser;
168
+ return Align(
169
+ alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
170
+ child: Container(
171
+ margin: const EdgeInsets.symmetric(vertical: 4),
172
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
173
+ constraints: BoxConstraints(
174
+ maxWidth: MediaQuery.of(context).size.width * 0.78,
175
+ ),
176
+ decoration: BoxDecoration(
177
+ color: isUser ? scheme.primaryContainer : scheme.surfaceContainerHighest,
178
+ borderRadius: BorderRadius.circular(14),
179
+ ),
180
+ child: Text(
181
+ message.content,
182
+ style: TextStyle(
183
+ color: isUser ? scheme.onPrimaryContainer : scheme.onSurface,
184
+ ),
185
+ ),
186
+ ),
187
+ );
188
+ }
189
+ }
190
+
191
+ class _TypingBubble extends StatelessWidget {
192
+ const _TypingBubble();
193
+
194
+ @override
195
+ Widget build(BuildContext context) {
196
+ return Align(
197
+ alignment: Alignment.centerLeft,
198
+ child: Container(
199
+ margin: const EdgeInsets.symmetric(vertical: 4),
200
+ padding: const EdgeInsets.all(14),
201
+ decoration: BoxDecoration(
202
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
203
+ borderRadius: BorderRadius.circular(14),
204
+ ),
205
+ child: const SizedBox(
206
+ width: 18,
207
+ height: 18,
208
+ child: CircularProgressIndicator(strokeWidth: 2),
209
+ ),
210
+ ),
211
+ );
212
+ }
213
+ }
app/lib/ui/features/reader/views/primary_action_bar.dart CHANGED
@@ -1,22 +1,38 @@
1
- /// The one dominant action per card (docs/04 primary_action, docs/06). The kind
2
- /// is derived server-side from content type. Phase-1 handlers acknowledge the
3
- /// action locally; deep integrations (calendar, Notion export) are P2.
 
4
  library;
5
 
6
- import 'package:flutter/material.dart';
7
 
8
  import '../../../../domain/models/card.dart';
9
- import '../../../../domain/models/enums.dart';
 
10
 
11
- class PrimaryActionBar extends StatelessWidget {
12
- const PrimaryActionBar({super.key, required this.action});
13
- final PrimaryAction action;
 
 
 
 
 
 
 
 
 
 
14
 
15
  @override
16
  Widget build(BuildContext context) {
17
  final scheme = Theme.of(context).colorScheme;
 
 
 
 
18
  return SafeArea(
19
- minimum: const EdgeInsets.fromLTRB(20, 0, 20, 16),
20
  child: Material(
21
  color: Colors.transparent,
22
  child: DecoratedBox(
@@ -29,37 +45,120 @@ class PrimaryActionBar extends StatelessWidget {
29
  ),
30
  ],
31
  ),
32
- child: FilledButton.icon(
33
- onPressed: () => _run(context),
34
- icon: Icon(_icon),
35
- label: Text(action.label),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  ),
37
  ),
38
  ),
39
  );
40
  }
41
 
42
- IconData get _icon {
43
- switch (action.kind) {
44
- case PrimaryActionKind.shoppingList:
45
- return Icons.add_shopping_cart_rounded;
46
- case PrimaryActionKind.schedule:
47
- return Icons.event_available_rounded;
48
- case PrimaryActionKind.savePlace:
49
- return Icons.bookmark_add_rounded;
50
- case PrimaryActionKind.reminder:
51
- return Icons.notifications_active_rounded;
52
- case PrimaryActionKind.export:
53
- return Icons.ios_share_rounded;
54
- case PrimaryActionKind.none:
55
- return Icons.bolt_rounded;
56
- }
57
  }
58
 
59
- void _run(BuildContext context) {
60
- // Phase-1: acknowledge. P2 wires real shopping-list/calendar/export targets.
61
- ScaffoldMessenger.of(context).showSnackBar(
62
- SnackBar(content: Text('${action.label} coming soon')),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
  }
 
1
+ /// The card's action area (docs/13). Always offers **Ask** (grounded chat) and
2
+ /// a content-aware "more" menu; when the server derived a dominant action it
3
+ /// also shows it as the primary button. Handlers run on-device against the
4
+ /// card's own blocks (see [CardActions]) — share/copy/Maps/calendar, all free.
5
  library;
6
 
7
+ import 'package:flutter/material.dart' hide Card;
8
 
9
  import '../../../../domain/models/card.dart';
10
+ import '../services/card_actions.dart';
11
+ import 'chat_screen.dart';
12
 
13
+ class PrimaryActionBar extends StatefulWidget {
14
+ const PrimaryActionBar({super.key, required this.card});
15
+ final Card card;
16
+
17
+ @override
18
+ State<PrimaryActionBar> createState() => _PrimaryActionBarState();
19
+ }
20
+
21
+ class _PrimaryActionBarState extends State<PrimaryActionBar> {
22
+ static const _actions = CardActions();
23
+ bool _busy = false;
24
+
25
+ Card get card => widget.card;
26
 
27
  @override
28
  Widget build(BuildContext context) {
29
  final scheme = Theme.of(context).colorScheme;
30
+ final primaryType = _actions.primaryType(card);
31
+ final hasPrimary =
32
+ card.primaryAction.isPresent && primaryType != null;
33
+
34
  return SafeArea(
35
+ minimum: const EdgeInsets.fromLTRB(16, 0, 16, 16),
36
  child: Material(
37
  color: Colors.transparent,
38
  child: DecoratedBox(
 
45
  ),
46
  ],
47
  ),
48
+ child: Row(
49
+ children: [
50
+ if (hasPrimary)
51
+ OutlinedButton.icon(
52
+ onPressed: _openChat,
53
+ icon: const Icon(Icons.chat_bubble_outline_rounded),
54
+ label: const Text('Ask'),
55
+ )
56
+ else
57
+ Expanded(
58
+ child: FilledButton.icon(
59
+ onPressed: _openChat,
60
+ icon: const Icon(Icons.chat_bubble_outline_rounded),
61
+ label: const Text('Ask this card'),
62
+ ),
63
+ ),
64
+ if (hasPrimary) ...[
65
+ const SizedBox(width: 10),
66
+ Expanded(
67
+ child: FilledButton.icon(
68
+ onPressed: _busy ? null : () => _run(primaryType),
69
+ icon: _busy
70
+ ? const SizedBox(
71
+ width: 18,
72
+ height: 18,
73
+ child: CircularProgressIndicator(strokeWidth: 2),
74
+ )
75
+ : Icon(_iconFor(primaryType)),
76
+ label: Text(card.primaryAction.label),
77
+ ),
78
+ ),
79
+ ],
80
+ const SizedBox(width: 6),
81
+ IconButton.filledTonal(
82
+ onPressed: _busy ? null : () => _openMore(primaryType),
83
+ icon: const Icon(Icons.more_horiz_rounded),
84
+ tooltip: 'More actions',
85
+ ),
86
+ ],
87
  ),
88
  ),
89
  ),
90
  );
91
  }
92
 
93
+ void _openChat() {
94
+ Navigator.of(context).push(
95
+ MaterialPageRoute(
96
+ builder: (_) =>
97
+ ChatScreen(cardId: card.cardId, title: card.base.oneLiner),
98
+ ),
99
+ );
 
 
 
 
 
 
 
 
100
  }
101
 
102
+ Future<void> _openMore(CardActionType? primaryType) async {
103
+ // The "more" sheet lists every available action except the one already
104
+ // shown as the primary button.
105
+ final specs = _actions
106
+ .available(card)
107
+ .where((s) => s.type != primaryType)
108
+ .toList();
109
+ final chosen = await showModalBottomSheet<CardActionType>(
110
+ context: context,
111
+ builder: (ctx) => SafeArea(
112
+ child: Column(
113
+ mainAxisSize: MainAxisSize.min,
114
+ children: [
115
+ for (final s in specs)
116
+ ListTile(
117
+ leading: Icon(s.icon),
118
+ title: Text(s.label),
119
+ onTap: () => Navigator.pop(ctx, s.type),
120
+ ),
121
+ ],
122
+ ),
123
+ ),
124
  );
125
+ if (chosen != null) await _run(chosen);
126
+ }
127
+
128
+ Future<void> _run(CardActionType type) async {
129
+ setState(() => _busy = true);
130
+ final result = await _actions.perform(card, type);
131
+ if (!mounted) return;
132
+ setState(() => _busy = false);
133
+
134
+ final message = switch (result) {
135
+ ActionResult.done => null, // the OS sheet/app is the feedback
136
+ ActionResult.copied => 'Copied to clipboard',
137
+ ActionResult.empty => 'Nothing in this card for that',
138
+ ActionResult.failed => "Couldn't complete that action",
139
+ };
140
+ if (message != null) {
141
+ ScaffoldMessenger.of(context)
142
+ .showSnackBar(SnackBar(content: Text(message)));
143
+ }
144
+ }
145
+
146
+ IconData _iconFor(CardActionType type) {
147
+ switch (type) {
148
+ case CardActionType.share:
149
+ return Icons.ios_share_rounded;
150
+ case CardActionType.shoppingList:
151
+ return Icons.add_shopping_cart_rounded;
152
+ case CardActionType.openMaps:
153
+ return Icons.map_rounded;
154
+ case CardActionType.addToCalendar:
155
+ return Icons.event_available_rounded;
156
+ case CardActionType.copy:
157
+ return Icons.copy_rounded;
158
+ case CardActionType.openOriginal:
159
+ return Icons.play_circle_outline_rounded;
160
+ case CardActionType.openLinks:
161
+ return Icons.link_rounded;
162
+ }
163
  }
164
  }
app/lib/ui/features/reader/views/reader_screen.dart CHANGED
@@ -117,9 +117,7 @@ class _ReaderView extends StatelessWidget {
117
  ),
118
  ],
119
  ),
120
- bottomSheet: card.primaryAction.isPresent && card.isReady
121
- ? PrimaryActionBar(action: card.primaryAction)
122
- : null,
123
  );
124
  }
125
 
 
117
  ),
118
  ],
119
  ),
120
+ bottomSheet: card.isReady ? PrimaryActionBar(card: card) : null,
 
 
121
  );
122
  }
123
 
app/pubspec.lock CHANGED
@@ -1,6 +1,14 @@
1
  # Generated by pub
2
  # See https://dart.dev/tools/pub/glossary#lockfile
3
  packages:
 
 
 
 
 
 
 
 
4
  args:
5
  dependency: transitive
6
  description:
@@ -81,6 +89,14 @@ packages:
81
  url: "https://pub.dev"
82
  source: hosted
83
  version: "1.19.1"
 
 
 
 
 
 
 
 
84
  crypto:
85
  dependency: transitive
86
  description:
@@ -264,6 +280,14 @@ packages:
264
  url: "https://pub.dev"
265
  source: hosted
266
  version: "1.17.0"
 
 
 
 
 
 
 
 
267
  nested:
268
  dependency: transitive
269
  description:
@@ -408,6 +432,22 @@ packages:
408
  url: "https://pub.dev"
409
  source: hosted
410
  version: "0.28.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  shared_preferences:
412
  dependency: "direct main"
413
  description:
@@ -573,6 +613,70 @@ packages:
573
  url: "https://pub.dev"
574
  source: hosted
575
  version: "1.4.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  uuid:
577
  dependency: transitive
578
  description:
@@ -605,6 +709,14 @@ packages:
605
  url: "https://pub.dev"
606
  source: hosted
607
  version: "1.1.1"
 
 
 
 
 
 
 
 
608
  xdg_directories:
609
  dependency: transitive
610
  description:
@@ -623,4 +735,4 @@ packages:
623
  version: "3.1.3"
624
  sdks:
625
  dart: ">=3.11.3 <4.0.0"
626
- flutter: ">=3.38.4"
 
1
  # Generated by pub
2
  # See https://dart.dev/tools/pub/glossary#lockfile
3
  packages:
4
+ add_2_calendar:
5
+ dependency: "direct main"
6
+ description:
7
+ name: add_2_calendar
8
+ sha256: "8274d1c7a776fdb46b7b7b9a4ed6b1b80ec8f2a9325fe46e2336687bcbc18ce5"
9
+ url: "https://pub.dev"
10
+ source: hosted
11
+ version: "3.1.1"
12
  args:
13
  dependency: transitive
14
  description:
 
89
  url: "https://pub.dev"
90
  source: hosted
91
  version: "1.19.1"
92
+ cross_file:
93
+ dependency: transitive
94
+ description:
95
+ name: cross_file
96
+ sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
97
+ url: "https://pub.dev"
98
+ source: hosted
99
+ version: "0.3.5+2"
100
  crypto:
101
  dependency: transitive
102
  description:
 
280
  url: "https://pub.dev"
281
  source: hosted
282
  version: "1.17.0"
283
+ mime:
284
+ dependency: transitive
285
+ description:
286
+ name: mime
287
+ sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
288
+ url: "https://pub.dev"
289
+ source: hosted
290
+ version: "2.0.0"
291
  nested:
292
  dependency: transitive
293
  description:
 
432
  url: "https://pub.dev"
433
  source: hosted
434
  version: "0.28.0"
435
+ share_plus:
436
+ dependency: "direct main"
437
+ description:
438
+ name: share_plus
439
+ sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
440
+ url: "https://pub.dev"
441
+ source: hosted
442
+ version: "10.1.4"
443
+ share_plus_platform_interface:
444
+ dependency: transitive
445
+ description:
446
+ name: share_plus_platform_interface
447
+ sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
448
+ url: "https://pub.dev"
449
+ source: hosted
450
+ version: "5.0.2"
451
  shared_preferences:
452
  dependency: "direct main"
453
  description:
 
613
  url: "https://pub.dev"
614
  source: hosted
615
  version: "1.4.0"
616
+ url_launcher:
617
+ dependency: "direct main"
618
+ description:
619
+ name: url_launcher
620
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
621
+ url: "https://pub.dev"
622
+ source: hosted
623
+ version: "6.3.2"
624
+ url_launcher_android:
625
+ dependency: transitive
626
+ description:
627
+ name: url_launcher_android
628
+ sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
629
+ url: "https://pub.dev"
630
+ source: hosted
631
+ version: "6.3.30"
632
+ url_launcher_ios:
633
+ dependency: transitive
634
+ description:
635
+ name: url_launcher_ios
636
+ sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
637
+ url: "https://pub.dev"
638
+ source: hosted
639
+ version: "6.4.1"
640
+ url_launcher_linux:
641
+ dependency: transitive
642
+ description:
643
+ name: url_launcher_linux
644
+ sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
645
+ url: "https://pub.dev"
646
+ source: hosted
647
+ version: "3.2.2"
648
+ url_launcher_macos:
649
+ dependency: transitive
650
+ description:
651
+ name: url_launcher_macos
652
+ sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
653
+ url: "https://pub.dev"
654
+ source: hosted
655
+ version: "3.2.5"
656
+ url_launcher_platform_interface:
657
+ dependency: transitive
658
+ description:
659
+ name: url_launcher_platform_interface
660
+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
661
+ url: "https://pub.dev"
662
+ source: hosted
663
+ version: "2.3.2"
664
+ url_launcher_web:
665
+ dependency: transitive
666
+ description:
667
+ name: url_launcher_web
668
+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
669
+ url: "https://pub.dev"
670
+ source: hosted
671
+ version: "2.4.3"
672
+ url_launcher_windows:
673
+ dependency: transitive
674
+ description:
675
+ name: url_launcher_windows
676
+ sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
677
+ url: "https://pub.dev"
678
+ source: hosted
679
+ version: "3.1.5"
680
  uuid:
681
  dependency: transitive
682
  description:
 
709
  url: "https://pub.dev"
710
  source: hosted
711
  version: "1.1.1"
712
+ win32:
713
+ dependency: transitive
714
+ description:
715
+ name: win32
716
+ sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
717
+ url: "https://pub.dev"
718
+ source: hosted
719
+ version: "5.15.0"
720
  xdg_directories:
721
  dependency: transitive
722
  description:
 
735
  version: "3.1.3"
736
  sdks:
737
  dart: ">=3.11.3 <4.0.0"
738
+ flutter: ">=3.41.0"
app/pubspec.yaml CHANGED
@@ -46,6 +46,12 @@ dependencies:
46
  # Cached, progressive thumbnail/keyframe loading in the library grid.
47
  cached_network_image: ^3.4.1
48
 
 
 
 
 
 
 
49
  dev_dependencies:
50
  flutter_test:
51
  sdk: flutter
 
46
  # Cached, progressive thumbnail/keyframe loading in the library grid.
47
  cached_network_image: ^3.4.1
48
 
49
+ # Action layer (docs/09): export/shopping-list -> OS share sheet,
50
+ # save-place -> Maps, reminder/schedule -> native calendar event.
51
+ share_plus: ^10.1.4
52
+ url_launcher: ^6.3.1
53
+ add_2_calendar: ^3.0.1
54
+
55
  dev_dependencies:
56
  flutter_test:
57
  sdk: flutter
app/test/artifact_test.dart ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Contract tests for the catalog artifact mirror (docs/12). Parsing must be
2
+ // tolerant: unknown type degrades to `other`, missing fields don't throw.
3
+
4
+ import 'package:flutter_test/flutter_test.dart';
5
+
6
+ import 'package:cachy/domain/models/artifact.dart';
7
+
8
+ void main() {
9
+ group('CatalogEntry.fromJson', () {
10
+ test('parses a full entry', () {
11
+ final e = CatalogEntry.fromJson({
12
+ 'id': 'a_1',
13
+ 'type': 'book',
14
+ 'title': 'Atomic Habits',
15
+ 'creator': 'James Clear',
16
+ 'year': 2018,
17
+ 'thumbnail': 'https://covers/x.jpg',
18
+ 'source_card_ids': ['c1', 'c2'],
19
+ });
20
+ expect(e.type, ArtifactType.book);
21
+ expect(e.title, 'Atomic Habits');
22
+ expect(e.subtitle, 'James Clear · 2018');
23
+ expect(e.sourceCardIds, ['c1', 'c2']);
24
+ });
25
+
26
+ test('tv_show wire maps to tvShow and back', () {
27
+ expect(ArtifactType.fromWire('tv_show'), ArtifactType.tvShow);
28
+ expect(ArtifactType.tvShow.wire, 'tv_show');
29
+ });
30
+
31
+ test('unknown type degrades to other', () {
32
+ final e = CatalogEntry.fromJson({'id': 'a', 'title': 'X', 'type': 'zzz'});
33
+ expect(e.type, ArtifactType.other);
34
+ });
35
+
36
+ test('missing optional fields do not throw', () {
37
+ final e = CatalogEntry.fromJson({'id': 'a', 'title': 'Solo'});
38
+ expect(e.creator, isNull);
39
+ expect(e.year, isNull);
40
+ expect(e.thumbnail, isNull);
41
+ expect(e.subtitle, '');
42
+ expect(e.sourceCardIds, isEmpty);
43
+ });
44
+ });
45
+ }
backend/app/api/cards.py CHANGED
@@ -14,7 +14,8 @@ from sqlalchemy import delete, select
14
 
15
  from app.models.card import Card, CardState
16
  from app.models.job import JobState
17
- from app.services import cache, events
 
18
  from app.store import db, media
19
 
20
  log = logging.getLogger("api.cards")
@@ -41,15 +42,21 @@ class PatchCardRequest(BaseModel):
41
  state: None = None # state is server-controlled; ignored if sent
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def _platform_for(url: str) -> str | None:
45
- u = url.lower()
46
- if "instagram.com" in u:
47
- return "instagram"
48
- if "tiktok.com" in u:
49
- return "tiktok"
50
- if "youtube.com" in u or "youtu.be" in u:
51
- return "youtube"
52
- return None
53
 
54
 
55
  # --------------------------------------------------------------------------- #
@@ -156,6 +163,31 @@ async def patch_card(card_id: str, req: PatchCardRequest) -> Card:
156
  return row.to_card()
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  @router.delete("/{card_id}")
160
  async def delete_card(card_id: str) -> dict:
161
  async with db.session() as session:
 
14
 
15
  from app.models.card import Card, CardState
16
  from app.models.job import JobState
17
+ from app.pipeline.ingestion.source import platform_for_url
18
+ from app.services import cache, events, llm_chat
19
  from app.store import db, media
20
 
21
  log = logging.getLogger("api.cards")
 
42
  state: None = None # state is server-controlled; ignored if sent
43
 
44
 
45
+ class ChatMessage(BaseModel):
46
+ role: str # "user" | "assistant"
47
+ content: str
48
+
49
+
50
+ class ChatRequest(BaseModel):
51
+ messages: list[ChatMessage]
52
+
53
+
54
+ class ChatResponse(BaseModel):
55
+ reply: str
56
+
57
+
58
  def _platform_for(url: str) -> str | None:
59
+ return platform_for_url(url)
 
 
 
 
 
 
 
60
 
61
 
62
  # --------------------------------------------------------------------------- #
 
163
  return row.to_card()
164
 
165
 
166
+ @router.post("/{card_id}/chat", response_model=ChatResponse)
167
+ async def chat_card(card_id: str, req: ChatRequest) -> ChatResponse:
168
+ """Grounded Q&A over one card (docs/13). Stateless: the client replays the
169
+ conversation each turn; nothing is stored. The model answers from the card's
170
+ structured content only."""
171
+ if not req.messages or req.messages[-1].role != "user":
172
+ raise HTTPException(status_code=422, detail="last message must be from user")
173
+
174
+ async with db.session() as session:
175
+ row = await db.get_card_row(session, card_id)
176
+ if row is None:
177
+ raise HTTPException(status_code=404, detail="card not found")
178
+ if row.state != CardState.READY.value:
179
+ raise HTTPException(status_code=409, detail="card is not ready")
180
+ card = row.to_card()
181
+
182
+ history = [m.model_dump() for m in req.messages]
183
+ reply = await asyncio.to_thread(llm_chat.answer, card, history)
184
+ if reply is None:
185
+ raise HTTPException(
186
+ status_code=503, detail="chat is unavailable (no LLM backend configured)"
187
+ )
188
+ return ChatResponse(reply=reply)
189
+
190
+
191
  @router.delete("/{card_id}")
192
  async def delete_card(card_id: str) -> dict:
193
  async with db.session() as session:
backend/app/api/catalog.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catalog endpoints (docs/12): the aggregated, deduplicated view of every
2
+ artifact referenced across all cards. Read-mostly; entries are created by the
3
+ worker, not by clients."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException, Query
8
+ from pydantic import BaseModel
9
+ from sqlalchemy import delete, select
10
+
11
+ from app.models.artifact import ArtifactType, CatalogEntry
12
+ from app.store import db
13
+
14
+ router = APIRouter(prefix="/catalog", tags=["catalog"])
15
+
16
+
17
+ class CatalogDetail(BaseModel):
18
+ entry: CatalogEntry
19
+ source_card_ids: list[str]
20
+
21
+
22
+ @router.get("", response_model=list[CatalogEntry])
23
+ async def list_catalog(
24
+ type: ArtifactType | None = None,
25
+ limit: int = Query(200, ge=1, le=500),
26
+ offset: int = Query(0, ge=0),
27
+ ) -> list[CatalogEntry]:
28
+ async with db.session() as session:
29
+ stmt = select(db.ArtifactRow).order_by(db.ArtifactRow.created_at.desc())
30
+ if type is not None:
31
+ stmt = stmt.where(db.ArtifactRow.type == type.value)
32
+ stmt = stmt.limit(limit).offset(offset)
33
+ rows = (await session.execute(stmt)).scalars().all()
34
+ return [r.to_entry() for r in rows]
35
+
36
+
37
+ @router.get("/{artifact_id}", response_model=CatalogDetail)
38
+ async def get_catalog_entry(artifact_id: str) -> CatalogDetail:
39
+ async with db.session() as session:
40
+ row = await session.get(db.ArtifactRow, artifact_id)
41
+ if row is None:
42
+ raise HTTPException(status_code=404, detail="artifact not found")
43
+ entry = row.to_entry()
44
+ return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids)
45
+
46
+
47
+ @router.delete("/{artifact_id}")
48
+ async def delete_catalog_entry(artifact_id: str) -> dict:
49
+ async with db.session() as session:
50
+ row = await session.get(db.ArtifactRow, artifact_id)
51
+ if row is None:
52
+ raise HTTPException(status_code=404, detail="artifact not found")
53
+ await session.execute(
54
+ delete(db.ArtifactRow).where(db.ArtifactRow.id == artifact_id)
55
+ )
56
+ await session.commit()
57
+ return {"deleted": artifact_id}
backend/app/main.py CHANGED
@@ -13,7 +13,7 @@ from fastapi import FastAPI
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from fastapi.staticfiles import StaticFiles
15
 
16
- from app.api import cards, search
17
  from app.models.card import SCHEMA_VERSION
18
  from app.pipeline import worker
19
  from app.store import db, media
@@ -57,6 +57,7 @@ app.add_middleware(
57
  )
58
 
59
  app.include_router(cards.router)
 
60
  app.include_router(search.router)
61
 
62
  # Serve extracted keyframes/thumbnails so the frontend faces load (docs/05).
 
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from fastapi.staticfiles import StaticFiles
15
 
16
+ from app.api import cards, catalog, search
17
  from app.models.card import SCHEMA_VERSION
18
  from app.pipeline import worker
19
  from app.store import db, media
 
57
  )
58
 
59
  app.include_router(cards.router)
60
+ app.include_router(catalog.router)
61
  app.include_router(search.router)
62
 
63
  # Serve extracted keyframes/thumbnails so the frontend faces load (docs/05).
backend/app/models/artifact.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Artifact catalog models (docs/12).
2
+
3
+ An *artifact* is a real-world thing a video references — a book, movie, podcast,
4
+ product, place, etc. — as opposed to the structured how-to content of the card
5
+ itself. Artifacts are extracted by the same single structuring LLM call (docs/04),
6
+ then aggregated across all cards into a global, deduplicated catalog.
7
+
8
+ This is a parallel surface to the block schema, NOT a new block type. The block
9
+ vocabulary is unchanged; the structuring output simply grows an `artifacts` list.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from enum import Enum
17
+ from typing import Optional
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+
22
+ class ArtifactType(str, Enum):
23
+ BOOK = "book"
24
+ MOVIE = "movie"
25
+ TV_SHOW = "tv_show"
26
+ PODCAST = "podcast"
27
+ MUSIC = "music"
28
+ PRODUCT = "product"
29
+ PLACE = "place"
30
+ APP = "app"
31
+ OTHER = "other"
32
+
33
+
34
+ def _new_id() -> str:
35
+ return "a_" + uuid.uuid4().hex[:8]
36
+
37
+
38
+ class Artifact(BaseModel):
39
+ """One referenced thing, as emitted by structuring (pre-aggregation)."""
40
+
41
+ type: ArtifactType = ArtifactType.OTHER
42
+ title: str
43
+ creator: Optional[str] = None # author / director / artist / host
44
+ year: Optional[int] = None
45
+ thumbnail: Optional[str] = None # resolved from a free image API (docs/12)
46
+
47
+
48
+ class CatalogEntry(BaseModel):
49
+ """An aggregated, deduplicated catalog item: one artifact, many source cards."""
50
+
51
+ id: str = Field(default_factory=_new_id)
52
+ type: ArtifactType = ArtifactType.OTHER
53
+ title: str
54
+ creator: Optional[str] = None
55
+ year: Optional[int] = None
56
+ thumbnail: Optional[str] = None
57
+ source_card_ids: list[str] = Field(default_factory=list)
58
+ created_at: str = Field(
59
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
60
+ )
backend/app/models/card.py CHANGED
@@ -13,7 +13,7 @@ from typing import Annotated, Literal, Optional, Union
13
 
14
  from pydantic import BaseModel, Field
15
 
16
- SCHEMA_VERSION = "1.0"
17
 
18
 
19
  # --------------------------------------------------------------------------- #
 
13
 
14
  from pydantic import BaseModel, Field
15
 
16
+ SCHEMA_VERSION = "1.1" # 1.1: structuring output grows an `artifacts` list (docs/12)
17
 
18
 
19
  # --------------------------------------------------------------------------- #
backend/app/pipeline/extraction.py CHANGED
@@ -202,6 +202,38 @@ def _aggregate(caption: str, transcript: str, ocr_text: str, source_line: str) -
202
  return "\n".join(parts)
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  # --------------------------------------------------------------------------- #
206
  # Entry point
207
  # --------------------------------------------------------------------------- #
@@ -211,6 +243,9 @@ def extract(
211
  ) -> ExtractionResult:
212
  """Run the extraction pipeline against a DownloadResult. `work_dir` is the
213
  per-card directory where audio/frames live."""
 
 
 
214
  os.makedirs(work_dir, exist_ok=True)
215
  transcript = ""
216
  frames: list[str] = []
 
202
  return "\n".join(parts)
203
 
204
 
205
+ def _aggregate_article(
206
+ title: str, text: str, author: str | None, source_line: str
207
+ ) -> str:
208
+ """Text-source bundle (docs/02 article path): no audio/frames, the body is the
209
+ content. Same labeled shape the structuring prompt already consumes."""
210
+ parts = [
211
+ f"TITLE: {title.strip()}" if title.strip() else "TITLE:",
212
+ f"AUTHOR: {author.strip()}" if author and author.strip() else "AUTHOR:",
213
+ f"ARTICLE TEXT: {text.strip()}" if text.strip() else "ARTICLE TEXT:",
214
+ f"SOURCE: {source_line}",
215
+ ]
216
+ return "\n".join(parts)
217
+
218
+
219
+ def _extract_article(download: DownloadResult, source_line: str) -> ExtractionResult:
220
+ """Article path: skip ffmpeg/Whisper/OCR entirely. The lead image (if any) is
221
+ a remote URL used directly as the thumbnail — nothing is downloaded."""
222
+ aggregated = _aggregate_article(
223
+ download.title, download.text, download.author, source_line
224
+ )
225
+ return ExtractionResult(
226
+ aggregated_text=aggregated,
227
+ transcript=download.text, # gives base-synth / paragraph-fallback real text
228
+ ocr_text="",
229
+ thumbnail=download.image_url,
230
+ keyframes=[],
231
+ had_transcript=False,
232
+ had_ocr=False,
233
+ had_visual=False,
234
+ )
235
+
236
+
237
  # --------------------------------------------------------------------------- #
238
  # Entry point
239
  # --------------------------------------------------------------------------- #
 
243
  ) -> ExtractionResult:
244
  """Run the extraction pipeline against a DownloadResult. `work_dir` is the
245
  per-card directory where audio/frames live."""
246
+ if download.media_type == "article":
247
+ return _extract_article(download, source_line)
248
+
249
  os.makedirs(work_dir, exist_ok=True)
250
  transcript = ""
251
  frames: list[str] = []
backend/app/pipeline/ingestion/article.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """article.py — readable-text ingestion for non-video sources (docs/02).
2
+
3
+ A parallel path to the resolver cascade: for URLs that are articles/posts rather
4
+ than short-form video (Reddit, Wikipedia, Substack, blogs, news, …), there is no
5
+ media to download — the content *is* text. This module fetches the page and
6
+ extracts the main readable body, title, author, and a lead image.
7
+
8
+ Free-first: trafilatura is a keyless, local extractor — no API, no
9
+ quota. Best-effort by design: any miss/timeout/too-thin result returns None and
10
+ the caller treats it as a normal ingestion failure (the cascade/graceful-fail
11
+ model of docs/02), never a crash.
12
+
13
+ Distinct from resolvers.py: that file is the fragile, untouched video-scraper
14
+ layer. This is new orchestration the app owns.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ log = logging.getLogger("ingestion.article")
25
+
26
+ # A body shorter than this is almost certainly a paywall/login wall or a failed
27
+ # extraction, not a real article — reject so the caller can fall through.
28
+ _MIN_CHARS = 200
29
+
30
+
31
+ @dataclass
32
+ class ArticleResult:
33
+ title: str
34
+ text: str
35
+ author: Optional[str] = None
36
+ image_url: Optional[str] = None
37
+ site: Optional[str] = None
38
+
39
+
40
+ def fetch_article(url: str) -> ArticleResult | None:
41
+ """Fetch + extract a readable article from `url`. Returns None on any failure
42
+ or if the extracted body is too thin to be a real article."""
43
+ try:
44
+ import trafilatura
45
+ except Exception as e: # noqa: BLE001 — dep missing -> article path simply unavailable
46
+ log.warning("trafilatura unavailable; article path disabled: %s", e)
47
+ return None
48
+
49
+ try:
50
+ downloaded = trafilatura.fetch_url(url)
51
+ if not downloaded:
52
+ log.info("article fetch returned nothing for %s", url)
53
+ return None
54
+ raw = trafilatura.extract(
55
+ downloaded,
56
+ output_format="json",
57
+ with_metadata=True,
58
+ favor_recall=True,
59
+ include_comments=False,
60
+ include_tables=True,
61
+ )
62
+ if not raw:
63
+ return None
64
+ data = json.loads(raw)
65
+ except Exception as e: # noqa: BLE001 — any extraction error is a normal miss
66
+ log.info("article extraction failed for %s: %s", url, e)
67
+ return None
68
+
69
+ text = (data.get("text") or "").strip()
70
+ if len(text) < _MIN_CHARS:
71
+ log.info("article body too thin (%d chars) for %s", len(text), url)
72
+ return None
73
+
74
+ title = (data.get("title") or "").strip()
75
+ author = (data.get("author") or "").strip() or None
76
+ image_url = (data.get("image") or "").strip() or None
77
+ site = (data.get("sitename") or data.get("hostname") or "").strip() or None
78
+
79
+ return ArticleResult(
80
+ title=title,
81
+ text=text,
82
+ author=author,
83
+ image_url=image_url,
84
+ site=site,
85
+ )
backend/app/pipeline/ingestion/downloader.py CHANGED
@@ -27,11 +27,15 @@ import uuid
27
  from dataclasses import dataclass, field
28
  from typing import Callable, Literal, Optional, Union
29
 
30
- from . import resolvers # the resolver functions, unchanged
31
 
32
  log = logging.getLogger("ingestion.downloader")
33
 
34
- MediaType = Literal["video", "images"]
 
 
 
 
35
 
36
  # Optional keyless resolvers, in cascade order. Probed by name; missing ones skip.
37
  _KEYLESS_NAMES = [
@@ -61,11 +65,17 @@ class DownloaderConfig:
61
  @dataclass
62
  class DownloadResult:
63
  """What the pipeline's extraction stage consumes next."""
64
- media_type: MediaType # "video" or "images"
65
- data: Union[str, list[str]] # path to .mp4, OR list of image paths
66
  caption: str # may be empty; OCR/transcript compensates
67
  resolver: str # which strategy succeeded (for observability)
68
 
 
 
 
 
 
 
69
 
70
  class DownloadError(RuntimeError):
71
  """Raised when every resolver in the cascade fails — an expected outcome."""
@@ -97,6 +107,44 @@ def _safe_call(name: str, fn: Callable):
97
  return None
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  # --------------------------------------------------------------------------- #
101
  # Orchestration (sync core)
102
  # --------------------------------------------------------------------------- #
@@ -111,6 +159,22 @@ def download_content(
111
  """
112
  config = config or DownloaderConfig()
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # Per-job isolation: unique dir per call so concurrent downloads never collide.
115
  job_dir = os.path.join(config.output_dir, uuid.uuid4().hex)
116
  os.makedirs(job_dir, exist_ok=True)
@@ -122,9 +186,13 @@ def download_content(
122
  log.debug("trying keyless resolver: %s", name)
123
  res = _safe_call(name, lambda fn=fn: fn(url, output_path))
124
  if res:
125
- path, caption = res
 
 
 
 
126
  log.info("download ok via %s", name)
127
- return DownloadResult("video", path, caption, name)
128
 
129
  # 2) RapidAPI — optional, only if a key is configured and resolvers exposes it.
130
  rapid = getattr(resolvers, "_download_rapidapi", None)
@@ -135,9 +203,13 @@ def download_content(
135
  lambda: rapid(url, output_path, config.rapidapi_key),
136
  )
137
  if res:
138
- path, caption = res
 
 
 
 
139
  log.info("download ok via rapidapi")
140
- return DownloadResult("video", path, caption, "rapidapi")
141
 
142
  # 3) yt-dlp — local fallback for videos/reels.
143
  yt = getattr(resolvers, "_download_yt_dlp", None)
 
27
  from dataclasses import dataclass, field
28
  from typing import Callable, Literal, Optional, Union
29
 
30
+ from . import article, resolvers # resolvers unchanged; article is the text path
31
 
32
  log = logging.getLogger("ingestion.downloader")
33
 
34
+ MediaType = Literal["video", "images", "article"]
35
+
36
+ # URLs on these hosts go to the fragile video resolver cascade; everything else
37
+ # is treated as a readable article/post (docs/02 article path).
38
+ _VIDEO_HOSTS = ("instagram.com", "tiktok.com", "youtube.com", "youtu.be")
39
 
40
  # Optional keyless resolvers, in cascade order. Probed by name; missing ones skip.
41
  _KEYLESS_NAMES = [
 
65
  @dataclass
66
  class DownloadResult:
67
  """What the pipeline's extraction stage consumes next."""
68
+ media_type: MediaType # "video" | "images" | "article"
69
+ data: Union[str, list[str]] # path to .mp4, list of image paths, or "" for article
70
  caption: str # may be empty; OCR/transcript compensates
71
  resolver: str # which strategy succeeded (for observability)
72
 
73
+ # Article path only (media_type == "article"): the content is text, not media.
74
+ text: str = "" # extracted readable body
75
+ title: str = "" # article/post title
76
+ author: str | None = None # byline, if any
77
+ image_url: str | None = None # remote lead-image URL (hotlinked, not downloaded)
78
+
79
 
80
  class DownloadError(RuntimeError):
81
  """Raised when every resolver in the cascade fails — an expected outcome."""
 
107
  return None
108
 
109
 
110
+ def _is_video_url(url: str) -> bool:
111
+ """True for the short-form video platforms handled by the resolver cascade."""
112
+ u = url.lower()
113
+ return any(host in u for host in _VIDEO_HOSTS)
114
+
115
+
116
+ def _article_result(url: str) -> DownloadResult | None:
117
+ """The text path: extract a readable article. None if nothing usable."""
118
+ art = article.fetch_article(url)
119
+ if art is None:
120
+ return None
121
+ log.info("download ok via article extractor (%d chars)", len(art.text))
122
+ return DownloadResult(
123
+ media_type="article",
124
+ data="",
125
+ caption=art.title,
126
+ resolver="article",
127
+ text=art.text,
128
+ title=art.title,
129
+ author=art.author,
130
+ image_url=art.image_url,
131
+ )
132
+
133
+
134
+ def _yt_dlp_result(url: str, output_path: str, cookies_path: str | None) -> DownloadResult | None:
135
+ """yt-dlp as a video safety net — it supports far more sites than the keyless
136
+ scrapers, so it can rescue a non-video host that is actually a video page."""
137
+ yt = getattr(resolvers, "_download_yt_dlp", None)
138
+ if not callable(yt):
139
+ return None
140
+ res = _safe_call("yt-dlp", lambda: yt(url, output_path, cookies_path))
141
+ if res:
142
+ path, caption = res
143
+ log.info("download ok via yt-dlp")
144
+ return DownloadResult("video", path, caption, "yt-dlp")
145
+ return None
146
+
147
+
148
  # --------------------------------------------------------------------------- #
149
  # Orchestration (sync core)
150
  # --------------------------------------------------------------------------- #
 
159
  """
160
  config = config or DownloaderConfig()
161
 
162
+ # Article path: non-video hosts carry readable text, not media. Try text
163
+ # extraction first; if it yields nothing, fall through to yt-dlp in case the
164
+ # page is actually a video the cascade can handle (docs/02 article path).
165
+ if not _is_video_url(url):
166
+ art = _article_result(url)
167
+ if art is not None:
168
+ return art
169
+ job_dir = os.path.join(config.output_dir, uuid.uuid4().hex)
170
+ os.makedirs(job_dir, exist_ok=True)
171
+ yt = _yt_dlp_result(
172
+ url, os.path.join(job_dir, "video.mp4"), config.cookies_path
173
+ )
174
+ if yt is not None:
175
+ return yt
176
+ raise DownloadError(f"no article or video extracted for {url}")
177
+
178
  # Per-job isolation: unique dir per call so concurrent downloads never collide.
179
  job_dir = os.path.join(config.output_dir, uuid.uuid4().hex)
180
  os.makedirs(job_dir, exist_ok=True)
 
186
  log.debug("trying keyless resolver: %s", name)
187
  res = _safe_call(name, lambda fn=fn: fn(url, output_path))
188
  if res:
189
+ if len(res) == 3:
190
+ m_type, path_or_list, caption = res
191
+ else:
192
+ path_or_list, caption = res
193
+ m_type = "video"
194
  log.info("download ok via %s", name)
195
+ return DownloadResult(m_type, path_or_list, caption, name)
196
 
197
  # 2) RapidAPI — optional, only if a key is configured and resolvers exposes it.
198
  rapid = getattr(resolvers, "_download_rapidapi", None)
 
203
  lambda: rapid(url, output_path, config.rapidapi_key),
204
  )
205
  if res:
206
+ if len(res) == 3:
207
+ m_type, path_or_list, caption = res
208
+ else:
209
+ path_or_list, caption = res
210
+ m_type = "video"
211
  log.info("download ok via rapidapi")
212
+ return DownloadResult(m_type, path_or_list, caption, "rapidapi")
213
 
214
  # 3) yt-dlp — local fallback for videos/reels.
215
  yt = getattr(resolvers, "_download_yt_dlp", None)
backend/app/pipeline/ingestion/resolvers.py CHANGED
@@ -1,27 +1,74 @@
1
- import os
2
- import yt_dlp
 
 
 
 
 
 
 
 
 
3
  import instaloader
4
- from typing import List, Union, Tuple
 
5
 
6
- def download_content(url: str, output_path: str, cookies_path: str = None) -> Tuple[str, Union[str, List[str]], str]:
7
- """
8
- Downloads content from the given URL.
9
- First tries yt-dlp (for video).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  If it fails or finds no video, tries instaloader (for images/carousels).
11
 
 
 
 
 
 
12
  Returns:
13
- (type, data, caption)
14
- type: "video" or "images"
15
- data: path to .mp4 OR list of paths to images
16
- caption: text caption of the post
17
  """
18
- # 1. Try yt-dlp first (for videos/reels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  video_res = _download_yt_dlp(url, output_path, cookies_path)
20
  if video_res:
21
  video_path, caption = video_res
22
  return "video", video_path, caption
23
 
24
- # 2. Try Instaloader (for carousels/images)
25
  if "instagram.com" in url:
26
  image_res = _download_instaloader(url)
27
  if image_res:
@@ -30,78 +77,632 @@ def download_content(url: str, output_path: str, cookies_path: str = None) -> Tu
30
 
31
  raise RuntimeError(f"Could not download content from {url} using any available method.")
32
 
33
- def _download_yt_dlp(url: str, output_path: str, cookies_path: str = None) -> Union[Tuple[str, str], None]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  opts = {
35
  "format": "bestvideo[ext=mp4]+bestaudio/best",
36
- "outtmpl": output_path,
37
  "merge_output_format": "mp4",
38
  "quiet": True,
39
  "no_warnings": True,
40
  }
41
 
42
- if cookies_path and os.path.exists(cookies_path):
43
- opts["cookiefile"] = cookies_path
 
 
44
 
45
  try:
46
  with yt_dlp.YoutubeDL(opts) as ydl:
47
- # Check if video formats exist before downloading
48
  info = ydl.extract_info(url, download=False)
49
- caption = info.get('description', '') or info.get('title', '')
50
 
51
- if not info.get('formats'):
52
  return None
53
  ydl.download([url])
54
- if os.path.exists(output_path):
55
- return output_path, caption
56
- except Exception:
 
57
  return None
58
  return None
59
 
60
- def _download_instaloader(url: str) -> Union[Tuple[List[str], str], None]:
61
- import re
62
- # Extract shortcode
 
 
 
 
 
 
 
63
  match = re.search(r"/(?:p|reels|reel)/([^/?#&]+)", url)
64
  if not match:
65
  return None
66
 
67
  shortcode = match.group(1)
 
68
  loader = instaloader.Instaloader(
69
  download_pictures=True,
70
- download_videos=False, # We use yt-dlp for videos
71
  download_video_thumbnails=False,
72
  download_geotags=False,
73
  download_comments=False,
74
  save_metadata=False,
75
  compress_json=False,
76
- dirname_pattern="temp_images"
77
  )
78
 
79
  try:
80
- # Cleanup previous temp folder
81
- import shutil
82
- if os.path.exists("temp_images"):
83
- shutil.rmtree("temp_images")
84
 
85
  post = instaloader.Post.from_shortcode(loader.context, shortcode)
86
  caption = post.caption or ""
87
- loader.download_post(post, target="temp_images")
88
 
89
- # Collect image paths
90
  images = [
91
- os.path.join("temp_images", f)
92
- for f in os.listdir("temp_images")
93
- if f.endswith((".jpg", ".png", ".webp"))
94
  ]
95
  if images:
96
  return sorted(images), caption
97
  return None
98
  except Exception as e:
99
- print(f"[Downloader] Instaloader failed: {e}")
100
  return None
101
 
102
- # Keep compatibility for existing calls if any
103
- def download_video(url: str, output_path: str, cookies_path: str = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
104
  res_type, res_data, _ = download_content(url, output_path, cookies_path)
105
  if res_type == "video":
106
- return res_data
107
- raise RuntimeError("Download found images instead of video.")
 
 
 
1
+ import json
2
+ import logging
3
+ import re
4
+ import shutil
5
+ import ssl
6
+ import time
7
+ import urllib.parse
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple, Union
11
+
12
  import instaloader
13
+ import requests
14
+ import yt_dlp
15
 
16
+ ssl._create_default_https_context = ssl._create_unverified_context
17
+ log = logging.getLogger("ingestion.resolvers")
18
+
19
+
20
+ def _is_valid_video_link(link: Optional[str]) -> bool:
21
+ """Checks if the extracted media link points to a video stream rather than an image."""
22
+ if not link:
23
+ return False
24
+ low = link.lower()
25
+ image_indicators = [".jpg", ".jpeg", ".png", ".webp", "dst-jpg", "image_urlgen", "format=jpg"]
26
+ if any(ind in low for ind in image_indicators):
27
+ return False
28
+ return True
29
+
30
+
31
+ def download_content(
32
+ url: str, output_path: Union[str, Path], cookies_path: Optional[Union[str, Path]] = None
33
+ ) -> Tuple[str, Union[str, List[str]], str]:
34
+ """Downloads content from the given URL.
35
+ First tries keyless scrapers (vidssave, savethevideo, saveig, downloadgram, anyvidsave, igreelsdl).
36
+ If they fail, tries yt-dlp (for video).
37
  If it fails or finds no video, tries instaloader (for images/carousels).
38
 
39
+ Args:
40
+ url: Source URL to ingest.
41
+ output_path: Target filesystem path for downloaded video.
42
+ cookies_path: Optional path to netscape cookie file.
43
+
44
  Returns:
45
+ Tuple of (media_type, data_path_or_list, caption).
 
 
 
46
  """
47
+ # 1. Try keyless scrapers first (in cascade order)
48
+ keyless_funcs = [
49
+ ("vidssave", _download_vidssave),
50
+ ("savethevideo", _download_savethevideo),
51
+ ("saveig", _download_saveig),
52
+ ("downloadgram", _download_downloadgram),
53
+ ("anyvidsave", _download_anyvidsave),
54
+ ("igreelsdl", _download_igreelsdl),
55
+ ]
56
+ for name, scraper_fn in keyless_funcs:
57
+ try:
58
+ res = scraper_fn(url, output_path)
59
+ if res:
60
+ media_type, data, caption = res
61
+ return media_type, data, caption
62
+ except Exception as e:
63
+ log.warning(f"Keyless resolver {name} failed in download_content for {url}: {e}")
64
+
65
+ # 2. Try yt-dlp (for videos/reels)
66
  video_res = _download_yt_dlp(url, output_path, cookies_path)
67
  if video_res:
68
  video_path, caption = video_res
69
  return "video", video_path, caption
70
 
71
+ # 3. Try Instaloader (for carousels/images)
72
  if "instagram.com" in url:
73
  image_res = _download_instaloader(url)
74
  if image_res:
 
77
 
78
  raise RuntimeError(f"Could not download content from {url} using any available method.")
79
 
80
+
81
+ def _download_vidssave(
82
+ url: str, output_path: Union[str, Path]
83
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
84
+ """Resolves URL using the public vidssave.com API.
85
+
86
+ Args:
87
+ url: Source URL of the video/reel.
88
+ output_path: Target filesystem path where the video should be saved.
89
+
90
+ Returns:
91
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
92
+ """
93
+ target_path = Path(output_path)
94
+ api_url = "https://api.vidssave.com/api/contentsite_api/media/parse"
95
+ headers = {
96
+ "accept": "*/*",
97
+ "accept-language": "en-US,en;q=0.9",
98
+ "content-type": "application/x-www-form-urlencoded",
99
+ "origin": "https://vidssave.com",
100
+ "referer": "https://vidssave.com/",
101
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
102
+ }
103
+ data = {
104
+ "auth": "20250901majwlqo",
105
+ "domain": "api-ak.vidssave.com",
106
+ "origin": "source",
107
+ "link": url,
108
+ }
109
+ try:
110
+ response = requests.post(api_url, headers=headers, data=data, timeout=20)
111
+ if response.status_code == 200:
112
+ res_json = response.json()
113
+ media_data = res_json.get("data", {})
114
+ caption = media_data.get("title", "")
115
+ resources = media_data.get("resources", [])
116
+ if resources:
117
+ first_url = resources[0].get("download_url")
118
+ if _is_valid_video_link(first_url):
119
+ log.debug(f"[vidssave] Got direct link: {first_url[:60] if first_url else ''}...")
120
+ video_response = requests.get(
121
+ first_url, stream=True, headers={"user-agent": headers["user-agent"]}, timeout=60
122
+ )
123
+ if video_response.status_code == 200:
124
+ target_path.parent.mkdir(parents=True, exist_ok=True)
125
+ with target_path.open("wb") as f:
126
+ for chunk in video_response.iter_content(chunk_size=8192):
127
+ if chunk:
128
+ f.write(chunk)
129
+ if target_path.exists() and target_path.stat().st_size > 0:
130
+ return "video", str(target_path), caption
131
+ else:
132
+ log.warning(f"[vidssave] Download failed with stream status: {video_response.status_code}")
133
+ else:
134
+ log.debug("[vidssave] Link points to images, saving as image carousel.")
135
+ saved_paths = []
136
+ target_path.parent.mkdir(parents=True, exist_ok=True)
137
+ base_stem = target_path.stem
138
+ for idx, res_dict in enumerate(resources, 1):
139
+ img_url = res_dict.get("download_url")
140
+ if not img_url:
141
+ continue
142
+ img_path = target_path.parent / f"{base_stem}_{idx}.jpg"
143
+ try:
144
+ img_resp = requests.get(
145
+ img_url, stream=True, headers={"user-agent": headers["user-agent"]}, timeout=30
146
+ )
147
+ if img_resp.status_code == 200:
148
+ with img_path.open("wb") as f:
149
+ for chunk in img_resp.iter_content(chunk_size=8192):
150
+ if chunk:
151
+ f.write(chunk)
152
+ if img_path.exists() and img_path.stat().st_size > 0:
153
+ saved_paths.append(str(img_path))
154
+ except Exception as img_err:
155
+ log.warning(f"[vidssave] Failed to download carousel image {idx}: {img_err}")
156
+ if saved_paths:
157
+ return "images", saved_paths, caption
158
+ else:
159
+ log.debug(f"[vidssave] API did not return resources: {res_json}")
160
+ else:
161
+ log.warning(f"[vidssave] API POST failed with status: {response.status_code}")
162
+ except Exception as e:
163
+ log.warning(f"[vidssave] Helper failed for {url}: {e}")
164
+ return None
165
+
166
+
167
+ def _download_savethevideo(
168
+ url: str, output_path: Union[str, Path]
169
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
170
+ """Resolves URL using the public savethevideo.com API by polling task status.
171
+
172
+ Args:
173
+ url: Source URL of the video/reel.
174
+ output_path: Target filesystem path where the video should be saved.
175
+
176
+ Returns:
177
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
178
+ """
179
+ target_path = Path(output_path)
180
+ api_url = "https://api.v02.savethevideo.com/tasks"
181
+ headers = {
182
+ "accept": "application/json",
183
+ "content-type": "application/json",
184
+ "origin": "https://www.savethevideo.com",
185
+ "referer": "https://www.savethevideo.com/",
186
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
187
+ }
188
+ payload = {"type": "info", "url": url}
189
+
190
+ def download_from_result(result_list: list) -> Optional[Tuple[str, Union[str, List[str]], str]]:
191
+ if not result_list:
192
+ log.debug("[savethevideo] Task result is empty.")
193
+ return None
194
+ video_url = result_list[0].get("url")
195
+ caption = result_list[0].get("description") or result_list[0].get("title", "")
196
+ if _is_valid_video_link(video_url):
197
+ log.debug(f"[savethevideo] Got direct link: {video_url[:60] if video_url else ''}...")
198
+ try:
199
+ video_response = requests.get(video_url, stream=True, timeout=60)
200
+ if video_response.status_code == 200:
201
+ target_path.parent.mkdir(parents=True, exist_ok=True)
202
+ with target_path.open("wb") as f:
203
+ for chunk in video_response.iter_content(chunk_size=8192):
204
+ if chunk:
205
+ f.write(chunk)
206
+ if target_path.exists() and target_path.stat().st_size > 0:
207
+ return "video", str(target_path), caption
208
+ else:
209
+ log.warning(f"[savethevideo] Download failed with status: {video_response.status_code}")
210
+ except Exception as e:
211
+ log.warning(f"[savethevideo] Stream download failed: {e}")
212
+ else:
213
+ log.debug("[savethevideo] Link points to images, saving as image carousel.")
214
+ saved_paths = []
215
+ target_path.parent.mkdir(parents=True, exist_ok=True)
216
+ base_stem = target_path.stem
217
+ for idx, res_item in enumerate(result_list, 1):
218
+ img_url = res_item.get("url")
219
+ if not img_url:
220
+ continue
221
+ img_path = target_path.parent / f"{base_stem}_{idx}.jpg"
222
+ try:
223
+ img_resp = requests.get(img_url, stream=True, timeout=30)
224
+ if img_resp.status_code == 200:
225
+ with img_path.open("wb") as f:
226
+ for chunk in img_resp.iter_content(chunk_size=8192):
227
+ if chunk:
228
+ f.write(chunk)
229
+ if img_path.exists() and img_path.stat().st_size > 0:
230
+ saved_paths.append(str(img_path))
231
+ except Exception as img_err:
232
+ log.warning(f"[savethevideo] Failed to download image {idx}: {img_err}")
233
+ if saved_paths:
234
+ return "images", saved_paths, caption
235
+ return None
236
+
237
+ try:
238
+ response = requests.post(api_url, headers=headers, json=payload, timeout=20)
239
+ if response.status_code == 200:
240
+ res_json = response.json()
241
+ if res_json.get("state") == "completed":
242
+ return download_from_result(res_json.get("result", []))
243
+ log.debug(f"[savethevideo] State is {res_json.get('state')}")
244
+ elif response.status_code == 202:
245
+ res_json = response.json()
246
+ task_id = res_json.get("id")
247
+ if not task_id:
248
+ log.warning("[savethevideo] Task created but no task ID returned.")
249
+ return None
250
+ poll_url = f"https://api.v02.savethevideo.com/tasks/{task_id}"
251
+ log.debug(f"[savethevideo] Polling task: {poll_url}")
252
+ for _ in range(15):
253
+ poll_resp = requests.get(
254
+ poll_url,
255
+ headers={"accept": "application/json", "referer": "https://www.savethevideo.com/"},
256
+ timeout=15,
257
+ )
258
+ if poll_resp.status_code == 200:
259
+ poll_json = poll_resp.json()
260
+ state = poll_json.get("state")
261
+ if state == "completed":
262
+ return download_from_result(poll_json.get("result", []))
263
+ if state == "failed":
264
+ log.warning("[savethevideo] Task failed on remote server.")
265
+ return None
266
+ time.sleep(2)
267
+ else:
268
+ log.warning(f"[savethevideo] Task creation failed with status: {response.status_code}")
269
+ except Exception as e:
270
+ log.warning(f"[savethevideo] Helper failed for {url}: {e}")
271
+ return None
272
+
273
+
274
+ def _download_saveig(
275
+ url: str, output_path: Union[str, Path]
276
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
277
+ """Resolves URL using the public saveig.in API.
278
+
279
+ Args:
280
+ url: Source URL of the video/reel.
281
+ output_path: Target filesystem path where the video should be saved.
282
+
283
+ Returns:
284
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
285
+ """
286
+ target_path = Path(output_path)
287
+ api_url = "https://saveig.in/wp-json/visolix/api/download"
288
+ headers = {
289
+ "accept": "application/json, text/plain, */*",
290
+ "content-type": "application/json",
291
+ "origin": "https://saveig.in",
292
+ "referer": "https://saveig.in/fastdl/",
293
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
294
+ }
295
+ payload = {"url": url, "format": "", "captcha_response": None}
296
+ try:
297
+ response = requests.post(api_url, headers=headers, json=payload, timeout=20)
298
+ if response.status_code != 200:
299
+ log.warning(f"[saveig] POST returned status code {response.status_code}")
300
+ return None
301
+ res_json = response.json()
302
+ if not res_json.get("status"):
303
+ log.debug(f"[saveig] API returned failure status: {res_json}")
304
+ return None
305
+ html_content = res_json.get("data", "")
306
+ match = re.search(r'href=["\']([^"\']*dl\.php\?id=[a-zA-Z0-9]+)["\']', html_content)
307
+ if not match:
308
+ log.debug("[saveig] Could not find download link in HTML response data.")
309
+ return None
310
+ dl_url = match.group(1)
311
+ if dl_url.startswith("/"):
312
+ dl_url = "https://saveig.in" + dl_url
313
+ elif dl_url.startswith("../"):
314
+ dl_url = "https://saveig.in/wp-content/plugins/visolix-video-downloader/" + dl_url
315
+ elif not dl_url.startswith("http"):
316
+ dl_url = "https://saveig.in/wp-content/plugins/visolix-video-downloader/includes/" + dl_url
317
+ dl_url = dl_url.replace("/includes/../", "/")
318
+ if _is_valid_video_link(dl_url):
319
+ log.debug(f"[saveig] Got proxy link: {dl_url[:60]}...")
320
+ video_resp = requests.get(dl_url, stream=True, headers={"user-agent": headers["user-agent"]}, timeout=60)
321
+ if video_resp.status_code != 200:
322
+ log.warning(f"[saveig] Proxy stream returned status code {video_resp.status_code}")
323
+ return None
324
+ target_path.parent.mkdir(parents=True, exist_ok=True)
325
+ with target_path.open("wb") as f:
326
+ for chunk in video_resp.iter_content(chunk_size=8192):
327
+ if chunk:
328
+ f.write(chunk)
329
+ if target_path.exists() and target_path.stat().st_size > 0:
330
+ return "video", str(target_path), ""
331
+ else:
332
+ log.debug("[saveig] Link points to image, saving as jpg.")
333
+ target_path.parent.mkdir(parents=True, exist_ok=True)
334
+ img_path = target_path.parent / f"{target_path.stem}_1.jpg"
335
+ img_resp = requests.get(dl_url, stream=True, headers={"user-agent": headers["user-agent"]}, timeout=60)
336
+ if img_resp.status_code == 200:
337
+ with img_path.open("wb") as f:
338
+ for chunk in img_resp.iter_content(chunk_size=8192):
339
+ if chunk:
340
+ f.write(chunk)
341
+ if img_path.exists() and img_path.stat().st_size > 0:
342
+ return "images", [str(img_path)], ""
343
+ except Exception as e:
344
+ log.warning(f"[saveig] Helper failed for {url}: {e}")
345
+ return None
346
+
347
+
348
+
349
+ def _download_downloadgram(
350
+ url: str, output_path: Union[str, Path]
351
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
352
+ """Resolves URL using the public downloadgram.org API.
353
+
354
+ Args:
355
+ url: Source URL of the video/reel.
356
+ output_path: Target filesystem path where the video should be saved.
357
+
358
+ Returns:
359
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
360
+ """
361
+ target_path = Path(output_path)
362
+ api_url = "https://api.downloadgram.org/media"
363
+ headers = {
364
+ "accept": "*/*",
365
+ "content-type": "application/x-www-form-urlencoded",
366
+ "origin": "https://downloadgram.org",
367
+ "referer": "https://downloadgram.org/",
368
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
369
+ }
370
+ payload = {"url": url, "v": "3", "lang": "en"}
371
+ try:
372
+ response = requests.post(api_url, headers=headers, data=payload, timeout=20)
373
+ if response.status_code != 200:
374
+ log.warning(f"[downloadgram] POST returned status code {response.status_code}")
375
+ return None
376
+ matches = re.findall(r"https://cdn\.downloadgram\.org/\?token=[a-zA-Z0-9_\-\.]+", response.text)
377
+ if not matches:
378
+ log.debug("[downloadgram] Could not find CDN link in response.")
379
+ return None
380
+ dl_url = matches[-1]
381
+ if _is_valid_video_link(dl_url):
382
+ log.debug(f"[downloadgram] Got direct link: {dl_url[:60]}...")
383
+ video_resp = requests.get(dl_url, headers={"user-agent": headers["user-agent"]}, stream=True, timeout=60)
384
+ if video_resp.status_code != 200:
385
+ log.warning(f"[downloadgram] Stream returned status code {video_resp.status_code}")
386
+ return None
387
+ target_path.parent.mkdir(parents=True, exist_ok=True)
388
+ with target_path.open("wb") as f:
389
+ for chunk in video_resp.iter_content(chunk_size=8192):
390
+ if chunk:
391
+ f.write(chunk)
392
+ if target_path.exists() and target_path.stat().st_size > 0:
393
+ return "video", str(target_path), ""
394
+ else:
395
+ log.debug("[downloadgram] Link points to image, saving as jpg.")
396
+ target_path.parent.mkdir(parents=True, exist_ok=True)
397
+ img_path = target_path.parent / f"{target_path.stem}_1.jpg"
398
+ img_resp = requests.get(dl_url, headers={"user-agent": headers["user-agent"]}, stream=True, timeout=60)
399
+ if img_resp.status_code == 200:
400
+ with img_path.open("wb") as f:
401
+ for chunk in img_resp.iter_content(chunk_size=8192):
402
+ if chunk:
403
+ f.write(chunk)
404
+ if img_path.exists() and img_path.stat().st_size > 0:
405
+ return "images", [str(img_path)], ""
406
+ except Exception as e:
407
+ log.warning(f"[downloadgram] Helper failed for {url}: {e}")
408
+ return None
409
+
410
+
411
+ def _download_anyvidsave(
412
+ url: str, output_path: Union[str, Path]
413
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
414
+ """Resolves URL using the public anyvidsave.in API.
415
+
416
+ Args:
417
+ url: Source URL of the video/reel.
418
+ output_path: Target filesystem path where the video should be saved.
419
+
420
+ Returns:
421
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
422
+ """
423
+ target_path = Path(output_path)
424
+ api_url = "https://anyvidsave.in/download.php"
425
+ headers = {
426
+ "Content-Type": "application/json",
427
+ "Origin": "https://anyvidsave.in",
428
+ "Referer": "https://anyvidsave.in/",
429
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/120.0.0.0",
430
+ }
431
+ data = {"url": url}
432
+ try:
433
+ response = requests.post(api_url, headers=headers, json=data, timeout=15)
434
+ if response.status_code == 200:
435
+ res_json = response.json()
436
+ if res_json.get("success") and not res_json.get("limit_reached"):
437
+ media_data = res_json.get("data", {})
438
+ caption = media_data.get("title", "")
439
+ links = media_data.get("links", [])
440
+ cdn_url = None
441
+ for link in links:
442
+ if link.get("type") == "mp4" or "video" in link.get("type", ""):
443
+ cdn_url = link.get("url")
444
+ break
445
+ if cdn_url and _is_valid_video_link(cdn_url):
446
+ log.debug(f"[anyvidsave] Got CDN link: {cdn_url[:60]}...")
447
+ video_response = requests.get(cdn_url, stream=True, timeout=30)
448
+ if video_response.status_code == 200:
449
+ target_path.parent.mkdir(parents=True, exist_ok=True)
450
+ with target_path.open("wb") as f:
451
+ for chunk in video_response.iter_content(chunk_size=8192):
452
+ if chunk:
453
+ f.write(chunk)
454
+ if target_path.exists() and target_path.stat().st_size > 0:
455
+ return "video", str(target_path), caption
456
+ else:
457
+ log.debug("[anyvidsave] Link points to images, collecting image links.")
458
+ saved_paths = []
459
+ target_path.parent.mkdir(parents=True, exist_ok=True)
460
+ base_stem = target_path.stem
461
+ for idx, l_item in enumerate(links, 1):
462
+ img_url = l_item.get("url")
463
+ if not img_url or _is_valid_video_link(img_url):
464
+ continue
465
+ img_path = target_path.parent / f"{base_stem}_{idx}.jpg"
466
+ try:
467
+ img_resp = requests.get(img_url, stream=True, timeout=30)
468
+ if img_resp.status_code == 200:
469
+ with img_path.open("wb") as f:
470
+ for chunk in img_resp.iter_content(chunk_size=8192):
471
+ if chunk:
472
+ f.write(chunk)
473
+ if img_path.exists() and img_path.stat().st_size > 0:
474
+ saved_paths.append(str(img_path))
475
+ except Exception as img_err:
476
+ log.warning(f"[anyvidsave] Failed to download image {idx}: {img_err}")
477
+ if saved_paths:
478
+ return "images", saved_paths, caption
479
+ except Exception as e:
480
+ log.warning(f"[anyvidsave] Helper failed for {url}: {e}")
481
+ return None
482
+
483
+
484
+ def _download_igreelsdl(
485
+ url: str, output_path: Union[str, Path]
486
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
487
+ """Resolves URL using the public igreelsdl.com API.
488
+
489
+ Args:
490
+ url: Source URL of the video/reel.
491
+ output_path: Target filesystem path where the video should be saved.
492
+
493
+ Returns:
494
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
495
+ """
496
+ target_path = Path(output_path)
497
+ resolve_url = f"https://igreelsdl.com/api/resolve?url={requests.utils.quote(url)}"
498
+ headers = {
499
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/120.0.0.0",
500
+ "Referer": "https://igreelsdl.com/",
501
+ }
502
+ try:
503
+ response = requests.get(resolve_url, headers=headers, timeout=15)
504
+ if response.status_code == 200:
505
+ res_json = response.json()
506
+ if res_json.get("success"):
507
+ caption = res_json.get("title", "")
508
+ source_url = res_json.get("sourceUrl", url)
509
+ formats = res_json.get("formats", [])
510
+ format_id = formats[0].get("formatId", "best") if formats else "best"
511
+ download_url = (
512
+ f"https://igreelsdl.com/api/download?"
513
+ f"url={requests.utils.quote(source_url)}&"
514
+ f"format_id={requests.utils.quote(format_id)}&"
515
+ f"filename=instagram-download.mp4&"
516
+ f"type=video"
517
+ )
518
+ if _is_valid_video_link(download_url):
519
+ log.debug(f"[igreelsdl] Got proxy link: {download_url[:60]}...")
520
+ video_response = requests.get(download_url, headers=headers, stream=True, timeout=30)
521
+ if video_response.status_code == 200:
522
+ target_path.parent.mkdir(parents=True, exist_ok=True)
523
+ with target_path.open("wb") as f:
524
+ for chunk in video_response.iter_content(chunk_size=8192):
525
+ if chunk:
526
+ f.write(chunk)
527
+ if target_path.exists() and target_path.stat().st_size > 0:
528
+ return "video", str(target_path), caption
529
+ else:
530
+ log.debug("[igreelsdl] Link points to image, saving as jpg.")
531
+ target_path.parent.mkdir(parents=True, exist_ok=True)
532
+ img_path = target_path.parent / f"{target_path.stem}_1.jpg"
533
+ img_resp = requests.get(download_url, headers=headers, stream=True, timeout=30)
534
+ if img_resp.status_code == 200:
535
+ with img_path.open("wb") as f:
536
+ for chunk in img_resp.iter_content(chunk_size=8192):
537
+ if chunk:
538
+ f.write(chunk)
539
+ if img_path.exists() and img_path.stat().st_size > 0:
540
+ return "images", [str(img_path)], caption
541
+ except Exception as e:
542
+ log.warning(f"[igreelsdl] Helper failed for {url}: {e}")
543
+ return None
544
+
545
+
546
+ def _download_rapidapi(
547
+ url: str, output_path: Union[str, Path], api_key: str
548
+ ) -> Optional[Tuple[str, Union[str, List[str]], str]]:
549
+ """Uses a RapidAPI social media downloader to get the direct unblocked CDN link.
550
+
551
+ Args:
552
+ url: Source URL of the video/reel.
553
+ output_path: Target filesystem path where the video should be saved.
554
+ api_key: RapidAPI subscription key.
555
+
556
+ Returns:
557
+ Tuple of (media_type, saved_path_or_list, caption) if successful, otherwise None.
558
+ """
559
+ target_path = Path(output_path)
560
+ endpoint = "https://instagram-downloader-download-instagram-videos-stories.p.rapidapi.com/index"
561
+ headers = {
562
+ "x-rapidapi-key": api_key,
563
+ "x-rapidapi-host": "instagram-downloader-download-instagram-videos-stories.p.rapidapi.com",
564
+ }
565
+ params = {"url": url}
566
+ try:
567
+ response = requests.get(endpoint, headers=headers, params=params, timeout=15)
568
+ if response.status_code == 200:
569
+ data = response.json()
570
+ cdn_url = data.get("media") or data.get("url") or data.get("download_url")
571
+ caption = data.get("caption") or data.get("title") or ""
572
+ if cdn_url and _is_valid_video_link(cdn_url):
573
+ log.debug(f"[rapidapi] Got CDN link: {cdn_url[:60]}...")
574
+ video_response = requests.get(cdn_url, stream=True, timeout=30)
575
+ if video_response.status_code == 200:
576
+ target_path.parent.mkdir(parents=True, exist_ok=True)
577
+ with target_path.open("wb") as f:
578
+ for chunk in video_response.iter_content(chunk_size=8192):
579
+ if chunk:
580
+ f.write(chunk)
581
+ if target_path.exists() and target_path.stat().st_size > 0:
582
+ return "video", str(target_path), caption
583
+ elif cdn_url:
584
+ log.debug("[rapidapi] Link points to image, saving as jpg.")
585
+ target_path.parent.mkdir(parents=True, exist_ok=True)
586
+ img_path = target_path.parent / f"{target_path.stem}_1.jpg"
587
+ img_resp = requests.get(cdn_url, stream=True, timeout=30)
588
+ if img_resp.status_code == 200:
589
+ with img_path.open("wb") as f:
590
+ for chunk in img_resp.iter_content(chunk_size=8192):
591
+ if chunk:
592
+ f.write(chunk)
593
+ if img_path.exists() and img_path.stat().st_size > 0:
594
+ return "images", [str(img_path)], caption
595
+ except Exception as e:
596
+ log.warning(f"[rapidapi] Helper failed for {url}: {e}")
597
+ return None
598
+
599
+
600
+ def _download_yt_dlp(
601
+ url: str, output_path: Union[str, Path], cookies_path: Optional[Union[str, Path]] = None
602
+ ) -> Optional[Tuple[str, str]]:
603
+ """Downloads video using local yt-dlp library.
604
+
605
+ Args:
606
+ url: Source URL to ingest.
607
+ output_path: Target filesystem path for downloaded video.
608
+ cookies_path: Optional path to netscape cookie file.
609
+
610
+ Returns:
611
+ Tuple of (output_path_str, caption) if successful, otherwise None.
612
+ """
613
+ out_path = Path(output_path)
614
  opts = {
615
  "format": "bestvideo[ext=mp4]+bestaudio/best",
616
+ "outtmpl": str(out_path),
617
  "merge_output_format": "mp4",
618
  "quiet": True,
619
  "no_warnings": True,
620
  }
621
 
622
+ if cookies_path:
623
+ cookie_p = Path(cookies_path)
624
+ if cookie_p.exists():
625
+ opts["cookiefile"] = str(cookie_p)
626
 
627
  try:
628
  with yt_dlp.YoutubeDL(opts) as ydl:
 
629
  info = ydl.extract_info(url, download=False)
630
+ caption = info.get("description", "") or info.get("title", "")
631
 
632
+ if not info.get("formats"):
633
  return None
634
  ydl.download([url])
635
+ if out_path.exists():
636
+ return str(out_path), caption
637
+ except Exception as e:
638
+ log.warning(f"yt-dlp download failed for {url}: {e}")
639
  return None
640
  return None
641
 
642
+
643
+ def _download_instaloader(url: str) -> Optional[Tuple[List[str], str]]:
644
+ """Downloads Instagram carousels/images using Instaloader.
645
+
646
+ Args:
647
+ url: Source Instagram post URL.
648
+
649
+ Returns:
650
+ Tuple of (sorted_image_path_list, caption) if successful, otherwise None.
651
+ """
652
  match = re.search(r"/(?:p|reels|reel)/([^/?#&]+)", url)
653
  if not match:
654
  return None
655
 
656
  shortcode = match.group(1)
657
+ temp_dir = Path("temp_images")
658
  loader = instaloader.Instaloader(
659
  download_pictures=True,
660
+ download_videos=False, # We use yt-dlp for videos
661
  download_video_thumbnails=False,
662
  download_geotags=False,
663
  download_comments=False,
664
  save_metadata=False,
665
  compress_json=False,
666
+ dirname_pattern=str(temp_dir),
667
  )
668
 
669
  try:
670
+ if temp_dir.exists():
671
+ shutil.rmtree(temp_dir)
 
 
672
 
673
  post = instaloader.Post.from_shortcode(loader.context, shortcode)
674
  caption = post.caption or ""
675
+ loader.download_post(post, target=str(temp_dir))
676
 
 
677
  images = [
678
+ str(p)
679
+ for p in temp_dir.iterdir()
680
+ if p.suffix.lower() in {".jpg", ".png", ".webp"}
681
  ]
682
  if images:
683
  return sorted(images), caption
684
  return None
685
  except Exception as e:
686
+ log.warning(f"Instaloader failed for {url}: {e}")
687
  return None
688
 
689
+
690
+ def download_video(
691
+ url: str, output_path: Union[str, Path], cookies_path: Optional[Union[str, Path]] = None
692
+ ) -> str:
693
+ """Downloads video content specifically, raising an error if images are returned.
694
+
695
+ Args:
696
+ url: Source URL to ingest.
697
+ output_path: Target filesystem path for downloaded video.
698
+ cookies_path: Optional path to netscape cookie file.
699
+
700
+ Returns:
701
+ Path string to the downloaded video file.
702
+ """
703
  res_type, res_data, _ = download_content(url, output_path, cookies_path)
704
  if res_type == "video":
705
+ return str(res_data)
706
+ raise RuntimeError(f"Download found images instead of video for {url}.")
707
+
708
+
backend/app/pipeline/ingestion/source.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source-platform labelling (docs/02). One place that maps a URL to a human
2
+ platform label, shared by the API (at card creation) and the worker (for the
3
+ observability source line). Covers the video platforms and the common article
4
+ sources, with a generic host fallback so any URL gets a sensible label."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from urllib.parse import urlparse
9
+
10
+ # host substring -> platform label
11
+ _KNOWN: tuple[tuple[str, str], ...] = (
12
+ ("instagram.com", "instagram"),
13
+ ("tiktok.com", "tiktok"),
14
+ ("youtube.com", "youtube"),
15
+ ("youtu.be", "youtube"),
16
+ ("reddit.com", "reddit"),
17
+ ("wikipedia.org", "wikipedia"),
18
+ ("linkedin.com", "linkedin"),
19
+ ("substack.com", "substack"),
20
+ ("medium.com", "medium"),
21
+ )
22
+
23
+
24
+ def platform_for_url(url: str) -> str | None:
25
+ """A platform label for `url`: a known platform, else the bare host (no
26
+ `www.`), else None for an unparseable URL."""
27
+ u = url.lower()
28
+ for needle, label in _KNOWN:
29
+ if needle in u:
30
+ return label
31
+ host = urlparse(url).netloc.lower()
32
+ if host.startswith("www."):
33
+ host = host[4:]
34
+ return host or None
backend/app/pipeline/structuring.py CHANGED
@@ -14,6 +14,7 @@ import logging
14
  import re
15
 
16
  from app.config import get_settings
 
17
  from app.models.card import (
18
  Base,
19
  Block,
@@ -57,12 +58,19 @@ _ACTION_FOR_TYPE: dict[ContentType, tuple[PrimaryActionKind, str]] = {
57
 
58
 
59
  class StructuredCard:
60
- """Validated structuring output: base + blocks + primary_action."""
61
-
62
- def __init__(self, base: Base, blocks: list[dict], primary_action: PrimaryAction):
 
 
 
 
 
 
63
  self.base = base
64
  self.blocks = blocks # list of plain dicts (validated), ready for JSON column
65
  self.primary_action = primary_action
 
66
 
67
 
68
  # --------------------------------------------------------------------------- #
@@ -92,7 +100,13 @@ Return ONLY a JSON object (no prose, no markdown fences) with this exact shape:
92
  "content_type": "recipe|workout|tutorial|tip|product_list|travel|news_explainer|other",
93
  "type_confidence": 0.0-1.0
94
  }},
95
- "blocks": [ ... ] // ordered list using ONLY the allowed block types
 
 
 
 
 
 
96
  }}
97
 
98
  Rules:
@@ -100,6 +114,9 @@ Rules:
100
  - Choose content_type, then arrange blocks to fit it (e.g. recipe -> heading,
101
  key_value(time/serves), checklist(ingredients), step_list(steps)).
102
  - Use ONLY the allowed block types below. Output strict JSON.
 
 
 
103
 
104
  {vocab}
105
 
@@ -202,6 +219,36 @@ def _coerce_blocks(raw_blocks: list) -> list[dict]:
202
  return out
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  def _synthesize_base(bundle: str, transcript: str, caption: str) -> Base:
206
  """When the model omits one_liner/tldr, build something usable from raw text."""
207
  source = (transcript or caption or "").strip()
@@ -262,12 +309,19 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str
262
  base.one_liner = base.one_liner or synth.one_liner
263
  base.tldr = base.tldr or synth.tldr
264
 
 
 
265
  blocks = _coerce_blocks(data.get("blocks") or [])
266
  if not blocks:
267
- # an empty/garbage block list still gets a usable body
268
- return _paragraph_fallback(bundle, transcript, caption)
269
-
270
- return StructuredCard(base, blocks, _primary_action_for(base.content_type))
 
 
 
 
 
271
 
272
 
273
  # --------------------------------------------------------------------------- #
 
14
  import re
15
 
16
  from app.config import get_settings
17
+ from app.models.artifact import Artifact, ArtifactType
18
  from app.models.card import (
19
  Base,
20
  Block,
 
58
 
59
 
60
  class StructuredCard:
61
+ """Validated structuring output: base + blocks + primary_action + artifacts."""
62
+
63
+ def __init__(
64
+ self,
65
+ base: Base,
66
+ blocks: list[dict],
67
+ primary_action: PrimaryAction,
68
+ artifacts: list[Artifact] | None = None,
69
+ ):
70
  self.base = base
71
  self.blocks = blocks # list of plain dicts (validated), ready for JSON column
72
  self.primary_action = primary_action
73
+ self.artifacts = artifacts or [] # referenced things for the catalog (docs/12)
74
 
75
 
76
  # --------------------------------------------------------------------------- #
 
100
  "content_type": "recipe|workout|tutorial|tip|product_list|travel|news_explainer|other",
101
  "type_confidence": 0.0-1.0
102
  }},
103
+ "blocks": [ ... ], // ordered list using ONLY the allowed block types
104
+ "artifacts": [ // real, named things the video REFERENCES (may be empty)
105
+ {{ "type": "book|movie|tv_show|podcast|music|product|place|app|other",
106
+ "title": str, // the proper name of the thing
107
+ "creator": str|null, // author / director / artist / host / brand
108
+ "year": int|null }}
109
+ ]
110
  }}
111
 
112
  Rules:
 
114
  - Choose content_type, then arrange blocks to fit it (e.g. recipe -> heading,
115
  key_value(time/serves), checklist(ingredients), step_list(steps)).
116
  - Use ONLY the allowed block types below. Output strict JSON.
117
+ - artifacts: include ONLY concrete, named, real-world things the video names
118
+ (e.g. a specific book, movie, podcast, product, or place). Do NOT invent any;
119
+ if the video names none, return an empty list. Use the most specific type.
120
 
121
  {vocab}
122
 
 
219
  return out
220
 
221
 
222
+ def _coerce_artifacts(raw_artifacts: list) -> list[Artifact]:
223
+ """Validate referenced things; drop anything without a usable title or with a
224
+ bad shape. Never trust the model — a malformed entry is silently skipped."""
225
+ out: list[Artifact] = []
226
+ if not isinstance(raw_artifacts, list):
227
+ return out
228
+ seen: set[tuple[str, str]] = set()
229
+ for raw in raw_artifacts:
230
+ if not isinstance(raw, dict):
231
+ continue
232
+ title = (raw.get("title") or "").strip()
233
+ if not title:
234
+ continue
235
+ try:
236
+ atype = ArtifactType(raw.get("type", "other"))
237
+ except ValueError:
238
+ atype = ArtifactType.OTHER
239
+ key = (atype.value, title.lower())
240
+ if key in seen:
241
+ continue
242
+ seen.add(key)
243
+ creator = (raw.get("creator") or None)
244
+ if isinstance(creator, str):
245
+ creator = creator.strip() or None
246
+ year = raw.get("year")
247
+ year = int(year) if isinstance(year, (int, float)) else None
248
+ out.append(Artifact(type=atype, title=title, creator=creator, year=year))
249
+ return out
250
+
251
+
252
  def _synthesize_base(bundle: str, transcript: str, caption: str) -> Base:
253
  """When the model omits one_liner/tldr, build something usable from raw text."""
254
  source = (transcript or caption or "").strip()
 
309
  base.one_liner = base.one_liner or synth.one_liner
310
  base.tldr = base.tldr or synth.tldr
311
 
312
+ artifacts = _coerce_artifacts(data.get("artifacts") or [])
313
+
314
  blocks = _coerce_blocks(data.get("blocks") or [])
315
  if not blocks:
316
+ # an empty/garbage block list still gets a usable body, but keep any
317
+ # artifacts the model did surface (the catalog doesn't need blocks).
318
+ fallback = _paragraph_fallback(bundle, transcript, caption)
319
+ fallback.artifacts = artifacts
320
+ return fallback
321
+
322
+ return StructuredCard(
323
+ base, blocks, _primary_action_for(base.content_type), artifacts
324
+ )
325
 
326
 
327
  # --------------------------------------------------------------------------- #
backend/app/pipeline/worker.py CHANGED
@@ -26,7 +26,7 @@ from app.pipeline.ingestion.downloader import (
26
  download_content_async,
27
  )
28
  from app.pipeline.structuring import structure_async
29
- from app.services import events, notify
30
  from app.store import db, media
31
 
32
  log = logging.getLogger("pipeline.worker")
@@ -38,17 +38,6 @@ def _utcnow() -> datetime:
38
  return datetime.now(timezone.utc)
39
 
40
 
41
- def _platform_for(url: str) -> str | None:
42
- u = url.lower()
43
- if "instagram.com" in u:
44
- return "instagram"
45
- if "tiktok.com" in u:
46
- return "tiktok"
47
- if "youtube.com" in u or "youtu.be" in u:
48
- return "youtube"
49
- return None
50
-
51
-
52
  # --------------------------------------------------------------------------- #
53
  # Queue claim
54
  # --------------------------------------------------------------------------- #
@@ -115,26 +104,49 @@ async def _write_blocks(session, card_id: str, blocks: list[dict]) -> None:
115
 
116
 
117
  async def _write_media_and_meta(
118
- session, card_id: str, extraction, caption: str, resolver: str
 
119
  ) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  await session.execute(
121
- update(db.CardRow)
122
- .where(db.CardRow.id == card_id)
123
- .values(
124
- thumbnail=extraction.thumbnail,
125
- keyframes=extraction.keyframes,
126
- caption=caption,
127
- resolver=resolver,
128
- extraction={
129
- "transcript": extraction.had_transcript,
130
- "ocr": extraction.had_ocr,
131
- "visual": extraction.had_visual,
132
- },
133
- )
134
  )
135
  await session.commit()
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  async def _finish_ready(session, card_id: str, job: db.JobRow) -> None:
139
  await session.execute(
140
  update(db.CardRow)
@@ -220,9 +232,15 @@ async def _run_job(session, job: db.JobRow) -> None:
220
  await _write_base(session, card_id, structured)
221
  await _write_blocks(session, card_id, structured.blocks)
222
  await _write_media_and_meta(
223
- session, card_id, extraction, download.caption or "", download.resolver
 
224
  )
225
 
 
 
 
 
 
226
  # Optionally discard the source video, keep keyframes+thumbnail (docs/11)
227
  if get_settings().discard_source_video and download.media_type == "video":
228
  media.remove_path(str(download.data))
 
26
  download_content_async,
27
  )
28
  from app.pipeline.structuring import structure_async
29
+ from app.services import artifact_images, events, notify
30
  from app.store import db, media
31
 
32
  log = logging.getLogger("pipeline.worker")
 
38
  return datetime.now(timezone.utc)
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
41
  # --------------------------------------------------------------------------- #
42
  # Queue claim
43
  # --------------------------------------------------------------------------- #
 
104
 
105
 
106
  async def _write_media_and_meta(
107
+ session, card_id: str, extraction, caption: str, resolver: str,
108
+ creator: str | None = None,
109
  ) -> None:
110
+ values = dict(
111
+ thumbnail=extraction.thumbnail,
112
+ keyframes=extraction.keyframes,
113
+ caption=caption,
114
+ resolver=resolver,
115
+ extraction={
116
+ "transcript": extraction.had_transcript,
117
+ "ocr": extraction.had_ocr,
118
+ "visual": extraction.had_visual,
119
+ },
120
+ )
121
+ if creator: # article byline -> shown in the source line
122
+ values["creator"] = creator
123
  await session.execute(
124
+ update(db.CardRow).where(db.CardRow.id == card_id).values(**values)
 
 
 
 
 
 
 
 
 
 
 
 
125
  )
126
  await session.commit()
127
 
128
 
129
+ async def _persist_artifacts(session, card_id: str, artifacts) -> None:
130
+ """Aggregate referenced things into the global catalog (docs/12). Each lookup
131
+ is best-effort and isolated — a failure here never affects the card itself."""
132
+ for art in artifacts:
133
+ try:
134
+ thumbnail = await asyncio.to_thread(
135
+ artifact_images.resolve_thumbnail, art
136
+ )
137
+ await db.upsert_artifact(
138
+ session,
139
+ card_id=card_id,
140
+ type_=art.type.value,
141
+ title=art.title,
142
+ creator=art.creator,
143
+ year=art.year,
144
+ thumbnail=thumbnail,
145
+ )
146
+ except Exception: # noqa: BLE001 — catalog is non-critical to the card
147
+ log.warning("catalog upsert failed for %r", art.title, exc_info=True)
148
+
149
+
150
  async def _finish_ready(session, card_id: str, job: db.JobRow) -> None:
151
  await session.execute(
152
  update(db.CardRow)
 
232
  await _write_base(session, card_id, structured)
233
  await _write_blocks(session, card_id, structured.blocks)
234
  await _write_media_and_meta(
235
+ session, card_id, extraction, download.caption or "", download.resolver,
236
+ creator=download.author,
237
  )
238
 
239
+ # 5) Catalog: aggregate any referenced artifacts + fetch their thumbnails.
240
+ if structured.artifacts:
241
+ events.publish(card_id, "cataloging", "processing", "Cataloging references")
242
+ await _persist_artifacts(session, card_id, structured.artifacts)
243
+
244
  # Optionally discard the source video, keep keyframes+thumbnail (docs/11)
245
  if get_settings().discard_source_video and download.media_type == "video":
246
  media.remove_path(str(download.data))
backend/app/services/artifact_images.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Free, keyless thumbnail lookup for catalog artifacts (docs/12).
2
+
3
+ Free-first: every source here is a public API with no key and no
4
+ quota worth budgeting — iTunes Search (movies/podcasts/music/tv), Open Library
5
+ (books), Wikipedia REST (everything else). Best-effort by design: any miss,
6
+ timeout, or error returns None and the catalog simply shows a placeholder.
7
+
8
+ The returned value is a remote https URL (hotlinked) — nothing is downloaded or
9
+ stored, which fits the ephemeral free-tier filesystem.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+
19
+ from app.models.artifact import Artifact, ArtifactType
20
+
21
+ log = logging.getLogger("services.artifact_images")
22
+
23
+ _TIMEOUT = httpx.Timeout(6.0)
24
+ _HEADERS = {"User-Agent": "Cachy/0.1 (catalog thumbnail lookup)"}
25
+
26
+ # iTunes Search `entity` per artifact type (covers most media).
27
+ _ITUNES_ENTITY: dict[ArtifactType, str] = {
28
+ ArtifactType.MOVIE: "movie",
29
+ ArtifactType.TV_SHOW: "tvShow",
30
+ ArtifactType.PODCAST: "podcast",
31
+ ArtifactType.MUSIC: "album",
32
+ ArtifactType.APP: "software",
33
+ }
34
+
35
+
36
+ def resolve_thumbnail(artifact: Artifact) -> str | None:
37
+ """Route by type to a free image source. Returns a remote URL or None."""
38
+ try:
39
+ if artifact.type == ArtifactType.BOOK:
40
+ return _from_open_library(artifact) or _from_wikipedia(artifact)
41
+ if artifact.type in _ITUNES_ENTITY:
42
+ return _from_itunes(artifact) or _from_wikipedia(artifact)
43
+ # product / place / other -> Wikipedia is the best free generic source.
44
+ return _from_wikipedia(artifact)
45
+ except Exception as e: # noqa: BLE001 — thumbnail lookup must never break a card
46
+ log.info("thumbnail lookup failed for %r: %s", artifact.title, e)
47
+ return None
48
+
49
+
50
+ def _query(artifact: Artifact) -> str:
51
+ parts = [artifact.title]
52
+ if artifact.creator:
53
+ parts.append(artifact.creator)
54
+ return " ".join(parts)
55
+
56
+
57
+ def _from_itunes(artifact: Artifact) -> str | None:
58
+ entity = _ITUNES_ENTITY[artifact.type]
59
+ params = {"term": _query(artifact), "entity": entity, "limit": 1}
60
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
61
+ resp = client.get("https://itunes.apple.com/search", params=params)
62
+ resp.raise_for_status()
63
+ results = resp.json().get("results") or []
64
+ if not results:
65
+ return None
66
+ art = results[0].get("artworkUrl100")
67
+ if not art:
68
+ return None
69
+ # Upscale the 100px thumbnail iTunes returns to a crisper catalog cover.
70
+ return art.replace("100x100bb", "400x400bb")
71
+
72
+
73
+ def _from_open_library(artifact: Artifact) -> str | None:
74
+ params = {"title": artifact.title, "limit": 1}
75
+ if artifact.creator:
76
+ params["author"] = artifact.creator
77
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
78
+ resp = client.get("https://openlibrary.org/search.json", params=params)
79
+ resp.raise_for_status()
80
+ docs = resp.json().get("docs") or []
81
+ if not docs:
82
+ return None
83
+ cover_id = docs[0].get("cover_i")
84
+ if cover_id:
85
+ return f"https://covers.openlibrary.org/b/id/{cover_id}-L.jpg"
86
+ isbns = docs[0].get("isbn") or []
87
+ if isbns:
88
+ return f"https://covers.openlibrary.org/b/isbn/{isbns[0]}-L.jpg"
89
+ return None
90
+
91
+
92
+ def _from_wikipedia(artifact: Artifact) -> str | None:
93
+ title = quote(artifact.title.replace(" ", "_"))
94
+ url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
95
+ with httpx.Client(
96
+ timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True
97
+ ) as client:
98
+ resp = client.get(url)
99
+ if resp.status_code != 200:
100
+ return None
101
+ data = resp.json()
102
+ thumb = data.get("thumbnail") or {}
103
+ return thumb.get("source")
backend/app/services/llm_chat.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat Q&A over a single card (docs/13).
2
+
3
+ A grounded question-answering helper: the user asks about a card, the model
4
+ answers using ONLY that card's structured content as context. Reuses the same
5
+
6
+ Stateless by design — the client holds the conversation and replays it on each
7
+ turn; nothing is persisted server-side.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ from app.config import get_settings
15
+ from app.models.card import Card
16
+
17
+ log = logging.getLogger("services.llm_chat")
18
+
19
+ _MAX_TURNS = 12 # cap replayed history so context stays small + cheap
20
+ _MAX_TOKENS = 600
21
+
22
+ _SYSTEM = """You are Cachy, answering questions about ONE saved knowledge card.
23
+ Use ONLY the card content below as your source of truth. If the answer is not in
24
+ the card, say so plainly — do not invent facts. Be concise and direct.
25
+
26
+ --- CARD ---
27
+ {context}
28
+ --- END CARD ---"""
29
+
30
+
31
+ def card_context(card: Card) -> str:
32
+ """Flatten a card's base + blocks into plain text the model can reason over."""
33
+ lines: list[str] = []
34
+ if card.base.one_liner:
35
+ lines.append(card.base.one_liner)
36
+ if card.base.tldr:
37
+ lines.append(card.base.tldr)
38
+ for block in card.blocks:
39
+ # blocks are pydantic Block objects; normalise to a plain dict view.
40
+ raw = block.model_dump() if hasattr(block, "model_dump") else block
41
+ lines.append(_block_text(raw))
42
+ return "\n".join(l for l in lines if l and l.strip())
43
+
44
+
45
+ def _block_text(block: dict) -> str:
46
+ """Best-effort text view of any block dict (tolerant of unknown shapes)."""
47
+ if not isinstance(block, dict):
48
+ return ""
49
+ btype = block.get("type")
50
+ if btype == "heading":
51
+ return f"# {block.get('text', '')}"
52
+ if btype in ("paragraph", "callout"):
53
+ return block.get("text", "")
54
+ if btype == "bullet_list":
55
+ return "\n".join(f"- {i}" for i in block.get("items", []))
56
+ if btype == "step_list":
57
+ steps = block.get("steps", [])
58
+ return "\n".join(
59
+ f"{n}. {s.get('text', '')}" for n, s in enumerate(steps, 1)
60
+ if isinstance(s, dict)
61
+ )
62
+ if btype == "key_value":
63
+ pairs = block.get("pairs", [])
64
+ return "\n".join(
65
+ f"{p.get('key', '')}: {p.get('value', '')}" for p in pairs
66
+ if isinstance(p, dict)
67
+ )
68
+ if btype == "checklist":
69
+ items = block.get("items", [])
70
+ return "\n".join(
71
+ f"- {i.get('text', '')}" for i in items if isinstance(i, dict)
72
+ )
73
+ if btype == "link":
74
+ return block.get("label") or block.get("url", "")
75
+ if btype == "map":
76
+ places = block.get("places", [])
77
+ return "\n".join(p.get("name", "") for p in places if isinstance(p, dict))
78
+ if btype == "table":
79
+ rows = block.get("rows", [])
80
+ return "\n".join(
81
+ " | ".join(str(c) for c in r) for r in rows if isinstance(r, list)
82
+ )
83
+ # unknown / forward-compat
84
+ return block.get("text", "") if isinstance(block.get("text"), str) else ""
85
+
86
+
87
+ def _messages(context: str, history: list[dict]) -> list[dict]:
88
+ recent = [m for m in history if m.get("role") in ("user", "assistant")][-_MAX_TURNS:]
89
+ return [{"role": "system", "content": _SYSTEM.format(context=context)}, *recent]
90
+
91
+
92
+ def answer(card: Card, history: list[dict]) -> str | None:
93
+ """Return the assistant's reply, or None if no backend is configured / it fails."""
94
+ context = card_context(card)
95
+ messages = _messages(context, history)
96
+ settings = get_settings()
97
+ if settings.hf_enabled:
98
+ return _call_hf(messages)
99
+ if settings.groq_llm_enabled:
100
+ return _call_groq(messages)
101
+ return None
102
+
103
+
104
+ def _call_hf(messages: list[dict]) -> str | None:
105
+ settings = get_settings()
106
+ try:
107
+ from huggingface_hub import InferenceClient
108
+
109
+ client = InferenceClient(api_key=settings.hf_api_key)
110
+ resp = client.chat_completion(
111
+ model=settings.hf_model,
112
+ messages=messages,
113
+ temperature=0.3,
114
+ max_tokens=_MAX_TOKENS,
115
+ )
116
+ text = resp.choices[0].message.content if resp.choices else ""
117
+ return (text or "").strip() or None
118
+ except Exception as e: # noqa: BLE001
119
+ log.warning("chat call (huggingface) failed: %s", e)
120
+ return None
121
+
122
+
123
+ def _call_groq(messages: list[dict]) -> str | None:
124
+ settings = get_settings()
125
+ try:
126
+ from groq import Groq
127
+
128
+ client = Groq(api_key=settings.groq_api_key)
129
+ resp = client.chat.completions.create(
130
+ model="llama-3.1-70b-versatile", # free tier, matches structuring
131
+ messages=messages,
132
+ temperature=0.3,
133
+ max_tokens=_MAX_TOKENS,
134
+ )
135
+ text = resp.choices[0].message.content if resp.choices else ""
136
+ return (text or "").strip() or None
137
+ except Exception as e: # noqa: BLE001
138
+ log.warning("chat call (groq) failed: %s", e)
139
+ return None
backend/app/store/db.py CHANGED
@@ -26,6 +26,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
26
 
27
  from app.config import get_settings
28
  from app.store import media as media_store
 
29
  from app.models.card import (
30
  Base as CardBase,
31
  Card,
@@ -124,6 +125,38 @@ class CardRow(Base):
124
  )
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  class JobRow(Base):
128
  __tablename__ = "jobs"
129
 
@@ -184,3 +217,50 @@ async def get_card_row(db: AsyncSession, card_id: str) -> CardRow | None:
184
  async def find_card_by_url(db: AsyncSession, url: str) -> CardRow | None:
185
  res = await db.execute(select(CardRow).where(CardRow.source_url == url))
186
  return res.scalar_one_or_none()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  from app.config import get_settings
28
  from app.store import media as media_store
29
+ from app.models.artifact import ArtifactType, CatalogEntry
30
  from app.models.card import (
31
  Base as CardBase,
32
  Card,
 
125
  )
126
 
127
 
128
+ class ArtifactRow(Base):
129
+ """A deduplicated catalog item (docs/12): one referenced thing, many source
130
+ cards. Dedupe key is (type, title_norm). Thumbnails are remote URLs."""
131
+
132
+ __tablename__ = "artifacts"
133
+
134
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=_new_uuid)
135
+ type: Mapped[str] = mapped_column(String, default="other", index=True)
136
+ title: Mapped[str] = mapped_column(Text)
137
+ title_norm: Mapped[str] = mapped_column(String, index=True)
138
+ creator: Mapped[str | None] = mapped_column(String, nullable=True)
139
+ year: Mapped[int | None] = mapped_column(Integer, nullable=True)
140
+ thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True)
141
+ source_card_ids: Mapped[list] = mapped_column(JSON, default=list)
142
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
143
+ updated_at: Mapped[datetime] = mapped_column(
144
+ DateTime, default=_utcnow, onupdate=_utcnow
145
+ )
146
+
147
+ def to_entry(self) -> CatalogEntry:
148
+ return CatalogEntry(
149
+ id=self.id,
150
+ type=ArtifactType(self.type) if self.type else ArtifactType.OTHER,
151
+ title=self.title,
152
+ creator=self.creator,
153
+ year=self.year,
154
+ thumbnail=self.thumbnail,
155
+ source_card_ids=list(self.source_card_ids or []),
156
+ created_at=(self.created_at or _utcnow()).isoformat(),
157
+ )
158
+
159
+
160
  class JobRow(Base):
161
  __tablename__ = "jobs"
162
 
 
217
  async def find_card_by_url(db: AsyncSession, url: str) -> CardRow | None:
218
  res = await db.execute(select(CardRow).where(CardRow.source_url == url))
219
  return res.scalar_one_or_none()
220
+
221
+
222
+ def _norm_title(title: str) -> str:
223
+ return " ".join(title.lower().split())
224
+
225
+
226
+ async def upsert_artifact(
227
+ db: AsyncSession,
228
+ *,
229
+ card_id: str,
230
+ type_: str,
231
+ title: str,
232
+ creator: str | None,
233
+ year: int | None,
234
+ thumbnail: str | None,
235
+ ) -> ArtifactRow:
236
+ """Insert a catalog item or merge into the existing one (dedupe by type+title).
237
+ Appends card_id to source_card_ids and backfills a missing thumbnail."""
238
+ norm = _norm_title(title)
239
+ res = await db.execute(
240
+ select(ArtifactRow).where(
241
+ ArtifactRow.type == type_, ArtifactRow.title_norm == norm
242
+ )
243
+ )
244
+ row = res.scalar_one_or_none()
245
+ if row is None:
246
+ row = ArtifactRow(
247
+ type=type_,
248
+ title=title,
249
+ title_norm=norm,
250
+ creator=creator,
251
+ year=year,
252
+ thumbnail=thumbnail,
253
+ source_card_ids=[card_id],
254
+ )
255
+ db.add(row)
256
+ else:
257
+ if card_id not in (row.source_card_ids or []):
258
+ row.source_card_ids = [*(row.source_card_ids or []), card_id]
259
+ if not row.thumbnail and thumbnail:
260
+ row.thumbnail = thumbnail
261
+ if not row.creator and creator:
262
+ row.creator = creator
263
+ if not row.year and year:
264
+ row.year = year
265
+ await db.commit()
266
+ return row
backend/pyproject.toml CHANGED
@@ -13,8 +13,10 @@ dependencies = [
13
  "aiosqlite>=0.20",
14
  "python-multipart>=0.0.9",
15
  # ingestion
 
16
  "yt-dlp>=2024.4.9",
17
  "instaloader>=4.11",
 
18
  # extraction
19
  "Pillow>=10.0",
20
  "imagehash>=4.3",
 
13
  "aiosqlite>=0.20",
14
  "python-multipart>=0.0.9",
15
  # ingestion
16
+ "requests>=2.31",
17
  "yt-dlp>=2024.4.9",
18
  "instaloader>=4.11",
19
+ "trafilatura>=1.8", # keyless readable-article extraction (docs/02 article path)
20
  # extraction
21
  "Pillow>=10.0",
22
  "imagehash>=4.3",
backend/test_carousel_out.mp4 ADDED
Binary file (47.6 kB). View file
 
backend/tests/test_api.py CHANGED
@@ -18,7 +18,7 @@ async def client(database):
18
  async def test_health(client):
19
  r = await client.get("/health")
20
  assert r.status_code == 200
21
- assert r.json()["schema_version"] == "1.0"
22
 
23
 
24
  async def test_create_returns_id_and_queued(client):
@@ -63,3 +63,121 @@ async def test_get_list_patch_delete(client):
63
  async def test_get_missing_card_404(client):
64
  r = await client.get("/cards/does-not-exist")
65
  assert r.status_code == 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  async def test_health(client):
19
  r = await client.get("/health")
20
  assert r.status_code == 200
21
+ assert r.json()["schema_version"] == "1.1"
22
 
23
 
24
  async def test_create_returns_id_and_queued(client):
 
63
  async def test_get_missing_card_404(client):
64
  r = await client.get("/cards/does-not-exist")
65
  assert r.status_code == 404
66
+
67
+
68
+ async def test_catalog_empty_initially(client):
69
+ r = await client.get("/catalog")
70
+ assert r.status_code == 200
71
+ assert r.json() == []
72
+
73
+
74
+ async def test_catalog_upsert_dedupes_and_lists(client, database):
75
+ async with database.session() as s:
76
+ await database.upsert_artifact(
77
+ s, card_id="c1", type_="book", title="Atomic Habits",
78
+ creator="James Clear", year=2018, thumbnail="http://x/a.jpg",
79
+ )
80
+ await database.upsert_artifact(
81
+ s, card_id="c2", type_="book", title="atomic habits",
82
+ creator=None, year=None, thumbnail=None,
83
+ )
84
+ await database.upsert_artifact(
85
+ s, card_id="c3", type_="movie", title="Inception",
86
+ creator="Nolan", year=2010, thumbnail=None,
87
+ )
88
+
89
+ r = await client.get("/catalog")
90
+ entries = r.json()
91
+ assert len(entries) == 2 # the two books deduped into one
92
+ book = next(e for e in entries if e["type"] == "book")
93
+ assert sorted(book["source_card_ids"]) == ["c1", "c2"]
94
+ assert book["thumbnail"] == "http://x/a.jpg" # backfilled-keep
95
+
96
+ # type filter
97
+ r2 = await client.get("/catalog", params={"type": "movie"})
98
+ assert [e["title"] for e in r2.json()] == ["Inception"]
99
+
100
+
101
+ async def _make_ready_card(database) -> str:
102
+ async with database.session() as s:
103
+ row = database.CardRow(
104
+ source_url="https://instagram.com/reel/chat",
105
+ state="ready",
106
+ content_type="recipe",
107
+ one_liner="Easy pancakes",
108
+ tldr="Mix, pour, flip.",
109
+ blocks=[
110
+ {"type": "checklist", "id": "b1",
111
+ "items": [{"text": "flour", "checked": False}]},
112
+ ],
113
+ )
114
+ s.add(row)
115
+ await s.commit()
116
+ return row.id
117
+
118
+
119
+ async def test_chat_rejects_non_user_last_message(client):
120
+ r = await client.post(
121
+ "/cards/whatever/chat",
122
+ json={"messages": [{"role": "assistant", "content": "hi"}]},
123
+ )
124
+ assert r.status_code == 422
125
+
126
+
127
+ async def test_chat_409_when_card_not_ready(client):
128
+ created = await client.post("/cards", json={"url": "https://instagram.com/reel/q"})
129
+ card_id = created.json()["card_id"] # stays QUEUED (no worker in tests)
130
+ r = await client.post(
131
+ f"/cards/{card_id}/chat",
132
+ json={"messages": [{"role": "user", "content": "what is this?"}]},
133
+ )
134
+ assert r.status_code == 409
135
+
136
+
137
+ async def test_chat_503_without_llm_backend(client, database):
138
+ card_id = await _make_ready_card(database)
139
+ r = await client.post(
140
+ f"/cards/{card_id}/chat",
141
+ json={"messages": [{"role": "user", "content": "how much flour?"}]},
142
+ )
143
+ assert r.status_code == 503 # LLM_BACKEND=none in the test env
144
+
145
+
146
+ async def test_chat_returns_reply_when_backend_answers(client, database, monkeypatch):
147
+ card_id = await _make_ready_card(database)
148
+
149
+ captured = {}
150
+
151
+ def fake_answer(card, history):
152
+ captured["title"] = card.base.one_liner
153
+ captured["history"] = history
154
+ return "You need flour."
155
+
156
+ monkeypatch.setattr("app.api.cards.llm_chat.answer", fake_answer)
157
+
158
+ r = await client.post(
159
+ f"/cards/{card_id}/chat",
160
+ json={"messages": [{"role": "user", "content": "ingredients?"}]},
161
+ )
162
+ assert r.status_code == 200
163
+ assert r.json()["reply"] == "You need flour."
164
+ assert captured["title"] == "Easy pancakes"
165
+ assert captured["history"][-1]["content"] == "ingredients?"
166
+
167
+
168
+ async def test_catalog_detail_and_delete(client, database):
169
+ async with database.session() as s:
170
+ row = await database.upsert_artifact(
171
+ s, card_id="c1", type_="podcast", title="Lex Fridman",
172
+ creator=None, year=None, thumbnail=None,
173
+ )
174
+ artifact_id = row.id
175
+
176
+ g = await client.get(f"/catalog/{artifact_id}")
177
+ assert g.status_code == 200
178
+ assert g.json()["entry"]["title"] == "Lex Fridman"
179
+ assert g.json()["source_card_ids"] == ["c1"]
180
+
181
+ d = await client.delete(f"/catalog/{artifact_id}")
182
+ assert d.status_code == 200
183
+ assert (await client.get(f"/catalog/{artifact_id}")).status_code == 404
backend/tests/test_article.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Article ingestion path (docs/02): readable-text extraction for non-video
2
+ sources, routing, and the extraction-stage branch. No network — trafilatura is
3
+ stubbed."""
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from app.pipeline.extraction import extract
10
+ from app.pipeline.ingestion import article, downloader
11
+ from app.pipeline.ingestion.downloader import DownloadResult
12
+ from app.pipeline.ingestion.source import platform_for_url
13
+
14
+
15
+ # --------------------------------------------------------------------------- #
16
+ # article.fetch_article
17
+ # --------------------------------------------------------------------------- #
18
+
19
+ def _stub_trafilatura(monkeypatch, *, html="<html></html>", payload=None):
20
+ import trafilatura
21
+ monkeypatch.setattr(trafilatura, "fetch_url", lambda url: html)
22
+ monkeypatch.setattr(
23
+ trafilatura, "extract",
24
+ lambda *a, **k: (json.dumps(payload) if payload is not None else None),
25
+ )
26
+
27
+
28
+ def test_fetch_article_extracts_fields(monkeypatch):
29
+ _stub_trafilatura(monkeypatch, payload={
30
+ "title": "Why Sleep Matters",
31
+ "text": "Sleep is essential. " * 30,
32
+ "author": "Jane Doe",
33
+ "image": "https://img/cover.jpg",
34
+ "sitename": "Substack",
35
+ })
36
+ art = article.fetch_article("https://x.substack.com/p/sleep")
37
+ assert art is not None
38
+ assert art.title == "Why Sleep Matters"
39
+ assert art.author == "Jane Doe"
40
+ assert art.image_url == "https://img/cover.jpg"
41
+ assert art.site == "Substack"
42
+
43
+
44
+ def test_fetch_article_too_thin_returns_none(monkeypatch):
45
+ _stub_trafilatura(monkeypatch, payload={"title": "x", "text": "too short"})
46
+ assert article.fetch_article("https://x.com/p") is None
47
+
48
+
49
+ def test_fetch_article_handles_empty_fetch(monkeypatch):
50
+ _stub_trafilatura(monkeypatch, html="")
51
+ assert article.fetch_article("https://x.com/p") is None
52
+
53
+
54
+ def test_fetch_article_handles_no_extract(monkeypatch):
55
+ _stub_trafilatura(monkeypatch, payload=None) # extract returns None
56
+ assert article.fetch_article("https://x.com/p") is None
57
+
58
+
59
+ # --------------------------------------------------------------------------- #
60
+ # routing
61
+ # --------------------------------------------------------------------------- #
62
+
63
+ @pytest.mark.parametrize("url,is_video", [
64
+ ("https://www.instagram.com/reel/abc", True),
65
+ ("https://youtu.be/abc", True),
66
+ ("https://www.tiktok.com/@x/video/1", True),
67
+ ("https://www.reddit.com/r/x/comments/1/title", False),
68
+ ("https://en.wikipedia.org/wiki/Sleep", False),
69
+ ("https://someblog.com/post", False),
70
+ ])
71
+ def test_is_video_url(url, is_video):
72
+ assert downloader._is_video_url(url) is is_video
73
+
74
+
75
+ def test_download_content_routes_article(monkeypatch):
76
+ monkeypatch.setattr(article, "fetch_article", lambda url: article.ArticleResult(
77
+ title="T", text="body " * 50, author="A", image_url="https://i/x.jpg",
78
+ site="reddit",
79
+ ))
80
+ res = downloader.download_content("https://www.reddit.com/r/x/comments/1/t")
81
+ assert res.media_type == "article"
82
+ assert res.resolver == "article"
83
+ assert res.title == "T"
84
+ assert res.author == "A"
85
+ assert res.image_url == "https://i/x.jpg"
86
+
87
+
88
+ def test_platform_for_url():
89
+ assert platform_for_url("https://www.reddit.com/r/x") == "reddit"
90
+ assert platform_for_url("https://en.wikipedia.org/wiki/X") == "wikipedia"
91
+ assert platform_for_url("https://x.substack.com/p/y") == "substack"
92
+ assert platform_for_url("https://www.someblog.com/post") == "someblog.com"
93
+ assert platform_for_url("https://youtu.be/abc") == "youtube"
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # extraction branch
98
+ # --------------------------------------------------------------------------- #
99
+
100
+ def test_extract_article_branch_skips_media(tmp_path):
101
+ download = DownloadResult(
102
+ media_type="article", data="", caption="My Title", resolver="article",
103
+ text="The body of the article goes here.", title="My Title",
104
+ author="Author", image_url="https://img/lead.jpg",
105
+ )
106
+ result = extract(download, str(tmp_path), "reddit / article")
107
+ assert "TITLE: My Title" in result.aggregated_text
108
+ assert "ARTICLE TEXT: The body of the article" in result.aggregated_text
109
+ assert result.thumbnail == "https://img/lead.jpg" # remote, not downloaded
110
+ assert result.keyframes == []
111
+ assert result.had_transcript is False
backend/tests/test_pipeline.py CHANGED
@@ -69,6 +69,37 @@ async def test_job_reaches_ready_with_sane_card(database, stub_pipeline):
69
  assert card.meta.extraction.transcript is True
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  async def test_ingestion_failure_dead_letters_after_retries(database, monkeypatch):
73
  async def boom(url, config=None):
74
  raise DownloadError("all resolvers failed")
 
69
  assert card.meta.extraction.transcript is True
70
 
71
 
72
+ async def test_article_job_reaches_ready(database, monkeypatch):
73
+ """A non-video source runs through the article path: no media, real
74
+ extraction branch + structuring (offline -> paragraph fallback), READY."""
75
+ async def fake_download(url, config=None):
76
+ return DownloadResult(
77
+ media_type="article", data="", caption="Sleep Guide",
78
+ resolver="article", text="Sleep early. Avoid screens. " * 20,
79
+ title="Sleep Guide", author="Jane Doe",
80
+ image_url="https://img/cover.jpg",
81
+ )
82
+
83
+ monkeypatch.setattr(worker, "download_content_async", fake_download)
84
+ card_id, _ = await _make_card_and_job("https://en.wikipedia.org/wiki/Sleep")
85
+
86
+ async with db.session() as s:
87
+ jid = await _first_job_id(s, card_id)
88
+ job = await s.get(db.JobRow, jid)
89
+ await worker._run_job(s, job)
90
+
91
+ async with db.session() as s:
92
+ row = await db.get_card_row(s, card_id)
93
+ card = row.to_card()
94
+
95
+ assert card.state == CardState.READY
96
+ assert card.base.one_liner and card.base.tldr
97
+ assert card.blocks # paragraph fallback at minimum
98
+ assert card.media.thumbnail == "https://img/cover.jpg" # remote passthrough
99
+ assert card.source.resolver == "article"
100
+ assert card.source.creator == "Jane Doe" # byline persisted
101
+
102
+
103
  async def test_ingestion_failure_dead_letters_after_retries(database, monkeypatch):
104
  async def boom(url, config=None):
105
  raise DownloadError("all resolvers failed")
backend/tests/test_structuring.py CHANGED
@@ -67,3 +67,34 @@ def test_primary_action_mapping():
67
  raw_recipe = '{"base":{"one_liner":"a","tldr":"b","content_type":"recipe"},"blocks":[{"type":"paragraph","text":"x"}]}'
68
  sc = structuring._validate(raw_recipe, bundle="", transcript="", caption="")
69
  assert sc.primary_action.kind == PrimaryActionKind.SHOPPING_LIST
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  raw_recipe = '{"base":{"one_liner":"a","tldr":"b","content_type":"recipe"},"blocks":[{"type":"paragraph","text":"x"}]}'
68
  sc = structuring._validate(raw_recipe, bundle="", transcript="", caption="")
69
  assert sc.primary_action.kind == PrimaryActionKind.SHOPPING_LIST
70
+
71
+
72
+ def test_artifacts_extracted_and_validated():
73
+ raw = (
74
+ '{"base":{"one_liner":"5 books","tldr":"reading list","content_type":"tip"},'
75
+ '"blocks":[{"type":"bullet_list","items":["a"]}],'
76
+ '"artifacts":['
77
+ '{"type":"book","title":"Atomic Habits","creator":"James Clear","year":2018},'
78
+ '{"type":"bogus","title":"Dune"},'
79
+ '{"title":""},'
80
+ '"not a dict",'
81
+ '{"type":"book","title":"atomic habits"}]}' # dup of first (case-insensitive)
82
+ )
83
+ sc = structuring._validate(raw, bundle="", transcript="", caption="")
84
+ titles = [(a.type.value, a.title) for a in sc.artifacts]
85
+ assert ("book", "Atomic Habits") in titles
86
+ assert ("other", "Dune") in titles # bad type coerced to other
87
+ assert len(sc.artifacts) == 2 # empty-title, non-dict, and dup dropped
88
+
89
+
90
+ def test_artifacts_kept_even_when_blocks_empty():
91
+ raw = '{"base":{"one_liner":"x","tldr":"y"},"blocks":[],"artifacts":[{"type":"movie","title":"Inception"}]}'
92
+ sc = structuring._validate(raw, bundle="", transcript="body text", caption="")
93
+ assert any(b["type"] == "paragraph" for b in sc.blocks) # fallback body
94
+ assert [a.title for a in sc.artifacts] == ["Inception"]
95
+
96
+
97
+ def test_no_artifacts_is_normal():
98
+ raw = '{"base":{"one_liner":"x","tldr":"y","content_type":"tip"},"blocks":[{"type":"paragraph","text":"z"}]}'
99
+ sc = structuring._validate(raw, bundle="", transcript="", caption="")
100
+ assert sc.artifacts == []
docs/02-ingestion.md CHANGED
@@ -75,6 +75,41 @@ async def ingest(job):
75
  - **Resolver rot.** Expect individual resolvers to break over time. Monitor
76
  per-resolver success rate (it's recorded on every card) and prune/replace dead ones.
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  ## Caching
79
 
80
  Cache by source URL. Re-sharing the same reel returns the existing card instead
 
75
  - **Resolver rot.** Expect individual resolvers to break over time. Monitor
76
  per-resolver success rate (it's recorded on every card) and prune/replace dead ones.
77
 
78
+ ## Article path (non-video sources)
79
+
80
+ Not every shared link is short-form video. Reddit, Wikipedia, LinkedIn,
81
+ Substack, news, and blogs are **text** — there is no media to download, the
82
+ content *is* the article. These take a parallel path instead of the resolver
83
+ cascade.
84
+
85
+ **Routing (domain classify).** `downloader._is_video_url` checks the host:
86
+ Instagram / TikTok / YouTube go to the fragile resolver cascade (unchanged);
87
+ **everything else is treated as an article**. yt-dlp stays a safety net — if
88
+ article extraction yields nothing usable, a non-video host is retried through
89
+ yt-dlp in case the page is actually a video it supports.
90
+
91
+ **Extraction.** `ingestion/article.py` (new orchestration the app owns — *not*
92
+ title, readable body, author, and a remote lead-image URL. Best-effort: a thin
93
+ body (paywall/login wall) or any error → `None` → normal ingestion failure (a
94
+ LinkedIn post behind auth simply fails gracefully, same model as a dead
95
+ resolver).
96
+
97
+ ```
98
+ DownloadResult(media_type="article", data="", caption=title, resolver="article",
99
+ text=…, title=…, author=…, image_url=…)
100
+ ```
101
+
102
+ **Pipeline fit.** The extraction stage branches on `media_type == "article"`:
103
+ it skips ffmpeg / Whisper / OCR entirely, builds the labeled text bundle from
104
+ title + body, and uses the remote `image_url` directly as the thumbnail
105
+ (nothing is downloaded — fits the ephemeral free tier). Structuring (docs/04) is
106
+ **unchanged**: article text flows in as the bundle exactly like a transcript
107
+ does. No block-schema change.
108
+
109
+ **Platform label.** `ingestion/source.py::platform_for_url` maps a URL to a
110
+ label (reddit / wikipedia / linkedin / substack / medium / known video
111
+ platforms), falling back to the bare host — shown in the card's source line.
112
+
113
  ## Caching
114
 
115
  Cache by source URL. Re-sharing the same reel returns the existing card instead
docs/04-structuring-and-schema.md CHANGED
@@ -17,7 +17,7 @@ not new UI code.
17
 
18
  ```json
19
  {
20
- "schema_version": "1.0",
21
  "card_id": "uuid",
22
  "state": "queued | processing | ready | failed",
23
  "failure_reason": null,
@@ -170,8 +170,18 @@ The guarantee: **a card always renders something sane**, even on a degraded
170
  model response. This validation step is the difference between "occasionally
171
  renders garbage" and "always usable."
172
 
 
 
 
 
 
 
 
 
 
173
  ## Versioning
174
 
175
  `schema_version` is on every card. When the vocabulary changes, bump it; the
176
  client renders known versions and degrades gracefully on unknown future blocks
177
- (render their `text`/`items` if present, else skip).
 
 
17
 
18
  ```json
19
  {
20
+ "schema_version": "1.1",
21
  "card_id": "uuid",
22
  "state": "queued | processing | ready | failed",
23
  "failure_reason": null,
 
170
  model response. This validation step is the difference between "occasionally
171
  renders garbage" and "always usable."
172
 
173
+ ## Artifacts (catalog) — separate from blocks
174
+
175
+ The same single structuring call also emits an `artifacts` list — concrete,
176
+ named things the video *references* (books, movies, podcasts, products, places).
177
+ These are **not blocks** and never render inside the card; they feed the global
178
+ catalog. Validated separately (title required, dedupe, drop malformed). See
179
+ `@docs/12-catalog-and-artifacts.md`. Their addition to the structuring output is
180
+ what took `schema_version` to `1.1`.
181
+
182
  ## Versioning
183
 
184
  `schema_version` is on every card. When the vocabulary changes, bump it; the
185
  client renders known versions and degrades gracefully on unknown future blocks
186
+ (render their `text`/`items` if present, else skip). **1.1** added the
187
+ `artifacts` list to the structuring output (block vocabulary unchanged).
docs/09-features.md CHANGED
@@ -5,6 +5,8 @@ Full feature list by area. Phase tags: **[P1]** MVP, **[P2]**, **[P3]**.
5
  ## Ingestion
6
  - Share-sheet integration (Instagram first) **[P1]**, TikTok + YouTube Shorts **[P2]**
7
  - Link paste fallback **[P1]**
 
 
8
  - Batch share — multiple reels queue independently without blocking **[P1]**
9
  - Duplicate detection — re-share returns existing card **[P1]**
10
  - Graceful late-surfaced failure for unsupported/private/unavailable **[P1]**
@@ -31,17 +33,27 @@ Full feature list by area. Phase tags: **[P1]** MVP, **[P2]**, **[P3]**.
31
  - Lookup-able products **[P2]**
32
  - Mappable places **[P2]**
33
 
34
- ## Action layer
35
  - One primary action per card **[P1]** (kind derived from content type)
36
- - Shopping/checklist generation **[P2]**
37
- - Reminders / calendar events **[P2]**
38
- - Export to Notion / Obsidian / Apple Notes / markdown **[P2]**
 
 
 
 
39
 
40
  ## Visual
41
  - Keyframe thumbnails + visual library grid **[P1]**
42
  - Color/motion/depth system, progressive-render animation **[P1]**
43
  - Maps with pins, charts, product thumbnails, visual step strips **[P2]**
44
 
 
 
 
 
 
 
45
  ## Library & retrieval
46
  - Card states surfaced (queued/processing/ready/failed) **[P1]**
47
  - Basic search (full-text) **[P1]**
 
5
  ## Ingestion
6
  - Share-sheet integration (Instagram first) **[P1]**, TikTok + YouTube Shorts **[P2]**
7
  - Link paste fallback **[P1]**
8
+ - Article/text sources — Reddit, Wikipedia, LinkedIn, Substack, blogs, news (docs/02) **[done]**
9
+ - Domain-classified routing (video cascade vs. keyless article extraction) **[done]**
10
  - Batch share — multiple reels queue independently without blocking **[P1]**
11
  - Duplicate detection — re-share returns existing card **[P1]**
12
  - Graceful late-surfaced failure for unsupported/private/unavailable **[P1]**
 
33
  - Lookup-able products **[P2]**
34
  - Mappable places **[P2]**
35
 
36
+ ## Action layer (docs/13)
37
  - One primary action per card **[P1]** (kind derived from content type)
38
+ - Content-aware action set — actions unlocked by the blocks a card contains **[done]**
39
+ - Common actions on every card: Copy, Share, Open original **[done]**
40
+ - Shopping/checklist generation (share as checkable list) **[done]**
41
+ - Reminders / calendar events (native add-event) **[done]**
42
+ - Export to Notion / Obsidian / Apple Notes / markdown (share markdown) **[done]**
43
+ - Open place in Maps; open card links **[done]**
44
+ - Grounded chat — Ask questions about a single card **[done]**
45
 
46
  ## Visual
47
  - Keyframe thumbnails + visual library grid **[P1]**
48
  - Color/motion/depth system, progressive-render animation **[P1]**
49
  - Maps with pins, charts, product thumbnails, visual step strips **[P2]**
50
 
51
+ ## Catalog (artifacts)
52
+ - Artifact extraction from referenced things (book/movie/podcast/product/place…) **[P1]**
53
+ - Global deduplicated catalog space, grouped by type **[P1]**
54
+ - Free keyless thumbnail retrieval (iTunes / Open Library / Wikipedia) **[P1]**
55
+ - Source-card backreferences per artifact **[P1]**
56
+
57
  ## Library & retrieval
58
  - Card states surfaced (queued/processing/ready/failed) **[P1]**
59
  - Basic search (full-text) **[P1]**
docs/12-catalog-and-artifacts.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 12 — Catalog & Artifacts
2
+
3
+ A second top-level space alongside the card Library: a **catalog** of the
4
+ real-world things videos *reference* — books, movies, podcasts, music, products,
5
+ places — deduplicated across every card and shown as a wall of covers.
6
+
7
+ This is a **parallel surface to the block schema, not a new block type**. The
8
+ block vocabulary (docs/04) is unchanged. Structuring's output simply grows an
9
+ `artifacts` list, which the worker aggregates into a global catalog.
10
+
11
+ ## What an artifact is
12
+
13
+ A concrete, named thing the video mentions, distinct from the how-to content of
14
+ the card itself. A booktuber's video *is* a `tip`/`product_list` card **and**
15
+ contributes its named books to the catalog.
16
+
17
+ ```json
18
+ // artifact (as emitted by structuring, per card)
19
+ { "type": "book|movie|tv_show|podcast|music|product|place|app|other",
20
+ "title": "Atomic Habits",
21
+ "creator": "James Clear", // author / director / artist / host / brand (nullable)
22
+ "year": 2018 } // nullable
23
+ ```
24
+
25
+ ## Pipeline fit (one LLM call, still)
26
+
27
+ Artifact extraction piggybacks on the **existing single text-only structuring
28
+ call** (docs/04) — the prompt gains an `artifacts` field in its JSON output. No
29
+
30
+ ```
31
+ structuring LLM → { base, blocks, artifacts }
32
+ → validate blocks (docs/04, unchanged)
33
+ → validate artifacts (drop title-less / malformed; dedupe within the card)
34
+ → worker: for each artifact
35
+ → resolve thumbnail (free image API, best-effort)
36
+ → upsert into the global catalog (dedupe by type + normalized title)
37
+ ```
38
+
39
+ Artifacts are validated like blocks: never trusted raw. A malformed entry is
40
+ dropped; an empty list is normal. Artifact failure never affects the card —
41
+ the card still reaches READY.
42
+
43
+ ## Thumbnails (free, keyless, hotlinked)
44
+
45
+ `services/artifact_images.py` routes by type to a public, no-key API and returns
46
+ a remote `https` URL (nothing is downloaded or stored — fits the ephemeral
47
+ free tier). Best-effort: any miss/timeout/error → `None` → typed placeholder.
48
+
49
+ | Artifact type | Source | Key? |
50
+ |---|---|---|
51
+ | book | Open Library (cover by id/isbn), Wikipedia fallback | no |
52
+ | movie / tv_show / podcast / music / app | iTunes Search API (`artworkUrl`) | no |
53
+ | product / place / other | Wikipedia REST summary thumbnail | no |
54
+
55
+ ## Aggregation & dedupe
56
+
57
+ A global `artifacts` table (docs/08 style: one row per catalog item). Dedupe key
58
+ is `(type, normalized_title)`. Re-seeing the same artifact in another card appends
59
+ that `card_id` to `source_card_ids` and backfills any missing thumbnail/creator/
60
+ year rather than creating a duplicate.
61
+
62
+ ## API
63
+
64
+ ```
65
+ GET /catalog list entries (optional ?type=, paginated)
66
+ GET /catalog/{id} one entry + its source_card_ids
67
+ DELETE /catalog/{id} remove an entry (data ownership)
68
+ ```
69
+
70
+ `CatalogEntry`:
71
+
72
+ ```json
73
+ { "id": "a_xxxxxxxx", "type": "book", "title": "Atomic Habits",
74
+ "creator": "James Clear", "year": 2018,
75
+ "thumbnail": "https://covers.openlibrary.org/...",
76
+ "source_card_ids": ["uuid", "uuid"], "created_at": "ISO-8601" }
77
+ ```
78
+
79
+ ## Frontend
80
+
81
+ A second tab in the root `NavigationBar` (Library | Catalog). The Catalog screen
82
+ shows entries grouped by type, each a cover tile with title + creator·year. Covers
83
+ load via `Image.network` and degrade to a typed icon placeholder on error — never
84
+
85
+ ## Schema version
86
+
87
+ Adding `artifacts` to the structuring/card contract bumps `SCHEMA_VERSION`
88
+ **1.0 → 1.1**. The block vocabulary is unchanged, so existing cards and the
89
+ renderer are unaffected; the client tolerates the new version (it never hard-fails
90
+ on a version it doesn't recognize).
docs/13-actions-and-chat.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 13 — Actions & Chat
2
+
3
+ How a card becomes *useful*, not just readable. Two surfaces: a **content-aware
4
+ action layer** and **grounded chat (Ask)**. Both are additive — no block-schema
5
+ change (docs/04 is unchanged); everything is derived from a card's existing
6
+ content.
7
+
8
+ ## Action layer
9
+
10
+ The backend still derives one dominant `primary_action` per card (docs/04). The
11
+ client now goes further: it inspects the card's **blocks** and offers every
12
+ action that fits, plus a common set on every card. All payloads are built
13
+ client-side from blocks, so the wire `payload` stays empty.
14
+
15
+ | Action | Shown when | Handler (free, on-device) |
16
+ |---|---|---|
17
+ | Ask | always | opens grounded chat (below) |
18
+ | Copy | always | card → markdown → clipboard |
19
+ | Share | always | card → markdown → OS share sheet (`share_plus`) |
20
+ | Add to calendar | always | native add-event sheet (`add_2_calendar`) |
21
+ | Open original | has source url | launches the reel (`url_launcher`) |
22
+ | Open in Maps | has a `map`/place block | Maps search/coords (`url_launcher`) |
23
+ | Shopping list | has `checklist`/`bullet_list` | items → checkable list → share |
24
+ | Open links | has a `link` block | launches the first link |
25
+
26
+ The dominant `primary_action` is rendered as the big button; the rest live
27
+ behind a "more" menu. The reader shows the bar whenever the card is READY (Ask
28
+ is available even when there is no primary action).
29
+
30
+ Frontend: `ui/features/reader/services/card_actions.dart` (`available()`,
31
+ `primaryType()`, `perform()`), surfaced by `views/primary_action_bar.dart`.
32
+
33
+ ### Android config
34
+
35
+ - `url_launcher` https + `add_2_calendar` insert intents are declared in
36
+ `android/app/src/main/AndroidManifest.xml` `<queries>` (Android 11+ package
37
+ visibility).
38
+
39
+ ## Chat (Ask)
40
+
41
+ Grounded Q&A over a single card. The model answers using **only that card's
42
+ structured content** as context; if the answer isn't there, it says so.
43
+
44
+ ```
45
+ POST /cards/{card_id}/chat
46
+ body: { "messages": [ { "role": "user|assistant", "content": str }, ... ] }
47
+ → 200 { "reply": str }
48
+ → 409 card is not READY
49
+ → 422 last message is not from the user
50
+ → 503 no LLM backend configured
51
+ ```
52
+
53
+ **Stateless.** Nothing is persisted server-side: the client holds the
54
+ conversation and replays the full history each turn (capped to the last
55
+ `_MAX_TURNS` for cost). Grounding context is the card's `base` (one_liner, tldr)
56
+ plus a flattened plain-text view of its blocks.
57
+
58
+ **Free-first.** Reuses the same selectable backend as structuring
59
+ (`llm_backend = huggingface | groq | none`) via `services/llm_chat.py`. With no
60
+ key, the endpoint returns 503 and the UI shows an "unavailable" message — the
61
+ card still reads and every other action still works.
62
+
63
+ **Trust.** The chat header carries the standard "AI-generated · may contain
64
+ errors" note (docs/09 trust & safety).
65
+
66
+ Frontend: `ui/features/reader/view_models/chat_view_model.dart` +
67
+ `views/chat_screen.dart`, opened from the **Ask** action.