Phase 1 done
Browse files- app/android/app/src/main/AndroidManifest.xml +10 -0
- app/ios/Runner/Info.plist +2 -0
- app/lib/data/repositories/card_repository.dart +14 -0
- app/lib/data/services/api_client.dart +36 -0
- app/lib/domain/models/artifact.dart +115 -0
- app/lib/main.dart +2 -2
- app/lib/ui/core/home_shell.dart +46 -0
- app/lib/ui/features/catalog/view_models/catalog_view_model.dart +91 -0
- app/lib/ui/features/catalog/views/catalog_screen.dart +320 -0
- app/lib/ui/features/library/view_models/library_view_model.dart +59 -0
- app/lib/ui/features/library/views/library_screen.dart +131 -32
- app/lib/ui/features/reader/services/card_actions.dart +263 -0
- app/lib/ui/features/reader/view_models/chat_view_model.dart +62 -0
- app/lib/ui/features/reader/views/chat_screen.dart +213 -0
- app/lib/ui/features/reader/views/primary_action_bar.dart +131 -32
- app/lib/ui/features/reader/views/reader_screen.dart +1 -3
- app/pubspec.lock +113 -1
- app/pubspec.yaml +6 -0
- app/test/artifact_test.dart +45 -0
- backend/app/api/cards.py +41 -9
- backend/app/api/catalog.py +57 -0
- backend/app/main.py +2 -1
- backend/app/models/artifact.py +60 -0
- backend/app/models/card.py +1 -1
- backend/app/pipeline/extraction.py +35 -0
- backend/app/pipeline/ingestion/article.py +85 -0
- backend/app/pipeline/ingestion/downloader.py +80 -8
- backend/app/pipeline/ingestion/resolvers.py +643 -42
- backend/app/pipeline/ingestion/source.py +34 -0
- backend/app/pipeline/structuring.py +62 -8
- backend/app/pipeline/worker.py +45 -27
- backend/app/services/artifact_images.py +103 -0
- backend/app/services/llm_chat.py +139 -0
- backend/app/store/db.py +80 -0
- backend/pyproject.toml +2 -0
- backend/test_carousel_out.mp4 +0 -0
- backend/tests/test_api.py +119 -1
- backend/tests/test_article.py +111 -0
- backend/tests/test_pipeline.py +31 -0
- backend/tests/test_structuring.py +31 -0
- docs/02-ingestion.md +35 -0
- docs/04-structuring-and-schema.md +12 -2
- docs/09-features.md +16 -4
- docs/12-catalog-and-artifacts.md +90 -0
- 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
|
| 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(
|
| 53 |
-
child:
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
),
|
| 57 |
),
|
| 58 |
),
|
| 59 |
floatingActionButton: FloatingActionButton.extended(
|
| 60 |
onPressed: () => _openPaste(context),
|
| 61 |
icon: const Icon(Icons.add_link_rounded),
|
| 62 |
-
label: const Text('Add
|
| 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
|
| 87 |
);
|
| 88 |
case LibraryStatus.idle:
|
| 89 |
case LibraryStatus.ready:
|
| 90 |
-
return
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
),
|
| 100 |
-
|
| 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
|
| 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://
|
| 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
|
| 2 |
-
///
|
| 3 |
-
///
|
|
|
|
| 4 |
library;
|
| 5 |
|
| 6 |
-
import 'package:flutter/material.dart';
|
| 7 |
|
| 8 |
import '../../../../domain/models/card.dart';
|
| 9 |
-
import '../
|
|
|
|
| 10 |
|
| 11 |
-
class PrimaryActionBar extends
|
| 12 |
-
const PrimaryActionBar({super.key, required this.
|
| 13 |
-
final
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
@override
|
| 16 |
Widget build(BuildContext context) {
|
| 17 |
final scheme = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
return SafeArea(
|
| 19 |
-
minimum: const EdgeInsets.fromLTRB(
|
| 20 |
child: Material(
|
| 21 |
color: Colors.transparent,
|
| 22 |
child: DecoratedBox(
|
|
@@ -29,37 +45,120 @@ class PrimaryActionBar extends StatelessWidget {
|
|
| 29 |
),
|
| 30 |
],
|
| 31 |
),
|
| 32 |
-
child:
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
),
|
| 37 |
),
|
| 38 |
),
|
| 39 |
);
|
| 40 |
}
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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
|
| 60 |
-
//
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
| 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.
|
| 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 #
|
| 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"
|
| 65 |
-
data: Union[str, list[str]] # path to .mp4,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
log.info("download ok via %s", name)
|
| 127 |
-
return DownloadResult(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
log.info("download ok via rapidapi")
|
| 140 |
-
return DownloadResult(
|
| 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
|
| 2 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import instaloader
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
If it fails or finds no video, tries instaloader (for images/carousels).
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
Returns:
|
| 13 |
-
(
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
opts = {
|
| 35 |
"format": "bestvideo[ext=mp4]+bestaudio/best",
|
| 36 |
-
"outtmpl":
|
| 37 |
"merge_output_format": "mp4",
|
| 38 |
"quiet": True,
|
| 39 |
"no_warnings": True,
|
| 40 |
}
|
| 41 |
|
| 42 |
-
if cookies_path
|
| 43 |
-
|
|
|
|
|
|
|
| 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(
|
| 50 |
|
| 51 |
-
if not info.get(
|
| 52 |
return None
|
| 53 |
ydl.download([url])
|
| 54 |
-
if
|
| 55 |
-
return
|
| 56 |
-
except Exception:
|
|
|
|
| 57 |
return None
|
| 58 |
return None
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 71 |
download_video_thumbnails=False,
|
| 72 |
download_geotags=False,
|
| 73 |
download_comments=False,
|
| 74 |
save_metadata=False,
|
| 75 |
compress_json=False,
|
| 76 |
-
dirname_pattern=
|
| 77 |
)
|
| 78 |
|
| 79 |
try:
|
| 80 |
-
|
| 81 |
-
|
| 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=
|
| 88 |
|
| 89 |
-
# Collect image paths
|
| 90 |
images = [
|
| 91 |
-
|
| 92 |
-
for
|
| 93 |
-
if
|
| 94 |
]
|
| 95 |
if images:
|
| 96 |
return sorted(images), caption
|
| 97 |
return None
|
| 98 |
except Exception as e:
|
| 99 |
-
|
| 100 |
return None
|
| 101 |
|
| 102 |
-
|
| 103 |
-
def download_video(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [ ... ]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
-
|
| 37 |
-
-
|
| 38 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|