|
| 1 | +// Pure decision logic for the social-posts feature — IO-free and |
| 2 | +// VM-testable (house rule: the service/XHR layer stays logic-free). |
| 3 | + |
| 4 | +import 'package:fula_files/core/services/website_prompt_builder.dart'; |
| 5 | + |
| 6 | +/// Raster image extensions eligible as Gemini reference material (no SVG). |
| 7 | +const Set<String> kSocialImageExtensions = { |
| 8 | + 'png', 'jpg', 'jpeg', 'webp', 'gif', |
| 9 | +}; |
| 10 | + |
| 11 | +/// Backend cap on reference assets per request. |
| 12 | +const int kSocialMaxAssets = 14; |
| 13 | + |
| 14 | +/// Sanitized bucket prefix for a website group — byte-identical to the |
| 15 | +/// transform native + web use for `website-assets` keys |
| 16 | +/// (`replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_')`), clamped to the |
| 17 | +/// backend's 100-char assetPrefix bound. |
| 18 | +String socialAssetPrefix(String displayName) { |
| 19 | + final sanitized = displayName.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_'); |
| 20 | + final clamped = |
| 21 | + sanitized.length > 100 ? sanitized.substring(0, 100) : sanitized; |
| 22 | + return clamped.isEmpty ? '_' : clamped; |
| 23 | +} |
| 24 | + |
| 25 | +bool _isImageFileName(String fileName) { |
| 26 | + final dot = fileName.lastIndexOf('.'); |
| 27 | + if (dot < 0 || dot == fileName.length - 1) return false; |
| 28 | + return kSocialImageExtensions.contains( |
| 29 | + fileName.substring(dot + 1).toLowerCase()); |
| 30 | +} |
| 31 | + |
| 32 | +/// Request body for POST /api/v1/social/generate. Filters the generation's |
| 33 | +/// assets to URL-backed raster images and caps at [kSocialMaxAssets]. |
| 34 | +Map<String, dynamic> buildSocialGeneratePayload({ |
| 35 | + required String generationId, |
| 36 | + required String websiteUrl, |
| 37 | + required String userPrompt, |
| 38 | + required List<({String fileName, String type, String url})> assets, |
| 39 | + required String displayName, |
| 40 | +}) { |
| 41 | + final images = assets |
| 42 | + .where((a) => a.url.isNotEmpty && _isImageFileName(a.fileName)) |
| 43 | + .take(kSocialMaxAssets) |
| 44 | + .map((a) => {'fileName': a.fileName, 'type': a.type, 'url': a.url}) |
| 45 | + .toList(); |
| 46 | + return { |
| 47 | + 'generationId': generationId, |
| 48 | + 'websiteUrl': websiteUrl, |
| 49 | + 'prompt': userPrompt, |
| 50 | + 'assets': images, |
| 51 | + 'assetPrefix': socialAssetPrefix(displayName), |
| 52 | + }; |
| 53 | +} |
| 54 | + |
| 55 | +/// Website URL to embed in captions: the stable front door when the group |
| 56 | +/// has one, else the generation's own gateway URL. |
| 57 | +String? resolveSocialWebsiteUrl( |
| 58 | + String? frontDoorUrl, String? generationGatewayUrl) { |
| 59 | + if (frontDoorUrl != null && frontDoorUrl.isNotEmpty) return frontDoorUrl; |
| 60 | + if (generationGatewayUrl != null && generationGatewayUrl.isNotEmpty) { |
| 61 | + return generationGatewayUrl; |
| 62 | + } |
| 63 | + return null; |
| 64 | +} |
| 65 | + |
| 66 | +/// Human prompt for the caption/image brief: the user's own words, |
| 67 | +/// stripped of the enriched Website Name/Category/Styles/… envelope. |
| 68 | +String socialUserPrompt(String storedPrompt) { |
| 69 | + final parsed = parseStoredWebsitePrompt(storedPrompt); |
| 70 | + return parsed.userBody.isNotEmpty ? parsed.userBody : storedPrompt.trim(); |
| 71 | +} |
| 72 | + |
| 73 | +/// Merge sidecar entries from any number of blobs (+ the local map), |
| 74 | +/// keyed by generationId, LATEST-updatedAt-wins. Differs deliberately |
| 75 | +/// from the first-wins generations/pointers merges: social entries mutate |
| 76 | +/// (pending → completed) and re-runs replace results, so the newest write |
| 77 | +/// must win. Raw maps in/out — unknown keys survive rewrites by old |
| 78 | +/// clients. Malformed entries are skipped without dropping siblings. |
| 79 | +Map<String, Map<String, dynamic>> mergeSocialPosts( |
| 80 | + Iterable<Iterable<dynamic>> entryLists) { |
| 81 | + final byId = <String, Map<String, dynamic>>{}; |
| 82 | + DateTime updatedAtOf(Map<String, dynamic> m) { |
| 83 | + final v = m['updatedAt']; |
| 84 | + return v is String |
| 85 | + ? (DateTime.tryParse(v) ?? DateTime.fromMillisecondsSinceEpoch(0)) |
| 86 | + : DateTime.fromMillisecondsSinceEpoch(0); |
| 87 | + } |
| 88 | + |
| 89 | + for (final list in entryLists) { |
| 90 | + for (final raw in list) { |
| 91 | + if (raw is! Map) continue; |
| 92 | + final m = raw.cast<String, dynamic>(); |
| 93 | + final id = m['generationId']; |
| 94 | + if (id is! String || id.isEmpty) continue; |
| 95 | + final existing = byId[id]; |
| 96 | + if (existing == null || |
| 97 | + updatedAtOf(m).isAfter(updatedAtOf(existing))) { |
| 98 | + byId[id] = m; |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + return byId; |
| 103 | +} |
| 104 | + |
| 105 | +/// Caption choice per Buffer channel: short for X/Twitter-like services, |
| 106 | +/// long everywhere else. |
| 107 | +String captionForBufferService(String service, |
| 108 | + {required String long, required String short}) { |
| 109 | + final s = service.toLowerCase(); |
| 110 | + final isX = s.contains('twitter') || s == 'x' || s.startsWith('x_'); |
| 111 | + return isX ? short : long; |
| 112 | +} |
| 113 | + |
| 114 | +/// What load() should do with a sidecar record found at startup. |
| 115 | +enum SocialResumeAction { none, resumePoll, markInterrupted } |
| 116 | + |
| 117 | +/// Resume decision for a record. Running + jobId → resume REGARDLESS of |
| 118 | +/// age (poll-first: the server may have finished while every tab was |
| 119 | +/// closed; a genuinely dead job answers 404/error on the first poll). |
| 120 | +/// A pending record with no jobId means the owning tab died between click |
| 121 | +/// and 202 — stale after [interruptedAfter]. |
| 122 | +SocialResumeAction socialResumeAction({ |
| 123 | + required String status, |
| 124 | + required String? jobId, |
| 125 | + required DateTime createdAt, |
| 126 | + required DateTime now, |
| 127 | + Duration interruptedAfter = const Duration(minutes: 5), |
| 128 | +}) { |
| 129 | + final running = |
| 130 | + status == 'pending' || status == 'generating' || status == 'publishing'; |
| 131 | + if (!running) return SocialResumeAction.none; |
| 132 | + if (jobId != null && jobId.isNotEmpty) return SocialResumeAction.resumePoll; |
| 133 | + return now.difference(createdAt) > interruptedAfter |
| 134 | + ? SocialResumeAction.markInterrupted |
| 135 | + : SocialResumeAction.none; |
| 136 | +} |
| 137 | + |
| 138 | +/// Poll backoff: ×1.5 capped at 10s (website-gen parity). |
| 139 | +Duration nextSocialPollInterval(Duration current) { |
| 140 | + const max = Duration(seconds: 10); |
| 141 | + if (current >= max) return max; |
| 142 | + final next = Duration(milliseconds: (current.inMilliseconds * 1.5).toInt()); |
| 143 | + return next > max ? max : next; |
| 144 | +} |
| 145 | + |
| 146 | +/// One-line summary + all-ok flag for a Buffer post run. |
| 147 | +({String summary, bool allOk}) summarizeBufferResults( |
| 148 | + List<({String channelId, bool ok, String? error})> results) { |
| 149 | + final okCount = results.where((r) => r.ok).length; |
| 150 | + final total = results.length; |
| 151 | + if (total == 0) return (summary: 'No channels selected', allOk: false); |
| 152 | + if (okCount == total) { |
| 153 | + return ( |
| 154 | + summary: total == 1 |
| 155 | + ? 'Posted to your Buffer queue' |
| 156 | + : 'Posted to all $total channels', |
| 157 | + allOk: true, |
| 158 | + ); |
| 159 | + } |
| 160 | + return (summary: 'Posted to $okCount of $total channels', allOk: false); |
| 161 | +} |
0 commit comments