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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ lib/
main.dart App root: splash -> onboarding -> home, revealed from the bottom
core/ No widgets (except updates.dart's Flutter binding); safe for the background isolate
config.dart Hosts, user agent, page size
models.dart Creator, Post, MediaFile, Poll, Favorite, Like, Follow + NotifyLevel, Community*, UpdateNotice, ... and JSON parsing; threadComments
api.dart PawchiveApi (all HTTP); scrapers for DMs, popular posts, similar artists, community chats
models.dart Creator, Post, MediaFile, Poll, Favorite, Like, Follow + NotifyLevel, Community*, UpdateNotice, PawchiveLink, HashLookup, ... and JSON parsing; threadComments
api.dart PawchiveApi (all HTTP); scrapers for DMs, popular posts, tags, tagged posts, similar artists, community chats; random redirects; hash lookup
file_hash.dart sha256Hex (streamed, for hash lookup) and isSha256
store.dart Store: SharedPreferencesAsync + secure storage (session cookie); AppSettings
backup.dart Backup file (follows, likes, sign-in sealed with AES-GCM + PBKDF2), mergeFollows / mergeFavorites
feeds.dart Feed (paged ChangeNotifier), OffsetFeed, PopularFeed, FollowingFeed (k-way merge)
Expand All @@ -47,7 +48,7 @@ lib/
ui/
app_scope.dart AppScope.of(context) / AppScope.read(context) give AppState
theme.dart Palette (from assets), buildTheme(), BrandMark, SectionLabel
widgets.dart NetImage, CreatorAvatar, ServiceBadge, HtmlContent, FeedView, StatusView, time helpers
widgets.dart NetImage, CreatorAvatar, ServiceBadge, HtmlContent, FeedView, StatusView, BackToTop, scroll / copy / time helpers
splash.dart BooruPlus-style splash + revealFromBottom transition
onboarding.dart Four parallax slides (art lags, text leads), notification permission, optional sign-in
home.dart HomeScreen (floating SliverAppBar + For You / Following / Popular tabs, Popular's bottom bar) and Sidebar (drawer)
Expand All @@ -62,6 +63,10 @@ lib/
post_page.dart Full post: text, poll, actions, media, attachments, tags, comments
media_viewer.dart Full-screen viewer: every image and video in one vertical scroll, zoomable images, Chewie video
creator_page.dart Artist profile (banner, site, links, Follow, Similar artists sheet) + tabs; FollowButton, ArtistTile
creator_media.dart CreatorMediaTab: an artist's images, GIFs and videos as a filterable grid
tag_page.dart TagPostsPage: posts with a tag from every artist; openTag
random_page.dart RandomPage: a random post or artist, shuffle and gear buttons
hash_lookup_page.dart HashLookupPage: SHA-256 pasted or worked out from a picked file
community_page.dart CommunityChatPage: a Patreon community channel as a chat log, newest at the bottom
backup.dart Export / import dialogs (SAF folder and file pickers)
settings_cards.dart BooruPlus-style settings panels (SettingsGroup, SettingsSwitchCard, SettingsChoiceCard, ...)
Expand All @@ -71,6 +76,8 @@ lib/
test/
for_you_test.dart Taste, source sampling, blending and ForYouFeed over fake fetches
core_test.dart Parsing, URLs, entity decoding, polls, fancards, comment threads, DM / popular / similar-artist / community scraping
explore_test.dart Tag page and tagged posts, random redirects, hash lookup, sha256Hex, the Random setting
back_to_top_test.dart BackToTop shows after two screens and scrolls up
backup_test.dart Backup encoding and sealing, merges, AppState likes and their account sync
update_checker_test.dart UpdateChecker against a fake Pawchive (MockClient + in-memory prefs)
downloads_test.dart DownloadManager: URL resolution, naming, failure and retry (in-memory target)
Expand Down Expand Up @@ -156,8 +163,31 @@ docs/pawchive-api.md API reference and quirks
- **Back to top.** Tapping the open tab or the logo on Home, the open
Popular section in the bottom bar, or the open tab or collapsed name on a
profile calls `scrollNestedToTop`. NestedScrollView moves all its inner
lists together, so every tab goes up. Back on Following or Popular
lists together, so every tab goes up. `BackToTop` (around `FeedView`,
popular artists, the media grid, tags and hash results) shows a pink
arrow at the bottom center once a list is two screens down; it uses
`scrollNestedToTop` inside a NestedScrollView and `scrollToTop`
otherwise. Both jump to 1.5 screens first so the animation doesn't build
(and load thumbnails for) every row. Back on Following or Popular
returns to For You (`PopScope` on HomeScreen) instead of leaving.
- **Media tab.** `CreatorMediaTab` pages the artist's posts with its own
`OffsetFeed`, created only when the tab opens, and shows `post.media` as
a 3-column grid filtered All / Photos / GIFs / Videos. When a filter
leaves fewer than 24 tiles it loads up to 4 more pages by itself, then
offers "Look further". Videos use the post's first image as a poster.
- **Tags.** Explore's Tags tab lists the site's popular tags
(`AppState.popularTags`, once per session, filtered on the device) under
a "Posts tagged #query" row for any typed tag. `TagPostsPage` scrapes
`/posts?tag=` (the API ignores `tag`). A tag on a post asks whether to
show that artist's posts or everyone's.
- **Random.** `RandomPage` reads where `/posts/random` or `/artists/random`
redirects (`followRedirects = false`) and shows a `PostPage` or
`CreatorPage` under two FABs: shuffle, and a gear to switch kind. The
first visit asks which (`AppSettings.randomKind`, null until chosen).
- **Hash lookup.** `HashLookupPage` takes a pasted SHA-256 or picks a file
with SAF and hashes it from `SafStream().readFileStream` with
`sha256Hex` (no upload), then calls `/search_hash/{hash}`; a 404 is "no
posts". Discord matches are only counted.
- **Quick actions.** Holding a post in a feed opens `showPostMenu` (artist,
like, favorite, download, copy link / text, open). Post text and comments
are in a `SelectionArea`; posts and profiles have Copy link (`copyLink`).
Expand All @@ -167,7 +197,8 @@ docs/pawchive-api.md API reference and quirks
download, the video that may play). Images show the thumbnail at once and
load the full file only while on screen, through `_FullFileGate` (two at a
time, one second apart). The list stops scrolling while an image is
zoomed. Holding an image shows a Download pill under it (inside its
zoomed. Holding an image presses it in over the long-press time (`_hold`,
so the hold visibly registers) and then shows a Download pill under it (inside its
bottom edge when there's no room on screen); sliding onto it and lifting
downloads that file, and it stays tappable for 4 s after lifting
elsewhere. Videos have Download and Open in browser in Chewie's ⋮ menu
Expand Down
8 changes: 8 additions & 0 deletions docs/pawchive-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ applies.
| `GET /{service}/user/{id}/fancards` | Fanbox only. `[{id, user_id, file_id, price, hash, ext, mime, size, added, ...}]` with no `path`; see Quirks. |
| `GET /{service}/user/{id}/post/{post}/comments` | `[{id, parent_id, commenter_name, content, published}]` |
| `GET /creators` | Every creator, about 16 MB. Only used by artist search, once per session. |
| `GET /search_hash/{sha256}` | Hash lookup. `{hash, posts, discord_posts}`: `posts` are post objects with a short `content`, archived `file`s (with `node`) and `attachments`; `discord_posts` is a list or `null`. An unknown hash is a **404** with body `{}`. The schema also lists `mime`, `size`, `ext` and dates, which the live response leaves out. |
| `GET /account/favorites?type=artist` | Needs the session cookie (401 otherwise). |
| `GET /account/favorites?type=post` | The account's favorite posts, read as posts (`id`, `user`, `service`, ...). Needs the session cookie. Not checked against a live account; the schema only lists creator fields. |
| `POST`/`DELETE /favorites/post/{service}/{creator}/{post}` | Like / unlike a post. Needs the session cookie. |
Expand All @@ -54,6 +55,9 @@ Web pages Pawress scrapes (`https://pawchive.pw`, not the API):
| `/posts/popular?date=&period=&o=` | 50 `article.post-card` per page with `data-id`, `data-service`, `data-user`, a `post-card__header` title, a `/thumbnail/data{path}` image, `<time datetime="YYYY-MM-DD HH:MM:SS">` and "N favorites". No params means the last 24 hours. `period=day` takes any date; `week` and `month` only work with the start date the page itself links to in `#daily`, `#weekly` and `#monthly` (a week started 2026-10-27 for 2026-11-02, a month starts on the 1st). Other dates return no posts. |
| `/{service}/user/{id}/community[/{channel}]?o=` | Patreon community chats; see Quirks. |
| `/{service}/user/{id}/recommended` | "Similar artists": up to 50 `a.user-card` with `data-id`, `data-service` and `div.user-card__name`. No favorites or dates. |
| `/posts/tags` | Popular tags, about 2000, most used first: `<article><a href="/posts?tag=..."><span>{tag}</span><span>{count}</span></a></article>` in `#tag-container`. |
| `/posts?tag=&o=` | Posts with a tag, newest first, as the same `article.post-card`s as the popular page (no favorites), 50 per page with "Showing 1 - 50 of N". |
| `/posts/random`, `/artists/random` | A **302** to a random `/{service}/user/{id}/post/{post}` or `/{service}/user/{id}`. Pawress reads `Location` without following it. |
| `POST`/`DELETE /favorites/creator/{service}/{id}` | Needs the session cookie. |

## Quirks
Expand Down Expand Up @@ -109,5 +113,9 @@ Web pages Pawress scrapes (`https://pawchive.pw`, not the API):
author. `cmsg__text` holds the HTML, followed by `a.cmsg__image` (full
file `href`, thumbnail `img src`, sometimes Patreon's CDN) and
`a.cmsg__embed` link previews.
- **Tag search isn't in the API.** `/api/v1/posts?tag=` returns the plain
recent posts (the parameter is ignored), and `/api/v1/posts/tags`,
`/api/v1/posts/random` and `/api/v1/artists/random` are 404s, so the web
pages above are used (`parseTagPage`, `taggedPosts`, `randomPost`).
- **Popular artists** have no endpoint either; `/artists` on the site sorts
the same `/creators` data that Pawress sorts on the device.
70 changes: 69 additions & 1 deletion lib/core/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ class PawchiveApi {
String method,
Uri uri, {
Map<String, String>? headers,
bool followRedirects = true,
}) async {
final request = http.Request(method, uri)
..headers.addAll(headers ?? _headers);
..headers.addAll(headers ?? _headers)
..followRedirects = followRedirects;
final http.Response response;
try {
response = await http.Response.fromStream(
Expand Down Expand Up @@ -216,6 +218,60 @@ class PawchiveApi {
}
}

/// The site's most used tags, most used first (about 2000), read from the
/// `/posts/tags` web page; the API has no tag list.
Future<List<CreatorTag>> popularTags() async =>
parseTagPage(await _html('/posts/tags'));

/// Posts with [tag] across the site, newest first, read from the
/// `/posts?tag=` web page: the API's `/posts` ignores `tag`.
Future<List<Post>> taggedPosts(String tag, {int offset = 0}) async =>
parsePopularPage(
await _html('/posts', {
'tag': tag,
'o': offset == 0 ? null : '$offset',
}),
).posts;

/// The posts holding the file with this SHA-256, or null when Pawchive
/// has no such file (a 404).
Future<HashLookup?> searchHash(String sha256) async {
try {
final json = await _get('/search_hash/${sha256.toLowerCase()}');
return HashLookup.fromJson(
json is Map<String, dynamic> ? json : const {},
sha256,
);
} on ApiException catch (e) {
if (e.status == 404) return null;
rethrow;
}
}

/// A random post, from where the site's `/posts/random` redirects.
Future<PawchiveLink> randomPost() => _random('/posts/random');

/// A random artist, from where the site's `/artists/random` redirects.
/// Only the id is known; the name comes from the profile.
Future<Creator> randomCreator() async {
final link = await _random('/artists/random');
return Creator(id: link.user, service: link.service, name: link.user);
}

Future<PawchiveLink> _random(String path) async {
final response = await _send(
'GET',
_uri(path, null, siteUrl),
headers: {..._headers, 'Accept': 'text/html'},
followRedirects: false,
);
final link = PawchiveLink.tryParse(response.headers['location'] ?? '');
if (link == null) {
throw const ApiException('Pawchive did not pick anything. Try again.');
}
return link;
}

/// Read from the creator's "similar artists" web page.
Future<List<Creator>> recommendedCreators(String service, String id) async =>
parseCreatorCards(await _html('/$service/user/$id/recommended'));
Expand Down Expand Up @@ -372,6 +428,18 @@ PopularPage parsePopularPage(String html) {
return PopularPage(posts, ranges);
}

final _tagCard = RegExp(
r'<article>\s*<a href="/posts\?tag=[^"]*">\s*<span>(.*?)</span>\s*'
r'<span>(\d+)</span>',
dotAll: true,
);

@visibleForTesting
List<CreatorTag> parseTagPage(String html) => [
for (final card in _tagCard.allMatches(html))
CreatorTag(_text(card.group(1)!), int.parse(card.group(2)!)),
];

final _creatorCard = RegExp(
r'<a\s+href="[^"]*"\s+class="user-card[^"]*"\s+data-id="([^"]*)"\s+'
r'data-service="([^"]*)"(.*?)</a>',
Expand Down
11 changes: 11 additions & 0 deletions lib/core/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class AppState extends ChangeNotifier {
List<UpdateNotice>? _alert;

Future<List<Creator>>? _allCreators;
Future<List<CreatorTag>>? _popularTags;

bool get signedIn => api.session != null;
int get unreadNotices => notices.where((n) => !n.read).length;
Expand Down Expand Up @@ -538,6 +539,16 @@ class AppState extends ChangeNotifier {
return pending;
}

/// The site's popular tags, read once per session for Explore.
Future<List<CreatorTag>> popularTags() {
final pending = _popularTags ??= api.popularTags();
pending.catchError((Object _) {
_popularTags = null;
return <CreatorTag>[];
});
return pending;
}

static String _files(int count) => count == 1 ? '1 file' : '$count files';

void _say(String text) {
Expand Down
24 changes: 24 additions & 0 deletions lib/core/file_hash.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:cryptography/cryptography.dart';

/// A SHA-256 as Pawchive's hash lookup takes it: 64 hex digits.
bool isSha256(String text) => RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(text);

/// The SHA-256 of [chunks] in lowercase hex, read a chunk at a time so a
/// large video never sits in memory whole. [onProgress] gets the bytes read
/// so far.
Future<String> sha256Hex(
Stream<List<int>> chunks, {
void Function(int read)? onProgress,
}) async {
final sink = Sha256().newHashSink();
var read = 0;
await for (final chunk in chunks) {
sink.add(chunk);
read += chunk.length;
onProgress?.call(read);
}
sink.close();
final hash = await sink.hash();
return [for (final byte in hash.bytes) byte.toRadixString(16).padLeft(2, '0')]
.join();
}
54 changes: 54 additions & 0 deletions lib/core/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,60 @@ class CreatorTag {
CreatorTag(_str(json['tag']), _int(json['post_count']));
}

/// A post or artist page on Pawchive, as the random pages redirect to:
/// `/{service}/user/{user}[/post/{post}]`.
class PawchiveLink {
const PawchiveLink({required this.service, required this.user, this.post});

final String service;
final String user;
final String? post;

static final _pattern = RegExp(
r'/([\w-]+)/user/([^/?#]+)(?:/post/([^/?#]+))?',
);

static PawchiveLink? tryParse(String url) {
final match = _pattern.firstMatch(url);
if (match == null) return null;
return PawchiveLink(
service: match.group(1)!,
user: Uri.decodeComponent(match.group(2)!),
post: match.group(3),
);
}
}

/// What `/search_hash/{sha256}` found: the posts holding the file and how
/// many Discord messages do (Pawress doesn't show Discord servers).
class HashLookup {
const HashLookup({
required this.hash,
this.posts = const [],
this.discordCount = 0,
this.mime,
this.size,
});

final String hash;
final List<Post> posts;
final int discordCount;
final String? mime;
final int? size;

factory HashLookup.fromJson(Map<String, dynamic> json, [String hash = '']) =>
HashLookup(
hash: _optStr(json['hash']) ?? hash,
posts: [
for (final item in (json['posts'] as List? ?? const []))
if (item is Map<String, dynamic>) Post.fromJson(item),
],
discordCount: (json['discord_posts'] as List? ?? const []).length,
mime: _optStr(json['mime']),
size: json['size'] == null ? null : _int(json['size']),
);
}

class Fancard {
const Fancard({required this.file, this.price, this.added});

Expand Down
11 changes: 11 additions & 0 deletions lib/core/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import 'models.dart';

enum ThemeChoice { plum, amoled }

/// What the sidebar's Random picks.
enum RandomKind { post, artist }

/// How much of For You comes from followed artists.
enum ForYouMix {
following('Following', .85),
Expand Down Expand Up @@ -36,6 +39,7 @@ class AppSettings {
this.downloadFolderName,
this.askDownloadFolder = false,
this.checkUpdates = true,
this.randomKind,
});

final ThemeChoice theme;
Expand Down Expand Up @@ -70,6 +74,9 @@ class AppSettings {
/// Look for a newer GitHub release when the app starts.
final bool checkUpdates;

/// Null until Random is first opened and asks.
final RandomKind? randomKind;

static const intervals = [15, 30, 60, 180, 360, 720];

AppSettings copyWith({
Expand All @@ -85,6 +92,7 @@ class AppSettings {
bool clearDownloadFolder = false,
bool? askDownloadFolder,
bool? checkUpdates,
RandomKind? randomKind,
}) => AppSettings(
theme: theme ?? this.theme,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
Expand All @@ -102,6 +110,7 @@ class AppSettings {
: downloadFolder?.name ?? downloadFolderName,
askDownloadFolder: askDownloadFolder ?? this.askDownloadFolder,
checkUpdates: checkUpdates ?? this.checkUpdates,
randomKind: randomKind ?? this.randomKind,
);

factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
Expand All @@ -120,6 +129,7 @@ class AppSettings {
downloadFolderName: json['downloadFolderName'] as String?,
askDownloadFolder: json['askDownloadFolder'] as bool? ?? false,
checkUpdates: json['checkUpdates'] as bool? ?? true,
randomKind: RandomKind.values.asNameMap()[json['randomKind']],
);

Map<String, dynamic> toJson() => {
Expand All @@ -135,6 +145,7 @@ class AppSettings {
'downloadFolderName': downloadFolderName,
'askDownloadFolder': askDownloadFolder,
'checkUpdates': checkUpdates,
'randomKind': randomKind?.name,
};
}

Expand Down
Loading
Loading