322 Commits

Author SHA1 Message Date
Dylan Knutson
aad67622fc fix for routes 2025-03-13 16:48:49 +00:00
Dylan Knutson
32b9d606e7 visual fingerprinting 2025-03-11 01:06:58 +00:00
Dylan Knutson
a88382d54d more fingerprint fixes 2025-03-09 23:10:58 +00:00
Dylan Knutson
c9d967fd74 factor out resizing logic 2025-03-09 22:40:37 +00:00
Dylan Knutson
70fb486cff create multiple fingerprints wip 2025-03-09 20:33:49 +00:00
Dylan Knutson
87e1d50ae2 more fingerprint 2025-03-05 19:40:33 +00:00
Dylan Knutson
59a0f8a349 nil fix when getting post title 2025-03-04 17:38:13 +00:00
Dylan Knutson
259ace9862 tweaks for march 2025 user page updates 2025-03-04 17:30:27 +00:00
Dylan Knutson
67de25a2c2 more progress on visual search 2025-03-04 15:16:11 +00:00
Dylan Knutson
fdffd40277 simplify post file thumbnails 2025-03-04 01:58:23 +00:00
Dylan Knutson
6e4cb797fb firt tests forpost file thumbnail spec 2025-03-03 23:28:05 +00:00
Dylan Knutson
f969ceb371 [WIP] perceptual hashing 2025-03-03 17:05:58 +00:00
Dylan Knutson
6b395d63d4 button toggle for ip address role active 2025-03-03 17:03:16 +00:00
Dylan Knutson
b080ac896f tests for ip address role policy 2025-03-03 16:44:21 +00:00
Dylan Knutson
04661a8505 ip address role take 1 2025-03-03 05:47:51 +00:00
Dylan Knutson
111a22ff8a policy simplification 2025-03-03 04:57:48 +00:00
Dylan Knutson
24e6d0cf66 more domain logos 2025-03-03 04:43:17 +00:00
Dylan Knutson
c0ddef96f0 improvements for rich text, plain text link embedding 2025-03-03 01:10:54 +00:00
Dylan Knutson
720a2ab1b8 animated gif resizing 2025-03-02 23:28:19 +00:00
Dylan Knutson
1a84b885f2 retry blob entry migration once on unique violation 2025-03-02 19:29:51 +00:00
Dylan Knutson
e49fe33dc6 user page job improvement for skipping gallery scans 2025-03-02 19:28:47 +00:00
Dylan Knutson
ac50c47865 rest of ip address role model / admin dash work 2025-03-02 09:26:39 +00:00
Dylan Knutson
df9c42656c first pass on ip address roles 2025-03-02 08:21:14 +00:00
Dylan Knutson
23ff88e595 improve cache busting based on policy 2025-03-02 07:45:07 +00:00
Dylan Knutson
db67ba23bc compute and cache popover props in helper to avoid react_on_rails cache bugs 2025-03-02 02:05:09 +00:00
Dylan Knutson
3bf1cb13ef light color for hover popups 2025-03-02 01:41:37 +00:00
Dylan Knutson
e1e2f1d472 refactor hover preview 2025-03-02 01:11:01 +00:00
Dylan Knutson
f87c75186f use popover links in most places 2025-03-02 00:58:24 +00:00
Dylan Knutson
0dabfa42e5 popover link for users 2025-03-01 23:38:32 +00:00
Dylan Knutson
7843f0faa5 popover for inline links 2025-03-01 20:23:44 +00:00
Dylan Knutson
f5f05c9267 generic collapsable sections 2025-03-01 05:07:02 +00:00
Dylan Knutson
ad3d564d58 add dtext parsing 2025-03-01 03:47:20 +00:00
Dylan Knutson
7437586dda better behavior for users that are not found 2025-02-28 21:51:29 +00:00
Dylan Knutson
74bafc027a fixes for plain text bbcode rendering 2025-02-28 21:26:20 +00:00
Dylan Knutson
06fc36c4db list of users following/followed by user 2025-02-28 02:37:39 +00:00
Dylan Knutson
ed525ee142 env controls for enqeueuing jobs, link uuids in execution details 2025-02-28 02:21:19 +00:00
Dylan Knutson
ec7cd52a76 only enqueue next most important scan 2025-02-28 01:34:59 +00:00
Dylan Knutson
0223a8ef1c incremental impl in user page job 2025-02-28 01:20:04 +00:00
Dylan Knutson
b16b2009b0 better similarity helpers 2025-02-27 22:55:48 +00:00
Dylan Knutson
bfbbf5d7d4 remove old domain views, controllers 2025-02-27 21:22:26 +00:00
Dylan Knutson
8c2593b414 create domain factors, similar posts/users sections implemented 2025-02-27 07:05:51 +00:00
Dylan Knutson
41a8dab3d3 restyle log entry page 2025-02-26 00:34:40 +00:00
Dylan Knutson
79159b2d31 move entirely to BlobFile 2025-02-25 19:59:41 +00:00
Dylan Knutson
1647ba574c more good_job arguments, specs tolerate enqueuing user links 2025-02-25 17:05:33 +00:00
Dylan Knutson
97ab826f14 improve argument display for user avatars 2025-02-25 05:53:27 +00:00
Dylan Knutson
c7047ef8aa enqueue from links 2025-02-25 05:47:44 +00:00
Dylan Knutson
4dbdb68514 DSL for scans with intervals 2025-02-25 04:35:58 +00:00
Dylan Knutson
41324f019f more precise fa job priorities 2025-02-25 02:57:44 +00:00
Dylan Knutson
eb5ecb956d improved intro blurb 2025-02-25 01:04:41 +00:00
Dylan Knutson
c555c043a9 fix user script controller 2025-02-25 00:52:32 +00:00
Dylan Knutson
ccd5404a10 user scripts improvements 2025-02-25 00:21:49 +00:00
Dylan Knutson
2faa485a35 user scripts improvements 2025-02-24 23:45:35 +00:00
Dylan Knutson
3ea8dbfe83 more domain handling, inline link rewriting 2025-02-22 23:38:34 +00:00
Dylan Knutson
1801d475e7 improved parsing and html rendering 2025-02-22 22:50:07 +00:00
Dylan Knutson
a2813ca125 bbcode, doc parsing, render msword documents 2025-02-22 07:49:21 +00:00
Dylan Knutson
b470d1a669 source link url suffix matching 2025-02-22 06:00:11 +00:00
Dylan Knutson
2e1922c68f tests for source links 2025-02-22 04:48:14 +00:00
Dylan Knutson
8fb884c92c ib source links 2025-02-22 03:57:58 +00:00
Dylan Knutson
2700ef0f99 tweak to post scan enqueue logic 2025-02-21 22:36:07 +00:00
Dylan Knutson
36bd296c1a Improve static file job handling and user avatar processing 2025-02-21 19:53:37 +00:00
Dylan Knutson
50d875982a unify listing page scanning logic 2025-02-21 18:55:49 +00:00
Dylan Knutson
fe0711c7d9 add favicon 2025-02-21 17:31:23 +00:00
Dylan Knutson
eeb1511e52 lots of tweaks, thruster proxy 2025-02-20 22:07:45 +00:00
Dylan Knutson
18d304842e more e621 user fav migration tweaks 2025-02-20 02:04:42 +00:00
Dylan Knutson
93b0de6073 fix how account state is shown, link to last page scan hle 2025-02-19 23:22:24 +00:00
Dylan Knutson
784682bb44 fix where ib file can get an md5 after first scan 2025-02-19 21:38:03 +00:00
Dylan Knutson
4a1858f057 add periodic favs scanner 2025-02-19 21:19:38 +00:00
Dylan Knutson
32e927dcce specs for user favs scanning 2025-02-19 20:37:22 +00:00
Dylan Knutson
27253ff50b make sorbet structs comparable 2025-02-17 19:35:45 +00:00
Dylan Knutson
cfb8d6e714 separate out blob file migrator 2025-02-17 04:57:43 +00:00
Dylan Knutson
ab52ad7ebf show inkbunny descriptions, do not show files on public internet 2025-02-15 07:31:36 +00:00
Dylan Knutson
c1b3887c58 better description sanitization 2025-02-15 07:05:30 +00:00
Dylan Knutson
e375570a0f more typing 2025-02-15 06:17:25 +00:00
Dylan Knutson
a31aabaab2 Add post groups support with new controllers, views, and policies
This commit introduces comprehensive support for post groups across different domains:
- Created PostGroupsController to handle viewing post groups
- Added new views for displaying post groups and their associated posts
- Implemented policies for post groups
- Enhanced models to support post group functionality
- Updated routes to support post group navigation
- Added helper methods for post group interactions
- Improved GoodJob argument rendering for post groups

The changes provide a unified way to view and interact with post collections across different domains like Inkbunny and E621.
2025-02-14 22:03:01 +00:00
Dylan Knutson
8c86c02ffc more migration to log tagging 2025-02-14 19:24:21 +00:00
Dylan Knutson
1133837ed0 Enhance logging with more informative and consistent tags 2025-02-14 08:39:35 +00:00
Dylan Knutson
cf506b735a partial migration to tagged logs 2025-02-14 08:13:21 +00:00
Dylan Knutson
049f83660c script for fixing e621 posts 2025-02-13 21:18:54 +00:00
Dylan Knutson
fb9e36f527 GoodJob argument rendering, fix e621 post scanning, manual GoodJob execution by job class 2025-02-13 19:21:28 +00:00
Dylan Knutson
1f7a45cea2 kaminari types, move views around 2025-02-13 07:35:08 +00:00
Dylan Knutson
aef521ea7e better user name similarity search 2025-02-12 21:34:32 +00:00
Dylan Knutson
13c2d3cbed backfill user search names 2025-02-12 21:03:53 +00:00
Dylan Knutson
ff579c1a30 refactor fa jobs to accept old model tyles 2025-02-12 20:13:36 +00:00
Dylan Knutson
6c253818ff migrate inkbunny jobs to unified domain models 2025-02-12 19:26:02 +00:00
Dylan Knutson
c2cbe78fd1 migrate e621 jobs to unified domain models 2025-02-12 18:35:40 +00:00
Dylan Knutson
512119ebb4 backfill fa posted_at field 2025-02-12 05:22:51 +00:00
Dylan Knutson
af15c6feff more migrating views, get user search working 2025-02-12 01:59:33 +00:00
Dylan Knutson
cf5feb366a post favs view, fa and ib post show pages 2025-02-11 20:31:20 +00:00
Dylan Knutson
1761c89dc5 more migrating views over to new unified schema 2025-02-07 04:55:46 +00:00
Dylan Knutson
9a462713b6 fixes for migration script 2025-02-06 18:41:14 +00:00
Dylan Knutson
4bb0eae722 split up migration to domain spec 2025-02-05 17:49:45 +00:00
Dylan Knutson
35ba1db97e migrate inkbunny to domain script 2025-02-05 17:13:00 +00:00
Dylan Knutson
aea94c98cd migration script 2025-02-05 03:46:16 +00:00
Dylan Knutson
428cb0a491 fixed select, pluck 2025-02-04 20:17:27 +00:00
Dylan Knutson
b01f54cc4f basic indexes fixed, migration script 2025-02-04 19:41:30 +00:00
Dylan Knutson
acbdf72e8e parse submission date in etc, conver to to utc 2025-02-03 23:48:40 +00:00
Dylan Knutson
fc8e74d2fb only enqueue scan post job if new record 2025-02-03 19:41:16 +00:00
Dylan Knutson
bcd845759e migrate fa posts to json_attributes 2025-02-02 03:43:19 +00:00
Dylan Knutson
c4f0a73cfd todo update 2025-01-31 05:55:08 +00:00
Dylan Knutson
507e6ee715 json attribute aliases 2025-01-30 19:06:32 +00:00
Dylan Knutson
5c14d26f5f migrate out of e621 post state_detail 2025-01-29 20:17:14 +00:00
Dylan Knutson
4d5784b630 deferred jobs in models 2025-01-29 17:29:30 +00:00
Dylan Knutson
8f81468fc0 e621 user fav jobs fixes 2025-01-29 07:17:19 +00:00
Dylan Knutson
6c33c35a12 abstraction for http_client 2025-01-28 23:50:12 +00:00
Dylan Knutson
de4874c886 e621 fav jobs 2025-01-28 23:28:35 +00:00
Dylan Knutson
dc6965ab7b good_job cron for periodic tasks 2025-01-27 18:41:05 +00:00
Dylan Knutson
49fd8ccd48 tablespaces, structure.sql 2025-01-27 18:12:18 +00:00
Dylan Knutson
6f8afdd2a6 remove delayed_job, use structure.sql 2025-01-27 17:05:25 +00:00
Dylan Knutson
2d4f672b6a Update development environment and ignore files
- Added more directories to `.cursorignore` to exclude temporary and log files
- Updated TODO.md with a new task for downloading E621 user favorites
- Modified Dockerfile.devcontainer to install PostgreSQL 17 client
- Updated VS Code extensions script to add Docker extension
- Added Dockerfile formatter in VS Code settings
- Configured Cursor as the default Git editor in the development container
2025-01-26 19:34:44 +00:00
Dylan Knutson
0700adaa55 Enhance PostsHelper and View Logic for Improved Post Metadata Display
- Updated `PostsHelper` to enforce strict typing and added new methods for guessing HTTP log entries related to scanned posts and file downloads.
- Refactored the `post_state_string` method to handle unknown states more gracefully.
- Modified the view template to replace the old scanned and file description logic with links to log entries, providing clearer metadata about post actions.
- Removed deprecated tests related to the old description methods and added new tests for the updated functionality.

These changes improve the clarity and usability of post metadata in the application.
2025-01-20 18:00:08 +00:00
Dylan Knutson
557258ff9f Fix user gallery job pagination logic and add test for empty submission pages
- Updated pagination condition in `user_gallery_job.rb` to break when the number of pages is less than or equal to the current page.
- Added a new test case in `user_gallery_job_spec.rb` to handle scenarios with empty submission pages, ensuring no new records are created.
- Introduced a fixture file `api_search_empty_submissions.json` to simulate API responses with no submissions.
2025-01-20 16:25:26 +00:00
Dylan Knutson
64efbee162 Enhance routing configuration for admin features
- Added PgHero engine mount to the admin routes for database performance monitoring.
- Can access log entries page from outside VPN connection
2025-01-20 16:25:04 +00:00
Dylan Knutson
828f52fe81 Refactor Dockerfile to use system-wide Git configuration for delta pager
- Changed Git configuration commands in the Dockerfile to apply system-wide settings instead of user-specific settings for the 'vscode' user.
- This update ensures that the delta pager and related configurations are available globally within the container, improving consistency and usability for all users.
2025-01-20 16:24:35 +00:00
Dylan Knutson
73ff4ee472 backfill job handles url_names correctly 2025-01-13 20:20:23 +00:00
Dylan Knutson
f14c73a152 favs backfill job 2025-01-13 03:23:24 +00:00
Dylan Knutson
2789cf2c7f Refactor and enhance scraper metrics and job processing
- Introduced new metrics tracking for HTTP client requests and job processing, including `HttpClientMetrics` and `JobBaseMetrics`.
- Updated `Scraper::HttpClient` to utilize the new metrics for observing request start and finish events.
- Enhanced `Scraper::JobBase` with metrics for job execution, including start, finish, and enqueued jobs.
- Refactored `InkbunnyHttpClientConfig` to enforce strict typing and improve the handling of session IDs in URIs.
- Added tests for new metrics functionality and improved existing tests for job processing and URI mapping.

These changes improve observability and maintainability of the scraper and job processing systems.
2025-01-06 17:55:13 +00:00
Dylan Knutson
3f56df3af3 Enhance Inkbunny job processing and database schema
- Added support for pool-specific API searches in `ApiSearchPageProcessor` by introducing `pool_id` as a parameter.
- Created a new `UpdatePoolJob` class to handle deep updates for pools, including enqueuing jobs for posts needing updates.
- Updated `UpdatePostsJob` to manage missing posts and pools more effectively, ensuring proper job enqueuing.
- Modified the `Domain::Inkbunny::Pool` model to include a reference to `deep_update_log_entry` for tracking updates.
- Updated database schema to add `deep_update_log_entry_id` to the `domain_inkbunny_pools` table.
- Added tests for the new job functionality and ensured correct processing of API responses.

These changes improve the maintainability and robustness of the Inkbunny job processing system.
2025-01-05 20:15:17 +00:00
Dylan Knutson
80ee303503 Enhance Inkbunny job processing and database schema
- Marked tasks as complete in TODO.md for the Inkbunny index scan job and log attachment features.
- Refactored `UpdatePostsJob` to improve method naming and enhance error handling for missing posts.
- Introduced associations between `Post`, `Pool`, and `PoolJoin` models to manage relationships effectively.
- Updated database schema to include `ib_pool_id` in pools and modified the `domain_inkbunny_pool_joins` table structure.
- Added tests to ensure correct association of posts with pools and validate left/right post IDs in pool joins.
2025-01-05 19:32:58 +00:00
Dylan Knutson
f5748cd005 Add HTTP gem for request proxying and enhance application layout 2025-01-04 20:39:37 +00:00
Dylan Knutson
f0502f500d Enhance strict typing and refactor API and job classes
- Updated `ApiController` in the FA domain to enforce strict typing with Sorbet, including the addition of type signatures and improved method parameters.
- Refactored `users_for_name` method to accept a limit parameter for better control over user search results.
- Enhanced error handling in the `UserTimelineTweetsJob` to ensure proper logging and response management.
- Updated `GalleryDlClient` to include strict typing and improved method signatures for better clarity and maintainability.
- Refactored Prometheus metrics configuration to improve naming consistency and clarity.

These changes aim to improve type safety, maintainability, and robustness across the application.
2025-01-04 18:41:29 +00:00
Dylan Knutson
4d6c67b5a1 Add GoodJob metrics integration and update Prometheus configuration
- Introduced a new `GoodJobMetricsWithQueues` class for tracking GoodJob queue counts using Prometheus.
- Updated `application.rb` to include Prometheus exporter dependencies and configuration.
- Added a new `prometheus_exporter.yml` file to manage Prometheus settings across environments.
- Modified `puma.rb` to remove redundant Prometheus instrumentation code.
- Enabled eager loading in the development environment for better performance.
- Updated GoodJob initializer to start the new metrics tracking.

These changes enhance monitoring capabilities for job processing and improve application performance.
2025-01-04 07:23:08 +00:00
Dylan Knutson
fcf98c8067 Add Prometheus monitoring integration and enhance application metrics 2025-01-04 05:59:26 +00:00
Dylan Knutson
9f0f6877d9 Update project configuration and enhance OpenTelemetry integration
- Modified `.gitignore` to include and manage `.devcontainer/signoz/data/*` while preserving `.keep` files.
- Updated `.prettierrc` to include the `@prettier/plugin-xml` plugin and configured XML formatting options.
- Added OpenTelemetry SDK and exporter gems to the `Gemfile` for enhanced monitoring capabilities.
- Removed `package-lock.json` as part of the transition to Yarn for dependency management.
- Enhanced `.devcontainer` configuration with new services for SigNoz, including ClickHouse and related configurations.
- Introduced new ClickHouse configuration files for user and cluster settings.
- Updated Nginx and OpenTelemetry collector configurations to support new logging and monitoring features.
- Improved user experience in the `UserSearchBar` component by updating the placeholder text.

These changes aim to improve project maintainability, monitoring capabilities, and user experience.
2025-01-04 00:55:19 +00:00
Dylan Knutson
d6afdf424b Enhance UserMenu with GoodJob integration and update application layout
- Added `goodJobPath` to `UserMenuProps` and integrated a link to the Jobs Queue for admin users.
- Updated the application layout to pass the new `goodJobPath` to the UserMenu component.
- Configured routes to mount the GoodJob engine for admin users, enabling job management features.

These changes improve the user experience by providing quick access to job management for administrators.
2025-01-03 06:06:24 +00:00
Dylan Knutson
4af584fffd Add GoodJob logging enhancements and custom styles
- Introduced a new `good_job_custom.css` file for custom styling of GoodJob logs.
- Added a new `pixiv.png` icon for domain-specific logging in the `e621` posts helper.
- Enhanced the `GoodJobHelper` module to parse ANSI escape codes for better log formatting.
- Implemented a new `GoodJobExecutionLogLinesCollection` model to store log lines associated with job executions.
- Updated views to display job execution details and logs with improved formatting and styling.
- Refactored `ColorLogger` to support log line accumulation for better log management.

These changes aim to improve the logging experience and visual representation of job execution details in the GoodJob dashboard.
2025-01-03 05:58:22 +00:00
Dylan Knutson
ed299a404d Add JobHelper module and enhance Inkbunny job processing
- Introduced a new `JobHelper` module to encapsulate job retrieval and execution logic, enforcing strict typing with Sorbet.
- Updated `UpdatePostsJob` to improve logging and error handling for file processing, including handling files with null MD5 sums.
- Enhanced validation in `Domain::Inkbunny::File` model to conditionally require presence of certain attributes based on file state.
- Added a new fixture for testing scenarios involving submissions with null MD5 sums, improving test coverage and robustness.

These changes aim to improve type safety, maintainability, and error handling in job processing logic.
2025-01-02 17:52:11 +00:00
Dylan Knutson
48337c08bc Enhance strict typing and refactor job classes in FA and Inkbunny domains
- Updated job classes in the FA and Inkbunny domains to enforce strict typing with Sorbet, including the addition of type signatures in various job classes.
- Refactored initialization methods to improve argument handling and ensure proper type management.
- Enhanced error handling and logging mechanisms across job classes for better traceability.
- Improved job argument handling, including the introduction of `first_log_entry` and `causing_log_entry` for better context in job processing.
- Streamlined the processing of API responses and improved the management of user and post data.

These changes aim to enhance type safety, maintainability, and robustness of the job processing logic.
2025-01-02 03:55:02 +00:00
Dylan Knutson
a9d639b66d Enhance strict typing and improve job initialization in FA and Inkbunny domains
- Refactored job classes in the FA domain to enforce strict typing with Sorbet, including the addition of type signatures in `FavsJob` and `Base` classes.
- Updated initialization methods to accept variable arguments and ensure proper type handling.
- Improved error handling in `ApiSearchPageProcessor` to gracefully manage null values for `last_file_update_datetime`.
- Added a new fixture for testing scenarios where `last_file_update_datetime` is null, enhancing test coverage.

These changes aim to improve type safety, maintainability, and robustness of the job processing logic.
2025-01-02 02:08:38 +00:00
Dylan Knutson
e931897c6c Enhance strict typing and refactor models for improved type safety
- Updated various Ruby files to enforce strict typing with Sorbet, including controllers, jobs, and models.
- Refactored method signatures across multiple classes to ensure better type checking and documentation.
- Introduced `requires_ancestor` in several modules to enforce class hierarchy requirements.
- Enhanced the `DbSampler` and `Scraper` classes with type signatures for better clarity and maintainability.
- Added new methods in the `Domain::Fa::User` class for scan management, improving functionality and type safety.

These changes aim to enhance the overall stability and maintainability of the codebase.
2025-01-01 23:14:27 +00:00
Dylan Knutson
3a878deeec more strict typing 2025-01-01 22:30:55 +00:00
Dylan Knutson
e89dca1fa4 Add RSpec-Sorbet integration and enhance type safety across the codebase
- Added `rspec-sorbet` gem to the Gemfile for improved type checking in tests.
- Updated various Ruby files to enforce strict typing with Sorbet, enhancing type safety.
- Refactored job classes and models to include type signatures, ensuring better type checking and documentation.
- Modified tests to utilize RSpec-Sorbet features, improving clarity and maintainability.

These changes aim to enhance the overall stability and maintainability of the codebase.
2025-01-01 21:10:54 +00:00
Dylan Knutson
1243a2f1f5 remove sst code 2025-01-01 03:31:17 +00:00
Dylan Knutson
17cd07bb91 add typed where possible 2025-01-01 03:29:53 +00:00
Dylan Knutson
69ea16daf6 Update Dockerfile and Ruby files for improved dependency management and type safety
- Upgraded bundler version from 2.4.5 to 2.5.6 in Dockerfile for better compatibility.
- Updated gem versions for faiss and rails_live_reload to their latest versions.
- Modified Ruby files to enforce strict typing with Sorbet, enhancing type safety across various models and jobs.
- Added type signatures to methods in Inkbunny and FA domain models, ensuring better type checking and documentation.
- Improved job argument handling in Inkbunny jobs, including priority settings for deferred jobs.
- Refactored initialization logic in several models to ensure default states are set correctly.

These changes aim to enhance the overall stability and maintainability of the codebase.
2025-01-01 02:55:10 +00:00
Dylan Knutson
2d68b7bc15 add first typed files 2025-01-01 02:05:25 +00:00
Dylan Knutson
077b7b9876 init sorbet 2025-01-01 01:14:26 +00:00
Dylan Knutson
8e9e720695 Refactor Inkbunny job processing and enhance post management
- Updated Inkbunny job classes to streamline argument handling by removing `ignore_signature_args :caused_by_entry`.
- Enhanced `ApiSearchPageProcessor` and `UpdatePostsJob` to include `caused_by_entry` for better logging and tracking of job origins.
- Introduced `deep_update_log_entry` and `shallow_update_log_entry` associations in Inkbunny post and user models for improved tracking of updates.
- Added `posted_at` attribute to `IndexedPost` model, ensuring synchronization with the postable's posted date.
- Enhanced views to display user posts in a more organized manner, including handling cases where media files are missing.
- Improved tests for Inkbunny jobs and models to ensure robust coverage of new functionality and maintainability.
2025-01-01 00:20:33 +00:00
Dylan Knutson
a9bccb00e2 better log entry caused by row styling 2024-12-31 21:23:23 +00:00
Dylan Knutson
fa235a2310 Refactor Inkbunny job handling and enhance gallery scanning
- Removed the `ignore_signature_args :caused_by_entry` from multiple Inkbunny job classes to streamline argument handling.
- Introduced `scanned_gallery_at` attribute in the Inkbunny user model to manage gallery scan timing.
- Added `UserGalleryJob` to handle gallery scanning for Inkbunny users, ensuring efficient processing of user submissions.
- Implemented `ApiSearchPageProcessor` for processing API search results, improving modularity and reusability.
- Updated `LatestPostsJob` to utilize the new processor for fetching and processing submissions.
- Enhanced tests for Inkbunny jobs, ensuring robust coverage for new functionality and improved job argument handling.
2024-12-31 20:08:33 +00:00
Dylan Knutson
f1c91f1119 Refactor Inkbunny job handling and enhance avatar management
- Removed the direct assignment of user avatar URL in JobHelper, delegating it to the post update logic.
- Updated UpdatePostsJob to enqueue UserAvatarJob when the avatar URL changes, improving avatar management.
- Refactored tests to utilize new methods for checking enqueued job arguments, enhancing clarity and maintainability.
- Improved user avatar handling in UserAvatarJob, ensuring proper state management and logging.
- Added new utility methods in SpecUtil for better job argument retrieval in tests.
2024-12-31 05:54:21 +00:00
Dylan Knutson
1f3fa0074e Add UserAvatarJob for Inkbunny avatar management and refactor user model 2024-12-30 22:54:23 +00:00
Dylan Knutson
37e269321f Enhance Inkbunny job processing and update post handling
- Updated Rakefile to enqueue periodic jobs for Inkbunny latest posts, improving background processing.
- Added a check in UpdatePostsJob to handle cases with empty post IDs, preventing unnecessary processing.
- Enhanced IndexedPost model to support posting dates for Inkbunny posts.
- Refined view for displaying indexed posts, improving the presentation of posting dates and user experience.
2024-12-30 21:57:32 +00:00
Dylan Knutson
999e67db35 Refactor and enhance tests for Domain::Fa and Domain::E621
- Updated factories for Domain::Fa::User and Domain::E621::Post to improve test data creation.
- Refactored job specs for Domain::E621 to streamline HTTP client mocking and improve clarity.
- Enhanced tests for Domain::Fa::HomePageJob to ensure proper job enqueuing and user interactions.
- Removed obsolete test files related to Domain::Fa::PostEnqueuer and Domain::Fa::UserEnqueuer to clean up the test suite.
- Improved validation tests for Domain::Fa::Post and Domain::Fa::User models to ensure robustness.
2024-12-30 20:51:40 +00:00
Dylan Knutson
60d7e2920a remove unused code, factor calculators 2024-12-30 20:30:23 +00:00
Dylan Knutson
c226eb20ed Refactor tests and enhance functionality for Domain::Fa and Domain::E621
- Removed outdated tests for Domain::Fa::PostsController and Domain::Fa::UsersController.
- Added new tests for Domain::Fa::PostsController to verify successful responses for the show action.
- Introduced a new UsersController spec to test the show action for user retrieval.
- Created a factory for Domain::E621::Post to streamline test data creation.
- Added comprehensive tests for BlobFile model, ensuring correct functionality and file operations.
- Implemented tests for HttpLogEntryHeader and LogStoreSstEntry models to validate header scrubbing and version parsing.
- Deleted obsolete test files and directories to clean up the test suite.
2024-12-30 20:11:06 +00:00
Dylan Knutson
4b09b926a0 Update blob entry handling and enhance staging configuration
- Changed the staging server port from 3000 to 3001 in the Procfile for better port management.
- Introduced a new BlobEntry model to replace BlobEntryP, ensuring a more consistent data structure across the application.
- Updated various controllers and views to utilize the new BlobEntry model, enhancing data retrieval and rendering processes.
- Added a new BlobEntriesController to manage blob entries, including a show action for retrieving content based on SHA256.
- Enhanced the Rakefile to enqueue periodic jobs for updating posts, improving background processing capabilities.
- Updated routes to reflect the new BlobEntry model and ensure proper resource handling.
- Improved tests for blob entry functionality, ensuring robust coverage and reliability in data handling.
2024-12-30 19:35:27 +00:00
Dylan Knutson
97dff5abf9 Add Inkbunny post management functionality
- Introduced a new model for managing Inkbunny posts, including creation, updating, and retrieval of post data.
- Implemented a job system for handling updates to posts and files, ensuring efficient processing of submissions.
- Enhanced the GlobalStatesController to manage Inkbunny credentials, allowing users to set either username/password or session ID.
- Updated routes to support Inkbunny post viewing and management, including parameterized routes for post IDs.
- Created policies to manage access to post details based on user roles, ensuring only authorized users can view sensitive information.
- Improved views for displaying Inkbunny posts, including enhanced layouts and user interaction elements.
- Added comprehensive tests for the new functionality, ensuring robust coverage for post management and credential handling.
2024-12-30 08:07:27 +00:00
Dylan Knutson
44778f6541 Add Inkbunny credentials management functionality
- Introduced methods for managing Inkbunny cookies in the GlobalStatesController, including `ib_cookies`, `edit_ib_cookies`, and `update_ib_cookies`.
- Added a new policy for managing Inkbunny cookies, restricting access to admin users.
- Created views for displaying and editing Inkbunny credentials, enhancing user interaction.
- Updated routes to include paths for Inkbunny cookies management.
- Enhanced tests for the new functionality in the GlobalStatesController spec, ensuring proper handling of credentials.
2024-12-30 03:15:08 +00:00
Dylan Knutson
c9858ee354 Refactor FA cookie management and enhance testing
- Updated the FA cookie management in the Scraper::FaHttpClientConfig class to validate cookie formats and handle cookies more robustly.
- Removed the fa.yml configuration file and integrated cookie retrieval directly within the class.
- Added comprehensive tests for cookie handling in the new fa_http_client_config_spec.rb file.
- Updated the Gemfile to include parallel_tests in the test and development group.
- Modified the .rspec file to enable color output for better readability in test results.
- Simplified test commands in the justfile for improved usability.
- Adjusted the rails_helper.rb to ensure the test environment is correctly set up.
2024-12-30 02:00:30 +00:00
Dylan Knutson
28ab0cc023 Add FA Cookies management functionality
- Introduced methods for managing FurAffinity cookies in the GlobalStatesController, including `fa_cookies`, `edit_fa_cookies`, and `update_fa_cookies`.
- Added a new policy for managing FA cookies, restricting access to admin users.
- Created views for displaying and editing FA cookies, enhancing user interaction.
- Updated routes to include paths for FA cookies management.
- Added comprehensive tests for the new functionality in the GlobalStatesController spec.
2024-12-30 01:39:21 +00:00
Dylan Knutson
fbc3a53c25 global state model 2024-12-30 01:19:00 +00:00
Dylan Knutson
eb5a6d3190 Implement IVFFLAT probe settings and enhance post/user controllers
- Added a new method `set_ivfflat_probes!` in `ApplicationController` to configure IVFFLAT probe settings.
- Integrated `set_ivfflat_probes!` as a before action in `Domain::Fa::PostsController` and `Domain::Fa::UsersController` for the `show` action.
- Updated `set_domain_fa_post` method to include the `response` association in the post query.
- Increased the limit of similar posts displayed from 5 to 20 and included the creator's avatar in the post includes for better data retrieval.
2024-12-30 00:37:09 +00:00
Dylan Knutson
af119ed683 Enhance Gemfile, update styles, and improve log entry handling
- Added `db-query-matchers` gem for improved query testing capabilities.
- Updated `sanitize` gem version to `~> 6.1` for better security and features.
- Refactored styles in `application.tailwind.css` for better responsiveness.
- Improved `LogEntriesController` to utilize `response_size` for more accurate data handling.
- Added a new `favorites` action in `Domain::Fa::PostsController` for better user experience.
- Enhanced `fa_post_description_sanitized` method in `Domain::Fa::PostsHelper` for improved HTML sanitization.
- Updated views for `Domain::Fa::Posts` to streamline layout and improve user interaction.
- Improved pagination controls for better navigation across post listings.
2024-12-29 20:30:10 +00:00
Dylan Knutson
b639ec2618 Update .gitignore, Gemfile, and various job classes for improved functionality
- Added '*.notes.md' and '*.export' to .gitignore to prevent unnecessary files from being tracked.
- Refactored job classes in the Domain::Fa module to enhance logging and job enqueuing processes, including:
  - Improved logging messages in Domain::Fa::Job::Base for better clarity.
  - Added support for a new 'enqueue_favs_scan' option in user job processing.
  - Enhanced the FavsJob to utilize active fav post joins and added a 'removed' flag for better management of favorites.
- Updated user and post models to include scopes for active favorites and improved error handling in user creation.
- Enhanced the page parser to support new formats for favorites pages and added tests for these changes.
2024-12-29 01:26:39 +00:00
Dylan Knutson
cdf064bfdf add todo 2024-12-28 17:38:41 +00:00
Dylan Knutson
a60284c0d4 fix posted date parsing for legacy scanned posts 2024-12-28 17:33:29 +00:00
Dylan Knutson
56fa72619a Add domain icons for InkBunny, Newgrounds, and Patreon; update E621 post helper and improve file URL handling 2024-12-27 22:35:31 +00:00
Dylan Knutson
efccf79f64 Add pundit-matchers gem and enhance indexed post handling
- Added `pundit-matchers` gem to improve policy testing capabilities.
- Updated `BlobsController` to support a new "tiny" size option for avatars.
- Enhanced `IndexablePostsHelper` with a `show_path` method for different postable types.
- Refactored `IndexedPost` model to include methods for retrieving artist information and external links.
- Modified `Domain::E621::Post` model to initialize `tags_array` as a hash.
- Updated views for indexed posts to support new display formats (gallery and table).
- Improved test coverage with new user factory and updated specs for controller and job behaviors.
2024-12-27 21:56:26 +00:00
Dylan Knutson
e1b3fa4401 more policy and auth work 2024-12-27 20:27:16 +00:00
Dylan Knutson
9f67a525b7 initial commit for devise and user auth 2024-12-27 19:03:08 +00:00
Dylan Knutson
50af3d90d8 Refactor user avatar handling and improve user status display
- Updated user avatar creation method to ensure avatar is always created or retrieved.
- Enhanced user status display in views with improved styling and additional information.
- Added missing commas in various places for better code consistency.
- Improved error handling in user avatar job for better logging and state management.
- Updated tests to reflect changes in avatar handling and user model methods.
2024-12-27 18:40:18 +00:00
Dylan Knutson
e7a584bc57 Add testing utilities and improve test coverage with FactoryBot integration
- Added `shoulda-matchers` for enhanced RSpec testing capabilities.
- Introduced `factory_bot_rails` for easier test data creation.
- Created factories for `HttpLogEntry`, `BlobEntry`, and `Domain::Fa::Post` models.
- Updated `rails_helper.rb` to include FactoryBot methods and configure Shoulda matchers.
- Enhanced `HttpLogEntry` model with a new `response_size` method.
- Refactored `justfile` to include parallel test execution.
- Improved `Gemfile` and `Gemfile.lock` with new testing gems.
2024-12-27 16:59:27 +00:00
Dylan Knutson
b1d06df6d2 Add domain icons and enhance post views for E621 and FurAffinity 2024-12-26 00:41:59 +00:00
Dylan Knutson
6d5f494c64 Remove legacy related code 2024-12-25 23:53:29 +00:00
Dylan Knutson
3c41cd5b7d add e621 posts controller, index posts filter view 2024-12-25 23:37:38 +00:00
Dylan Knutson
6b4e11e907 indexed posts enums 2024-12-25 21:53:47 +00:00
Dylan Knutson
c70240b143 indexed posts, more specs 2024-12-25 17:30:03 +00:00
Dylan Knutson
9c7a83eb4e fix e621 scan post job 2024-12-24 06:07:23 +00:00
Dylan Knutson
22af93ada7 better styling for fa posts, users, index pages 2024-12-23 16:47:14 +00:00
Dylan Knutson
8d4f30ba43 refactor UserSearchBar, js formatting 2024-12-22 19:10:33 +00:00
Dylan Knutson
9349a5466c make user search bar work on mobile better 2024-12-21 20:31:28 +00:00
Dylan Knutson
ced01f1b9e upgrade to rails 7.2, ui improvements 2024-12-21 19:47:56 +00:00
Dylan Knutson
2ce6dc7b96 fix sqlite exporter 2024-12-21 09:08:55 +00:00
Dylan Knutson
6922c07b8c fa post factor calculator 2024-12-19 20:46:08 +00:00
Dylan Knutson
985c2c2347 more UI for showing fa posts 2024-12-19 20:04:18 +00:00
Dylan Knutson
c63e1b8cb2 fix tailwind suggestions, staging live reload 2024-12-19 19:16:42 +00:00
Dylan Knutson
8ac13f0602 tailwind updates, inkbunny listing stuff 2024-12-19 06:04:37 +00:00
Dylan Knutson
2e36e08828 blob file migrator speedup 2024-12-19 02:05:03 +00:00
Dylan Knutson
16fab739b5 fix specs, add migrator 2024-12-18 22:53:05 +00:00
Dylan Knutson
8051c86bb4 fish conf dir, justfile 2024-12-18 18:51:07 +00:00
Dylan Knutson
3eb9be47bc format 2024-12-17 23:09:06 +00:00
Dylan Knutson
2ee31f4e74 update good_job 2024-12-17 19:00:33 +00:00
Dylan Knutson
9e58ee067b remove proxy code 2024-12-17 17:57:17 +00:00
Dylan Knutson
1b59b44435 before redoing devcontainer files 2024-12-17 06:34:47 -08:00
Dylan Knutson
955a3021ae dockerfile 2023-10-08 19:40:54 -07:00
Dylan Knutson
b97b82b1d8 create inkbunny 2023-09-14 18:31:31 -07:00
Dylan Knutson
4a31bd99e8 fix fa api endpoint 2023-09-13 08:45:47 -07:00
Dylan Knutson
ca22face6c [WIP] Initial inkbunny login 2023-08-27 10:39:09 -07:00
Dylan Knutson
4f880fd419 account for posts with replacement files 2023-08-26 10:13:25 -07:00
Dylan Knutson
7ee7b57965 log e621 post md5 mismatch without failing 2023-08-26 09:55:27 -07:00
Dylan Knutson
ebfea0ab7c fix css warning 2023-08-25 17:37:50 -07:00
Dylan Knutson
6436fe8fa6 generalize disabled page check 2023-08-25 17:31:16 -07:00
Dylan Knutson
9a3742abf1 stop scanning favs if user removed 2023-08-25 17:15:57 -07:00
Dylan Knutson
0a980259dc allow empty favs list 2023-08-25 17:05:16 -07:00
Dylan Knutson
fea167459d username parsing for fa+ users 2023-08-25 16:57:36 -07:00
Dylan Knutson
2a5e236a7f add incremental user page (fa) job 2023-08-25 13:56:37 -07:00
Dylan Knutson
1fa22351d5 update csv import job 2023-08-24 21:02:26 -07:00
Dylan Knutson
01f8d0b962 fix avatar images not appearing in profile description 2023-08-23 10:12:23 -07:00
Dylan Knutson
85dec62850 user avatar fixer job 2023-08-23 09:01:35 -07:00
Dylan Knutson
3ab0fa4fa3 joins in log entries controller to speed up query 2023-08-22 23:49:52 -07:00
Dylan Knutson
5b94a0a7de log content type and status code in metrics 2023-08-22 20:01:57 -07:00
Dylan Knutson
2e0c2fdf51 add e621 to periodic tasks 2023-08-22 17:47:39 -07:00
Dylan Knutson
ea5a2a7d6c add taggings to e621 posts 2023-08-22 17:44:03 -07:00
Dylan Knutson
d358cdbd7f add e621 posts job 2023-08-22 16:44:25 -07:00
Dylan Knutson
bd0fad859e use influxdb 1.8 2023-08-21 16:05:08 -07:00
Dylan Knutson
0e744bbdbe add influxdb 2023-08-21 12:05:19 -07:00
Dylan Knutson
531cd1bb43 add contact info 2023-08-21 11:39:57 -07:00
Dylan Knutson
552532a95c default not found image 2023-08-21 11:22:11 -07:00
Dylan Knutson
ad78d41f06 use vips for resizing 2023-08-21 11:16:08 -07:00
Dylan Knutson
93e389855a skip api token check for FA search_user_names 2023-08-21 11:01:32 -07:00
Dylan Knutson
6ec902a859 permission check for posts page 2023-08-21 10:46:18 -07:00
Dylan Knutson
fb78c3a27d add some caching and preloading to user show page 2023-08-21 09:01:54 -07:00
Dylan Knutson
6620633f22 more flexible page parsing, guess avatar uri 2023-08-21 07:54:12 -07:00
Dylan Knutson
3f5b0eadc6 more useful fa user pages, post pages 2023-08-20 19:42:24 -07:00
Dylan Knutson
657713192b remove blob entry 2023-08-18 19:32:39 -07:00
Dylan Knutson
173a4f2c78 pass on fa user search 2023-08-18 19:22:39 -07:00
Dylan Knutson
401a730226 update good_job 2023-08-18 18:18:48 -07:00
Dylan Knutson
5988152835 stop scanning favs page if no new 2023-08-18 18:10:29 -07:00
Dylan Knutson
7e33f70f19 fix metrics reporting for http clients 2023-08-18 17:31:03 -07:00
Dylan Knutson
b8cadb9855 fix up metrics reporting 2023-08-18 17:01:33 -07:00
Dylan Knutson
8751ce4856 sleep on 403 http error 2023-08-18 16:14:06 -07:00
Dylan Knutson
0977ac4343 Home page job while browse page is protected 2023-07-07 11:08:08 -07:00
Dylan Knutson
09f1db712d ignore buggy user 2023-05-23 11:08:03 -07:00
Dylan Knutson
3263e8aca8 remove id pk from follows, use composite index 2023-05-23 10:36:21 -07:00
Dylan Knutson
03804c8cf1 smaller buckets for fav job inserts 2023-05-22 11:10:49 -07:00
Dylan Knutson
031b8f965d use init_from_args in favs and avatar job 2023-05-22 10:26:52 -07:00
Dylan Knutson
7276ef6cbd fa user and post controller cleanup 2023-05-21 18:55:16 -07:00
Dylan Knutson
fab12a4fe4 enable explain analyze in pghero 2023-05-21 09:09:26 -07:00
Dylan Knutson
7229900eaa prep statement, use join to exclude existing 2023-05-21 08:34:28 -07:00
Dylan Knutson
5ad6e89889 no cors in prod, sqlite mime type 2023-05-21 08:25:44 -07:00
Dylan Knutson
1cddb94af6 add tests for blob entry migration script 2023-05-21 08:23:03 -07:00
Dylan Knutson
4f4c7fabc7 db sampler script for creating realistic development environment 2023-05-20 20:11:10 -07:00
Dylan Knutson
d16b613f33 initial sqlite exporter script 2023-05-20 18:06:58 -07:00
Dylan Knutson
3ae55422e0 digest check for inserted blob entry p 2023-05-19 17:44:55 -07:00
Dylan Knutson
3a9478e0f4 periodic tasks 2023-05-19 17:40:29 -07:00
Dylan Knutson
c424b7dacd add pghero to rails app, dymk fork 2023-05-19 17:33:53 -07:00
Dylan Knutson
ff8e539579 remove legacy blob entry stuff, only write to BlobEntryP 2023-05-19 16:51:27 -07:00
Dylan Knutson
2833dc806f remove unused proxies 2023-05-18 20:43:18 -07:00
Dylan Knutson
9423a50bc3 Blob entry migration util 2023-05-18 20:12:10 -07:00
Dylan Knutson
67c28cb8d2 Blob entry import/export helper 2023-05-18 18:13:48 -07:00
Dylan Knutson
5b508060ff user fav job + refactors 2023-05-18 15:07:50 -07:00
Dylan Knutson
c7a2a3481a allow spaces in account names 2023-05-17 10:04:45 -07:00
Dylan Knutson
df712f65db more parsing fixes 2023-05-16 23:11:45 -07:00
Dylan Knutson
c34faef0dc fix parsing admin pages 2023-05-16 19:08:14 -07:00
Dylan Knutson
37ad4b2ea8 job for fixing fa names 2023-05-16 18:58:52 -07:00
Dylan Knutson
17d2a87089 remove account status prefix from parsed page 2023-05-04 09:12:37 -07:00
Dylan Knutson
99ee3aaa91 name exception for kammiu 2023-05-04 08:51:15 -07:00
Dylan Knutson
c3d8c7afa7 Names actually can have a dash prefix 2023-05-04 08:47:09 -07:00
Dylan Knutson
d7f3cd4074 Improved error checking for long fa names 2023-05-03 15:09:46 -07:00
Dylan Knutson
dbbe6788e8 Fa::User: name_to_url_name fixes, generalize timestamp checking 2023-05-03 14:58:45 -07:00
Dylan Knutson
aa1eaef5fd fill gaps in fa browse page jobs 2023-05-02 21:09:04 -07:00
Dylan Knutson
bb1e760d2e another ignored char in url name 2023-05-02 13:07:55 -07:00
Dylan Knutson
254367eb62 fix tests, avatar job enqueues user page job 2023-05-02 13:05:23 -07:00
Dylan Knutson
cc1fb9847f enqueue user page job if no avatar uri is found 2023-05-02 12:59:01 -07:00
Dylan Knutson
32fe41ff04 handle buggy fa favcount, add tests, make follows associations more clear 2023-05-02 12:49:22 -07:00
Dylan Knutson
3f0d845472 helper method to migrate posts between users 2023-05-02 11:30:36 -07:00
Dylan Knutson
7758927865 add syfaro api key, use user avatar if it exists 2023-04-26 22:05:21 -07:00
Dylan Knutson
158fb9b478 less logging in scan post job 2023-04-16 15:33:23 -07:00
Dylan Knutson
75503e2a99 sleep and raise on IP ban 2023-04-14 14:36:24 -07:00
Dylan Knutson
dc4c1b1df9 enqueuers ignore failed jobs 2023-04-14 14:32:01 -07:00
Dylan Knutson
b8163f9e77 look at wildcard queues 2023-04-14 09:26:08 -07:00
Dylan Knutson
5505e7089e tighter ip constraint 2023-04-08 18:22:54 -07:00
Dylan Knutson
84866c0f6a rufo format 2023-04-08 13:52:16 -07:00
Dylan Knutson
df43a77fe2 add procfiles to root 2023-04-08 13:46:49 -07:00
Dylan Knutson
15fc61c0d0 raise on image not found 2023-04-07 12:39:56 -07:00
Dylan Knutson
fde45e9704 user enqueuer - avatar 2023-04-07 12:19:38 -07:00
Dylan Knutson
3e62f9949c fa_user_avatar queue working 2023-04-07 12:10:04 -07:00
Dylan Knutson
e99daf4b59 enqueue user avatar job 2023-04-07 12:08:54 -07:00
Dylan Knutson
35aa025778 user avatar job 2023-04-07 11:54:01 -07:00
Dylan Knutson
ab13af43af add rake task for dumping fa user info 2023-04-07 09:27:09 -07:00
Dylan Knutson
e57b0f4fc9 add soft_fox_lad api key 2023-04-06 15:50:20 -07:00
Dylan Knutson
db4ea55b28 clientside trie 2023-04-04 17:13:40 -07:00
Dylan Knutson
230bd5757d reorganize initializers 2023-04-04 13:35:51 -07:00
Dylan Knutson
f317aa273e final migration over to react_on_rails / react18 2023-04-04 23:40:57 +09:00
Dylan Knutson
18fff3bc07 react_on_rails initial generate 2023-04-04 21:47:52 +09:00
Dylan Knutson
ca33644f84 commit before react_on_rails install 2023-04-04 21:37:54 +09:00
Dylan Knutson
3dc43530f8 use typescript 2023-04-04 21:03:50 +09:00
Dylan Knutson
f1e40a405f more features in search bar 2023-04-04 20:09:44 +09:00
Dylan Knutson
57083dc74c user enqueuer for more job types 2023-04-03 22:33:13 +09:00
Dylan Knutson
5c1318d768 basic user search impl 2023-04-03 22:13:02 +09:00
Dylan Knutson
71f54ae5e7 basic search bar requests 2023-04-03 21:01:26 +09:00
Dylan Knutson
d19255a2c9 initial user search bar scaffold in react 2023-04-02 22:19:30 +09:00
Dylan Knutson
6b4c3c2294 initial react setup 2023-04-02 21:34:17 +09:00
Dylan Knutson
4c774faafd task for filling in fa post holes 2023-04-02 20:51:29 +09:00
Dylan Knutson
450a5844eb post enqueuer file jobs 2023-04-02 20:24:48 +09:00
Dylan Knutson
0d4511cbcf bulk user enqueuer 2023-04-02 20:06:30 +09:00
Dylan Knutson
a0d52575f3 fix HasColorLogger 2023-04-02 19:58:59 +09:00
Dylan Knutson
9468e570d9 add icon to input 2023-04-02 14:47:34 +09:00
Dylan Knutson
c2997f4d5f placeholder root page 2023-04-02 14:23:46 +09:00
Dylan Knutson
96b0804a0f site title 2023-04-02 13:11:57 +09:00
Dylan Knutson
9d5f1138d3 api improvements, color logger spec 2023-04-02 12:48:42 +09:00
Dylan Knutson
1a912103f1 add tailwind css and home page scaffold 2023-04-01 23:27:40 +09:00
Dylan Knutson
6d2eff0849 scaffold out recommendation user script 2023-04-01 21:12:42 +09:00
Dylan Knutson
369c79f8df port over some files from test to spec 2023-04-01 14:42:52 +09:00
Dylan Knutson
8d85f7ebe1 add bulk enqueue job helper 2023-04-01 14:29:15 +09:00
Dylan Knutson
a413b31a2c improve post enqueuer speed 2023-04-01 11:22:00 +09:00
Dylan Knutson
effb21b7cc make factors and epochs variable 2023-04-01 07:18:25 +09:00
Dylan Knutson
3e6e1bf20b add spec to user enqueuer, add user page queue depth to watermark limit 2023-03-31 20:09:31 +09:00
Dylan Knutson
ff9aa66a4c add fa factor calculator 2023-03-31 19:52:37 +09:00
Dylan Knutson
d4dfa7309c add fa user enquerer 2023-03-31 17:41:25 +09:00
Dylan Knutson
c587aabbbe add fa user follows job 2023-03-31 17:29:44 +09:00
Dylan Knutson
c63f1dffcb more tests / examples for recommender 2023-03-31 11:34:28 +09:00
Dylan Knutson
3c45545eab remove separate dev envs, add faiss 2023-03-31 11:24:02 +09:00
Dylan Knutson
13c9ff0e8c separate dev env for curtus / regius 2023-03-30 22:34:44 +09:00
Dylan Knutson
db4c244196 pluck instead of full object select 2023-03-30 21:33:02 +09:00
Dylan Knutson
67181ce78a parser fixes for fa posts 2023-03-30 19:04:57 +09:00
Dylan Knutson
798b2e43cb remove stripped char from url name 2023-03-30 18:34:45 +09:00
Dylan Knutson
43848c3dd4 enqueue waiting posts job 2023-03-30 16:48:08 +09:00
Dylan Knutson
ff017290ec log on scan job finish 2023-03-28 23:09:43 +09:00
Dylan Knutson
fcf635f96c tune good_job, no twitter for serverhost-1 2023-03-28 16:34:18 +09:00
Dylan Knutson
b9673e9585 use kw argument initialization 2023-03-28 16:20:59 +09:00
Dylan Knutson
8ce85c6ef0 fix up queue depth selection logic 2023-03-28 16:03:43 +09:00
Dylan Knutson
4a8f4f241b bulk-enqueue in api controllers 2023-03-28 15:55:18 +09:00
Dylan Knutson
31d78ad0b9 add basic test for twitter timeline job 2023-03-28 15:54:31 +09:00
Dylan Knutson
b1a5496f09 add procfile, remove old log watcher stuff 2023-03-27 20:26:43 +09:00
Dylan Knutson
18545bbfd8 select limit for good_job 2023-03-27 19:13:30 +09:00
Dylan Knutson
24e52357be quiet color logger, temp fix for api controller, more browse page job tests 2023-03-27 17:10:34 +09:00
Dylan Knutson
29cdb1669c callback to bulk enqueue jobs 2023-03-26 20:53:09 +09:00
Dylan Knutson
2941b6a91d add logging / debugging to jobs 2023-03-26 20:31:50 +09:00
Dylan Knutson
5a34130044 comprehensive tests for browse page job 2023-03-26 13:46:18 +09:00
Dylan Knutson
edc4940ba2 add good_job, add spec for browse page job 2023-03-26 00:55:17 +09:00
Dylan Knutson
c2e3ce669e get tests running against local legacy db fixtures 2023-03-25 21:30:03 +09:00
1037 changed files with 46130 additions and 144538 deletions

View File

@@ -5,28 +5,3 @@ tmp
log
public
.bundle
gems
# Generated/build artifacts
node_modules
user_scripts/dist
app/assets/builds
vendor/javascript
# Sorbet generated files
sorbet/tapioca
sorbet/rbi/gems
sorbet/rbi/annotations
sorbet/rbi/dsl
# Configuration files with secrets
config/credentials.yml.enc
config/master.key
# Lock files
yarn.lock
Gemfile.lock
# Documentation
TODO.md
*.notes.md

View File

@@ -1,278 +0,0 @@
# How to use this codebase
- Run `bin/tapioca dsl` after changing a model or concern.
- Run `bin/tapioca gems` after changing the Gemfile.
- Run `srb tc` after making changes to Ruby files to ensure the codebase is typechecked.
- Run `bin/rspec <path_to_spec_file>` after a spec file is modified.
- Run `tapioca dsl` if models or concerns are modified.
- Run `bin/rspec <path_to_spec_file>` to run tests for a single file.
- There are no view-specific tests, so if a view changes then run the controller tests instead.
- For instance, if you modify `app/models/domain/post.rb`, run `bin/rspec spec/models/domain/post_spec.rb`. If you modify `app/views/domain/users/index.html.erb`, run `bin/rspec spec/controllers/domain/users_controller_spec.rb`.
- At the end of a long series of changes, run `just test`.
- If specs are failing, then fix the failures, and rerun with `bin/rspec <path_to_spec_file>`.
- If you need to add logging to a Job to debug it, set `quiet: false` on the spec you are debugging.
- Fish shell is used for development, not bash.
- When running scratch commands, use `bin/rails runner`, not `bin/rails console`.
# Typescript Development
- React is the only frontend framework used.
- Styling is done with Tailwind CSS and FontAwesome.
- Put new typescript files in `app/javascript/bundles/Main/components/`
# HTTP Mocking in Job Specs
When writing specs for jobs that make HTTP requests, use `HttpClientMockHelpers.init_with()` instead of manually creating doubles:
```ruby
# CORRECT: Use HttpClientMockHelpers.init_with
let(:client_mock_config) do
[
{
uri: "https://example.com/api/first-endpoint",
status_code: 200,
content_type: "application/json",
contents: first_response_body,
},
{
uri: "https://example.com/api/second-endpoint",
status_code: 200,
content_type: "application/json",
contents: second_response_body,
caused_by_entry: :any, # Use this for chained requests
},
]
end
before { @log_entries = HttpClientMockHelpers.init_with(client_mock_config) }
# WRONG: Don't create doubles manually
expect(http_client_mock).to receive(:get).and_return(
double(status_code: 200, body: response_body, log_entry: double),
)
# WRONG: Don't use the old init_http_client_mock method
@log_entries =
HttpClientMockHelpers.init_http_client_mock(
http_client_mock,
client_mock_config,
)
```
This pattern:
- Uses the preferred `init_with` helper method
- Automatically uses the global `http_client_mock` from `spec_helper.rb`
- Creates real HttpLogEntry objects that can be serialized by ActiveJob
- Follows the established codebase pattern
- Avoids "Unsupported argument type: RSpec::Mocks::Double" errors
- Use `caused_by_entry: :any` for HTTP requests that are chained (where one request's log entry becomes the `caused_by_entry` for the next request)
- No need to manually set up `http_client_mock` - it's handled globally in `spec_helper.rb`
# Job Enqueuing Verification in Specs
Use `SpecUtil.enqueued_job_args()` instead of mocking `perform_later`:
```ruby
# CORRECT: Test actual job enqueuing
enqueued_jobs = SpecUtil.enqueued_job_args(SomeJob)
expect(enqueued_jobs).to contain_exactly(hash_including(user: user))
expect(enqueued_jobs).to be_empty # For no jobs
# WRONG: Don't mock perform_later (breaks with .set chaining)
expect(SomeJob).to receive(:perform_later)
```
Benefits: More robust, tests actual behavior, no cleanup needed (tests run in transactions).
# Testing Jobs
When writing specs for jobs e.g. Domain::Site::SomethingJob, do not invoke `job.perform(...)` directly, always use `perform_now(...)` (defined in spec/helpers/perform_job_helpers.rb)
# === BACKLOG.MD GUIDELINES START ===
# Instructions for the usage of Backlog.md CLI Tool
## 1. Source of Truth
- Tasks live under **`backlog/tasks/`** (drafts under **`backlog/drafts/`**).
- Every implementation decision starts with reading the corresponding Markdown task file.
- Project documentation is in **`backlog/docs/`**.
- Project decisions are in **`backlog/decisions/`**.
## 2. Defining Tasks
### **Title**
Use a clear brief title that summarizes the task.
### **Description**: (The **"why"**)
Provide a concise summary of the task purpose and its goal. Do not add implementation details here. It
should explain the purpose and context of the task. Code snippets should be avoided.
### **Acceptance Criteria**: (The **"what"**)
List specific, measurable outcomes that define what means to reach the goal from the description. Use checkboxes (`- [ ]`) for tracking.
When defining `## Acceptance Criteria` for a task, focus on **outcomes, behaviors, and verifiable requirements** rather
than step-by-step implementation details.
Acceptance Criteria (AC) define _what_ conditions must be met for the task to be considered complete.
They should be testable and confirm that the core purpose of the task is achieved.
**Key Principles for Good ACs:**
- **Outcome-Oriented:** Focus on the result, not the method.
- **Testable/Verifiable:** Each criterion should be something that can be objectively tested or verified.
- **Clear and Concise:** Unambiguous language.
- **Complete:** Collectively, ACs should cover the scope of the task.
- **User-Focused (where applicable):** Frame ACs from the perspective of the end-user or the system's external behavior.
- _Good Example:_ "- [ ] User can successfully log in with valid credentials."
- _Good Example:_ "- [ ] System processes 1000 requests per second without errors."
- _Bad Example (Implementation Step):_ "- [ ] Add a new function `handleLogin()` in `auth.ts`."
### Task file
Once a task is created it will be stored in `backlog/tasks/` directory as a Markdown file with the format
`task-<id> - <title>.md` (e.g. `task-42 - Add GraphQL resolver.md`).
### Additional task requirements
- Tasks must be **atomic** and **testable**. If a task is too large, break it down into smaller subtasks.
Each task should represent a single unit of work that can be completed in a single PR.
- **Never** reference tasks that are to be done in the future or that are not yet created. You can only reference
previous
tasks (id < current task id).
- When creating multiple tasks, ensure they are **independent** and they do not depend on future tasks.
Example of wrong tasks splitting: task 1: "Add API endpoint for user data", task 2: "Define the user model and DB
schema".
Example of correct tasks splitting: task 1: "Add system for handling API requests", task 2: "Add user model and DB
schema", task 3: "Add API endpoint for user data".
## 3. Recommended Task Anatomy
```markdown
# task42 - Add GraphQL resolver
## Description (the why)
Short, imperative explanation of the goal of the task and why it is needed.
## Acceptance Criteria (the what)
- [ ] Resolver returns correct data for happy path
- [ ] Error response matches REST
- [ ] P95 latency ≤ 50 ms under 100 RPS
## Implementation Plan (the how)
1. Research existing GraphQL resolver patterns
2. Implement basic resolver with error handling
3. Add performance monitoring
4. Write unit and integration tests
5. Benchmark performance under load
## Implementation Notes (only added after working on the task)
- Approach taken
- Features implemented or modified
- Technical decisions and trade-offs
- Modified or added files
```
## 6. Implementing Tasks
Mandatory sections for every task:
- **Implementation Plan**: (The **"how"**) Outline the steps to achieve the task. Because the implementation details may
change after the task is created, **the implementation notes must be added only after putting the task in progress**
and before starting working on the task.
- **Implementation Notes**: Document your approach, decisions, challenges, and any deviations from the plan. This
section is added after you are done working on the task. It should summarize what you did and why you did it. Keep it
concise but informative.
**IMPORTANT**: Do not implement anything else that deviates from the **Acceptance Criteria**. If you need to
implement something that is not in the AC, update the AC first and then implement it or create a new task for it.
## 2. Typical Workflow
```bash
# 1 Identify work
backlog task list -s "To Do" --plain
# 2 Read details & documentation
backlog task 42 --plain
# Read also all documentation files in `backlog/docs/` directory.
# Read also all decision files in `backlog/decisions/` directory.
# 3 Start work: assign yourself & move column
backlog task edit 42 -a @{yourself} -s "In Progress"
# 4 Add implementation plan before starting
backlog task edit 42 --plan "1. Analyze current implementation\n2. Identify bottlenecks\n3. Refactor in phases"
# 5 Break work down if needed by creating subtasks or additional tasks
backlog task create "Refactor DB layer" -p 42 -a @{yourself} -d "Description" --ac "Tests pass,Performance improved"
# 6 Complete and mark Done
backlog task edit 42 -s Done --notes "Implemented GraphQL resolver with error handling and performance monitoring"
```
### 7. Final Steps Before Marking a Task as Done
Always ensure you have:
1. ✅ Marked all acceptance criteria as completed (change `- [ ]` to `- [x]`)
2. ✅ Added an `## Implementation Notes` section documenting your approach
3. ✅ Run all tests and linting checks
4. ✅ Updated relevant documentation
## 8. Definition of Done (DoD)
A task is **Done** only when **ALL** of the following are complete:
1. **Acceptance criteria** checklist in the task file is fully checked (all `- [ ]` changed to `- [x]`).
2. **Implementation plan** was followed or deviations were documented in Implementation Notes.
3. **Automated tests** (unit + integration) cover new logic.
4. **Static analysis**: linter & formatter succeed.
5. **Documentation**:
- All relevant docs updated (any relevant README file, backlog/docs, backlog/decisions, etc.).
- Task file **MUST** have an `## Implementation Notes` section added summarising:
- Approach taken
- Features implemented or modified
- Technical decisions and trade-offs
- Modified or added files
6. **Review**: self review code.
7. **Task hygiene**: status set to **Done** via CLI (`backlog task edit <id> -s Done`).
8. **No regressions**: performance, security and licence checks green.
⚠️ **IMPORTANT**: Never mark a task as Done without completing ALL items above.
## 9. Handy CLI Commands
| Purpose | Command |
| ---------------- | ---------------------------------------------------------------------- |
| Create task | `backlog task create "Add OAuth"` |
| Create with desc | `backlog task create "Feature" -d "Enables users to use this feature"` |
| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` |
| Create with deps | `backlog task create "Feature" --dep task-1,task-2` |
| Create sub task | `backlog task create -p 14 "Add Google auth"` |
| List tasks | `backlog task list --plain` |
| View detail | `backlog task 7 --plain` |
| Edit | `backlog task edit 7 -a @{yourself} -l auth,backend` |
| Add plan | `backlog task edit 7 --plan "Implementation approach"` |
| Add AC | `backlog task edit 7 --ac "New criterion,Another one"` |
| Add deps | `backlog task edit 7 --dep task-1,task-2` |
| Add notes | `backlog task edit 7 --notes "We added this and that feature because"` |
| Mark as done | `backlog task edit 7 -s "Done"` |
| Archive | `backlog task archive 7` |
| Draft flow | `backlog draft create "Spike GraphQL"` → `backlog draft promote 3.1` |
| Demote to draft | `backlog task demote <task-id>` |
## 10. Tips for AI Agents
- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output instead of using Backlog.md
interactive UI.
- When users mention to create a task, they mean to create a task using Backlog.md CLI tool.
# === BACKLOG.MD GUIDELINES END ===

View File

@@ -1,22 +1,38 @@
# Primary image
FROM mcr.microsoft.com/devcontainers/base:debian-12
# apt caching & install packages
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
FROM ruby:3.2.0 AS native-gems
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -qqy \
abiword \
apt-get install --no-install-recommends --no-install-suggests -y \
cmake
WORKDIR /usr/src/app
RUN gem install bundler -v '2.4.5'
COPY gems gems
WORKDIR /usr/src/app/gems/xdiff-rb
RUN bundle install
RUN rake compile
WORKDIR /usr/src/app/gems/rb-bsdiff
RUN bundle install
RUN rake compile
# Primary image
FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bookworm
# apt caching & install packages
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -y \
autoconf \
build-essential \
ca-certificates \
curl \
ffmpeg \
ffmpegthumbnailer \
file \
gnupg \
iputils-ping \
libblas-dev \
@@ -25,73 +41,22 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
libgdbm-dev \
libgdbm6 \
libgmp-dev \
libicu-dev \
liblapack-dev \
libncurses5-dev \
libpq-dev \
libreadline6-dev \
libreoffice \
libsqlite3-dev \
libssl-dev \
libvips42 \
libyaml-dev \
patch \
pdftohtml \
pkg-config \
rustc \
uuid-dev \
zlib1g-dev \
watchman \
zlib1g-dev
# Install vips dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -qqy \
automake \
gtk-doc-tools \
gobject-introspection \
libgirepository1.0-dev \
libglib2.0-dev \
libexpat1-dev \
libjpeg-dev \
libpng-dev \
libtiff5-dev \
libwebp-dev \
libheif-dev \
libexif-dev \
liblcms2-dev \
libxml2-dev \
libfftw3-dev \
liborc-0.4-dev \
libcgif-dev \
libjxl-dev \
libopenjp2-7-dev \
meson \
ninja-build
# Install imagemagick from source
RUN cd /tmp && \
wget -qO- https://imagemagick.org/archive/releases/ImageMagick-7.1.2-1.tar.xz | tar -xJ && \
cd ImageMagick-7.1.2-1 && \
./configure && \
make -j$(nproc) && \
make install && \
ldconfig && \
cd / && \
rm -rf /tmp/ImageMagick-7.1.2-1*
# Install vips from source
RUN cd /tmp && \
wget -qO- https://github.com/libvips/libvips/releases/download/v8.17.1/vips-8.17.1.tar.xz | tar -xJ && \
cd vips-8.17.1 && \
meson setup build --prefix=/usr/local -Dcgif=enabled && \
cd build && \
ninja && \
ninja install && \
ldconfig && \
cd / && \
rm -rf /tmp/vips-8.17.1*
ffmpeg \
ffmpegthumbnailer \
abiword \
pdftohtml \
libreoffice
# Install postgres 15 client
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -99,9 +64,9 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
sudo install -d /usr/share/postgresql-common/pgdg && \
curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc && \
sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
sudo apt update && \
sudo apt-get install --no-install-recommends --no-install-suggests -qqy \
postgresql-client-17
apt update && \
apt-get install --no-install-recommends --no-install-suggests -y \
postgresql-client-15
# Install & configure delta diff tool
RUN wget -O- https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_amd64.deb > /tmp/git-delta.deb && \
@@ -110,38 +75,29 @@ RUN wget -O- https://github.com/dandavison/delta/releases/download/0.18.2/git-de
RUN git config --system core.pager "delta" && \
git config --system interactive.diffFilter "delta --color-only" && \
git config --system delta.navigate "true" && \
git config --system delta.dark "true" && \
git config --system delta.side-by-side "true" && \
git config --system delta.navigate true && \
git config --system delta.dark true && \
git config --system delta.side-by-side true && \
git config --system merge.conflictstyle "zdiff3" && \
git config --system core.editor "cursor --wait" && \
git config --system diff.algorithm "histogram" && \
git config --system diff.colorMoved "default"
git config --system core.editor "cursor --wait"
# Install ruby
USER vscode
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
RUN echo 'eval "$(rbenv init - --no-rehash bash)"' >> ~/.bashrc
RUN git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build
RUN rbenv install 3.4.4 && \
rbenv global 3.4.4
# Install native gems
COPY --from=native-gems /usr/src/app/gems/xdiff-rb /gems/xdiff-rb
COPY --from=native-gems /usr/src/app/gems/rb-bsdiff /gems/rb-bsdiff
ENV RAILS_ENV development
# Pre install gems to speed up container startup
USER root
RUN mkdir -p /tmp/bundle-install-cache && \
chown -R vscode:vscode /tmp/bundle-install-cache
WORKDIR /tmp/bundle-install-cache
USER vscode
COPY Gemfile.lock Gemfile ./
COPY gems/has_aux_table ./gems/has_aux_table
RUN BUNDLE_FROZEN=true MAKE="make -j$(nproc)" bundle install --jobs $(nproc)
# [Optional] Uncomment this line to install additional gems.
RUN su vscode -c "gem install bundler -v '2.5.6'" && \
su vscode -c "gem install rake -v '13.0.6'" && \
su vscode -c "gem install ruby-lsp -v '0.22.1'"
# install exo
RUN curl -sL https://exo.deref.io/install | bash
RUN su vscode -c "curl -sL https://exo.deref.io/install | bash"
ENV PATH "/home/vscode/.exo/bin:$PATH"
# install just (command runner)
RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | sudo bash -s -- --to /usr/local/bin
RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install 18 && nvm use 18 && npm install -g yarn" 2>&1
ENV PATH /usr/local/share/nvm/current/bin:$PATH

View File

@@ -1,7 +1,8 @@
FROM postgres:17
FROM postgres:15
RUN apt-get update && apt-get install -y \
postgresql-17-pgvector \
postgresql-15-pgvector \
&& rm -rf /var/lib/apt/lists/*
RUN echo "CREATE EXTENSION vector;" >> /docker-entrypoint-initdb.d/01-vector.sql
COPY create-tablespaces.bash /docker-entrypoint-initdb.d/00-create-tablespaces.bash
RUN echo "CREATE EXTENSION pgvector;" >> /docker-entrypoint-initdb.d/01-pgvector.sql

View File

@@ -0,0 +1,9 @@
#!/bin/bash -ex
mkdir -p /tablespaces/mirai
chown postgres:postgres /tablespaces/mirai
chmod 750 /tablespaces/mirai
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
-c "CREATE TABLESPACE mirai LOCATION '/tablespaces/mirai'"

View File

@@ -7,28 +7,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/meaningful-ooo/devcontainer-features/fish:1": {},
"ghcr.io/nikobockerman/devcontainer-features/fish-persistent-data:2": {},
"ghcr.io/devcontainers-extra/features/npm-package:1": {
"package": "backlog.md"
}
},
"customizations": {
"vscode": {
"extensions": [
"Shopify.ruby-extensions-pack",
"dbaeumer.vscode-eslint",
"aliariff.vscode-erb-beautify",
"bradlc.vscode-tailwindcss",
"KoichiSasada.vscode-rdbg",
"qwtel.sqlite-viewer",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker",
"1YiB.rust-bundle",
"rust-lang.rust-analyzer",
"saoudrizwan.claude-dev",
"ritwickdey.LiveServer"
]
}
"ghcr.io/nikobockerman/devcontainer-features/fish-persistent-data:2": {}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or the host.
@@ -37,8 +16,8 @@
// "postCreateCommand": "bundle install && rake db:setup",
"postCreateCommand": ".devcontainer/post-create.sh",
"forwardPorts": [
3000, // rails
3001, // thrust
3000, // rails development
3001, // rails staging
9394, // prometheus exporter
"pgadmin:8080", // pgadmin
"grafana:3100", // grafana

View File

@@ -23,7 +23,8 @@ services:
dockerfile: Dockerfile.postgres
restart: unless-stopped
volumes:
- postgres-17-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql/data
- postgres-data-tablespaces:/tablespaces
- ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql
environment:
POSTGRES_USER: postgres
@@ -64,52 +65,10 @@ services:
volumes:
- devcontainer-redux-grafana-data:/var/lib/grafana
airvpn-netherlands-proxy:
image: qmcgaw/gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- HTTPPROXY=on
- SHADOWSOCKS=on
- HTTPPROXY_LOG=on
- VPN_SERVICE_PROVIDER=airvpn
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=INLA6x1gUVLRPKcCBgRmfpJBCXhOpyq3SvRd5EvCE08=
- WIREGUARD_PRESHARED_KEY=DR6CBW9yG5y+D+qpo8TZCizo5WKOooC/UFBdWk6lGEg=
- WIREGUARD_ADDRESSES=10.165.87.232,fd7d:76ee:e68f:a993:4d1b:a77a:b471:a606
- SERVER_COUNTRIES=Netherlands
airvpn-san-jose-proxy:
image: qmcgaw/gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- HTTPPROXY=on
- SHADOWSOCKS=on
- HTTPPROXY_LOG=on
- VPN_SERVICE_PROVIDER=airvpn
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=INLA6x1gUVLRPKcCBgRmfpJBCXhOpyq3SvRd5EvCE08=
- WIREGUARD_PRESHARED_KEY=DR6CBW9yG5y+D+qpo8TZCizo5WKOooC/UFBdWk6lGEg=
- WIREGUARD_ADDRESSES=10.165.87.232/32,fd7d:76ee:e68f:a993:4d1b:a77a:b471:a606/128
- SERVER_CITIES="San Jose California, Fremont California"
tor:
image: dockurr/tor
volumes:
- devcontainer-redux-tor-config:/etc/tor
- devcontainer-redux-tor-data:/var/lib/tor
restart: always
volumes:
postgres-17-data:
postgres-data:
postgres-data-tablespaces:
devcontainer-redux-gem-cache:
devcontainer-redux-blob-files:
devcontainer-redux-grafana-data:
devcontainer-redux-prometheus-data:
devcontainer-redux-tor-config:
devcontainer-redux-tor-data:

View File

@@ -1,10 +0,0 @@
# Agent detection - only activate minimal mode for actual agents
if test -n "$npm_config_yes"; or test -n "$CI"; or not status --is-interactive
set -gx AGENT_MODE true
else
set -gx AGENT_MODE false
end
if test $AGENT_MODE = true
# /usr/bin/bash -l
end

View File

@@ -1 +0,0 @@
set -gx PATH "/workspaces/redux-scraper/bin" $PATH

View File

@@ -1 +0,0 @@
status --is-interactive; and rbenv init - --no-rehash fish | source

View File

@@ -0,0 +1 @@
source "$HOME/.cargo/env.fish"

View File

@@ -24,14 +24,4 @@ function blob-files-stats
set -l files_dir (blob-files-dir || return 1)
printf "apparent size: %s\n" (du -sh --apparent-size $files_dir)
printf "actual size: %s\n" (du -sh $files_dir)
end
function curl-fa-onion
curl \
--socks5-hostname tor:9050 \
--compressed \
-A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0" \
-H "Accept-Encoding: gzip, deflate" \
-H "Connection: keep-alive" \
"http://g6jy5jkx466lrqojcngbnksugrcfxsl562bzuikrka5rv7srgguqbjid.onion/$argv[1]"
end
end

View File

@@ -13,77 +13,3 @@ settings.json
*.export
.devcontainer
user_scripts/dist
backlog
# Test directories (not needed in production)
spec
test
# Development and CI/CD files
.github
.ruby-lsp
.aider*
.cursorignore
.cursorrules
.rspec
.rspec_parallel
.rubocop.yml
.prettierrc
TODO.md
*.notes.md
things-to-fix.notes.md
mf-fitter-commands.notes.md
.aiderignore
# Sorbet type checking files (not needed in production)
sorbet
# Storage directory (contains uploaded files/cache)
storage
# Development database files
db/*.sqlite3
db/*.sqlite3-*
# Core dump files
core
# Yarn/npm cache and lock files that might conflict
yarn-error.log
yarn-debug.log*
.yarn-integrity
package-lock.json
# OS specific files
.DS_Store
Thumbs.db
# Editor specific files
*.swp
*.swo
*~
# Local environment files
.env.local
.env.*.local
# Compiled assets (will be rebuilt in Docker)
public/assets
public/packs
public/packs-test
app/assets/builds/*
# Flame graph files
flamegraph.svg
# Procfile variants (only need production one)
Procfile.dev
Procfile.dev-static
Procfile.staging
Procfile.worker
# Development scripts
justfile
# Documentation
README.md

6
.gitignore vendored
View File

@@ -8,6 +8,9 @@ build
tmp
core
*.bundle
lib/xdiff
ext/xdiff/Makefile
ext/xdiff/xdiff
user_scripts/dist
migrated_files.txt
@@ -15,7 +18,7 @@ migrated_files.txt
package-lock.json
*.notes.md
*.txt
# Ignore bundler config.
/.bundle
@@ -59,4 +62,3 @@ yarn-debug.log*
.yarn-integrity
.DS_Store
*.export
.aider*

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "gems/has_aux_table"]
path = gems/has_aux_table
url = ssh://git@git.dy.mk:2221/dymk/has_aux_table.git

View File

@@ -4,10 +4,6 @@
"trailingComma": "all",
"arrowParens": "always",
"singleQuote": true,
"semi": true,
"bracketSpacing": true,
"bracketSameLine": false,
"printWidth": 80,
"plugins": [
"prettier-plugin-tailwindcss",
"@prettier/plugin-ruby",
@@ -15,13 +11,5 @@
"@4az/prettier-plugin-html-erb"
],
"xmlQuoteAttributes": "double",
"xmlWhitespaceSensitivity": "ignore",
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"options": {
"parser": "typescript"
}
}
]
"xmlWhitespaceSensitivity": "ignore"
}

View File

@@ -1,2 +0,0 @@
--format progress
--format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log

View File

@@ -1 +1 @@
3.4.4
system

5
.vscode/launch.json vendored
View File

@@ -3,8 +3,9 @@
"configurations": [
{
"type": "rdbg",
"name": "rdbg - attach",
"request": "attach"
"name": "Attach rdbg",
"request": "attach",
"rdbgPath": "export GEM_HOME=/usr/local/rvm/gems/default && bundle exec rdbg"
}
]
}

View File

@@ -5,14 +5,6 @@
"workbench.preferredDarkColorTheme": "Spinel",
"workbench.preferredLightColorTheme": "Spinel Light",
"rubyLsp.formatter": "syntax_tree",
"rubyLsp.featureFlags": {
"fullTestDiscovery": true
},
"rubyLsp.addonSettings": {
"Ruby LSP RSpec": {
"debug": true
}
},
"files.associations": {
".env-cmdrc": "json"
},

View File

@@ -1,5 +1,25 @@
FROM ruby:3.2.6 AS native-gems
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -y \
cmake
WORKDIR /usr/src/app
RUN gem install bundler -v '2.5.6'
COPY gems gems
WORKDIR /usr/src/app/gems/xdiff-rb
RUN bundle _2.5.6_ install
RUN rake compile
WORKDIR /usr/src/app/gems/rb-bsdiff
RUN bundle _2.5.6_ install
RUN rake compile
# Primary image
FROM ruby:3.4.4
FROM ruby:3.2.6
USER root
# apt caching & install packages
@@ -14,10 +34,10 @@ RUN \
libblas-dev liblapack-dev
# preinstall gems that take a long time to install
RUN MAKE="make -j12" gem install bundler -v '2.6.7'
RUN MAKE="make -j12" gem install rice -v '4.3.3'
RUN MAKE="make -j12" gem install faiss -v '0.3.2'
RUN MAKE="make -j12" gem install rails_live_reload -v '0.3.6'
RUN MAKE="make -j12" gem install bundler -v '2.5.6' --verbose
RUN MAKE="make -j12" gem install rice -v '4.3.3' --verbose
RUN MAKE="make -j12" gem install faiss -v '0.3.2' --verbose
RUN MAKE="make -j12" gem install rails_live_reload -v '0.3.6' --verbose
RUN bundle config --global frozen 1
# set up nodejs 18.x deb repo
@@ -32,6 +52,7 @@ RUN \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -y \
libvips42 \
ca-certificates \
curl \
gnupg \
@@ -43,60 +64,12 @@ RUN \
pdftohtml \
libreoffice
# Install vips dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install --no-install-recommends --no-install-suggests -qqy \
automake \
gtk-doc-tools \
gobject-introspection \
libgirepository1.0-dev \
libglib2.0-dev \
libexpat1-dev \
libjpeg-dev \
libpng-dev \
libtiff5-dev \
libwebp-dev \
libheif-dev \
libexif-dev \
liblcms2-dev \
libxml2-dev \
libfftw3-dev \
liborc-0.4-dev \
libcgif-dev \
libjxl-dev \
libopenjp2-7-dev \
meson \
ninja-build
# Install imagemagick from source
RUN cd /tmp && \
wget -qO- https://imagemagick.org/archive/releases/ImageMagick-7.1.2-1.tar.xz | tar -xJ && \
cd ImageMagick-7.1.2-1 && \
./configure && \
make -j$(nproc) && \
make install && \
ldconfig && \
cd / && \
rm -rf /tmp/ImageMagick-7.1.2-1*
# Install vips from source
RUN cd /tmp && \
wget -qO- https://github.com/libvips/libvips/releases/download/v8.17.1/vips-8.17.1.tar.xz | tar -xJ && \
cd vips-8.17.1 && \
meson setup build --prefix=/usr/local -Dcgif=enabled && \
cd build && \
ninja && \
ninja install && \
ldconfig && \
cd / && \
rm -rf /tmp/vips-8.17.1*
COPY --from=native-gems /usr/src/app/gems/xdiff-rb /gems/xdiff-rb
COPY --from=native-gems /usr/src/app/gems/rb-bsdiff /gems/rb-bsdiff
WORKDIR /usr/src/app
COPY Gemfile Gemfile.lock ./
COPY gems/has_aux_table ./gems/has_aux_table
RUN ls -lah gems && BUNDLE_FROZEN=true MAKE="make -j$(nproc)" bundle install --jobs $(nproc)
RUN bundle _2.5.6_ install
# install js dependencies
COPY package.json yarn.lock ./

38
Gemfile
View File

@@ -1,17 +1,18 @@
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "~> 3"
gem "bundler", "~> 2.6.7"
ruby "3.2.6"
# ruby "3.0.3"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 7.2"
gem "has_aux_table", path: "gems/has_aux_table"
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
# Use sqlite3 as the database for Active Record
gem "pg"
gem "sqlite3", "~> 1.4"
gem "pry"
gem "pry-stack_explorer"
@@ -55,7 +56,7 @@ gem "bootsnap", require: false
group :development, :test, :staging do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", "~> 1.11", platforms: %i[mri mingw x64_mingw], require: false
gem "debug", "~> 1.10", platforms: %i[mri mingw x64_mingw]
end
group :development, :staging do
@@ -65,8 +66,7 @@ group :development, :staging do
gem "web-console"
# Speed up commands on slow machines / big apps [https://github.com/rails/spring]
gem "spring"
gem "spring-commands-rspec"
# gem "spring"
# Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
gem "memory_profiler"
@@ -100,9 +100,14 @@ end
group :test, :development do
gem "parallel_tests"
gem "spring-commands-parallel-tests"
end
gem "xdiff", path: "/gems/xdiff-rb"
# for legacy import
gem "diffy"
gem "rb-bsdiff", path: "/gems/rb-bsdiff"
gem "addressable"
gem "colorize"
gem "concurrent-ruby-edge", require: "concurrent-edge"
@@ -130,10 +135,6 @@ gem "ruby-bbcode"
gem "dtext_rb",
git: "https://github.com/e621ng/dtext_rb",
ref: "5ef8fd7a5205c832f4c18197911717e7d491494e"
gem "charlock_holmes"
# Telegram Bot API
gem "telegram-bot-ruby"
# gem "pghero", git: "https://github.com/dymk/pghero", ref: "e314f99"
gem "pghero", "~> 3.6"
@@ -147,11 +148,11 @@ gem "attr_json"
group :production, :staging do
gem "rails_semantic_logger", "~> 4.17"
gem "cloudflare-rails"
end
group :production do
gem "sd_notify"
gem "cloudflare-rails"
end
gem "rack", "~> 2.2"
@@ -164,9 +165,6 @@ gem "timeout"
group :development do
gem "prettier_print"
gem "syntax_tree", "~> 6.2"
gem "unicode_plot" # For terminal-based data visualization (Ruby API)
gem "rumale" # Professional machine learning library for Ruby
gem "ruby-lsp-rspec", require: false
end
gem "cssbundling-rails", "~> 1.4"
@@ -181,13 +179,7 @@ gem "pundit", "~> 2.4"
# Monitoring
gem "prometheus_exporter", "~> 2.2"
SORBET_VERSION = "0.5.12221"
gem "sorbet", SORBET_VERSION, group: :development
gem "sorbet-runtime", SORBET_VERSION
gem "tapioca", "0.16.6", require: false, group: %i[development test]
gem "sorbet-static-and-runtime"
gem "tapioca", require: false
gem "rspec-sorbet", group: [:test]
gem "sorbet-struct-comparable"
gem "skyfall", "~> 0.6.0"
gem "didkit", "~> 0.2.3"

View File

@@ -25,12 +25,14 @@ GIT
websocket-driver
PATH
remote: gems/has_aux_table
remote: /gems/rb-bsdiff
specs:
has_aux_table (0.1.0)
activerecord (>= 7.0)
activesupport (>= 7.0)
sorbet-runtime (~> 0.5)
rb-bsdiff (0.1.0)
PATH
remote: /gems/xdiff-rb
specs:
xdiff (0.0.1)
GEM
remote: https://rubygems.org/
@@ -110,11 +112,10 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
attr_json (2.5.0)
activerecord (>= 6.0.0, < 8.1)
base32 (0.3.4)
base64 (0.3.0)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1)
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
@@ -130,8 +131,6 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.9.9)
charlock_holmes (0.7.9)
cloudflare-rails (6.2.0)
actionpack (>= 7.1.0, < 8.1.0)
activesupport (>= 7.1.0, < 8.1.0)
@@ -144,18 +143,17 @@ GEM
concurrent-ruby (~> 1.3)
concurrent-ruby-ext (1.3.4)
concurrent-ruby (= 1.3.4)
connection_pool (2.5.3)
connection_pool (2.4.1)
crass (1.0.6)
cssbundling-rails (1.4.1)
railties (>= 6.0.0)
csv (3.3.5)
curb (1.0.6)
daemons (1.4.1)
date (3.4.1)
db-query-matchers (0.14.0)
activesupport (>= 4.0, < 8.1)
rspec (>= 3.0)
debug (1.11.0)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
debug_inspector (1.2.0)
@@ -167,8 +165,8 @@ GEM
warden (~> 1.2.3)
dhash-vips (0.2.3.0)
ruby-vips (~> 2.0, != 2.1.1, != 2.1.0)
didkit (0.2.3)
diff-lcs (1.5.1)
diffy (3.4.3)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
disco (0.5.1)
@@ -178,34 +176,10 @@ GEM
nokogiri (~> 1.13, >= 1.13.0)
rubyzip (~> 2.0)
domain_name (0.6.20240107)
drb (2.2.3)
dry-core (1.1.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-struct (1.8.0)
dry-core (~> 1.1)
dry-types (~> 1.8, >= 1.8.2)
ice_nine (~> 0.11)
zeitwerk (~> 2.6)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
enumerable-statistics (2.0.8)
drb (2.2.1)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
eventmachine (1.2.7)
execjs (2.10.0)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
@@ -215,17 +189,6 @@ GEM
faiss (0.3.2)
numo-narray
rice (>= 4.0.2)
faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faye-websocket (0.12.0)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.8.0)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
@@ -247,22 +210,16 @@ GEM
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
google-protobuf (4.31.1-aarch64-linux-gnu)
google-protobuf (4.29.2-aarch64-linux)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-aarch64-linux-musl)
google-protobuf (4.29.2-arm64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-arm64-darwin)
google-protobuf (4.29.2-x86_64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-gnu)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-musl)
google-protobuf (4.29.2-x86_64-linux)
bigdecimal
rake (>= 13)
htmlbeautifier (1.4.3)
@@ -275,9 +232,8 @@ GEM
http-cookie (1.0.8)
domain_name (~> 0.5)
http-form_data (2.3.0)
i18n (1.14.7)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
io-console (0.8.0)
irb (1.14.3)
rdoc (>= 4.0.0)
@@ -285,7 +241,6 @@ GEM
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.13.2)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -298,9 +253,6 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
lbfgsb (0.6.0)
numo-narray (>= 0.9.1)
libmf (0.4.0)
ffi
listen (3.9.0)
@@ -309,7 +261,7 @@ GEM
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.7.0)
logger (1.6.4)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -323,15 +275,11 @@ GEM
memory_profiler (1.1.0)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.25.5)
mmh3 (1.2.0)
minitest (5.25.4)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
neighbor (0.5.1)
activerecord (>= 7)
net-http (0.6.0)
uri
net-imap (0.5.4)
date
net-protocol
@@ -453,8 +401,6 @@ GEM
rbi (0.2.2)
prism (~> 1.0)
sorbet-runtime (>= 0.5.9204)
rbs (3.9.4)
logger
rdoc (6.10.0)
psych (>= 4.0.0)
react_on_rails (14.0.5)
@@ -498,12 +444,6 @@ GEM
rspec-support (3.13.2)
ruby-bbcode (2.1.1)
activesupport (>= 4.2.2)
ruby-lsp (0.25.0)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 5)
ruby-lsp-rspec (0.1.26)
ruby-lsp (~> 0.25.0)
ruby-prof (1.7.1)
ruby-prof-speedscope (0.3.0)
ruby-prof (~> 1.0)
@@ -512,91 +452,6 @@ GEM
logger
rubyzip (2.3.2)
rufo (0.18.0)
rumale (1.0.0)
numo-narray (>= 0.9.1)
rumale-clustering (~> 1.0.0)
rumale-core (~> 1.0.0)
rumale-decomposition (~> 1.0.0)
rumale-ensemble (~> 1.0.0)
rumale-evaluation_measure (~> 1.0.0)
rumale-feature_extraction (~> 1.0.0)
rumale-kernel_approximation (~> 1.0.0)
rumale-kernel_machine (~> 1.0.0)
rumale-linear_model (~> 1.0.0)
rumale-manifold (~> 1.0.0)
rumale-metric_learning (~> 1.0.0)
rumale-model_selection (~> 1.0.0)
rumale-naive_bayes (~> 1.0.0)
rumale-nearest_neighbors (~> 1.0.0)
rumale-neural_network (~> 1.0.0)
rumale-pipeline (~> 1.0.0)
rumale-preprocessing (~> 1.0.0)
rumale-tree (~> 1.0.0)
rumale-clustering (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-core (1.0.0)
csv (>= 3.1.9)
numo-narray (>= 0.9.1)
rumale-decomposition (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-ensemble (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-linear_model (~> 1.0.0)
rumale-model_selection (~> 1.0.0)
rumale-preprocessing (~> 1.0.0)
rumale-tree (~> 1.0.0)
rumale-evaluation_measure (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-feature_extraction (1.0.0)
mmh3 (~> 1.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-kernel_approximation (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-kernel_machine (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-linear_model (1.0.0)
lbfgsb (>= 0.3.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-manifold (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-decomposition (~> 1.0.0)
rumale-metric_learning (1.0.0)
lbfgsb (>= 0.3.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-decomposition (~> 1.0.0)
rumale-model_selection (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-evaluation_measure (~> 1.0.0)
rumale-preprocessing (~> 1.0.0)
rumale-naive_bayes (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-nearest_neighbors (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-neural_network (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-pipeline (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-preprocessing (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
rumale-tree (1.0.0)
numo-narray (>= 0.9.1)
rumale-core (~> 1.0.0)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -616,21 +471,15 @@ GEM
semantic_range (>= 2.3.0)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
skyfall (0.6.0)
base32 (~> 0.3, >= 0.3.4)
base64 (~> 0.1)
cbor (~> 0.5, >= 0.5.9.6)
eventmachine (~> 1.2, >= 1.2.7)
faye-websocket (~> 0.12)
sorbet (0.5.12221)
sorbet-static (= 0.5.12221)
sorbet-runtime (0.5.12221)
sorbet-static (0.5.12221-aarch64-linux)
sorbet-static (0.5.12221-universal-darwin)
sorbet-static (0.5.12221-x86_64-linux)
sorbet-static-and-runtime (0.5.12221)
sorbet (= 0.5.12221)
sorbet-runtime (= 0.5.12221)
sorbet (0.5.11711)
sorbet-static (= 0.5.11711)
sorbet-runtime (0.5.11711)
sorbet-static (0.5.11711-aarch64-linux)
sorbet-static (0.5.11711-universal-darwin)
sorbet-static (0.5.11711-x86_64-linux)
sorbet-static-and-runtime (0.5.11711)
sorbet (= 0.5.11711)
sorbet-runtime (= 0.5.11711)
sorbet-struct-comparable (1.3.0)
sorbet-runtime (>= 0.5)
spoom (1.5.0)
@@ -638,11 +487,6 @@ GEM
prism (>= 0.28.0)
sorbet-static-and-runtime (>= 0.5.10187)
thor (>= 0.19.2)
spring (4.3.0)
spring-commands-parallel-tests (1.0.1)
spring (>= 0.9.1)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
@@ -650,6 +494,10 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (1.7.3-aarch64-linux)
sqlite3 (1.7.3-arm64-darwin)
sqlite3 (1.7.3-x86_64-darwin)
sqlite3 (1.7.3-x86_64-linux)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
@@ -664,7 +512,7 @@ GEM
tailwindcss-ruby (3.4.17-arm64-darwin)
tailwindcss-ruby (3.4.17-x86_64-darwin)
tailwindcss-ruby (3.4.17-x86_64-linux)
tapioca (0.16.6)
tapioca (0.16.5)
bundler (>= 2.2.25)
netrc (>= 0.11.0)
parallel (>= 1.21.0)
@@ -673,11 +521,6 @@ GEM
spoom (>= 1.2.0)
thor (>= 1.2.0)
yard-sorbet
telegram-bot-ruby (2.4.0)
dry-struct (~> 1.6)
faraday (~> 2.0)
faraday-multipart (~> 1.0)
zeitwerk (~> 2.6)
thor (1.3.2)
thruster (0.1.11-aarch64-linux)
thruster (0.1.11-arm64-darwin)
@@ -689,9 +532,6 @@ GEM
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode_plot (0.0.5)
enumerable-statistics (>= 2.0.1)
uri (1.0.3)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
@@ -706,8 +546,7 @@ GEM
selenium-webdriver (~> 4.0, < 4.11)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
@@ -733,9 +572,7 @@ DEPENDENCIES
addressable
attr_json
bootsnap
bundler (~> 2.6.7)
capybara
charlock_holmes
cloudflare-rails
colorize
concurrent-ruby-edge
@@ -744,10 +581,10 @@ DEPENDENCIES
curb
daemons
db-query-matchers (~> 0.14)
debug (~> 1.11)
debug (~> 1.10)
devise (~> 4.9)
dhash-vips
didkit (~> 0.2.3)
diffy
discard
disco
docx
@@ -756,7 +593,6 @@ DEPENDENCIES
faiss
ffmpeg!
good_job (~> 4.6)
has_aux_table!
htmlbeautifier
http (~> 5.2)
http-cookie
@@ -785,49 +621,43 @@ DEPENDENCIES
rails-controller-testing
rails_live_reload!
rails_semantic_logger (~> 4.17)
rb-bsdiff!
react_on_rails
ripcord
rouge
rspec-rails (~> 7.0)
rspec-sorbet
ruby-bbcode
ruby-lsp-rspec
ruby-prof
ruby-prof-speedscope
ruby-vips
rufo
rumale
sanitize (~> 6.1)
sd_notify
selenium-webdriver
shakapacker (~> 6.6)
shoulda-matchers
skyfall (~> 0.6.0)
sorbet (= 0.5.12221)
sorbet-runtime (= 0.5.12221)
sorbet-static-and-runtime
sorbet-struct-comparable
spring
spring-commands-parallel-tests
spring-commands-rspec
sprockets-rails
sqlite3 (~> 1.4)
stackprof
stimulus-rails
syntax_tree (~> 6.2)
table_print
tailwindcss-rails (~> 3.0)
tapioca (= 0.16.6)
telegram-bot-ruby
tapioca
thruster
timeout
turbo-rails
tzinfo-data
unicode_plot
web-console
webdrivers
xdiff!
zstd-ruby
RUBY VERSION
ruby 3.2.6p234
BUNDLED WITH
2.6.7
2.6.2

View File

@@ -1,4 +1,4 @@
rails: RAILS_ENV=development HTTP_PORT=3001 thrust ./bin/rails server
rails: RAILS_ENV=development HTTP_PORT=3000 TARGET_PORT=3003 rdbg --command --nonstop --open -- thrust ./bin/rails server -p 3003
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
css: tailwindcss -c ./config/tailwind.config.js -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css --watch

View File

@@ -1,5 +1,5 @@
rails: RAILS_ENV=staging HTTP_PORT=3001 bundle exec thrust ./bin/rails server
rails: RAILS_ENV=staging HTTP_PORT=3001 TARGET_PORT=3002 bundle exec thrust ./bin/rails server -p 3002
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
css: RAILS_ENV=development yarn "build:css[debug]" --watch
prometheus-exporter: RAILS_ENV=staging bundle exec prometheus_exporter --bind 0.0.0.0 --prefix redux_ --label '{"environment": "staging"}'
prometheus_exporter: RAILS_ENV=staging bundle exec prometheus_exporter --bind 0.0.0.0 --prefix redux_ --label '{"environment": "staging"}'

View File

@@ -1,29 +1,3 @@
# Redux Scraper
A Ruby on Rails application for scraping and managing various content sources.
## Setup
This application is configured for optimal development and testing performance:
### Performance Optimizations
- **Bootsnap**: Accelerates gem loading and caching for faster boot times
- **Spring**: Preloads the Rails application for faster command execution
#### Rails Boot Performance
- Development boot time: ~270ms (87% faster than without optimization)
- Test environment startup: ~211ms (29% faster than without optimization)
To use Spring-optimized commands:
```bash
# Use bin/ executables for Spring acceleration
bin/rails console
bin/rails runner "puts 'Hello'"
bin/rspec spec/
```
# README
This README would normally document whatever steps are necessary to get the

387
Rakefile
View File

@@ -40,23 +40,22 @@ task periodic_tasks: %i[environment set_logger_stdout] do
loop { sleep 10 }
end
# TODO - migrate to Domain::Post / Domain::User
# namespace :db_sampler do
# task export: :environment do
# url_names = ENV["url_names"] || raise("need 'url_names' (comma-separated)")
# outfile = $stdout
# DbSampler.new(outfile).export(url_names.split(","))
# ensure
# outfile.close if outfile
# end
namespace :db_sampler do
task export: :environment do
url_names = ENV["url_names"] || raise("need 'url_names' (comma-separated)")
outfile = $stdout
DbSampler.new(outfile).export(url_names.split(","))
ensure
outfile.close if outfile
end
# task import: [:environment] do
# infile = $stdin
# DbSampler.new(infile).import
# ensure
# infile.close if infile
# end
# end
task import: [:environment] do
infile = $stdin
DbSampler.new(infile).import
ensure
infile.close if infile
end
end
task good_job: %i[environment set_ar_stdout set_logger_stdout] do
env_hash = {
@@ -94,6 +93,138 @@ task :reverse_csv do
out_csv.close
end
task migrate_to_domain: :environment do
only_user = ENV["only_user"]
allowed_domains = %w[e621 fa ib]
only_domains = (ENV["only_domains"] || "").split(",")
only_domains = allowed_domains if only_domains.empty?
if (only_domains - allowed_domains).any?
raise "only_domains must be a subset of #{allowed_domains.join(", ")}"
end
migrator = Domain::MigrateToDomain.new
if only_domains.include?("e621")
# migrator.migrate_e621_users(only_user: only_user)
# migrator.migrate_e621_posts(only_user: only_user)
migrator.migrate_e621_users_favs(only_user: only_user)
end
if only_domains.include?("fa")
# migrator.migrate_fa_users(only_user: only_user)
# migrator.migrate_fa_posts(only_user: only_user)
# migrator.migrate_fa_users_favs(only_user: only_user)
migrator.migrate_fa_users_followed_users(only_user: only_user)
end
if only_domains.include?("ib")
migrator.migrate_inkbunny_users(only_user: only_user)
migrator.migrate_inkbunny_posts(only_user: only_user)
migrator.migrate_inkbunny_pools(only_user: nil) if only_user.nil?
end
end
task infer_last_submission_log_entries: :environment do
only_fa_id = ENV["only_fa_id"]
start = ENV["start_at"]&.to_i || nil
if only_fa_id
relation = Domain::Fa::Post.where(fa_id: only_fa_id)
else
relation =
Domain::Fa::Post
.where(state: :ok)
.where(last_submission_page_id: nil)
.or(Domain::Fa::Post.where(state: :ok).where(posted_at: nil))
end
relation.find_each(batch_size: 10, start:) do |post|
parts = ["[id: #{post.id}]", "[fa_id: #{post.fa_id}]"]
log_entry = post.guess_last_submission_page
unless log_entry
parts << "[no log entry]"
next
end
contents = log_entry.response&.contents
unless contents
parts << "[no contents]"
next
end
parser = Domain::Fa::Parser::Page.new(contents)
if parser.submission_not_found?
parts << "[removed]"
post.state = :removed
else
posted_at = parser.submission.posted_date
post.posted_at ||= posted_at
parts << "[posted at: #{posted_at}]"
end
if post.last_submission_page_id.present? &&
log_entry.id != post.last_submission_page_id
parts << "[overwrite]"
end
post.last_submission_page_id = log_entry.id
parts << "[log entry: #{log_entry.id}]"
parts << "[uri: #{log_entry.uri.to_s}]"
post.save!
rescue => e
parts << "[error: #{e.message}]"
ensure
puts parts.join(" ")
end
end
task fix_fa_post_files: :environment do
file_ids = ENV["file_ids"]&.split(",") || raise("need 'file_ids'")
Domain::Fa::Post
.where(file_id: file_ids)
.find_each { |post| post.fix_file_by_uri! }
end
task fix_fa_post_files_by_csv: :environment do
require "csv"
csv_file = ENV["csv_file"] || raise("need 'csv_file'")
CSV
.open(csv_file, headers: true)
.each do |row|
id = row["id"].to_i
post = Domain::Fa::Post.find(id)
post.fix_file_by_uri!
end
end
task fix_buggy_fa_posts: :environment do
post_fa_ids = %w[7704069 7704068 6432347 6432346].map(&:to_i)
require "uri"
post_fa_ids.each do |fa_id|
post = Domain::Fa::Post.find_by(fa_id: fa_id)
next unless post&.file
post_file_url_str = Addressable::URI.parse(post.file_url_str).to_s
file_url_str = Addressable::URI.parse(CGI.unescape(post.file.uri.to_s)).to_s
hle = post.guess_last_submission_page
parser = Domain::Fa::Parser::Page.new(hle.response.contents)
if parser.submission_not_found?
post.file = nil
post.save!
puts "submission not found"
else
submission = parser.submission
full_res_img = Addressable::URI.parse(submission.full_res_img)
full_res_img.scheme = "https" if full_res_img.scheme.blank?
matches = full_res_img.to_s == post.file_url_str
end
end
end
task enqueue_fa_posts_missing_files: %i[environment set_logger_stdout] do
Domain::Post::FaPost
.where(state: "ok")
@@ -179,12 +310,12 @@ task perform_good_jobs: :environment do
queue_name: job.queue_name,
serialized_params: job.serialized_params,
scheduled_at: job.scheduled_at,
created_at: Time.now,
updated_at: Time.now,
created_at: Time.current,
updated_at: Time.current,
process_id: SecureRandom.uuid,
)
start_time = Time.now
start_time = Time.current
# Temporarily disable concurrency limits
job_class = job.job_class.constantize
@@ -194,28 +325,28 @@ task perform_good_jobs: :environment do
begin
# Perform the job with deserialized arguments
GoodJob::CurrentThread.job = job
job.update!(performed_at: Time.now)
job.update!(performed_at: Time.current)
job_instance.arguments = deserialized_args
job_instance.perform_now
# Update execution and job records
execution.update!(
finished_at: Time.now,
finished_at: Time.current,
error: nil,
error_event: nil,
duration: Time.now - start_time,
duration: Time.current - start_time,
)
job.update!(finished_at: Time.now)
job.update!(finished_at: Time.current)
puts "Job completed successfully"
rescue => e
puts "Job failed: #{e.message}"
# Update execution and job records with error
execution.update!(
finished_at: Time.now,
finished_at: Time.current,
error: e.message,
error_event: "execution_failed",
error_backtrace: e.backtrace,
duration: Time.now - start_time,
duration: Time.current - start_time,
)
job.update!(
error: "#{e.class}: #{e.message}",
@@ -276,6 +407,56 @@ rescue => e
binding.pry
end
task fix_fa_user_avatars: :environment do
new_users_missing_avatar =
Domain::User::FaUser.where.missing(:avatar).select(:url_name)
old_users_with_avatar =
Domain::Fa::User
.where(url_name: new_users_missing_avatar)
.includes(:avatar)
.filter(&:avatar)
old_users_with_avatar.each do |old_user|
old_avatar = old_user.avatar
new_user = Domain::User::FaUser.find_by(url_name: old_user.url_name)
if old_avatar.log_entry.nil?
puts "enqueue fresh download for #{old_user.url_name}"
new_avatar = Domain::UserAvatar.new
new_user.avatar = new_avatar
new_user.save!
Domain::Fa::Job::UserAvatarJob.perform_now(avatar: new_avatar)
new_avatar.reload
binding.pry
next
end
new_avatar = Domain::UserAvatar.new
new_avatar.log_entry_id = old_avatar.log_entry_id
new_avatar.last_log_entry_id = old_avatar.log_entry_id
new_avatar.url_str = old_avatar.file_url_str
new_avatar.downloaded_at = old_avatar.log_entry&.created_at
new_avatar.state =
case old_avatar.state
when "ok"
old_avatar.log_entry_id.present? ? "ok" : "pending"
when "file_not_found"
new_avatar.error_message = old_avatar.state
"file_404"
else
new_avatar.error_message = old_avatar.state
"http_error"
end
new_user.avatar = new_avatar
new_user.save!
puts "migrated #{old_user.url_name}"
rescue => e
puts "error: #{e.message}"
binding.pry
end
end
task run_fa_user_avatar_jobs: :environment do
avatars =
Domain::UserAvatar
@@ -292,23 +473,109 @@ task run_fa_user_avatar_jobs: :environment do
end
end
task create_post_file_fingerprints: %i[environment set_logger_stdout] do
task = Tasks::CreatePostFileFingerprintsTask.new
task sample_migrated_favs: :environment do
new_user = Domain::User::FaUser.where.not(migrated_user_favs_at: nil).last
old_user = Domain::Fa::User.find_by(url_name: new_user.url_name)
mode =
if ENV["post_file_descending"].present?
Tasks::CreatePostFileFingerprintsTask::Mode::PostFileDescending
elsif ENV["posts_descending"].present?
Tasks::CreatePostFileFingerprintsTask::Mode::PostsDescending
elsif ENV["user"].present?
Tasks::CreatePostFileFingerprintsTask::Mode::User
elsif ENV["users_descending"].present?
Tasks::CreatePostFileFingerprintsTask::Mode::UsersDescending
else
raise "need one of: post_file_descending, posts_descending, user, users_descending"
puts "user: #{new_user.url_name}"
puts "old fav count: #{old_user.fav_posts.count}"
puts "new fav count: #{new_user.faved_posts.count}"
end
task create_post_file_fingerprints: :environment do
def migrate_posts_for_user(user)
puts "migrating posts for #{user.to_param}"
pb =
ProgressBar.create(
total: user.posts.count,
format: "%t: %c/%C %B %p%% %a %e",
)
user
.posts
.includes(:files)
.find_in_batches(batch_size: 64) do |batch|
ReduxApplicationRecord.transaction do
batch.each { |post| migrate_post(post) }
pb.progress = [pb.progress + 1, pb.total].min
end
end
end
def migrate_post(post)
puts "migrating #{post.id} / #{post.to_param} / '#{post.title_for_view}'"
ColorLogger.quiet do
post.files.each do |file|
migrate_post_file(file)
rescue StandardError => e
puts "error: #{e.message}"
end
end
end
task.run(mode: mode, user_param: ENV["user"], start_at: ENV["start_at"])
def migrate_post_file(post_file)
job = Domain::PostFileThumbnailJob.new
ColorLogger.quiet do
job.perform({ post_file: })
rescue => e
puts "error: #{e.message}"
end
end
if ENV["post_file_descending"].present?
total = 49_783_962 # cache this value
pb = ProgressBar.create(total:, format: "%t: %c/%C %B %p%% %a %e")
i = 0
Domain::PostFile
.where(state: "ok")
.includes(:blob)
.find_each(
order: :desc,
batch_size: 32,
start: ENV["start_at"],
) do |post_file|
i += 1
if i % 100 == 0
puts "migrating #{post_file.id} / #{post_file.post.title_for_view}"
end
migrate_post_file(post_file)
pb.progress = [pb.progress + 1, pb.total].min
end
elsif ENV["posts_descending"].present?
# total = Domain::Post.count
total = 66_431_808 # cache this value
pb = ProgressBar.create(total:, format: "%t: %c/%C %B %p%% %a %e")
Domain::Post.find_each(order: :desc) do |post|
migrate_post(post) unless post.is_a?(Domain::Post::InkbunnyPost)
pb.progress = [pb.progress + 1, pb.total].min
end
elsif ENV["user"].present?
for_user = ENV["user"] || raise("need 'user'")
user = DomainController.find_model_from_param(Domain::User, for_user)
raise "user '#{for_user}' not found" unless user
migrate_posts_for_user(user)
elsif ENV["users_descending"].present?
# all users with posts, ordered by post count descending
migrated_file = File.open("migrated_files.txt", "a+")
migrated_file.seek(0)
migrated_users = migrated_file.readlines.map(&:strip)
users =
Domain::User::FaUser.order(
Arel.sql("json_attributes->>'num_watched_by' DESC NULLS LAST"),
).pluck(:id)
users.each do |user_id|
user = Domain::User::FaUser.find(user_id)
next if migrated_users.include?(user.to_param)
puts "migrating posts for #{user.to_param} (#{user.num_watched_by} watched by)"
migrate_posts_for_user(user)
migrated_file.write("#{user.to_param}\n")
migrated_file.flush
end
migrated_file.close
else
raise "need 'user' or 'users_descending'"
end
end
task enqueue_pending_post_files: :environment do
@@ -350,45 +617,3 @@ task find_post_files_with_empty_response: :environment do
pb.progress = [pb.progress + 1, pb.total].min
end
end
desc "Enqueue pending post file jobs"
task enqueue_pending_post_file_jobs: :environment do
Tasks::EnqueueDuePostFileJobsTask.new.run
end
desc "Compute null counter caches for all users"
task compute_null_user_counter_caches: :environment do
counter_caches = {
user_post_creations_count: :user_post_creations,
user_post_favs_count: :user_post_favs,
user_user_follows_from_count: :user_user_follows_from,
user_user_follows_to_count: :user_user_follows_to,
}
query =
Domain::User.where(
counter_caches.map { |col, _| "(\"#{col}\" IS NULL)" }.join(" OR "),
)
total = query.count
query = query.select(:id, *counter_caches.keys)
puts "computing #{counter_caches.keys.join(", ")} for #{total} users"
pb = ProgressBar.create(total:, format: "%t: %c/%C %B %p%% %a %e")
query.find_in_batches(batch_size: 32) do |batch|
ReduxApplicationRecord.transaction do
batch.each do |user|
nil_caches =
counter_caches.keys.filter { |cache| user.send(cache).nil? }
Domain::User.reset_counters(
user.id,
*nil_caches.map { |col| counter_caches[col] },
)
pb.progress = [pb.progress + 1, total].min
end
end
end
end
puts "set proc title to #{ARGV.first}"
Process.setproctitle(ARGV.first) if $0.split("/").last == "rake"

View File

@@ -38,10 +38,4 @@
- [ ] fix for IDs that have a dot in them - e.g. https://refurrer.com/users/fa@jakke.
- [ ] Rich inline links to e621 e.g. https://refurrer.com/posts/fa@60070060
- [ ] Find FaPost that have favs recorded but no scan / file, enqueue scan
- [x] Bunch of posts with empty responses: posts = Domain::Post.joins(files: :log_entry).where(files: { http_log_entries: { response_sha256: BlobFile::EMPTY_FILE_SHA256 }}).limit(10)
- [ ] Create GlobalState entries for last FA id on browse page, periodic scan to scan from the newest FA ID to the stored one
- [ ] GlobalState entries for long running backfill jobs, automatically restart them if they fail
- [ ] Flag to pass to jobs to log HTTP requests / responses to a directory, HTTP mock helper to read from that directory
- [ ] fix IP address incorrect for Cloudflare proxied requests
- [ ] SOCKS5 proxy for additional workers
- [ ] Backup FA scraper using foxbot & g6jy5jkx466lrqojcngbnksugrcfxsl562bzuikrka5rv7srgguqbjid.onion
- [ ] Bunch of posts with empty responses: posts = Domain::Post.joins(files: :log_entry).where(files: { http_log_entries: { response_sha256: BlobFile::EMPTY_FILE_SHA256 }}).limit(10)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 B

View File

@@ -49,11 +49,11 @@
}
.log-entry-table-header-cell {
@apply bg-slate-50 py-1 text-xs font-medium uppercase tracking-wider text-slate-500;
@apply border-b border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium uppercase tracking-wider text-slate-500;
}
.log-entry-table-row-cell {
@apply flex items-center py-1 text-sm;
@apply flex items-center border-b border-slate-200 px-2 py-1 text-sm group-hover:bg-slate-50;
}
.rich-text-content blockquote {

View File

@@ -29,6 +29,8 @@ class BlobEntriesController < ApplicationController
if show_blob_file(sha256, thumb)
return
elsif BlobFile.migrate_sha256!(sha256) && show_blob_file(sha256, thumb)
return
else
raise ActiveRecord::RecordNotFound
end
@@ -51,20 +53,6 @@ class BlobEntriesController < ApplicationController
file_ext = "gif"
end
# content-container may be pre-thumbnailed, see if the file is on the disk
if thumb == "content-container" && file_ext == "jpeg"
thumbnail_path =
Domain::PostFile::Thumbnail.absolute_file_path(
sha256,
"content_container",
0,
)
if File.exist?(thumbnail_path)
send_file(thumbnail_path, type: "image/jpeg", disposition: "inline")
return true
end
end
width, height = thumb_params
filename = "thumb-#{sha256}-#{thumb}.#{file_ext}"
cache_key = "vips:#{filename}"
@@ -156,50 +144,34 @@ class BlobEntriesController < ApplicationController
).returns(T.nilable([String, String]))
end
def thumbnail_image_file(blob_file, width, height, file_ext)
blob_file_path = blob_file.absolute_file_path
if file_ext == "gif"
VipsUtil.try_load_gif(
blob_file_path,
load_gif: -> do
Rack::MiniProfiler.step("vips: load gif") do
# Use libvips' gifload with n=-1 to load all frames
image = Vips::Image.gifload(blob_file_path, n: -1)
num_frames = image.get("n-pages")
image_width, image_height = image.width, (image.height / num_frames)
Rack::MiniProfiler.step("vips: load gif") do
# Use libvips' gifload with n=-1 to load all frames
image = Vips::Image.gifload(blob_file.absolute_file_path, n: -1)
num_frames = image.get("n-pages")
image_width, image_height = image.width, (image.height / num_frames)
if width >= image_width && height >= image_height
logger.info(
"gif is already smaller than requested thumbnail size",
)
return File.binread(blob_file_path), "image/gif"
end
if width >= image_width && height >= image_height
logger.info("gif is already smaller than requested thumbnail size")
return [
File.read(blob_file.absolute_file_path, mode: "rb"),
"image/gif"
]
end
Rack::MiniProfiler.step("vips: thumbnail gif") do
image = image.thumbnail_image(width, height: height)
image_buffer =
image.gifsave_buffer(
dither: 1,
effort: 1,
interframe_maxerror: 16,
interpalette_maxerror: 10,
interlace: true,
)
[image_buffer, "image/gif"]
end
end
end,
on_load_failed: ->(detected_content_type) do
case detected_content_type
when %r{image/png}
thumbnail_image_file(blob_file, width, height, "png")
when %r{image/jpeg}, %r{image/jpg}
thumbnail_image_file(blob_file, width, height, "jpeg")
else
raise
end
end,
)
Rack::MiniProfiler.step("vips: thumbnail gif") do
image = image.thumbnail_image(width, height: height)
image_buffer =
image.gifsave_buffer(
dither: 1,
effort: 1,
interframe_maxerror: 16,
interpalette_maxerror: 10,
interlace: true,
)
[image_buffer, "image/gif"]
end
end
else
# Original static image thumbnailing logic
image_buffer =
@@ -213,10 +185,7 @@ class BlobEntriesController < ApplicationController
Rack::MiniProfiler.step("vips: thumbnail image") do
logger.info("rendering thumbnail as jpeg")
[
T.let(image_buffer.jpegsave_buffer(interlace: true, Q: 95), String),
"image/jpeg",
]
[image_buffer.jpegsave_buffer(interlace: true, Q: 95), "image/jpeg"]
end
end
end

View File

@@ -0,0 +1,218 @@
# typed: true
class Domain::Fa::ApiController < ApplicationController
skip_before_action :authenticate_user!
before_action :validate_api_token!
skip_before_action :verify_authenticity_token,
only: %i[enqueue_objects object_statuses similar_users]
skip_before_action :validate_api_token!,
only: %i[object_statuses similar_users]
def object_statuses
fa_ids = (params[:fa_ids] || []).reject(&:blank?).map(&:to_i)
url_names = (params[:url_names] || []).reject(&:blank?)
url_name_to_user =
Domain::User::FaUser
.where(url_name: url_names)
.map { |user| [T.must(user.url_name), user] }
.to_h
fa_id_to_post =
Domain::Post::FaPost
.includes(:file)
.where(fa_id: fa_ids)
.map { |post| [T.must(post.fa_id), post] }
.to_h
posts_response = {}
users_response = {}
fa_ids.each do |fa_id|
post = fa_id_to_post[fa_id]
if post
post_state =
if post.file.present?
"have_file"
elsif post.scanned_at?
"scanned_post"
else
post.state
end
post_response = {
state: post_state,
seen_at: time_ago_or_never(post.created_at),
object_url: request.base_url + helpers.domain_post_path(post),
post_scan: {
last_at: time_ago_or_never(post.scanned_at),
due_for_scan: !post.scanned_at?,
},
file_scan: {
last_at: time_ago_or_never(post.file&.created_at),
due_for_scan: !post.file&.created_at?,
},
}
else
post_response = { state: "not_seen" }
end
posts_response[fa_id] = post_response
end
url_names.each do |url_name|
user = url_name_to_user[url_name]
if user
user_response = {
created_at: time_ago_or_never(user.created_at),
state: user.state,
object_url: request.base_url + helpers.domain_user_path(user),
page_scan: {
last_at: time_ago_or_never(user.scanned_page_at),
due_for_scan: user.page_scan.due?,
},
gallery_scan: {
last_at: time_ago_or_never(user.gallery_scan.at),
due_for_scan: user.gallery_scan.due?,
},
favs_scan: {
last_at: time_ago_or_never(user.favs_scan.at),
due_for_scan: user.favs_scan.due?,
},
}
else
user_response = { state: "not_seen" }
end
users_response[url_name] = user_response
end
render json: { posts: posts_response, users: users_response }
end
def enqueue_objects
@enqueue_counts ||= Hash.new { |h, k| h[k] = 0 }
fa_ids = (params[:fa_ids] || []).map(&:to_i)
url_names = (params[:url_names] || [])
url_names_to_enqueue = Set.new(params[:url_names_to_enqueue] || [])
fa_id_to_post =
Domain::Fa::Post
.includes(:file)
.where(fa_id: fa_ids)
.map { |post| [post.fa_id, post] }
.to_h
url_name_to_user =
Domain::Fa::User
.where(url_name: url_names)
.map { |user| [user.url_name, user] }
.to_h
fa_ids.each do |fa_id|
post = fa_id_to_post[fa_id]
defer_post_scan(post, fa_id)
end
url_names.each do |url_name|
user = url_name_to_user[url_name]
defer_user_scan(user, url_name, url_names_to_enqueue.include?(url_name))
end
enqueue_deferred!
render json: {
post_scans: @enqueue_counts[Domain::Fa::Job::ScanPostJob],
post_files: @enqueue_counts[Domain::Fa::Job::ScanFileJob],
user_pages: @enqueue_counts[Domain::Fa::Job::UserPageJob],
user_galleries: @enqueue_counts[Domain::Fa::Job::UserGalleryJob],
}
end
private
def defer_post_scan(post, fa_id)
if !post || !post.scanned?
defer_manual(Domain::Fa::Job::ScanPostJob, { fa_id: fa_id }, -17)
end
if post && post.file_uri && !post.file.present?
return(
defer_manual(
Domain::Fa::Job::ScanFileJob,
{ post: post },
-15,
"static_file",
)
)
end
end
def defer_user_scan(user, url_name, highpri)
if !user || user.due_for_page_scan?
defer_manual(
Domain::Fa::Job::UserPageJob,
{ url_name: url_name },
highpri ? -16 : -6,
)
return
end
if !user || user.due_for_gallery_scan?
defer_manual(
Domain::Fa::Job::UserGalleryJob,
{ url_name: url_name },
highpri ? -14 : -4,
)
return
end
false
end
def defer_manual(klass, args, priority, queue = "manual")
@@enqueue_deduper ||= Set.new
return unless @@enqueue_deduper.add?([klass, args, priority])
@deferred_jobs ||= []
@deferred_jobs << [klass, args, priority, queue]
@enqueue_counts[klass] += 1
end
def enqueue_deferred!
GoodJob::Bulk.enqueue do
while job = (@deferred_jobs || []).shift
klass, args, priority, queue = job
klass.set(priority: priority, queue: queue).perform_later(args)
end
end
end
def time_ago_or_never(time)
if time
helpers.time_ago_in_words(time, include_seconds: true) + " ago"
else
"never"
end
end
API_TOKENS = {
"a4eb03ac-b33c-439c-9b51-a834d1c5cf48" => "dymk",
"56cc81fe-8c00-4436-8981-4580eab00e66" => "taargus",
"9c38727f-f11d-41de-b775-0effd86d520c" => "xjal",
"e38c568f-a24d-4f26-87f0-dfcd898a359d" => "fyacin",
"41fa1144-d4cd-11ed-afa1-0242ac120002" => "soft_fox_lad",
"9b3cf444-5913-4efb-9935-bf26501232ff" => "syfaro",
}
def validate_api_token!
api_token = request.params[:api_token]
api_user_name = API_TOKENS[api_token]
return if api_user_name
return if VpnOnlyRouteConstraint.new.matches?(request)
render status: 403, json: { error: "not authenticated" }
end
end

View File

@@ -18,7 +18,7 @@ class Domain::PostsController < DomainController
visual_results
]
before_action :set_post!, only: %i[show]
before_action :set_user!, only: %i[user_created_posts]
before_action :set_user!, only: %i[user_favorite_posts user_created_posts]
before_action :set_post_group!, only: %i[posts_in_group]
class PostsIndexViewConfig < T::ImmutableStruct
@@ -65,12 +65,28 @@ class Domain::PostsController < DomainController
authorize @post
end
sig(:final) { void }
def user_favorite_posts
@posts_index_view_config =
PostsIndexViewConfig.new(
show_domain_filters: false,
show_creator_links: true,
index_type_header: "user_favorites",
)
@user = T.must(@user)
authorize @user
@posts = posts_relation(@user.faved_posts)
authorize @posts
render :index
end
sig(:final) { void }
def user_created_posts
@posts_index_view_config =
PostsIndexViewConfig.new(
show_domain_filters: false,
show_creator_links: false,
show_creator_links: true,
index_type_header: "user_created",
)
@@ -124,47 +140,35 @@ class Domain::PostsController < DomainController
authorize Domain::Post
# Process the uploaded image or URL
file_result = process_image_input
return unless file_result
file_path, content_type = file_result
image_result = process_image_input
return unless image_result
image_path, content_type = image_result
# Create thumbnail for the view if possible
tmp_dir = Dir.mktmpdir("visual-search")
thumbs_and_fingerprints =
helpers.generate_fingerprints(file_path, content_type, tmp_dir)
first_thumb_and_fingerprint = thumbs_and_fingerprints&.first
if thumbs_and_fingerprints.nil? || first_thumb_and_fingerprint.nil?
flash.now[:error] = "Error generating fingerprints"
render :visual_search
return
@uploaded_image_data_uri = create_thumbnail(image_path, content_type)
@uploaded_hash_value = generate_fingerprint(image_path)
@uploaded_detail_hash_value = generate_detail_fingerprint(image_path)
@post_file_fingerprints =
find_similar_fingerprints(@uploaded_hash_value).to_a
@post_file_fingerprints.sort! do |a, b|
helpers.calculate_similarity_percentage(
b.fingerprint_detail_value,
@uploaded_detail_hash_value,
) <=>
helpers.calculate_similarity_percentage(
a.fingerprint_detail_value,
@uploaded_detail_hash_value,
)
end
logger.info("generated #{thumbs_and_fingerprints.length} thumbs")
@uploaded_image_data_uri =
helpers.create_image_thumbnail_data_uri(
first_thumb_and_fingerprint.thumb_path,
"image/jpeg",
)
@uploaded_detail_hash_value = first_thumb_and_fingerprint.detail_fingerprint
before = Time.now
similar_fingerprints =
helpers.find_similar_fingerprints(
thumbs_and_fingerprints.map(&:to_fingerprint_and_detail),
).take(10)
@time_taken = Time.now - before
@matches = similar_fingerprints
@good_matches =
similar_fingerprints.select { |f| f.similarity_percentage >= 80 }
@bad_matches =
similar_fingerprints.select { |f| f.similarity_percentage < 80 }
@matches = @good_matches if @good_matches.any?
@post_file_fingerprints = @post_file_fingerprints.take(10)
@posts = @post_file_fingerprints.map(&:post_file).compact.map(&:post)
ensure
# Clean up any temporary files
FileUtils.rm_rf(tmp_dir) if tmp_dir
if @temp_file
@temp_file.unlink
@temp_file = nil
end
end
private
@@ -228,6 +232,47 @@ class Domain::PostsController < DomainController
nil
end
# Create a thumbnail from the image and return the data URI
sig do
params(image_path: String, content_type: String).returns(T.nilable(String))
end
def create_thumbnail(image_path, content_type)
helpers.create_image_thumbnail_data_uri(image_path, content_type)
end
# Generate a fingerprint from the image path
sig { params(image_path: String).returns(String) }
def generate_fingerprint(image_path)
# Use the new from_file_path method to create a fingerprint
Domain::PostFile::BitFingerprint.from_file_path(image_path)
end
# Generate a detail fingerprint from the image path
sig { params(image_path: String).returns(String) }
def generate_detail_fingerprint(image_path)
Domain::PostFile::BitFingerprint.detail_from_file_path(image_path)
end
# Find similar images based on the fingerprint
sig { params(fingerprint_value: String).returns(ActiveRecord::Relation) }
def find_similar_fingerprints(fingerprint_value)
# Use the model's similar_to_fingerprint method directly
subquery = <<~SQL
(
select distinct on (post_file_id) *, (fingerprint_value <~> '#{ActiveRecord::Base.connection.quote_string(fingerprint_value)}') as distance
from #{Domain::PostFile::BitFingerprint.table_name}
order by post_file_id, distance asc
) subquery
SQL
Domain::PostFile::BitFingerprint
.select("*")
.from(subquery)
.order("distance ASC")
.limit(32)
end
sig { override.returns(DomainController::DomainParamConfig) }
def self.param_config
DomainController::DomainParamConfig.new(
@@ -238,17 +283,14 @@ class Domain::PostsController < DomainController
end
sig(:final) do
params(
starting_relation: ActiveRecord::Relation,
skip_ordering: T::Boolean,
).returns(
params(starting_relation: ActiveRecord::Relation).returns(
T.all(ActiveRecord::Relation, Kaminari::ActiveRecordRelationMethods),
)
end
def posts_relation(starting_relation, skip_ordering: false)
def posts_relation(starting_relation)
relation = starting_relation
relation = T.unsafe(policy_scope(relation)).page(params[:page]).per(50)
relation = relation.order("posted_at DESC NULLS LAST") unless skip_ordering
relation = relation.order(relation.klass.post_order_attribute => :desc)
relation
end
end

View File

@@ -1,120 +0,0 @@
# typed: strict
class Domain::UserJobEventsController < DomainController
extend T::Sig
before_action :set_user!
sig { void }
def tracked_objects_kinds
@kinds =
T.let(
Domain::UserJobEvent::AddTrackedObject.kinds.keys.map(&:to_s),
T.nilable(T::Array[String]),
)
@kind_counts =
T.let(
@user&.add_tracked_objects&.reorder(nil)&.group(:kind)&.count,
T.nilable(T::Hash[String, Integer]),
)
@kinds_most_at =
T.let(
@user
&.add_tracked_objects
&.reorder(nil)
&.group(:kind)
&.maximum(:requested_at),
T.nilable(T::Hash[String, Time]),
)
end
sig { void }
def tracked_objects
set_and_validate_kind!
@tracked_objects =
T.let(
T
.must(@user)
.add_tracked_objects
.includes(:log_entry)
.where(kind: @kind)
.sort_by { |a| -a.requested_at.to_i },
T.untyped,
)
end
sig { void }
def backfill_scan_job
set_and_validate_kind!
@user = T.must(@user)
unless @user.is_a?(Domain::User::FaUser)
flash[:error] = "This user is not a FurAffinity user"
redirect_to tracked_objects_domain_user_job_events_path(
@user,
kind: @kind,
)
return
end
case @kind
when "favs"
now = Time.now
stats = Domain::Fa::BackfillTrackedObjectUserFavs.new(user: @user).run
flash[
:success
] = "Backfilled #{@user.url_name} favs, #{stats.total_created} favs scans created, #{stats.total_favs} favs, loaded #{stats.total_hles} logs, took #{helpers.distance_of_time_in_words_to_now(now, include_seconds: true)}"
end
redirect_to tracked_objects_domain_user_job_events_path(@user, kind: @kind)
end
sig { void }
def enqueue_scan_job
set_and_validate_kind!
@user = T.must(@user)
unless @user.is_a?(Domain::User::FaUser)
flash[:error] = "This user is not a FurAffinity user"
redirect_to tracked_objects_domain_user_job_events_path(
@user,
kind: @kind,
)
return
end
case @kind
when "favs"
flash[:success] = "Enqueued scan job for #{@user.url_name} favs"
Domain::Fa::Job::FavsJob.set(queue: "manual").perform_later(
user: @user,
force_scan: true,
)
else
flash[:error] = "Unimplemented kind: #{@kind}"
end
redirect_to tracked_objects_domain_user_job_events_path(@user, kind: @kind)
end
private
sig { override.returns(DomainController::DomainParamConfig) }
def self.param_config
DomainController::DomainParamConfig.new(
user_id_param: :domain_user_id,
post_id_param: :domain_post_id,
post_group_id_param: :domain_post_group_id,
)
end
sig { void }
def set_and_validate_kind!
@kind = T.let(params[:kind], T.nilable(String))
raise ActionController::RoutingError, "Not Found" if @kind.blank?
unless Domain::UserJobEvent::AddTrackedObject.kinds.include?(@kind)
raise ActionController::RoutingError, "Not Found"
end
end
end

View File

@@ -1,29 +0,0 @@
# typed: true
# frozen_string_literal: true
class Domain::UserPostFavsController < DomainController
before_action :set_user!, only: %i[favorites]
def self.param_config
DomainParamConfig.new(
post_id_param: :domain_post_id,
user_id_param: :domain_user_id,
post_group_id_param: :domain_post_group_id,
)
end
sig { void }
def favorites
@posts_index_view_config =
Domain::PostsController::PostsIndexViewConfig.new(
show_domain_filters: false,
show_creator_links: true,
index_type_header: "user_favorites",
)
user = T.cast(@user, Domain::User)
@user_post_favs =
user.user_post_favs.includes(:post).page(params[:page]).per(50)
authorize @user_post_favs
render :favorites
end
end

View File

@@ -3,8 +3,7 @@ class Domain::UsersController < DomainController
extend T::Sig
extend T::Helpers
before_action :set_user!,
only: %i[show followed_by following monitor_bluesky_user]
before_action :set_user!, only: %i[show followed_by following]
before_action :set_post!, only: %i[users_faving_post]
skip_before_action :authenticate_user!,
only: %i[
@@ -76,24 +75,6 @@ class Domain::UsersController < DomainController
authorize Domain::User
name = params[:name]&.downcase
name = ReduxApplicationRecord.sanitize_sql_like(name)
if name.starts_with?("did:plc:") || name.starts_with?("did:pkh:")
@user_search_names =
Domain::UserSearchName
.select(
"domain_user_search_names.*, domain_users.*, domain_users_bluesky_aux.did",
)
.select(
"levenshtein(domain_users_bluesky_aux.did, '#{name}') as distance",
)
.where(
user: Domain::User::BlueskyUser.where("did LIKE ?", "#{name}%"),
)
.joins(:user)
.limit(10)
return
end
@user_search_names =
Domain::UserSearchName
.select("domain_user_search_names.*, domain_users.*")
@@ -186,23 +167,6 @@ class Domain::UsersController < DomainController
}
end
sig { void }
def monitor_bluesky_user
user = T.cast(@user, Domain::User::BlueskyUser)
authorize user
monitor = Domain::Bluesky::MonitoredObject.build_for_user(user)
if monitor.save
Domain::Bluesky::Job::ScanUserJob.perform_later(user:)
Domain::Bluesky::Job::ScanPostsJob.perform_later(user:)
flash[:notice] = "User is now being monitored"
else
flash[
:alert
] = "Error monitoring user: #{monitor.errors.full_messages.join(", ")}"
end
redirect_to domain_user_path(user)
end
private
sig { override.returns(DomainController::DomainParamConfig) }
@@ -214,17 +178,16 @@ class Domain::UsersController < DomainController
)
end
# TODO - make a typed ImmutableStruct for the return type
sig { params(user: Domain::User::FaUser).returns(T::Hash[Symbol, T.untyped]) }
def user_to_similarity_entry(user)
profile_thumb_url = user.avatar&.log_entry&.uri_str
profile_thumb_url ||=
begin
pp_log_entry = get_best_user_page_http_log_entry_for(user)
if pp_log_entry
if pp_log_entry && (response_bytes = pp_log_entry.response_bytes)
parser =
Domain::Fa::Parser::Page.from_log_entry(
pp_log_entry,
Domain::Fa::Parser::Page.new(
response_bytes,
require_logged_in: false,
)
parser.user_page.profile_thumb_url

View File

@@ -11,8 +11,6 @@ class GlobalStatesController < ApplicationController
IB_COOKIE_KEYS = %w[inkbunny-username inkbunny-password inkbunny-sid].freeze
TELEGRAM_KEYS = %w[telegram-bot-token].freeze
def index
authorize GlobalState
@global_states = policy_scope(GlobalState).order(:key)
@@ -184,50 +182,6 @@ class GlobalStatesController < ApplicationController
end
end
def telegram_config
authorize GlobalState
@telegram_config =
TELEGRAM_KEYS.map do |key|
GlobalState.find_by(key: key) ||
GlobalState.new(key: key, value_type: :string)
end
end
def edit_telegram_config
authorize GlobalState
@telegram_config =
TELEGRAM_KEYS.map do |key|
GlobalState.find_by(key: key) ||
GlobalState.new(key: key, value_type: :string)
end
end
def update_telegram_config
authorize GlobalState
begin
ActiveRecord::Base.transaction do
telegram_config_params.each do |key, value|
state = GlobalState.find_or_initialize_by(key: key)
state.value = value
state.value_type = :string
state.save!
end
end
redirect_to telegram_config_global_states_path,
notice: "Telegram bot configuration was successfully updated."
rescue ActiveRecord::RecordInvalid => e
@telegram_config =
TELEGRAM_KEYS.map do |key|
GlobalState.find_by(key: key) ||
GlobalState.new(key: key, value_type: :string)
end
flash.now[:alert] = "Error updating Telegram bot configuration: #{e.message}"
render :edit_telegram_config, status: :unprocessable_entity
end
end
private
def set_global_state
@@ -247,8 +201,4 @@ class GlobalStatesController < ApplicationController
*IB_COOKIE_KEYS.reject { |key| key == "inkbunny-sid" },
)
end
def telegram_config_params
params.require(:telegram_config).permit(*TELEGRAM_KEYS)
end
end

View File

@@ -1,103 +0,0 @@
# typed: false
class TelegramBotLogsController < ApplicationController
before_action :set_telegram_bot_log, only: %i[show]
after_action :verify_authorized
def index
authorize TelegramBotLog
# Start with policy scope
@telegram_bot_logs = policy_scope(TelegramBotLog)
# Apply filters
@telegram_bot_logs = apply_filters(@telegram_bot_logs)
# Order by most recent first
@telegram_bot_logs = @telegram_bot_logs.recent
# Paginate with Kaminari
@limit = (params[:limit] || 50).to_i.clamp(1, 500)
@telegram_bot_logs = @telegram_bot_logs.page(params[:page]).per(@limit)
# Load associations for display
@telegram_bot_logs = @telegram_bot_logs.includes(:processed_image)
# Set up filter options for the view
@status_options = TelegramBotLog.statuses.keys
@filter_params =
params.slice(
:telegram_user_id,
:status,
:start_date,
:end_date,
:min_results,
:max_results,
:slow_requests,
)
end
def show
authorize @telegram_bot_log
# The processed_image association will be loaded automatically when accessed
end
private
def set_telegram_bot_log
@telegram_bot_log =
TelegramBotLog.includes(:processed_image).find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to telegram_bot_logs_path, alert: "Telegram bot log not found."
end
def apply_filters(scope)
# Filter by telegram user ID
if params[:telegram_user_id].present?
scope = scope.for_user(params[:telegram_user_id].to_i)
end
# Filter by status
if params[:status].present? && TelegramBotLog.statuses.key?(params[:status])
scope = scope.where(status: params[:status])
end
# Filter by date range
if params[:start_date].present?
begin
start_date = Date.parse(params[:start_date])
scope =
scope.where("request_timestamp >= ?", start_date.beginning_of_day)
rescue Date::Error
# Ignore invalid date
end
end
if params[:end_date].present?
begin
end_date = Date.parse(params[:end_date])
scope = scope.where("request_timestamp <= ?", end_date.end_of_day)
rescue Date::Error
# Ignore invalid date
end
end
# Filter by search results count
if params[:min_results].present?
scope =
scope.where("search_results_count >= ?", params[:min_results].to_i)
end
if params[:max_results].present?
scope =
scope.where("search_results_count <= ?", params[:max_results].to_i)
end
# Filter by performance metrics
if params[:slow_requests].present? && params[:slow_requests] == "true"
scope = scope.slow_requests
end
scope
end
end

View File

@@ -1,210 +0,0 @@
# typed: strict
# frozen_string_literal: true
module Domain::BlueskyPostHelper
extend T::Sig
include ActionView::Helpers::UrlHelper
include HelpersInterface
include Domain::PostsHelper
class FacetPart < T::Struct
const :type, Symbol
const :value, String
end
sig do
params(text: String, facets: T.nilable(T::Array[T.untyped])).returns(
T.nilable(String),
)
end
def render_bsky_post_facets(text, facets = nil)
return text if facets.blank?
facets =
begin
facets.map { |facet| Bluesky::Text::Facet.from_hash(facet) }
rescue => e
Rails.logger.error("error parsing Bluesky facets: #{e.message}")
return text
end
result_parts = T.let([], T::Array[FacetPart])
last_end = 0
# Sort facets by start position to handle them in order
sorted_facets = facets.sort_by(&:byteStart)
sorted_facets.each do |facet|
if facet.byteStart < 0 || facet.byteEnd <= facet.byteStart ||
facet.byteEnd > text.bytesize
next
end
# Skip overlapping facets
next if facet.byteStart < last_end
# Add text before this facet
if facet.byteStart > last_end
before_text = text.byteslice(last_end, facet.byteStart - last_end)
if before_text
result_parts << FacetPart.new(type: :text, value: before_text)
end
end
# Extract the facet text using byteslice for accurate character extraction
facet_text =
text.byteslice(facet.byteStart, facet.byteEnd - facet.byteStart)
next unless facet_text # Skip if byteslice returns nil
# Process the facet
rendered_facet = render_facet(facet, facet_text)
result_parts << FacetPart.new(type: :facet, value: rendered_facet)
last_end = facet.byteEnd
end
# Add remaining text after the last facet
if last_end < text.bytesize
remaining_text = text.byteslice(last_end, text.bytesize - last_end)
if remaining_text
result_parts << FacetPart.new(type: :text, value: remaining_text)
end
end
result_parts
.map do |part|
case part.type
when :text
part.value.gsub("\n", "<br />")
when :facet
part.value
end
end
.join
.html_safe
end
private
sig do
params(facet: Bluesky::Text::Facet, facet_text: String).returns(String)
end
def render_facet(facet, facet_text)
return facet_text unless facet.features.any?
# Process the first feature (Bluesky facets typically have one feature per facet)
feature = facet.features.first
return facet_text unless feature.is_a?(Bluesky::Text::FacetFeature)
case feature
when Bluesky::Text::FacetFeatureMention
render_mention_facet(feature, facet_text)
when Bluesky::Text::FacetFeatureURI
render_link_facet(feature, facet_text)
when Bluesky::Text::FacetFeatureTag
render_tag_facet(feature, facet_text)
else
# Unknown facet type, return original text
facet_text
end
end
sig do
params(
feature: Bluesky::Text::FacetFeatureMention,
facet_text: String,
).returns(String)
end
def render_mention_facet(feature, facet_text)
did = feature.did
return facet_text unless did.present?
# Try to find the user in the database
user = Domain::User::BlueskyUser.find_by(did: did)
if user
# Render the inline user partial
render(
partial: "domain/has_description_html/inline_link_domain_user",
locals: {
user: user,
link_text: facet_text,
visual_style: "description-section-link-light",
},
)
else
# Render external link to Bluesky profile
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: "https://bsky.app/profile/#{did}",
},
)
end
end
sig do
params(feature: Bluesky::Text::FacetFeatureURI, facet_text: String).returns(
String,
)
end
def render_link_facet(feature, facet_text)
uri = feature.uri
return facet_text unless uri.present?
source = link_for_source(uri)
if source.present? && (model = source.model)
case model
when Domain::Post
return(
render(
partial: "domain/has_description_html/inline_link_domain_post",
locals: {
post: model,
link_text: facet_text,
visual_style: "description-section-link-light",
},
)
)
when Domain::User
return(
render(
partial: "domain/has_description_html/inline_link_domain_user",
locals: {
user: model,
link_text: facet_text,
visual_style: "description-section-link-light",
},
)
)
end
end
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: uri,
},
)
end
sig do
params(feature: Bluesky::Text::FacetFeatureTag, facet_text: String).returns(
String,
)
end
def render_tag_facet(feature, facet_text)
tag = feature.tag
return facet_text unless tag.present?
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: "https://bsky.app/hashtag/#{tag}",
},
)
end
end

View File

@@ -57,16 +57,11 @@ module Domain::DescriptionsHelper
end
WEAK_URL_MATCHER_REGEX =
%r{(http(s)?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)}
sig { params(str: String).returns(T.nilable(String)) }
def extract_weak_url(str)
str.match(WEAK_URL_MATCHER_REGEX)&.[](0)
end
%r{(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)}
ALLOWED_INFERRED_URL_DOMAINS =
T.let(
%w[furaffinity.net inkbunny.net e621.net bsky.app]
%w[furaffinity.net inkbunny.net e621.net]
.flat_map { |domain| [domain, "www.#{domain}"] }
.freeze,
T::Array[String],
@@ -77,16 +72,6 @@ module Domain::DescriptionsHelper
html = model.description_html_for_view
return nil if html.blank?
is_bsky_description = model.is_a?(Domain::User::BlueskyUser)
visual_style =
(
if model.is_a?(Domain::User::BlueskyUser)
"description-section-link-light"
else
"description-section-link"
end
)
case model
when Domain::Post::E621Post
dtext_result = DText.parse(html)
@@ -110,23 +95,17 @@ module Domain::DescriptionsHelper
next unless node.text?
next unless node.ancestors("a").empty?
next unless (node_text = T.cast(node.text, T.nilable(String)))
next unless (url_text = extract_weak_url(node_text))
next if url_text.blank?
next unless (match = node_text.match(WEAK_URL_MATCHER_REGEX))
next unless (url_text = match[0])
unless (
uri =
try_parse_uri(model.description_html_base_domain, url_text)
)
next
end
if is_bsky_description
unless ALLOWED_EXTERNAL_LINK_DOMAINS.any? { |domain|
url_matches_domain?(domain, uri.host)
}
next
end
elsif ALLOWED_PLAIN_TEXT_URL_DOMAINS.none? do |domain|
url_matches_domain?(domain, uri.host)
end
unless ALLOWED_PLAIN_TEXT_URL_DOMAINS.any? { |domain|
url_matches_domain?(domain, uri.host)
}
next
end
@@ -178,12 +157,20 @@ module Domain::DescriptionsHelper
when Domain::Post
[
"domain/has_description_html/inline_link_domain_post",
{ post: found_model, link_text: node.text, visual_style: },
{
post: found_model,
link_text: node.text,
visual_style: "description-section-link",
},
]
when Domain::User
[
"domain/has_description_html/inline_link_domain_user",
{ user: found_model, link_text: node.text, visual_style: },
{
user: found_model,
link_text: node.text,
visual_style: "description-section-link",
},
]
else
raise "Unknown model type: #{found_link.model.class}"
@@ -204,24 +191,14 @@ module Domain::DescriptionsHelper
end
replacements[node] = Nokogiri::HTML5.fragment(
if is_bsky_description
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: node.text,
url: url.to_s,
},
)
else
render(
partial: "domain/has_description_html/inline_link_external",
locals: {
url: url.to_s,
title:,
icon_path: icon_path_for_domain(url.host),
},
)
end,
render(
partial: "domain/has_description_html/inline_link_external",
locals: {
url: url.to_s,
title:,
icon_path: icon_path_for_domain(url.host),
},
),
)
next { node_whitelist: [node] }
end
@@ -282,13 +259,6 @@ module Domain::DescriptionsHelper
"rounded-md px-1 transition-all",
"inline-flex items-center align-bottom",
].join(" ")
when "description-section-link-light"
[
"text-sky-600 border-slate-300",
"border border-transparent hover:border-slate-500 hover:text-sky-800 hover:bg-slate-200",
"rounded-md px-1 transition-all",
"inline-flex items-center align-bottom",
].join(" ")
else
"blue-link"
end
@@ -310,12 +280,12 @@ module Domain::DescriptionsHelper
.cache
.fetch(cache_key) do
num_posts =
user.has_created_posts? ? user.user_post_creations.size : nil
user.has_created_posts? ? user.user_post_creations.count : nil
registered_at = domain_user_registered_at_string_for_view(user)
num_followed_by =
user.has_followed_by_users? ? user.user_user_follows_to.size : nil
user.has_followed_by_users? ? user.user_user_follows_to.count : nil
num_followed =
user.has_followed_users? ? user.user_user_follows_from.size : nil
user.has_followed_users? ? user.user_user_follows_from.count : nil
avatar_thumb_size = icon_size == "large" ? "64-avatar" : "32-avatar"
{
@@ -345,27 +315,12 @@ module Domain::DescriptionsHelper
end
sig do
params(
post: Domain::Post,
link_text: String,
visual_style: String,
domain_icon: T::Boolean,
link_params: T::Hash[Symbol, T.untyped],
).returns(T::Hash[Symbol, T.untyped])
params(post: Domain::Post, link_text: String, visual_style: String).returns(
T::Hash[Symbol, T.untyped],
)
end
def props_for_post_hover_preview(
post,
link_text,
visual_style,
domain_icon: true,
link_params: {}
)
cache_key = [
post,
policy(post),
link_text,
"popover_inline_link_domain_post",
]
def props_for_post_hover_preview(post, link_text, visual_style)
cache_key = [post, policy(post), "popover_inline_link_domain_post"]
Rails
.cache
.fetch(cache_key) do
@@ -373,14 +328,10 @@ module Domain::DescriptionsHelper
linkText: link_text,
postId: post.to_param,
postTitle: post.title,
postPath:
Rails.application.routes.url_helpers.domain_post_path(
post,
link_params,
),
postPath: Rails.application.routes.url_helpers.domain_post_path(post),
postThumbnailPath: thumbnail_for_post_path(post),
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
postDomainIcon: domain_icon ? domain_model_icon_path(post) : nil,
postDomainIcon: domain_model_icon_path(post),
}.then do |props|
if creator = post.primary_creator_for_view
props[:creatorName] = creator.name_for_view

View File

@@ -17,10 +17,6 @@ module Domain::DomainModelHelper
"E621"
when Domain::DomainType::Inkbunny
"Inkbunny"
when Domain::DomainType::Sofurry
"Sofurry"
when Domain::DomainType::Bluesky
"Bluesky"
end
end
@@ -33,10 +29,18 @@ module Domain::DomainModelHelper
"E621"
when Domain::DomainType::Inkbunny
"IB"
when Domain::DomainType::Sofurry
"SF"
when Domain::DomainType::Bluesky
"BSKY"
end
end
sig { params(model: Domain::Post).returns(String) }
def title_for_post_model(model)
case model
when Domain::Post::FaPost
model.title
when Domain::Post::E621Post
model.title
when Domain::Post::InkbunnyPost
model.title
end || "(unknown)"
end
end

View File

@@ -5,7 +5,5 @@ class Domain::DomainType < T::Enum
Fa = new
E621 = new
Inkbunny = new
Sofurry = new
Bluesky = new
end
end

View File

@@ -11,7 +11,6 @@ module Domain::DomainsHelper
e621.net
furaffinity.net
inkbunny.net
bsky.app
].freeze
# If a link is detected in an anchor tag and is one of these domains,
@@ -46,10 +45,7 @@ module Domain::DomainsHelper
redbubble.com
spreadshirt.com
spreadshirt.de
subscribestar.adult
linktr.ee
t.me
trello.com
tumblr.com
twitch.tv
twitter.com
@@ -57,8 +53,6 @@ module Domain::DomainsHelper
weasyl.com
x.com
youtube.com
sofurry.com
aethy.com
] + ALLOWED_PLAIN_TEXT_URL_DOMAINS
).freeze,
T::Array[String],
@@ -91,12 +85,9 @@ module Domain::DomainsHelper
"t.me" => "telegram.png",
"tumblr.com" => "tumblr.png",
"twitter.com" => "x-twitter.png",
"trello.com" => "trello.png",
"weasyl.com" => "weasyl.png",
"wixmp.com" => "deviantart.png",
"x.com" => "x-twitter.png",
"linktr.ee" => "linktree.png",
"aethy.com" => "aethy.png",
}.freeze,
T::Hash[String, String],
)

View File

@@ -1,5 +1,5 @@
# typed: false
module Domain::Posts::E621PostsHelper
module Domain::E621::PostsHelper
def icon_asset_for_url(url)
domain = extract_domain(url)
return nil unless domain
@@ -81,7 +81,7 @@ module Domain::Posts::E621PostsHelper
return unless %w[www.furaffinity.net furaffinity.net].include?(uri.host)
fa_id = uri.path.match(%r{/view/(\d+)})[1]
return unless fa_id
raise("not implemented")
Domain::Fa::Post.find_by(fa_id: fa_id)
rescue StandardError
nil
end

View File

@@ -0,0 +1,165 @@
# typed: strict
module Domain::Fa::PostsHelper
extend T::Sig
include ActionView::Helpers::DateHelper
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::RenderingHelper
include ActionView::Helpers::TagHelper
sig { params(post: Domain::Fa::Post).returns(String) }
def post_state_string(post)
if post.have_file?
"file"
elsif post.scanned?
"scanned"
else
post.state || "unknown"
end
end
sig do
params(
params:
T.any(ActionController::Parameters, T::Hash[T.untyped, T.untyped]),
).returns(T.nilable(String))
end
def page_str(params)
if (params[:page] || 1).to_i > 1
"(page #{params[:page]})"
else
nil
end
end
sig { params(post: Domain::Fa::Post).returns(T.nilable(HttpLogEntry)) }
def guess_scanned_http_log_entry(post)
HttpLogEntry.find_all_by_uri(
"https://www.furaffinity.net/view/#{post.fa_id}",
).first
end
sig { params(post: Domain::Fa::Post).returns(T.nilable(HttpLogEntry)) }
def guess_file_downloaded_http_log_entry(post)
if (uri = post.file_uri)
HttpLogEntry.find_all_by_uri(uri).first
end
end
sig { params(html: String).returns(String) }
def fa_post_description_sanitized(html)
fa_post_id_to_node = {}
fa_user_url_name_to_node = {}
sanitizer =
Sanitize.new(
elements: %w[br img b i span strong],
attributes: {
"span" => %w[style],
},
css: {
properties: %w[font-size color],
},
transformers: [
Kernel.lambda do |env|
# Only allow and transform FA links
if env[:node_name] == "a"
node = env[:node]
# by default, assume the host is www.furaffinity.net
href = node["href"]&.downcase || ""
href = "//" + href if href.match?(/^(www\.)?furaffinity\.net/)
uri =
begin
URI.parse(href)
rescue URI::InvalidURIError
nil
end
valid_type = !uri.is_a?(URI::MailTo)
next { node_whitelist: [node] } if uri.nil? || !valid_type
uri.host ||= "www.furaffinity.net"
uri.scheme ||= "https"
path = uri.path
fa_host_matcher = /^(www\.)?furaffinity\.net$/
fa_post_matcher = %r{^/view/(\d+)/?$}
fa_user_matcher = %r{^/user/(\w+)/?$}
if fa_host_matcher.match?(uri.host) && path
if match = path.match(fa_post_matcher)
fa_id = match[1].to_i
fa_post_id_to_node[fa_id] = node
next { node_whitelist: [node] }
elsif match = path.match(fa_user_matcher)
fa_url_name = match[1]
fa_user_url_name_to_node[fa_url_name] = node
next { node_whitelist: [node] }
end
end
# Don't allow any other links
node.replace(node.children)
end
end,
],
)
fragment = Nokogiri::HTML5.fragment(sanitizer.send(:preprocess, html))
sanitizer.node!(fragment)
if fa_post_id_to_node.any?
# Batch load posts and their titles, ensuring fa_post_ids are strings
posts_by_id =
Domain::Fa::Post.where(fa_id: fa_post_id_to_node.keys).index_by(&:fa_id)
# Replace the link text with post titles if available
fa_post_id_to_node.each do |fa_id, node|
if (post = posts_by_id[fa_id])
node.replace(
Nokogiri::HTML5.fragment(
render(
partial: "domain/fa/posts/description_inline_link_fa_post",
locals: {
post: post,
},
),
),
)
else
node.replace(node.children)
end
end
end
if fa_user_url_name_to_node.any?
# Batch load users and their names, ensuring fa_user_url_names are strings
users_by_url_name =
Domain::Fa::User
.where(url_name: fa_user_url_name_to_node.keys)
.includes(:avatar)
.index_by(&:url_name)
# Replace the link text with user names if available
fa_user_url_name_to_node.each do |fa_url_name, node|
if (user = users_by_url_name[fa_url_name])
node.replace(
Nokogiri::HTML5.fragment(
render(
partial: "domain/fa/posts/description_inline_link_fa_user",
locals: {
user: user,
},
),
),
)
else
node.replace(node.children)
end
end
end
raw fragment.to_html(preserve_newline: true)
end
end

View File

@@ -0,0 +1,131 @@
# typed: false
module Domain::Fa::UsersHelper
extend T::Sig
def avatar_url(sha256, thumb: "32-avatar")
blob_path(HexUtil.bin2hex(sha256), format: "jpg", thumb: thumb)
end
def fa_user_avatar_path(user, thumb: nil)
if (sha256 = user.avatar&.file_sha256)
blob_path(HexUtil.bin2hex(sha256), format: "jpg", thumb: thumb)
else
# default / 'not found' avatar image
# "/blobs/9080fd4e7e23920eb2dccfe2d86903fc3e748eebb2e5aa8c657bbf6f3d941cdc/contents.jpg"
asset_path("user-circle.svg")
end
end
def sanitized_fa_user_profile_html(html)
# try to preload all the FA usernames in the profile
maybe_url_names =
Nokogiri
.HTML(html)
.css("a")
.flat_map do |node|
href = URI.parse(node["href"])
right_host = href.host.nil? || href.host == "www.furaffinity.net"
right_path = href.path =~ %r{/user/.+}
if right_host && right_path
[href]
else
[]
end
end
.map { |href| href.path.split("/")[2]&.downcase }
preloaded_users =
Domain::Fa::User
.where(url_name: maybe_url_names)
.select(:id, :state, :state_detail, :log_entry_detail, :url_name)
.joins(:avatar)
.includes(:avatar)
.index_by(&:url_name)
raw Sanitize.fragment(
html,
elements: %w[br img b i span strong],
attributes: {
"span" => %w[style],
"a" => [],
},
css: {
properties: %w[font-size color],
},
transformers:
lambda do |env|
return unless env[:node_name] == "a"
node = env[:node]
href = URI.parse(node["href"])
unless href.host == nil || href.host == "www.furaffinity.net"
return
end
return unless href.path =~ %r{/user/.+}
url_name = href.path.split("/")[2]&.downcase
Sanitize.node!(
node,
{ elements: %w[a], attributes: { "a" => %w[href] } },
)
node["href"] = domain_fa_user_path(url_name)
node["class"] = "text-slate-200 underline decoration-slate-200 " +
"decoration-dashed decoration-dashed decoration-1"
whitelist = [node]
user =
preloaded_users[url_name] ||
Domain::Fa::User.find_by(url_name: url_name)
if user
img = Nokogiri::XML::Node.new("img", node.document)
img["class"] = "inline w-5"
img["src"] = fa_user_avatar_path(user, thumb: "32-avatar")
node.prepend_child(img)
whitelist << img
end
{ node_allowlist: whitelist }
end,
)
end
# TODO - remove this once we've migrated similarity scores to new user model
sig do
params(
user: Domain::User::FaUser,
limit: Integer,
exclude_followed_by: T.nilable(Domain::User::FaUser),
).returns(T::Array[Domain::User::FaUser])
end
def similar_users_by_followed(user, limit: 10, exclude_followed_by: nil)
factors = Domain::Factors::UserUserFollowToFactors.find_by(user: user)
return [] if factors.nil?
relation =
Domain::NeighborFinder
.find_neighbors(factors)
.limit(limit)
.includes(:user)
if exclude_followed_by
relation =
relation.where.not(
user_id: exclude_followed_by.followed_users.select(:to_id),
)
end
relation.map { |factor| factor.user }
end
def fa_user_account_status(user)
log_entry_id = user.log_entry_detail["last_user_page_id"]
return "unknown" if log_entry_id.nil?
log_entry = HttpLogEntry.find_by(id: log_entry_id)
return "unknown" if log_entry.nil?
parser =
Domain::Fa::Parser::Page.new(
log_entry.response.contents,
require_logged_in: false,
)
return "unknown" unless parser.probably_user_page?
parser.user_page.account_status
end
end

View File

@@ -12,10 +12,11 @@ module Domain::ModelHelper
partial: String,
as: Symbol,
expires_in: ActiveSupport::Duration,
cache_key: T.untyped,
).returns(T.nilable(String))
end
def render_for_model(model, partial, as:, expires_in: 1.hour)
cache_key = [model, policy(model), partial].compact
def render_for_model(model, partial, as:, expires_in: 1.hour, cache_key: nil)
cache_key ||= [model, policy(model), partial]
Rails
.cache
.fetch(cache_key, expires_in:) do

View File

@@ -68,18 +68,4 @@ module Domain::PaginationHelper
path += "?#{uri.query}" if uri.query.present?
path
end
sig do
params(
params:
T.any(ActionController::Parameters, T::Hash[T.untyped, T.untyped]),
).returns(T.nilable(String))
end
def page_str(params)
if (params[:page] || 1).to_i > 1
"(page #{params[:page]})"
else
nil
end
end
end

View File

@@ -6,8 +6,8 @@ module Domain::PostGroupsHelper
abstract!
sig { params(post_group: Domain::PostGroup).returns(String) }
def domain_post_group_posts_path(post_group)
"#{domain_post_groups_path}/#{post_group.to_param}/posts"
def domain_post_group_path(post_group)
"#{domain_post_groups_path}/#{post_group.to_param}"
end
sig { returns(String) }

View File

@@ -43,16 +43,6 @@ module Domain::PostsHelper
domain_icon_path: "domain-icons/inkbunny.png",
domain_icon_title: "Inkbunny",
),
Domain::DomainType::Sofurry =>
DomainData.new(
domain_icon_path: "domain-icons/sofurry.png",
domain_icon_title: "SoFurry",
),
Domain::DomainType::Bluesky =>
DomainData.new(
domain_icon_path: "domain-icons/bluesky.png",
domain_icon_title: "Bluesky",
),
},
T::Hash[Domain::DomainType, DomainData],
)
@@ -72,7 +62,7 @@ module Domain::PostsHelper
def gallery_file_for_post(post)
file = post.primary_file_for_view
return nil unless file.present?
return nil unless file.state_ok? || file.last_status_code == 200
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
content_type = file.log_entry&.content_type
return nil unless content_type.present?
@@ -80,12 +70,10 @@ module Domain::PostsHelper
file
end
sig { params(post: Domain::Post).returns(T.any(T.nilable(String), Symbol)) }
sig { params(post: Domain::Post).returns(T.nilable(String)) }
def gallery_file_info_for_post(post)
return :post_pending if post.pending_scan?
file = post.primary_file_for_view
return nil unless file.present?
return :file_pending if file.state_pending?
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
content_type = file.log_entry&.content_type || ""
@@ -194,86 +182,6 @@ module Domain::PostsHelper
file.log_entry&.response_size&.then { |size| number_to_human_size(size) }
end
sig do
params(
post_files: T::Array[Domain::PostFile],
initial_file_index: T.nilable(Integer),
).returns(T::Hash[Symbol, T.untyped])
end
def props_for_post_files(post_files:, initial_file_index: nil)
files_data =
post_files.map.with_index do |post_file, index|
thumbnail_path = nil
content_html = nil
log_entry = post_file.log_entry
if log_entry && (log_entry.status_code == 200)
if (response_sha256 = log_entry.response_sha256)
thumbnail_path = {
type: "url",
value:
blob_path(
HexUtil.bin2hex(response_sha256),
format: "jpg",
thumb: "small",
),
}
end
# Generate content HTML
content_html =
ApplicationController.renderer.render(
partial: "log_entries/content_container",
locals: {
log_entry: log_entry,
},
assigns: {
current_user: nil,
},
)
elsif post_file.state_pending?
thumbnail_path = {
type: "icon",
value: "fa-solid fa-file-arrow-down",
}
end
{
id: post_file.id,
fileState: post_file.state,
thumbnailPath: thumbnail_path,
hasContent: post_file.log_entry&.status_code == 200,
index: index,
contentHtml: content_html,
fileDetails:
(
if log_entry
{
contentType: log_entry.content_type,
fileSize: log_entry.response_size,
responseTimeMs: log_entry.response_time_ms,
responseStatusCode: log_entry.status_code,
postFileState: post_file.state,
logEntryId: log_entry.id,
logEntryPath: log_entry_path(log_entry),
}
else
nil
end
),
}
end
# Validate initial_file_index
validated_initial_index = nil
if initial_file_index && initial_file_index >= 0 &&
initial_file_index < post_files.count
validated_initial_index = initial_file_index
end
{ files: files_data, initialSelectedIndex: validated_initial_index }
end
sig { params(url: String).returns(T.nilable(String)) }
def icon_asset_for_url(url)
domain = extract_domain(url)
@@ -366,10 +274,9 @@ module Domain::PostsHelper
IB_HOSTS = %w[*.inkbunny.net inkbunny.net]
IB_CDN_HOSTS = %w[*.ib.metapix.net ib.metapix.net]
E621_HOSTS = %w[www.e621.net e621.net]
BLUESKY_HOSTS = %w[bsky.app]
URL_SUFFIX_QUERY = T.let(<<-SQL.strip.chomp.freeze, String)
lower('url_str') = lower(?)
lower(json_attributes->>'url_str') = lower(?)
SQL
MATCHERS =
@@ -385,7 +292,10 @@ module Domain::PostsHelper
],
find_proc: ->(helper, match, _) do
if post = Domain::Post::FaPost.find_by(fa_id: match[1])
SourceResult.new(model: post, title: post.title_for_view)
SourceResult.new(
model: post,
title: helper.title_for_post_model(post),
)
end
end,
),
@@ -398,7 +308,7 @@ module Domain::PostsHelper
post_file =
Domain::PostFile.where(
"lower('url_str') IN (?, ?, ?, ?, ?, ?)",
"lower(json_attributes->>'url_str') IN (?, ?, ?, ?, ?, ?)",
"d.furaffinity.net#{url.host}/#{url.path}",
"//d.furaffinity.net#{url.host}/#{url.path}",
"https://d.furaffinity.net#{url.host}/#{url.path}",
@@ -408,7 +318,9 @@ module Domain::PostsHelper
).first
if post_file && (post = post_file.post)
SourceResult.new(model: post, title: post.title_for_view)
title =
T.bind(self, Domain::PostsHelper).title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end,
),
@@ -431,7 +343,8 @@ module Domain::PostsHelper
patterns: [%r{/s/(\d+)/?}, %r{/submissionview\.php\?id=(\d+)/?}],
find_proc: ->(helper, match, _) do
if post = Domain::Post::InkbunnyPost.find_by(ib_id: match[1])
SourceResult.new(model: post, title: post.title_for_view)
title = helper.title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end,
),
@@ -447,7 +360,8 @@ module Domain::PostsHelper
"ib.metapix.net#{url.path}",
).first
if post = post_file.post
SourceResult.new(model: post, title: post.title_for_view)
title = helper.title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end
end,
@@ -459,7 +373,7 @@ module Domain::PostsHelper
find_proc: ->(_, match, _) do
if user =
Domain::User::InkbunnyUser.where(
"name = lower(?)",
"lower(json_attributes->>'name') = lower(?)",
match[1],
).first
SourceResult.new(
@@ -475,7 +389,10 @@ module Domain::PostsHelper
patterns: [%r{/posts/(\d+)/?}],
find_proc: ->(helper, match, _) do
if post = Domain::Post::E621Post.find_by(e621_id: match[1])
SourceResult.new(model: post, title: post.title_for_view)
SourceResult.new(
model: post,
title: helper.title_for_post_model(post),
)
end
end,
),
@@ -492,44 +409,6 @@ module Domain::PostsHelper
end
end,
),
# Bluesky posts
SourceMatcher.new(
hosts: BLUESKY_HOSTS,
patterns: [%r{/profile/([^/]+)/post/([^/]+)/?$}],
find_proc: ->(helper, match, _) do
handle_or_did = match[1]
post_rkey = match[2]
if handle_or_did.start_with?("did:")
did = handle_or_did
else
user = Domain::User::BlueskyUser.find_by(handle: handle_or_did)
did = user&.did
end
next unless did
at_uri = "at://#{did}/app.bsky.feed.post/#{post_rkey}"
post = Domain::Post::BlueskyPost.find_by(at_uri:)
SourceResult.new(model: post, title: post.title_for_view) if post
end,
),
# Bluesky users
SourceMatcher.new(
hosts: BLUESKY_HOSTS,
patterns: [%r{/profile/([^/]+)\/?$}],
find_proc: ->(helper, match, _) do
handle_or_did = match[1]
user =
if handle_or_did.start_with?("did:")
Domain::User::BlueskyUser.find_by(did: handle_or_did)
else
Domain::User::BlueskyUser.find_by(handle: handle_or_did)
end
next unless user
SourceResult.new(
model: user,
title: user.name_for_view || handle_or_did,
)
end,
),
],
T::Array[SourceMatcher],
)
@@ -539,7 +418,7 @@ module Domain::PostsHelper
return nil if source.blank?
# normalize the source to a lowercase string with a protocol
source = source.downcase
source.downcase!
source = "https://" + source unless source.include?("://")
begin
uri = URI.parse(source)
@@ -594,67 +473,6 @@ module Domain::PostsHelper
post.keywords.map(&:strip).reject(&:blank?).compact
end
sig do
params(post: Domain::Post::InkbunnyPost).returns(
T.nilable(T::Array[String]),
)
end
def keywords_for_ib_post(post)
post.keywords&.map { |keyword| keyword["keyword_name"] }&.compact
end
sig do
params(time: T.nilable(T.any(ActiveSupport::TimeWithZone, Time))).returns(
String,
)
end
def time_ago_in_words_no_prefix(time)
return "never" if time.nil?
time = time.in_time_zone if time.is_a?(Time)
time_ago_in_words(time).delete_prefix("over ").delete_prefix("about ")
end
sig do
params(faved_at_type: Domain::UserPostFav::FavedAtType).returns(String)
end
def faved_at_type_icon(faved_at_type)
case faved_at_type
when Domain::UserPostFav::FavedAtType::PostedAt
"fa-clock" # Clock icon for fallback to posted_at
when Domain::UserPostFav::FavedAtType::Explicit
"fa-calendar-check" # Calendar check for explicitly set time
when Domain::UserPostFav::FavedAtType::Inferred
"fa-chart-line" # Chart line for inferred from regression model
when Domain::UserPostFav::FavedAtType::InferredNow
"fa-bolt" # Lightning bolt for computed on the fly
end
end
sig do
params(faved_at_type: Domain::UserPostFav::FavedAtType).returns(String)
end
def faved_at_type_tooltip(faved_at_type)
case faved_at_type
when Domain::UserPostFav::FavedAtType::PostedAt
"Estimated from posted date"
when Domain::UserPostFav::FavedAtType::Explicit
"Exact time recorded"
when Domain::UserPostFav::FavedAtType::Inferred
"Estimated from regression model"
when Domain::UserPostFav::FavedAtType::InferredNow
"Estimated in real-time from regression model"
end
end
sig { returns(T::Hash[Symbol, T.untyped]) }
def props_for_visual_search_form
{
actionUrl:
Rails.application.routes.url_helpers.visual_results_domain_posts_path,
csrfToken: form_authenticity_token,
}
end
private
sig { params(url: String).returns(T.nilable(String)) }
@@ -664,6 +482,8 @@ module Domain::PostsHelper
nil
end
private
TAG_CATEGORY_ORDER =
T.let(
%i[artist copyright character species general meta lore invalid].freeze,

View File

@@ -1,36 +0,0 @@
# typed: strict
module Domain::UserJobEventHelper
extend T::Sig
include HelpersInterface
sig { params(kind: String).returns(String) }
def add_tracked_object_kind_for_view(kind)
case kind
when "favs"
"Favs"
else
kind.titleize
end
end
sig { params(kind: String).returns(String) }
def add_tracked_object_kind_event_name(kind)
case kind
when "favs"
"favs scan"
else
"#{kind} scan"
end
end
sig { params(duration: ActiveSupport::Duration).returns(String) }
def format_duration_since_last_scan(duration)
if duration.in_days >= 1
pluralize(duration.in_days.round(0), "day")
elsif duration.in_hours >= 1
pluralize(duration.in_hours.round(0), "hour")
else
pluralize(duration.in_minutes.round(0), "min")
end
end
end

View File

@@ -1,32 +0,0 @@
# typed: true
module Domain::Users::FaUsersHelper
extend T::Sig
include HelpersInterface
# TODO - remove this once we've migrated similarity scores to new user model
sig do
params(
user: Domain::User::FaUser,
limit: Integer,
exclude_followed_by: T.nilable(Domain::User::FaUser),
).returns(T::Array[Domain::User::FaUser])
end
def similar_users_by_followed(user, limit: 10, exclude_followed_by: nil)
factors = Domain::Factors::UserUserFollowToFactors.find_by(user: user)
return [] if factors.nil?
relation =
Domain::NeighborFinder
.find_neighbors(factors)
.limit(limit)
.includes(:user)
if exclude_followed_by
relation =
relation.where.not(
user_id: exclude_followed_by.followed_users.select(:to_id),
)
end
relation.map { |factor| factor.user }
end
end

View File

@@ -31,7 +31,7 @@ module Domain::UsersHelper
end
def domain_user_registered_at_ts_for_view(user)
case user
when Domain::User::FaUser, Domain::User::E621User, Domain::User::BlueskyUser
when Domain::User::FaUser, Domain::User::E621User
user.registered_at
else
nil
@@ -91,6 +91,20 @@ module Domain::UsersHelper
end
end
sig { params(user: Domain::User).returns(String) }
def site_name_for_user(user)
case user
when Domain::User::E621User
"E621"
when Domain::User::FaUser
"Furaffinity"
when Domain::User::InkbunnyUser
"Inkbunny"
else
Kernel.raise "Unknown user type: #{user.class}"
end
end
sig { params(user: Domain::User).returns(String) }
def site_icon_path_for_user(user)
case user
@@ -100,10 +114,6 @@ module Domain::UsersHelper
asset_path("domain-icons/fa.png")
when Domain::User::InkbunnyUser
asset_path("domain-icons/inkbunny.png")
when Domain::User::SofurryUser
asset_path("domain-icons/sofurry.png")
when Domain::User::BlueskyUser
asset_path("domain-icons/bluesky.png")
else
Kernel.raise "Unknown user type: #{user.class}"
end
@@ -139,27 +149,6 @@ module Domain::UsersHelper
"#{domain_user_path(user)}/following"
end
sig { params(user: Domain::User, kind: String).returns(String) }
def tracked_objects_domain_user_job_events_path(user, kind:)
unless Domain::UserJobEvent::AddTrackedObject.kinds.include?(kind)
Kernel.raise "invalid kind: #{kind}"
end
"#{domain_user_path(user)}/job_events/tracked_objects/#{kind}"
end
sig { params(user: Domain::User).returns(String) }
def tracked_objects_domain_user_job_events_kinds_path(user)
"#{domain_user_path(user)}/job_events/tracked_objects"
end
sig { params(user: Domain::User, kind: String).returns(String) }
def enqueue_scan_job_domain_user_job_events_path(user, kind:)
unless Domain::UserJobEvent::AddTrackedObject.kinds.include?(kind)
Kernel.raise "invalid kind: #{kind}"
end
"#{domain_user_path(user)}/job_events/enqueue_scan_job/#{kind}"
end
class StatRow < T::ImmutableStruct
include T::Struct::ActsAsComparable
@@ -177,13 +166,13 @@ module Domain::UsersHelper
def stat_rows_for_user(user)
rows = T.let([], T::Array[StatRow])
if user.has_faved_posts?
rows << StatRow.new(name: "Favorites", value: user.user_post_favs.size)
rows << StatRow.new(name: "Favorites", value: user.user_post_favs.count)
end
if user.has_followed_by_users?
can_view_link = policy(user).followed_by?
rows << StatRow.new(
name: "Followed by",
value: user.user_user_follows_to.size,
value: user.user_user_follows_to.count,
link_to: can_view_link ? domain_user_followed_by_path(user) : nil,
)
end
@@ -191,7 +180,7 @@ module Domain::UsersHelper
can_view_link = policy(user).following?
rows << StatRow.new(
name: "Following",
value: user.user_user_follows_from.size,
value: user.user_user_follows_from.count,
link_to: can_view_link ? domain_user_following_path(user) : nil,
)
end
@@ -203,27 +192,6 @@ module Domain::UsersHelper
due_for_scan ? "fa-hourglass-half" : "fa-check"
end
if user.is_a?(Domain::User::BlueskyUser) && can_view_timestamps
rows << StatRow.new(
name: "Page scanned",
value: user.profile_scan,
link_to:
user.last_scan_log_entry && log_entry_path(user.last_scan_log_entry),
fa_icon_class: icon_for.call(user.profile_scan.due?),
hover_title: user.profile_scan.interval.inspect,
)
rows << StatRow.new(
name: "Posts scanned",
value: user.posts_scan,
link_to:
user.last_posts_scan_log_entry &&
log_entry_path(user.last_posts_scan_log_entry),
fa_icon_class: icon_for.call(user.posts_scan.due?),
hover_title: user.posts_scan.interval.inspect,
)
end
if user.is_a?(Domain::User::FaUser) && can_view_timestamps
if can_view_log_entries && hle = user.guess_last_user_page_log_entry
rows << StatRow.new(
@@ -245,8 +213,6 @@ module Domain::UsersHelper
rows << StatRow.new(
name: "Favs",
value: user.favs_scan,
link_to:
tracked_objects_domain_user_job_events_path(user, kind: "favs"),
fa_icon_class: icon_for.call(user.favs_scan.due?),
hover_title: user.favs_scan.interval.inspect,
)

View File

@@ -1,15 +1,10 @@
# typed: strict
# typed: true
module Domain
module VisualSearchHelper
extend T::Sig
# Calculate the similarity percentage between two fingerprint hash values
# @param hash_value [String] The hash value to compare
# @param reference_hash_value [String] The reference hash value to compare against
# @return [Float] The similarity percentage between 0 and 100
sig do
params(hash_value: String, reference_hash_value: String).returns(Float)
end
def calculate_similarity_percentage(hash_value, reference_hash_value)
# Calculate hamming distance between the two hash values
distance =
@@ -29,7 +24,6 @@ module Domain
# Determine the background color class based on similarity percentage
# @param similarity_percentage [Float] The similarity percentage between 0 and 100
# @return [String] The Tailwind CSS background color class
sig { params(similarity_percentage: Float).returns(String) }
def match_badge_bg_color(similarity_percentage)
case similarity_percentage
when 90..100
@@ -46,7 +40,6 @@ module Domain
# Determine the text color class based on similarity percentage
# @param similarity_percentage [Float] The similarity percentage between 0 and 100
# @return [String] The Tailwind CSS text color class
sig { params(similarity_percentage: Float).returns(String) }
def match_text_color(similarity_percentage)
case similarity_percentage
when 90..100
@@ -63,128 +56,8 @@ module Domain
# Get the CSS classes for the match percentage badge
# @param similarity_percentage [Float] The similarity percentage between 0 and 100
# @return [String] The complete CSS classes for the match percentage badge
sig { params(similarity_percentage: Float).returns(String) }
def match_badge_classes(similarity_percentage)
"#{match_badge_bg_color(similarity_percentage)} text-white text-xs rounded-full px-3 py-1 shadow-md"
end
class SimilarFingerprintResult < T::Struct
include T::Struct::ActsAsComparable
const :fingerprint, Domain::PostFile::BitFingerprint
const :similarity_percentage, Float
end
class FingerprintAndDetail < T::Struct
include T::Struct::ActsAsComparable
const :fingerprint, String
const :detail_fingerprint, String
end
# Find similar images based on the fingerprint
sig do
params(
fingerprints: T::Array[FingerprintAndDetail],
limit: Integer,
oversearch: Integer,
includes: T.untyped,
).returns(T::Array[SimilarFingerprintResult])
end
def find_similar_fingerprints(
fingerprints,
limit: 32,
oversearch: 2,
includes: {}
)
ActiveRecord::Base.connection.execute("SET ivfflat.probes = 20")
results =
fingerprints.flat_map do |f|
Domain::PostFile::BitFingerprint
.order(
Arel.sql "(fingerprint_value <~> '#{ActiveRecord::Base.connection.quote_string(f.fingerprint)}')"
)
.limit(limit * oversearch)
.includes(includes)
.to_a
.uniq(&:post_file_id)
.map do |other_fingerprint|
SimilarFingerprintResult.new(
fingerprint: other_fingerprint,
similarity_percentage:
calculate_similarity_percentage(
f.detail_fingerprint,
T.must(other_fingerprint.fingerprint_detail_value),
),
)
end
.sort { |a, b| b.similarity_percentage <=> a.similarity_percentage }
.take(limit)
end
results
.group_by { |s| T.must(s.fingerprint.post_file_id) }
.map do |post_file_id, similar_fingerprints|
T.must(similar_fingerprints.max_by(&:similarity_percentage))
end
.sort_by(&:similarity_percentage)
.reverse
end
class GenerateFingerprintsResult < T::Struct
extend T::Sig
include T::Struct::ActsAsComparable
const :thumb_path, String
const :fingerprint, String
const :detail_fingerprint, String
sig { returns(FingerprintAndDetail) }
def to_fingerprint_and_detail
FingerprintAndDetail.new(
fingerprint: fingerprint,
detail_fingerprint: detail_fingerprint,
)
end
end
# Generate a fingerprint from the image path
sig do
params(image_path: String, content_type: String, tmp_dir: String).returns(
T.nilable(T::Array[GenerateFingerprintsResult]),
)
end
def generate_fingerprints(image_path, content_type, tmp_dir)
# Use the new from_file_path method to create a fingerprint
media = LoadedMedia.from_file(content_type, image_path)
return nil unless media
thumbnail_options =
LoadedMedia::ThumbnailOptions.new(
width: 128,
height: 128,
quality: 95,
size: :force,
interlace: false,
for_frames: [0.0, 0.1, 0.5, 0.9, 1.0],
)
frame_nums =
thumbnail_options
.for_frames
.map do |frame_fraction|
(frame_fraction * (media.num_frames - 1)).to_i
end
.uniq
.sort
frame_nums.map do |frame_num|
tmp_file = File.join(tmp_dir, "frame-#{frame_num}.jpg")
media.write_frame_thumbnail(frame_num, tmp_file, thumbnail_options)
GenerateFingerprintsResult.new(
thumb_path: tmp_file,
fingerprint:
Domain::PostFile::BitFingerprint.from_file_path(tmp_file),
detail_fingerprint:
Domain::PostFile::BitFingerprint.detail_from_file_path(tmp_file),
)
end
"#{match_badge_bg_color(similarity_percentage)} text-white font-semibold text-xs rounded-full px-3 py-1 shadow-md"
end
end
end

View File

@@ -8,7 +8,6 @@ module DomainSourceHelper
"furaffinity" => "Domain::Post::FaPost",
"e621" => "Domain::Post::E621Post",
"inkbunny" => "Domain::Post::InkbunnyPost",
"bluesky" => "Domain::Post::BlueskyPost",
}
end

View File

@@ -1,59 +0,0 @@
# typed: strict
module FaUriHelper
extend T::Sig
FA_CDN_HOSTS = %w[d.facdn.net d.furaffinity.net].freeze
class FaMediaUrlInfo < T::ImmutableStruct
extend T::Sig
include T::Struct::ActsAsComparable
const :url_name, String
const :original_file_posted, Integer
const :latest_file_posted, Integer
const :filename, String
const :filename_with_ts, String
sig { returns(Time) }
def original_file_posted_at
Time.at(original_file_posted)
end
sig { returns(Time) }
def latest_file_posted_at
Time.at(latest_file_posted)
end
end
sig { params(url_str: String).returns(T.nilable(FaMediaUrlInfo)) }
def self.parse_fa_media_url(url_str)
uri = Addressable::URI.parse(url_str)
return nil unless is_fa_cdn_host?(uri.host)
# paths are in the form of `art/<user.url_name>/<latest_file_ts>/<og_file_ts>.<rest_of_filename>`
# latest_file_ts is the timestamp of the most up to date file that has been uploaded for the post
# og_file_ts is the timestamp of when the post was originally made
path = uri.path
match =
path.match(
%r{/art/(?<url_name>[^/]+)/(stories/)?(?<latest_ts>\d+)/(?<original_ts>\d+)\.(?<filename>.*)},
)
return nil unless match
url_name = match[:url_name]
latest_ts = match[:latest_ts].to_i
original_ts = match[:original_ts].to_i
filename = match[:filename]
FaMediaUrlInfo.new(
url_name:,
original_file_posted: original_ts,
latest_file_posted: latest_ts,
filename:,
filename_with_ts: path.split("/").last,
)
end
sig { params(host: String).returns(T::Boolean) }
def self.is_fa_cdn_host?(host)
FA_CDN_HOSTS.include?(host)
end
end

View File

@@ -9,9 +9,9 @@ module GoodJobHelper
class AnsiSegment < T::Struct
include T::Struct::ActsAsComparable
prop :text, String
prop :class_names, T::Array[String], default: []
prop :url, T.nilable(String), default: nil
const :text, String
const :class_names, T::Array[String]
const :url, T.nilable(String)
end
# ANSI escape code pattern
@@ -20,7 +20,7 @@ module GoodJobHelper
sig { params(text: String).returns(T::Array[AnsiSegment]) }
def parse_ansi(text)
segments = T.let([], T::Array[AnsiSegment])
segments = []
current_classes = T::Array[String].new
# Split the text into parts based on ANSI codes
@@ -48,34 +48,24 @@ module GoodJobHelper
end
end
segments.each_with_index do |s0, idx|
s1 = segments[idx + 1] || next
if s0.text == "[hle " && s1.text.match(/\d+/)
segments[idx + 1] = AnsiSegment.new(
text: s1.text,
class_names: s1.class_names,
url: Rails.application.routes.url_helpers.log_entry_path(s1.text),
)
end
end
# go through segments and detect UUIDs, splitting the segment at the uuid
# and adding them to the segments array. Should result in a <before>, <uuid>,
# <after> tuple.
segments.flat_map do |segment|
if (idx = segment.text.index(UUID_REGEX))
if segment.text.match?(UUID_REGEX)
idx = segment.text.index(UUID_REGEX)
[
AnsiSegment.new(
text: segment.text[0...idx] || "",
text: segment.text[0...idx],
class_names: segment.class_names,
),
AnsiSegment.new(
text: segment.text[idx...idx + 36] || "",
text: segment.text[idx...idx + 36],
class_names: ["log-uuid"],
url: "/jobs/jobs/#{segment.text[idx...idx + 36]}",
),
AnsiSegment.new(
text: segment.text[idx + 36..] || "",
text: segment.text[idx + 36..],
class_names: segment.class_names,
),
]
@@ -93,18 +83,11 @@ module GoodJobHelper
sig { params(job: GoodJob::Job).returns(T::Array[JobArg]) }
def arguments_for_job(job)
begin
deserialized =
T.cast(
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
T::Hash[String, T.untyped],
)
rescue ActiveJob::DeserializationError => e
Rails.logger.error(
"error deserializing job arguments: #{e.class.name} - #{e.message}",
deserialized =
T.cast(
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
T::Hash[String, T.untyped],
)
return [JobArg.new(key: :error, value: e.message, inferred: true)]
end
args_hash =
T.cast(deserialized["arguments"].first, T::Hash[Symbol, T.untyped])
args =

View File

@@ -223,13 +223,6 @@ module LogEntriesHelper
tempfile&.close
end
sig { params(str: String).returns(String) }
def reencode_as_utf8_lossy(str)
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
rescue StandardError
str
end
sig { params(rich_text_body: String).returns(String) }
def try_convert_bbcode_to_html(rich_text_body)
rich_text_body.bbcode_to_html(false)
@@ -237,12 +230,6 @@ module LogEntriesHelper
rich_text_body
end
sig { params(rich_text_body: String).returns(String) }
def try_detect_encoding(rich_text_body)
encoding = CharlockHolmes::EncodingDetector.detect(rich_text_body)
encoding ? encoding[:encoding] : "UTF-8"
end
sig { params(log_entry: HttpLogEntry).returns(T.nilable(String)) }
def render_rich_text_content(log_entry)
content_type = log_entry.content_type
@@ -252,9 +239,8 @@ module LogEntriesHelper
is_plain_text = content_type.starts_with?("text/plain")
if is_plain_text
encoding_name = try_detect_encoding(rich_text_body)
rich_text_body = rich_text_body.force_encoding(encoding_name)
rich_text_body = reencode_as_utf8_lossy(rich_text_body)
# rich_text_body.gsub!(/(\r\n|\n|\r)+/, "<br />")
rich_text_body = rich_text_body.force_encoding("UTF-8")
document_html = try_convert_bbcode_to_html(rich_text_body)
elsif content_type.starts_with?("application/pdf")
document_html = convert_with_pdftohtml(rich_text_body)
@@ -370,18 +356,4 @@ module LogEntriesHelper
end
raw fragment
end
sig { params(performed_by: String).returns(String) }
def performed_by_to_short_code(performed_by)
case performed_by
when "direct"
"DR"
when "airvpn-1-netherlands"
"NL"
when "airvpn-2-san-jose"
"SJ"
else
"??"
end
end
end

View File

@@ -6,6 +6,54 @@ module PathsHelper
include HelpersInterface
abstract!
# sig do
# params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
# String,
# )
# end
# def domain_post_path(post, params = {})
# to_path("#{domain_posts_path}/#{post.to_param}", params)
# end
# sig do
# params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
# String,
# )
# end
# def domain_post_faved_by_path(post, params = {})
# to_path("#{domain_post_path(post)}/faved_by", params)
# end
# sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
# def domain_posts_path(params = {})
# to_path("/posts", params)
# end
# sig do
# params(
# post_group: Domain::PostGroup,
# params: T::Hash[Symbol, T.untyped],
# ).returns(String)
# end
# def domain_post_group_posts_path(post_group, params = {})
# to_path("#{domain_post_group_path(post_group)}/posts", params)
# end
# sig do
# params(
# post_group: Domain::PostGroup,
# params: T::Hash[Symbol, T.untyped],
# ).returns(String)
# end
# def domain_post_group_path(post_group, params = {})
# to_path("#{domain_post_groups_path}/#{post_group.to_param}", params)
# end
# sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
# def domain_post_groups_path(params = {})
# to_path("/pools", params)
# end
private
sig do

View File

@@ -0,0 +1,22 @@
# typed: true
module SourceHelper
def self.source_name_to_class_name
{
"furaffinity" => "Domain::Fa::Post",
"e621" => "Domain::E621::Post",
"inkbunny" => "Domain::Inkbunny::Post",
}
end
def self.all_source_names
source_name_to_class_name.keys
end
def self.source_names_to_class_names(list)
list.map { |source| source_name_to_class_name[source] }.compact
end
def self.has_all_sources?(list)
list.sort == all_source_names.sort
end
end

View File

@@ -1,47 +0,0 @@
# typed: strict
module TelegramBotLogsHelper
extend T::Sig
sig { params(telegram_bot_log: TelegramBotLog).returns(String) }
def status_color_class(telegram_bot_log)
case telegram_bot_log.status
when "processing"
"bg-blue-100 text-blue-800"
when "success"
"bg-green-100 text-green-800"
when "error"
"bg-red-100 text-red-800"
when "invalid_image"
"bg-orange-100 text-orange-800"
else
"bg-slate-100 text-slate-800"
end
end
sig { params(blob_file: T.nilable(BlobFile)).returns(String) }
def image_dimensions_for_blob_file(blob_file)
return "N/A" unless blob_file
return "N/A" unless blob_file.content_type&.start_with?("image/")
begin
media =
LoadedMedia.from_file(
T.must(blob_file.content_type),
blob_file.absolute_file_path,
)
if media.is_a?(LoadedMedia::StaticImage)
vips_image = media.instance_variable_get(:@vips_image)
"#{vips_image.width}×#{vips_image.height}"
elsif media.is_a?(LoadedMedia::Gif)
width = media.instance_variable_get(:@width)
height = media.instance_variable_get(:@height)
"#{width}×#{height}"
else
"N/A"
end
rescue StandardError
"Unable to determine"
end
end
end

View File

@@ -1,43 +0,0 @@
import * as React from 'react';
import { FileData } from './PostFiles';
import { FileDetails } from './FileDetails';
interface DisplayedFileProps {
file: FileData;
}
export const DisplayedFile: React.FC<DisplayedFileProps> = ({ file }) => {
return (
<>
{/* File content display */}
<div className="file-content-display mb-4">
{file.contentHtml ? (
<div dangerouslySetInnerHTML={{ __html: file.contentHtml }} />
) : (
<section className="flex grow justify-center text-slate-500">
<div>
<i className="fa-solid fa-file-arrow-down mr-2"></i>
{fileStateContent(file.fileState)}
</div>
</section>
)}
</div>
{/* File details */}
{file.fileDetails && <FileDetails {...file.fileDetails} />}
</>
);
};
function fileStateContent(fileState: FileData['fileState']) {
switch (fileState) {
case 'pending':
return 'File pending download';
case 'terminal_error':
return 'File download failed';
}
return 'No file content available';
}
export default DisplayedFile;

View File

@@ -1,85 +0,0 @@
import * as React from 'react';
import { FileData } from './PostFiles';
interface FileCarouselProps {
files: FileData[];
totalFiles: number;
selectedIndex: number;
onFileSelect: (fileId: number, index: number) => void;
}
export const FileCarousel: React.FC<FileCarouselProps> = ({
files,
totalFiles,
selectedIndex,
onFileSelect,
}) => {
const handleFileClick = (file: FileData) => {
onFileSelect(file.id, file.index);
};
// Only render if there are multiple files
if (files.length <= 1) {
return null;
}
return (
<div className="mb-4">
<div className="flex gap-2 overflow-x-auto" id="file-carousel">
{files.map((file) => {
const isSelected = file.index === selectedIndex;
const buttonClasses = [
'flex-shrink-0',
'w-20',
'h-20',
'rounded-md',
'border-2',
'transition-all',
'duration-200',
'hover:border-blue-400',
isSelected ? 'border-blue-500' : 'border-gray-300',
];
if (file.thumbnailPath?.type === 'url') {
buttonClasses.push('overflow-hidden');
} else {
buttonClasses.push(
'bg-gray-100',
'flex',
'items-center',
'justify-center',
);
}
const thumbnail =
file.thumbnailPath?.type === 'url' ? (
<img
src={file.thumbnailPath.value}
className="h-full w-full object-cover"
alt={`File ${file.index + 1}`}
/>
) : file.thumbnailPath?.type === 'icon' ? (
<i className={`${file.thumbnailPath.value} text-slate-500`}></i>
) : (
<i className="fa-solid fa-file text-gray-400"></i>
);
return (
<button
key={file.id}
className={buttonClasses.join(' ')}
onClick={() => handleFileClick(file)}
data-file-id={file.id}
data-index={file.index}
title={`File ${file.index + 1} of ${totalFiles}`}
>
{thumbnail}
</button>
);
})}
</div>
</div>
);
};
export default FileCarousel;

View File

@@ -1,113 +0,0 @@
import * as React from 'react';
import { PostFileState } from './PostFiles';
import { byteCountToHumanSize } from '../utils/byteCountToHumanSize';
import SkySection from './SkySection';
export interface FileDetailsProps {
contentType: string;
fileSize: number;
responseTimeMs: number;
responseStatusCode: number;
postFileState: PostFileState;
logEntryId: number;
logEntryPath: string;
}
export const FileDetails: React.FC<FileDetailsProps> = ({
contentType,
fileSize,
responseTimeMs,
responseStatusCode,
postFileState,
logEntryId,
logEntryPath,
}) => {
return (
<SkySection
title="File Details"
contentClassName="grid grid-cols-3 sm:grid-cols-6 text-sm"
>
<TitleStat
label="Type"
value={contentType}
iconClass="fa-solid fa-file"
/>
<TitleStat
label="Size"
value={byteCountToHumanSize(fileSize)}
iconClass="fa-solid fa-weight-hanging"
/>
<TitleStat
label="Time"
value={responseTimeMs == -1 ? undefined : `${responseTimeMs}ms`}
iconClass="fa-solid fa-clock"
/>
<TitleStat
label="Status"
value={responseStatusCode}
textClass={
responseStatusCode == 200 ? 'text-green-600' : 'text-red-600'
}
iconClass="fa-solid fa-signal"
/>
<TitleStat
label="State"
value={postFileState}
textClass={postFileState == 'ok' ? 'text-green-600' : 'text-red-600'}
iconClass="fa-solid fa-circle-check"
/>
<TitleStat label="Log Entry" iconClass="fa-solid fa-file-pen">
<a
href={logEntryPath}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800"
>
#{logEntryId}
</a>
</TitleStat>
</SkySection>
);
};
const TitleStat: React.FC<{
label: string;
value?: string | number;
iconClass: string;
textClass?: string;
children?: React.ReactNode;
}> = ({ label, value, iconClass, textClass = 'text-slate-600', children }) => {
function valueElement(value: string | number | undefined) {
const defaultTextClass = 'font-normal';
if (value === undefined) {
return <span className="text-slate-500">&mdash;</span>;
} else if (typeof value === 'number') {
return (
<span className={`${textClass} ${defaultTextClass}`}>
{value.toLocaleString()}
</span>
);
} else {
return (
<span className={`${textClass} ${defaultTextClass}`}>{value}</span>
);
}
}
const gridInnerBorderClasses =
'border-r border-b border-slate-300 last:border-r-0 sm:last:border-r-0 [&:nth-child(3)]:border-r-0 sm:[&:nth-child(3)]:border-r [&:nth-last-child(-n+3)]:border-b-0 sm:[&:nth-last-child(-n+6)]:border-b-0';
return (
<div
className={`flex flex-col justify-center px-2 py-1 ${gridInnerBorderClasses}`}
>
<div className="flex items-center gap-2 font-light text-slate-600">
<i className={iconClass}></i>
<span>{label}</span>
</div>
{children || valueElement(value)}
</div>
);
};
export default FileDetails;

View File

@@ -10,7 +10,6 @@ const COMMON_LIST_ELEM_CLASSES = `
interface PropTypes {
value: string;
subvalue?: string;
subtext?: string;
thumb?: string;
isLast: boolean;
@@ -22,7 +21,6 @@ interface PropTypes {
export default function ListItem({
value,
subvalue,
thumb,
isLast,
selected,
@@ -31,11 +29,11 @@ export default function ListItem({
subtext,
domainIcon,
}: PropTypes) {
const groupHoverClassName = 'group-hover:text-slate-200';
const iconClassName = ['ml-2'];
const textClassName = [
COMMON_LIST_ELEM_CLASSES,
'group flex items-center justify-between',
'relative flex items-center justify-between',
'border-t-0',
isLast && 'rounded-b-lg',
style === 'item' && selected && 'bg-slate-700 text-slate-100',
style === 'info' && 'text-slate-500 italic',
@@ -56,7 +54,7 @@ export default function ListItem({
{style === 'error' && (
<Icon type="exclamation-circle" className={iconClassName.join(' ')} />
)}
<div className={`${textClassName.join(' ')}`}>
<div className={textClassName.join(' ')}>
<div className="inline-block w-8">
{thumb && (
<img
@@ -66,44 +64,14 @@ export default function ListItem({
/>
)}
</div>
<div className="flex flex-grow flex-col pl-1">
<span
className={['text-lg font-light', subvalue && 'leading-tight']
.filter(Boolean)
.join(' ')}
>
{value}
</span>
{subvalue && (
<span
className={[
'text-sm font-normal group-hover:text-slate-200',
!selected && 'text-slate-500',
selected && 'text-slate-200',
]
.filter(Boolean)
.join(' ')}
>
{subvalue}
</span>
)}
</div>
<div className="inline-block flex-grow pl-1">{value}</div>
{subtext && (
<div
className={[
'vertical-align-middle float-right inline-block pl-1 text-sm italic',
!selected && 'text-slate-500',
selected && 'text-slate-300',
groupHoverClassName,
]
.filter(Boolean)
.join(' ')}
>
<div className="vertical-align-middle float-right inline-block pl-1 text-sm italic text-slate-500">
{subtext}
</div>
)}
{domainIcon && (
<img src={domainIcon} alt="domain icon" className="ml-1 inline w-6" />
<img src={domainIcon} alt="domain icon" className="inline w-6 pl-1" />
)}
</div>
</a>

View File

@@ -1,89 +0,0 @@
import * as React from 'react';
interface PolynomialEquationProps {
coefficients: number[];
className?: string;
}
export const PolynomialEquation: React.FC<PolynomialEquationProps> = ({
coefficients,
className = '',
}) => {
const renderTerm = (coeff: number, degree: number, isFirst: boolean) => {
if (Math.abs(coeff) < 1e-10) return null; // Skip near-zero coefficients
const absCoeff = Math.abs(coeff);
const isPositive = coeff >= 0;
// Determine coefficient display
let coeffDisplay = '';
if (degree === 0) {
// Constant term
coeffDisplay = absCoeff.toFixed(3);
} else if (absCoeff === 1) {
// Coefficient of 1, don't show it
coeffDisplay = '';
} else {
// Regular coefficient
coeffDisplay = absCoeff.toFixed(3);
}
// Determine variable display
let variableDisplay = null;
if (degree === 0) {
// No variable for constant term
variableDisplay = null;
} else if (degree === 1) {
// Linear term: just x
variableDisplay = <span>x</span>;
} else {
// Higher degree: x with superscript
variableDisplay = (
<span>
x<sup>{degree}</sup>
</span>
);
}
// Determine sign display
let signDisplay = null;
if (isFirst) {
// First term: only show minus if negative
signDisplay = isPositive ? null : <span></span>;
} else {
// Subsequent terms: always show sign with spacing
signDisplay = <span className="mx-1">{isPositive ? '+' : ''}</span>;
}
return (
<React.Fragment key={degree}>
{signDisplay}
{coeffDisplay && <span>{coeffDisplay}</span>}
{variableDisplay}
</React.Fragment>
);
};
const terms = [];
let hasTerms = false;
// Render terms from highest to lowest degree
for (let i = coefficients.length - 1; i >= 0; i--) {
const term = renderTerm(coefficients[i], i, !hasTerms);
if (term !== null) {
terms.push(term);
hasTerms = true;
}
}
if (!hasTerms) {
return <span className={className}>y = 0</span>;
}
return (
<span className={className}>
<span>y = </span>
{terms}
</span>
);
};

View File

@@ -1,123 +0,0 @@
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { FileCarousel } from './FileCarousel';
import { DisplayedFile } from './DisplayedFile';
import { FileDetailsProps } from './FileDetails';
export type PostFileState =
| 'pending'
| 'ok'
| 'file_error'
| 'retryable_error'
| 'terminal_error'
| 'removed';
export interface FileData {
id: number;
fileState: PostFileState;
thumbnailPath?: { type: 'icon' | 'url'; value: string };
hasContent: boolean;
index: number;
contentHtml?: string;
fileDetails?: FileDetailsProps;
}
interface PostFilesProps {
files: FileData[];
initialSelectedIndex?: number;
}
export const PostFiles: React.FC<PostFilesProps> = ({
files,
initialSelectedIndex,
}) => {
if (initialSelectedIndex == null) {
initialSelectedIndex = files.findIndex((file) => file.fileState === 'ok');
if (initialSelectedIndex === -1) {
initialSelectedIndex = 0;
}
}
const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
// Update URL parameter when selected file changes
const updateUrlWithFileIndex = (index: number) => {
if (typeof window === 'undefined' || files.length <= 1) return;
const url = new URL(window.location.href);
url.searchParams.set('idx', index.toString());
window.history.replaceState({}, '', url.toString());
};
const handleFileSelect = (fileId: number, index: number) => {
setSelectedIndex(index);
updateUrlWithFileIndex(index);
};
const navigateToNextFile = () => {
if (files.length > 1) {
const nextIndex = (selectedIndex + 1) % files.length;
handleFileSelect(files[nextIndex].id, nextIndex);
}
};
const navigateToPreviousFile = () => {
if (files.length > 1) {
const prevIndex = (selectedIndex - 1 + files.length) % files.length;
handleFileSelect(files[prevIndex].id, prevIndex);
}
};
// Add keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Only handle arrow keys if we have multiple files
if (files.length <= 1) return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
navigateToPreviousFile();
break;
case 'ArrowRight':
event.preventDefault();
navigateToNextFile();
break;
}
};
// Add event listener to document
document.addEventListener('keydown', handleKeyDown);
// Cleanup event listener on unmount
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectedIndex, files.length]);
const selectedFile = files[selectedIndex];
return (
<section id="file-display-section">
{files.length == 0 && (
<div className="flex grow justify-center text-slate-500">
<div className="flex items-center gap-2">
<i className="fa-solid fa-file-circle-exclamation"></i>
No files
</div>
</div>
)}
{files.length > 1 && (
<FileCarousel
files={files}
totalFiles={files.length}
selectedIndex={selectedIndex}
onFileSelect={handleFileSelect}
/>
)}
{selectedFile && <DisplayedFile file={selectedFile} />}
</section>
);
};
export default PostFiles;

View File

@@ -12,7 +12,7 @@ interface PostHoverPreviewWrapperProps {
postPath: string;
postThumbnailPath: string;
postThumbnailAlt: string;
postDomainIcon?: string;
postDomainIcon: string;
creatorName?: string;
creatorAvatarPath?: string;
}
@@ -43,14 +43,14 @@ export const PostHoverPreviewWrapper: React.FC<
href={postPath}
className={anchorClassNamesForVisualStyle(visualStyle, true)}
>
{postDomainIcon && (
{visualStyle === 'description-section-link' && (
<img
src={postDomainIcon}
alt={postTitle || postThumbnailAlt}
alt={postTitle}
className={iconClassNamesForSize('small')}
/>
)}
<span className="truncate">{linkText}</span>
{linkText}
</a>
</PostHoverPreview>
);

View File

@@ -1,30 +0,0 @@
import * as React from 'react';
export interface SkySectionProps {
title: string;
children?: React.ReactNode;
contentClassName?: string;
}
export const SkySection: React.FC<SkySectionProps> = ({
title,
children,
contentClassName,
}) => {
return (
<div className="sky-section w-full">
<SkySectionHeader title={title} />
<div className={contentClassName}>{children}</div>
</div>
);
};
export default SkySection;
export const SkySectionHeader: React.FC<SkySectionProps> = ({ title }) => {
return (
<div className="section-header flex items-center justify-between border-b py-2">
<span>{title}</span>
</div>
);
};

View File

@@ -1,167 +0,0 @@
import * as React from 'react';
export interface TableCell {
value: string;
sortKey: string | number;
}
export interface TableData {
id: string;
cells: TableCell[];
}
export interface TableHeader {
label: string;
key: string;
align: 'left' | 'right';
}
interface SortableTableProps {
headers: TableHeader[];
data: TableData[];
defaultSortKey: string;
defaultSortOrder: 'asc' | 'desc';
}
type SortOrder = 'asc' | 'desc';
export const SortableTable: React.FC<SortableTableProps> = ({
headers,
data,
defaultSortKey,
defaultSortOrder,
}) => {
const [sortKey, setSortKey] = React.useState<string>(defaultSortKey);
const [sortOrder, setSortOrder] = React.useState<SortOrder>(defaultSortOrder);
const handleSort = (headerKey: string) => {
if (sortKey === headerKey) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(headerKey);
setSortOrder('desc');
}
};
const sortedData = React.useMemo(() => {
const headerIndex = headers.findIndex((h) => h.key === sortKey);
if (headerIndex === -1) return data;
return [...data].sort((a, b) => {
const aValue = a.cells[headerIndex]?.sortKey;
const bValue = b.cells[headerIndex]?.sortKey;
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
const comparison = aValue - bValue;
return sortOrder === 'asc' ? comparison : -comparison;
}
return 0;
});
}, [data, sortKey, sortOrder, headers]);
const gridStyle: React.CSSProperties = {
borderRadius: '0.5rem',
border: '1px solid #e2e8f0',
backgroundColor: 'white',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
overflow: 'hidden',
};
const containerStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr auto auto',
};
const headerStyle: React.CSSProperties = {
backgroundColor: '#f8fafc',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
color: '#334155',
cursor: 'pointer',
userSelect: 'none',
};
const cellStyle: React.CSSProperties = {
padding: '0.25rem 1rem',
borderRight: '1px solid #e2e8f0',
};
const getSortIndicator = (headerKey: string) => {
if (sortKey !== headerKey) {
return (
<span
style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}
>
</span>
);
}
return (
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
);
};
return (
<div style={gridStyle}>
<div style={containerStyle}>
{/* Header Row */}
<div className="contents">
{headers.map((header, index) => (
<div
key={header.key}
style={{
...headerStyle,
textAlign: header.align,
...(index === headers.length - 1
? { borderRight: 'none' }
: {}),
}}
onClick={() => handleSort(header.key)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8fafc';
}}
>
{header.label}
{getSortIndicator(header.key)}
</div>
))}
</div>
{/* Data Rows */}
{sortedData.map((row) => (
<div key={row.id} className="group contents">
{row.cells.map((cell, cellIndex) => (
<div
key={cellIndex}
style={{
...cellStyle,
textAlign: headers[cellIndex].align,
fontSize: '0.875rem',
fontWeight: cellIndex === 0 ? 500 : 400,
color: cellIndex === 0 ? '#0f172a' : '#64748b',
...(cellIndex === row.cells.length - 1
? { borderRight: 'none' }
: {}),
}}
className="transition-colors duration-150 group-hover:bg-slate-50"
>
{cell.value}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default SortableTable;

View File

@@ -1,46 +0,0 @@
import * as React from 'react';
interface StatsCardProps {
requestCount: number;
timeWindow: string;
requestsPerSecond: string;
totalBytes: string;
bytesPerSecond: string;
}
export const StatsCard: React.FC<StatsCardProps> = ({
requestCount,
timeWindow,
requestsPerSecond,
totalBytes,
bytesPerSecond,
}) => {
const cardStyle: React.CSSProperties = {
borderRadius: '0.5rem',
border: '1px solid #e2e8f0',
backgroundColor: 'white',
padding: '1rem',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
};
return (
<div className="mt-6" style={cardStyle}>
<div className="text-xl font-bold text-slate-900">
{requestCount} requests
<span className="text-base font-normal text-slate-600">
{' '}
in last {timeWindow}
</span>
</div>
<div className="mt-1 text-slate-600">
<span className="font-medium">{requestsPerSecond}</span> requests/sec
</div>
<div className="mt-1 text-sm text-slate-600">
<span className="font-medium">{totalBytes}</span> transferred {' '}
<span className="font-medium">{bytesPerSecond}</span>/sec
</div>
</div>
);
};
export default StatsCard;

View File

@@ -1,223 +0,0 @@
import * as React from 'react';
import { StatsCard } from './StatsCard';
import { SortableTable, TableData } from './SortableTable';
interface StatsPageProps {
timeWindow: number; // in seconds
lastWindowCount: number;
lastWindowBytes: number;
requestsPerSecond: string;
totalBytesFormatted: string;
bytesPerSecondFormatted: string;
timeWindowFormatted: string;
contentTypeCounts: Array<{
content_type: string;
count: number;
bytes: number;
countFormatted: string;
bytesFormatted: string;
}>;
byDomainCounts: Array<{
domain: string;
count: number;
bytes: number;
countFormatted: string;
bytesFormatted: string;
}>;
availableTimeWindows: Array<{
seconds: number;
label: string;
active: boolean;
path: string;
}>;
}
export const StatsPage: React.FC<StatsPageProps> = ({
lastWindowCount,
requestsPerSecond,
totalBytesFormatted,
bytesPerSecondFormatted,
timeWindowFormatted,
contentTypeCounts,
byDomainCounts,
availableTimeWindows,
}) => {
const contentTypeData: TableData[] = contentTypeCounts.map((item) => ({
id: item.content_type,
cells: [
{ value: item.content_type, sortKey: item.content_type },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes },
],
}));
const domainData: TableData[] = byDomainCounts.map((item) => ({
id: item.domain,
cells: [
{ value: item.domain, sortKey: item.domain },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes },
],
}));
return (
<div className="mx-auto mt-8 max-w-7xl px-4">
{/* Header Section */}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
{/* Top Bar */}
<div className="border-b border-slate-200 bg-slate-50 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">
HTTP Request Analytics
</h1>
<a
href="/log_entries"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-white hover:text-slate-900 hover:shadow-sm"
>
<i className="fas fa-arrow-left" />
Back to Log Entries
</a>
</div>
</div>
{/* Stats Summary */}
<div className="border-t border-slate-200 bg-gradient-to-br from-blue-50 to-indigo-50 px-6 py-6">
<div className="mb-4 text-center">
<h3 className="mb-1 text-lg font-semibold text-slate-900">
Summary for {timeWindowFormatted}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Total Requests */}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Total Requests
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{lastWindowCount.toLocaleString()}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<i className="fas fa-chart-bar text-blue-600" />
</div>
</div>
</div>
{/* Requests per Second */}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Requests/sec
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{requestsPerSecond}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<i className="fas fa-bolt text-green-600" />
</div>
</div>
</div>
{/* Total Data */}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Total Data
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{totalBytesFormatted}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<i className="fas fa-database text-purple-600" />
</div>
</div>
</div>
{/* Data per Second */}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Data/sec
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{bytesPerSecondFormatted}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
<i className="fas fa-download text-orange-600" />
</div>
</div>
</div>
</div>
</div>
{/* Time Window Selector */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 px-4 py-4">
<div className="text-center">
<div className="inline-flex flex-wrap justify-center gap-1 rounded-lg border border-white/50 bg-white/70 p-1 shadow-md backdrop-blur-sm">
{availableTimeWindows.map((timeWindowOption, index) => (
<React.Fragment key={timeWindowOption.seconds}>
{timeWindowOption.active ? (
<span className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm">
{timeWindowOption.label}
</span>
) : (
<a
href={timeWindowOption.path}
className="rounded-md border border-transparent px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-slate-200 hover:bg-white hover:text-slate-900 hover:shadow-sm"
>
{timeWindowOption.label}
</a>
)}
</React.Fragment>
))}
</div>
</div>
</div>
</div>
{/* Tables Grid - 2 columns */}
<div className="my-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<h2 className="mb-3 text-xl font-bold text-slate-900">
By Content Type
</h2>
<SortableTable
headers={[
{ label: 'Content Type', key: 'content_type', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' },
]}
data={contentTypeData}
defaultSortKey="count"
defaultSortOrder="desc"
/>
</div>
<div>
<h2 className="mb-3 text-xl font-bold text-slate-900">By Domain</h2>
<SortableTable
headers={[
{ label: 'Domain', key: 'domain', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' },
]}
data={domainData}
defaultSortKey="bytes"
defaultSortOrder="desc"
/>
</div>
</div>
</div>
);
};
export default StatsPage;

View File

@@ -1,226 +0,0 @@
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import {
calculateTrendLines,
calculateChartStatistics,
type ChartStatistics,
} from '../utils/chartStatistics';
import { PolynomialEquation } from './PolynomialEquation';
declare global {
interface Window {
Chart: any;
}
}
interface TrackedObjectData {
x: number;
y: number;
scanDate: string;
durationFormatted: string;
}
interface TrackedObjectsChartProps {
data: TrackedObjectData[];
objectKind: string;
}
export const TrackedObjectsChart: React.FC<TrackedObjectsChartProps> = ({
data,
objectKind,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<any>(null);
const [statistics, setStatistics] = useState<ChartStatistics | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (!canvasRef.current || data.length === 0 || !window.Chart) {
return;
}
// Add a small delay to ensure DOM is stable
const initTimer = setTimeout(() => {
if (!canvasRef.current) return;
// Calculate trend lines and statistics
const trendLines = calculateTrendLines(data);
const chartStatistics = calculateChartStatistics(data);
setStatistics(chartStatistics);
// Create trend line datasets for all polynomial fits
const minX = Math.min(...data.map((p) => p.x));
const maxX = Math.max(...data.map((p) => p.x));
const numPoints = 50; // More points for smooth curve
const trendLineDatasets = trendLines.map((trendLine) => {
const trendLineData = [];
for (let i = 0; i <= numPoints; i++) {
const x = minX + (maxX - minX) * (i / numPoints);
// Calculate y using polynomial: y = c₀ + c₁x + c₂x² + ...
let y = 0;
for (let j = 0; j < trendLine.coeffs.length; j++) {
y += trendLine.coeffs[j] * Math.pow(x, j);
}
trendLineData.push({ x, y });
}
return {
label: `${trendLine.degree === 1 ? 'Linear' : 'Quadratic'} Trend (R² = ${trendLine.rSquared.toFixed(3)})`,
data: trendLineData,
type: 'line',
borderColor: trendLine.color,
backgroundColor: trendLine.color.replace('1)', '0.1)'),
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 2,
pointBackgroundColor: 'transparent',
pointBorderColor: 'transparent',
pointHoverBackgroundColor: trendLine.color,
pointHoverBorderColor: trendLine.color,
fill: false,
tension: 0,
};
});
const ctx = canvasRef.current.getContext('2d');
// Destroy existing chart if it exists
if (chartRef.current) {
chartRef.current.destroy();
}
chartRef.current = new window.Chart(ctx, {
type: 'scatter',
data: {
datasets: [
{
label: `${objectKind} Added`,
data: data,
backgroundColor: 'rgba(59, 130, 246, 0.6)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 1,
pointRadius: 5,
pointHoverRadius: 7,
},
...trendLineDatasets,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0, // Disable animations to prevent jitter
},
scales: {
x: {
title: {
display: true,
text: `${objectKind} Added`,
},
min: 0,
},
y: {
title: {
display: true,
text: 'Days Since Last Scan',
},
min: 0,
},
},
plugins: {
tooltip: {
intersect: false,
mode: 'nearest',
callbacks: {
title: function (context: any) {
const dataPoint = context[0];
if (dataPoint.dataset.label?.includes('Trend')) {
return `Predict ${context[0].parsed.x.toFixed(0)} ${objectKind.toLowerCase()} added`;
}
return data[dataPoint.dataIndex]?.scanDate || '';
},
label: function (context: any) {
if (context.dataset.label?.includes('Trend')) {
return `${context.parsed.y.toFixed(1)} days`;
}
return `${context.parsed.x} ${objectKind.toLowerCase()} added`;
},
afterLabel: function (context: any) {
if (!context.dataset.label?.includes('Trend')) {
const dataPoint = data[context.dataIndex];
return `Time gap: ${dataPoint?.durationFormatted || 'N/A'}`;
}
return '';
},
},
},
legend: {
display: true,
position: 'top' as const,
},
},
},
});
setIsInitialized(true);
}, 100); // 100ms delay
return () => {
clearTimeout(initTimer);
if (chartRef.current) {
chartRef.current.destroy();
}
};
}, [data, objectKind]);
return (
<section className="rounded-md border border-slate-300 bg-white p-2">
<div className="h-96 w-full">
<canvas ref={canvasRef} />
</div>
{statistics && (
<div className="mt-2 space-y-2 text-center text-sm text-slate-600">
<div className="flex justify-center gap-6">
<div>
<span className="font-medium">{objectKind} per day: </span>
<span className="font-mono">
{statistics.favsPerDay > 0
? statistics.favsPerDay.toFixed(2)
: 'N/A'}
</span>
</div>
</div>
{statistics.trendLines.length > 0 ? (
<div className="space-y-1">
{statistics.trendLines.map((trendLine, index) => (
<div key={index} className="flex justify-center gap-4 text-xs">
<div>
<span
className="font-medium"
style={{ color: trendLine.color }}
>
{trendLine.degree === 1 ? 'Linear' : 'Quadratic'} R²:
</span>
<span className="ml-1 font-mono">
{trendLine.rSquared.toFixed(3)}
</span>
</div>
<PolynomialEquation
coefficients={trendLine.coeffs}
className="font-mono text-slate-500"
/>
</div>
))}
</div>
) : (
<div className="text-xs text-slate-500">
Need at least 2 data points for trend analysis
</div>
)}
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,144 @@
import * as React from 'react';
import { useRef, useEffect, useState } from 'react';
interface UserMenuProps {
userEmail: string;
userRole?: 'admin' | 'moderator';
editProfilePath: string;
signOutPath: string;
csrfToken: string;
globalStatesPath: string;
goodJobPath: string;
grafanaPath: string;
prometheusPath: string;
}
export const UserMenu: React.FC<UserMenuProps> = ({
userEmail,
userRole,
editProfilePath,
signOutPath,
csrfToken,
globalStatesPath,
goodJobPath,
grafanaPath,
prometheusPath,
}) => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSignOut = (e: React.FormEvent) => {
e.preventDefault();
const form = document.createElement('form');
form.method = 'POST';
form.action = signOutPath;
form.style.display = 'none';
const methodInput = document.createElement('input');
methodInput.type = 'hidden';
methodInput.name = '_method';
methodInput.value = 'delete';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'authenticity_token';
csrfInput.value = csrfToken;
form.appendChild(methodInput);
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
};
return (
<div className="relative" ref={menuRef}>
<button
className="flex items-center space-x-2 text-slate-600 hover:text-slate-900 focus:outline-none"
onClick={() => setIsOpen(!isOpen)}
>
<i className="fas fa-user-circle text-2xl" />
<i className="fas fa-chevron-down text-xs" />
</button>
<div
className={`absolute right-0 z-50 mt-2 w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ${
isOpen ? 'visible opacity-100' : 'invisible opacity-0'
}`}
>
<div className="border-b border-slate-200 px-4 py-2 text-sm text-slate-700">
<div className="font-medium">{userEmail}</div>
{userRole === 'admin' && (
<span className="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
Admin
</span>
)}
{userRole === 'moderator' && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
Mod
</span>
)}
</div>
{userRole === 'admin' && (
<>
<a
href={globalStatesPath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-cogs mr-2 w-5" />
<span>Global State</span>
</a>
<a
href={goodJobPath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-tasks mr-2 w-5" />
<span>Jobs Queue</span>
</a>
<a
href={grafanaPath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-chart-line mr-2 w-5" />
<span>Grafana</span>
</a>
<a
href={prometheusPath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-chart-bar mr-2 w-5" />
<span>Prometheus</span>
</a>
</>
)}
<a
href={editProfilePath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-cog mr-2 w-5" />
<span>Edit Profile</span>
</a>
<button
onClick={handleSignOut}
className="flex w-full items-center px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-sign-out-alt mr-2 w-5" />
<span>Sign Out</span>
</button>
</div>
</div>
);
};

View File

@@ -3,6 +3,7 @@ import * as React from 'react';
import { useCallback, useRef, useState } from 'react';
import Icon from './Icon';
import ListItem from './ListItem';
import Trie, { TrieNode } from '../lib/Trie';
// 1. Group related constants
const CONFIG = {
@@ -17,7 +18,7 @@ const STYLES = {
],
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
INPUT_CLASSNAME: `text-slate-500 focus:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
INPUT_CLASSNAME: `text-slate-500 group-focus-within:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
} as const;
// 2. Simplify logging
@@ -33,7 +34,6 @@ interface PropTypes {
interface User {
id: number;
name: string;
name_secondary?: string;
thumb?: string;
show_path: string;
num_posts?: number;
@@ -46,6 +46,10 @@ interface ServerResponse {
users: User[];
}
type TrieValue = [number, string];
type TrieType = Trie<TrieValue>;
type TrieNodeType = TrieNode<TrieValue>;
export default function UserSearchBar({ isServerRendered }: PropTypes) {
isServerRendered = !!isServerRendered;
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
@@ -115,6 +119,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
}
} catch (err) {
if (!err.message.includes('aborted')) {
log.error('error loading user trie: ', err);
setState((s) => ({
...s,
errorMessage: `error loading users: ` + err.message,
@@ -143,6 +148,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
const searchForUserDebounced = useCallback(
debounce(async (userName) => {
log.info('sending search for ', userName);
setState((s) => ({ ...s, typingSettled: true }));
searchForUser(userName);
}, 250),
@@ -152,6 +158,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
function invokeIdx(idx) {
const user = state.userList[idx];
if (user) {
log.info('selecting user: ', user);
setState((s) => ({ ...s, userName: user.name }));
inputRef.current.value = user.name;
window.location.href = user.show_path;
@@ -178,7 +185,9 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
function UserSearchBarItems() {
return (
<div className="divide-y divide-slate-300 overflow-hidden border border-slate-300 bg-slate-50 shadow-lg sm:rounded-xl">
<div
className={`${anyShown || 'border-b-0'} divide-y divide-inherit rounded-b-lg border border-t-0 border-inherit`}
>
{visibility.error ? (
<ListItem
key="error"
@@ -199,24 +208,13 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
) : null}
{visibility.items
? state.userList.map(
(
{
name,
name_secondary,
thumb,
show_path,
num_posts,
domain_icon,
},
idx,
) => (
({ name, thumb, show_path, num_posts, domain_icon }, idx) => (
<ListItem
key={'name-' + name}
isLast={idx == state.userList.length - 1}
selected={idx == state.selectedIdx}
style="item"
value={name}
subvalue={name_secondary}
thumb={thumb}
href={show_path}
subtext={num_posts ? `${num_posts.toString()} posts` : ''}
@@ -269,7 +267,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
return (
<div
className={[
'relative mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
'group mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
'focus-within:border-slate-400 sm:max-w-md',
'border-slate-300 bg-slate-50 p-2 shadow-lg',
].join(' ')}
@@ -292,6 +290,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
STYLES.INPUT_CLASSNAME,
'rounded-lg outline-none',
'bg-slate-50 placeholder:italic',
anyShown && 'rounded-b-none',
]
.filter(Boolean)
.join(' ')}
@@ -307,11 +306,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
ref={inputRef}
/>
</label>
{anyShown && (
<div className="absolute left-0 right-0 top-full mt-1">
<UserSearchBarItems />
</div>
)}
<UserSearchBarItems />
</div>
);
}

View File

@@ -1,858 +0,0 @@
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface VisualSearchFormProps {
actionUrl: string;
csrfToken: string;
}
interface FeedbackMessage {
text: string;
type: 'success' | 'error' | 'warning';
}
interface ImageState {
file: File;
previewUrl: string;
originalFileSize: number | null;
thumbnailFile?: File; // For video thumbnails
isVideo?: boolean;
}
const ACCEPTED_IMAGE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'video/mp4',
];
const ACCEPTED_EXTENSIONS =
'image/png,image/jpeg,image/jpg,image/gif,image/webp,video/mp4';
// Feedback Message Component
interface FeedbackMessageProps {
message: FeedbackMessage;
}
function FeedbackMessageDisplay({ message }: FeedbackMessageProps) {
const getClassName = (type: FeedbackMessage['type']) => {
switch (type) {
case 'success':
return 'text-green-600';
case 'error':
return 'text-red-600';
case 'warning':
return 'text-amber-600';
default:
return 'text-slate-600';
}
};
return (
<p className={`text-sm ${getClassName(message.type)} mt-2`}>
{message.text}
</p>
);
}
// Image Preview Component
interface ImagePreviewProps {
imageState: ImageState;
onRemove: () => void;
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Helper function to resize images larger than 2MB
async function resizeImageIfNeeded(file: File): Promise<File> {
const MAX_SIZE_MB = 2;
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
if (file.size <= MAX_SIZE_BYTES) {
return file;
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const img = new Image();
img.onload = () => {
// Calculate compression ratio based on file size
const compressionRatio = Math.sqrt(MAX_SIZE_BYTES / file.size);
// Calculate new dimensions maintaining aspect ratio
const newWidth = Math.floor(img.width * compressionRatio);
const newHeight = Math.floor(img.height * compressionRatio);
// Set canvas dimensions
canvas.width = newWidth;
canvas.height = newHeight;
// Draw resized image
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// Convert to blob with quality adjustment
canvas.toBlob(
(blob) => {
if (blob) {
const resizedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now(),
});
resolve(resizedFile);
} else {
resolve(file); // Fallback to original if resize fails
}
},
file.type,
0.85, // Quality setting (85%)
);
};
img.onerror = () => {
resolve(file); // Fallback to original if image load fails
};
img.src = URL.createObjectURL(file);
});
}
// Helper function to generate thumbnail from video file
async function generateVideoThumbnail(file: File): Promise<File> {
return new Promise((resolve) => {
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
video.onloadedmetadata = () => {
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Seek to 1 second into the video (or 10% through if shorter)
const seekTime = Math.min(1, video.duration * 0.1);
video.currentTime = seekTime;
};
video.onseeked = () => {
// Draw the current frame to canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert to blob as JPEG
canvas.toBlob(
(blob) => {
if (blob) {
// Create a new file with thumbnail data but keep original video file name base
const thumbnailName =
file.name.replace(/\.[^/.]+$/, '') + '_thumbnail.jpg';
const thumbnailFile = new File([blob], thumbnailName, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(thumbnailFile);
} else {
resolve(file); // Fallback to original file
}
},
'image/jpeg',
0.8, // Quality setting (80%)
);
};
video.onerror = () => {
resolve(file); // Fallback to original file if video processing fails
};
// Load the video file
video.src = URL.createObjectURL(file);
video.load();
});
}
function ImagePreview({ imageState, onRemove }: ImagePreviewProps) {
const isVideo = imageState.isVideo;
return (
<div className="flex items-center gap-4">
<div className="relative max-h-32 max-w-32 flex-shrink-0">
<img
src={imageState.previewUrl}
alt={isVideo ? 'Video thumbnail' : 'Selected image thumbnail'}
className="max-h-32 max-w-32 rounded-md object-cover shadow-sm"
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-full bg-black bg-opacity-70 p-2">
<svg
className="h-4 w-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
</div>
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
<h3 className="text-sm font-medium text-green-700">
{isVideo ? 'Selected Video' : 'Selected Image'}
</h3>
<p
className="max-w-32 truncate text-xs text-green-600"
title={imageState.file.name}
>
{imageState.file.name}
</p>
{isVideo ? (
<p className="text-xs text-slate-500">
{formatFileSize(imageState.file.size)} (thumbnail generated)
</p>
) : imageState.originalFileSize ? (
<div className="text-xs text-slate-500">
<div>Original: {formatFileSize(imageState.originalFileSize)}</div>
<div>Resized: {formatFileSize(imageState.file.size)}</div>
</div>
) : (
<p className="text-xs text-slate-500">
{formatFileSize(imageState.file.size)}
</p>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="mt-1 self-start rounded bg-slate-600 px-2 py-1 text-xs font-medium text-slate-100 transition-colors hover:bg-red-600 focus:bg-red-600 focus:outline-none"
title={isVideo ? 'Clear video' : 'Clear image'}
>
{isVideo ? 'Remove Video' : 'Remove Image'}
</button>
</div>
</div>
);
}
// Empty Drop Zone Component
interface EmptyDropZoneProps {
isMobile: boolean;
}
function EmptyDropZone({ isMobile }: EmptyDropZoneProps) {
return (
<div className="m-4 flex flex-col items-center justify-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="hidden font-medium text-slate-600 sm:block">
Drag and drop an image or video here
</p>
<p className="block font-medium text-slate-600 sm:hidden">
tap here to paste an image or video from the clipboard
</p>
<p className="text-xs text-slate-500">or use one of the options below</p>
</div>
);
}
// Image Drop Zone Component
interface ImageDropZoneProps {
imageState: ImageState | null;
isDragOver: boolean;
isMobile: boolean;
feedbackMessage: FeedbackMessage | null;
onClearImage: () => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onPaste: (e: ClipboardEvent) => void | Promise<void>;
pasteInputRef: React.RefObject<HTMLInputElement>;
dropZoneRef: React.RefObject<HTMLDivElement>;
}
function ImageDropZone({
imageState,
isDragOver,
isMobile,
feedbackMessage,
onClearImage,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onClick,
onKeyDown,
onPaste,
pasteInputRef,
dropZoneRef,
}: ImageDropZoneProps) {
return (
<div
ref={dropZoneRef}
onClick={onClick}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onKeyDown={onKeyDown}
className={`relative mb-4 rounded-lg border-2 border-dashed p-2 text-center transition-colors duration-200 focus:border-blue-500 ${
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-slate-300'
}`}
tabIndex={0}
>
<input
ref={pasteInputRef}
type="text"
className="pointer-events-none absolute opacity-0"
style={{ left: '-9999px' }}
autoComplete="off"
onPaste={async (e) => await onPaste(e.nativeEvent)}
onContextMenu={(e) => isMobile && e.stopPropagation()}
/>
<div className="flex flex-col items-center justify-center gap-2">
{imageState ? (
<ImagePreview imageState={imageState} onRemove={onClearImage} />
) : (
<EmptyDropZone isMobile={isMobile} />
)}
</div>
{feedbackMessage && <FeedbackMessageDisplay message={feedbackMessage} />}
</div>
);
}
// File Upload Section Component
interface FileUploadSectionProps {
fileInputRef: React.RefObject<HTMLInputElement>;
onFileChange: (file: File | null) => Promise<void>;
}
function FileUploadSection({
fileInputRef,
onFileChange,
}: FileUploadSectionProps) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<h3 className="text-lg font-medium text-slate-500">
Upload an image or video
</h3>
<div className="flex flex-col gap-1">
<label
htmlFor="image-file-input"
className="text-sm font-medium text-slate-700"
>
Choose an image or video file
</label>
<input
ref={fileInputRef}
id="image-file-input"
name="image_file"
type="file"
accept={ACCEPTED_EXTENSIONS}
className="w-full rounded-md border-slate-300 text-sm"
onChange={async (e) => {
const file = e.target.files?.[0];
await onFileChange(file || null);
}}
/>
<p className="text-xs text-slate-500">
Supported formats: JPG, PNG, GIF, WebP, MP4
</p>
</div>
</div>
);
}
// URL Upload Section Component
interface UrlUploadSectionProps {
imageUrl: string;
onUrlChange: (url: string) => void;
}
function UrlUploadSection({ imageUrl, onUrlChange }: UrlUploadSectionProps) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<h3 className="text-lg font-medium text-slate-500">
Provide image or video URL
</h3>
<div className="flex flex-col gap-1">
<label
htmlFor="image-url-input"
className="text-sm font-medium text-slate-700"
>
Image or Video URL
</label>
<input
id="image-url-input"
name="image_url"
type="url"
value={imageUrl}
onChange={(e) => onUrlChange(e.target.value)}
className="w-full rounded-md border-slate-300 text-sm"
placeholder="https://example.com/image.jpg or https://example.com/video.mp4"
/>
<p className="text-xs text-slate-500">
Enter the direct URL to an image or video
</p>
</div>
</div>
);
}
// Upload Options Component
interface UploadOptionsProps {
imageUrl: string;
isFileSelected: boolean;
fileInputRef: React.RefObject<HTMLInputElement>;
onFileChange: (file: File | null) => Promise<void>;
onUrlChange: (url: string) => void;
}
function UploadOptions({
imageUrl,
isFileSelected,
fileInputRef,
onFileChange,
onUrlChange,
}: UploadOptionsProps) {
return (
<div
className={`flex flex-col justify-between gap-4 sm:flex-row ${
isFileSelected ? 'hidden' : ''
}`}
>
<FileUploadSection
fileInputRef={fileInputRef}
onFileChange={onFileChange}
/>
<div className="flex flex-col items-center justify-center">
<h3 className="text-lg font-medium text-slate-500">or</h3>
</div>
<UrlUploadSection imageUrl={imageUrl} onUrlChange={onUrlChange} />
</div>
);
}
// Submit Button Component
function SubmitButton() {
return (
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Search for Similar Images
</button>
);
}
export default function VisualSearchForm({
actionUrl,
csrfToken,
}: VisualSearchFormProps) {
const [imageState, setImageState] = useState<ImageState | null>(null);
const [imageUrl, setImageUrl] = useState<string>('');
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
const [feedbackMessage, setFeedbackMessage] =
useState<FeedbackMessage | null>(null);
const dragDropRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pasteInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Detect mobile device
useEffect(() => {
const detectMobile = () => {
const userAgent =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
const touchPoints =
navigator.maxTouchPoints && navigator.maxTouchPoints > 2;
return userAgent || touchPoints;
};
setIsMobile(detectMobile());
}, []);
// Cleanup object URL on unmount
useEffect(() => {
return () => {
if (imageState?.previewUrl) {
URL.revokeObjectURL(imageState.previewUrl);
}
};
}, [imageState?.previewUrl]);
// Show feedback message with auto-dismiss
const showFeedback = useCallback(
(text: string, type: FeedbackMessage['type']) => {
setFeedbackMessage({ text, type });
setTimeout(() => setFeedbackMessage(null), 5000);
},
[],
);
const clearFeedback = useCallback(() => {
setFeedbackMessage(null);
}, []);
// Clear selected image
const clearImage = useCallback(() => {
if (imageState?.previewUrl) {
URL.revokeObjectURL(imageState.previewUrl);
}
setImageState(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [imageState?.previewUrl]);
// Handle image file selection
const handleImageFile = useCallback(
async (file: File) => {
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
showFeedback(
'Please select a valid image or video file (JPG, PNG, GIF, WebP, MP4)',
'error',
);
return;
}
// Clean up previous preview URL
if (imageState?.previewUrl) {
URL.revokeObjectURL(imageState.previewUrl);
}
// Show processing message for large files or videos
const originalSize = file.size;
const isLargeFile = originalSize > 2 * 1024 * 1024; // 2MB
const isVideo = file.type.startsWith('video/');
if (isLargeFile || isVideo) {
showFeedback(
isVideo
? 'Generating video thumbnail...'
: 'Processing large image...',
'warning',
);
}
try {
let processedFile: File;
let thumbnailFile: File | undefined;
let previewUrl: string;
if (isVideo) {
// For video files, generate thumbnail for preview but keep original for upload
thumbnailFile = await generateVideoThumbnail(file);
processedFile = file; // Keep original video for upload
previewUrl = URL.createObjectURL(thumbnailFile);
// Set the original video file in the file input for form submission
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInputRef.current!.files = dataTransfer.files;
} else {
// For image files, process as before
processedFile = await resizeImageIfNeeded(file);
previewUrl = URL.createObjectURL(processedFile);
const dataTransfer = new DataTransfer();
dataTransfer.items.add(processedFile);
fileInputRef.current!.files = dataTransfer.files;
}
clearFeedback();
// Track original size if image was resized
const wasResized = !isVideo && processedFile.size < originalSize;
// Set all image state at once
setImageState({
file: processedFile,
previewUrl,
originalFileSize: wasResized ? originalSize : null,
thumbnailFile,
isVideo,
});
// Visual feedback
setIsDragOver(true);
setTimeout(() => setIsDragOver(false), 1000);
} catch (error) {
showFeedback(
isVideo
? 'Error processing video. Please try another file.'
: 'Error processing image. Please try another file.',
'error',
);
}
},
[showFeedback, imageState?.previewUrl],
);
// File change handler
const handleFileChange = useCallback(
async (file: File | null) => {
if (file) {
await handleImageFile(file);
} else {
clearImage();
}
},
[handleImageFile, clearImage],
);
// Drag and drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.match('image.*') || file.type.match('video.*')) {
await handleImageFile(file);
} else {
showFeedback(
`Please drop an image or video file (got ${file.type})`,
'error',
);
}
}
},
[handleImageFile, showFeedback],
);
// Modern Clipboard API handler
const tryClipboardAPIRead = useCallback(async (): Promise<boolean> => {
try {
if (!navigator.clipboard || !navigator.clipboard.read) {
return false;
}
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type.startsWith('image/')) {
const blob = await clipboardItem.getType(type);
const file = new File([blob], 'clipboard-image.png', {
type: blob.type,
});
await handleImageFile(file);
return true;
}
}
}
showFeedback(
'No image or video found in clipboard. Copy an image or video first, then try again.',
'warning',
);
return false;
} catch (err) {
return false;
}
}, [handleImageFile, showFeedback]);
// Paste event handler
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
// Allow normal paste behavior for text input fields
const target = e.target as HTMLElement;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
) {
// Let the input handle the paste normally
return;
}
e.preventDefault();
const clipboardItems = e.clipboardData?.items;
if (!clipboardItems) return;
let imageFile: File | null = null;
// Look for image data in clipboard
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.indexOf('image') !== -1) {
imageFile = item.getAsFile();
break;
}
if (item.type.indexOf('video') !== -1) {
imageFile = item.getAsFile();
break;
}
}
if (imageFile) {
await handleImageFile(imageFile);
// focus the drag/drop zone
dragDropRef.current?.focus();
} else {
showFeedback(
'No image or video found in clipboard. Copy an image or video first, then paste here.',
'warning',
);
}
},
[handleImageFile, showFeedback],
);
// Mobile paste instruction
const showMobilePasteInstruction = useCallback(() => {
showFeedback('Tap this area and select "Paste" from the menu', 'warning');
}, [showFeedback]);
// Click handler for drag-drop area
const handleDragDropClick = useCallback(async () => {
if (isMobile) {
pasteInputRef.current?.focus();
const success = await tryClipboardAPIRead();
if (!success) {
showMobilePasteInstruction();
}
} else {
dragDropRef.current?.focus();
}
}, [isMobile, tryClipboardAPIRead, showMobilePasteInstruction]);
// Keyboard event handler
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
formRef.current?.submit();
} else if (e.key === ' ') {
e.preventDefault();
if (isMobile) {
pasteInputRef.current?.focus();
showMobilePasteInstruction();
}
}
},
[isMobile, showMobilePasteInstruction],
);
// Set up paste event listeners
useEffect(() => {
const pasteHandler = async (e: ClipboardEvent) => await handlePaste(e);
document.addEventListener('paste', pasteHandler);
return () => document.removeEventListener('paste', pasteHandler);
}, [handlePaste]);
// Mobile touch support
useEffect(() => {
if (!isMobile || !dragDropRef.current) return;
const handleTouchStart = () => {
setTimeout(() => {
pasteInputRef.current?.focus();
}, 100);
};
const dragDropElement = dragDropRef.current;
dragDropElement.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
return () => {
dragDropElement.removeEventListener('touchstart', handleTouchStart);
};
}, [isMobile, dragDropRef, pasteInputRef]);
return (
<div className="overflow-hidden border border-slate-300 bg-white shadow-sm sm:rounded-lg">
<form
ref={formRef}
method="post"
action={actionUrl}
encType="multipart/form-data"
className="flex flex-col gap-4 p-4 sm:p-6"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<ImageDropZone
imageState={imageState}
isDragOver={isDragOver}
isMobile={isMobile}
feedbackMessage={feedbackMessage}
onClearImage={clearImage}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleDragDropClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
pasteInputRef={pasteInputRef}
dropZoneRef={dragDropRef}
/>
<UploadOptions
imageUrl={imageUrl}
isFileSelected={!!imageState}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onUrlChange={setImageUrl}
/>
<div className="my-2 border-t border-slate-200"></div>
<SubmitButton />
</form>
</div>
);
}

View File

@@ -0,0 +1,91 @@
interface SerializedTrie<T> {
// terminal node?
t: 1 | 0;
// value of the node
v: T;
// optional children
c?: { [s: string]: SerializedTrie<T> };
}
export class TrieNode<T> {
public terminal: boolean;
public value: T;
public children: Map<string, TrieNode<T>>;
public serialized: SerializedTrie<T>;
constructor(ser: SerializedTrie<T>) {
this.terminal = ser.t == 1;
this.value = ser.v;
this.children = new Map();
this.serialized = ser;
if (ser.c != null) {
for (const [key, value] of Object.entries(ser.c)) {
this.children.set(key, new TrieNode(value));
}
}
}
}
export default class Trie<T> {
public root: TrieNode<T>;
constructor(ser: SerializedTrie<T>) {
this.root = new TrieNode(ser);
}
public nodeForPrefix(key: string): {
chain: string[];
node: TrieNode<T> | null;
} {
let chain = [];
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let exactChild = null;
console.log('remaining: ', remaining);
for (const [childKey, child] of node.children.entries()) {
if (remaining.startsWith(childKey)) {
console.log('exact match for: ', childKey);
exactChild = child;
chain.push(childKey);
remaining = remaining.slice(childKey.length);
break;
}
}
// if an exact match was found, continue iterating
if (exactChild) {
node = exactChild;
continue;
}
console.log('looking for partial match for ', remaining);
for (const [childKey, child] of node.children.entries()) {
const startsWith = childKey.startsWith(remaining);
console.log(
'test ',
childKey,
' against ',
remaining,
': ',
startsWith,
' ',
child.serialized,
);
if (startsWith) {
console.log('partial match for: ', remaining, ': ', child.serialized);
chain.push(childKey);
return { chain, node: child };
}
}
console.log('did not find partial, bailing!');
return { chain, node: null };
}
// // return remaining.length === 0 && node && node.terminal ? node : null;
console.log('returning child ', node, ' for remaining ', remaining);
return { chain, node };
}
}

View File

@@ -1,23 +0,0 @@
/**
* Converts a byte count to a human-readable size string.
*
* @param bytes - The number of bytes to convert
* @param decimals - Number of decimal places to show (default: 1)
* @returns A human-readable size string (e.g., "1.2 KB", "3.4 MB")
*/
export function byteCountToHumanSize(
bytes: number,
decimals: number = 1,
): string {
if (bytes === 0) return '0 B';
if (bytes < 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${size} ${sizes[i]}`;
}

View File

@@ -1,283 +0,0 @@
interface DataPoint {
x: number;
y: number;
}
export interface TrendLineResult {
// Polynomial coefficients [c, b, a] for y = ax² + bx + c
// Index 0 = constant term, Index 1 = linear term, Index 2 = quadratic term, etc.
coeffs: number[];
rSquared: number;
degree: number;
equation: string;
color: string; // For display purposes
}
export interface ChartStatistics {
trendLines: TrendLineResult[];
favsPerDay: number;
}
/**
* Calculate linear regression for given data points
*/
const calculateLinearRegression = (data: DataPoint[]): TrendLineResult => {
const n = data.length;
const sumX = data.reduce((sum, point) => sum + point.x, 0);
const sumY = data.reduce((sum, point) => sum + point.y, 0);
const sumXY = data.reduce((sum, point) => sum + point.x * point.y, 0);
const sumX2 = data.reduce((sum, point) => sum + point.x * point.x, 0);
const denominator = n * sumX2 - sumX * sumX;
// Check for singular case (all x values are the same)
if (Math.abs(denominator) < 1e-10) {
throw new Error(
'Cannot calculate linear regression - all x values are identical',
);
}
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
// Validate coefficients
if (!isFinite(slope) || !isFinite(intercept)) {
throw new Error('Invalid linear regression coefficients calculated');
}
const coeffs = [intercept, slope]; // [c, b] for y = bx + c
const rSquared = calculateRSquared(data, coeffs);
const equation = formatPolynomialEquation(coeffs);
return {
coeffs,
rSquared,
degree: 1,
equation,
color: 'rgba(34, 197, 94, 1)', // Green
};
};
/**
* Calculate second-order polynomial regression for given data points
* Fits y = ax² + bx + c using least squares method
*/
const calculateQuadraticRegression = (data: DataPoint[]): TrendLineResult => {
const n = data.length;
// Calculate sums needed for polynomial regression
const sumX = data.reduce((sum, point) => sum + point.x, 0);
const sumY = data.reduce((sum, point) => sum + point.y, 0);
const sumX2 = data.reduce((sum, point) => sum + point.x * point.x, 0);
const sumX3 = data.reduce(
(sum, point) => sum + point.x * point.x * point.x,
0,
);
const sumX4 = data.reduce((sum, point) => sum + Math.pow(point.x, 4), 0);
const sumXY = data.reduce((sum, point) => sum + point.x * point.y, 0);
const sumX2Y = data.reduce(
(sum, point) => sum + point.x * point.x * point.y,
0,
);
// Set up matrix for normal equations
// [n sumX sumX2 ] [c] [sumY ]
// [sumX sumX2 sumX3 ] [b] = [sumXY ]
// [sumX2 sumX3 sumX4] [a] [sumX2Y]
const matrix = [
[n, sumX, sumX2],
[sumX, sumX2, sumX3],
[sumX2, sumX3, sumX4],
];
const vector = [sumY, sumXY, sumX2Y];
// Solve using Cramer's rule
const det = (m: number[][]) =>
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
const detA = det(matrix);
// Check for singular matrix (determinant near zero)
if (Math.abs(detA) < 1e-10) {
throw new Error('Matrix is singular - cannot solve quadratic regression');
}
// Calculate coefficients
const detC = det([
[vector[0], sumX, sumX2],
[vector[1], sumX2, sumX3],
[vector[2], sumX3, sumX4],
]);
const detB = det([
[n, vector[0], sumX2],
[sumX, vector[1], sumX3],
[sumX2, vector[2], sumX4],
]);
const detA_coeff = det([
[n, sumX, vector[0]],
[sumX, sumX2, vector[1]],
[sumX2, sumX3, vector[2]],
]);
const c = detC / detA;
const b = detB / detA;
const a = detA_coeff / detA;
// Validate coefficients
if (!isFinite(a) || !isFinite(b) || !isFinite(c)) {
throw new Error('Invalid coefficients calculated');
}
const coeffs = [c, b, a]; // [constant, linear, quadratic]
const rSquared = calculateRSquared(data, coeffs);
const equation = formatPolynomialEquation(coeffs);
return {
coeffs,
rSquared,
degree: 2,
equation,
color: 'rgba(239, 68, 68, 1)', // Red
};
};
/**
* Calculate R-squared (coefficient of determination) for polynomial fit
*/
export const calculateRSquared = (
data: DataPoint[],
coeffs: number[],
): number => {
if (data.length < 2) {
return 0; // Cannot calculate meaningful R² with fewer than 2 points
}
const meanY = data.reduce((sum, point) => sum + point.y, 0) / data.length;
let ssRes = 0; // Sum of squares of residuals
let ssTot = 0; // Total sum of squares
data.forEach((point) => {
// Calculate predicted Y using polynomial: y = c₀ + c₁x + c₂x² + ...
let predictedY = 0;
for (let i = 0; i < coeffs.length; i++) {
predictedY += coeffs[i] * Math.pow(point.x, i);
}
ssRes += Math.pow(point.y - predictedY, 2);
ssTot += Math.pow(point.y - meanY, 2);
});
// Handle edge case where all y values are the same
if (Math.abs(ssTot) < 1e-10) {
return ssRes < 1e-10 ? 1.0 : 0.0; // Perfect fit if residuals are also zero
}
const rSquared = 1 - ssRes / ssTot;
// Validate result
if (!isFinite(rSquared)) {
return 0;
}
return rSquared;
};
/**
* Generate polynomial equation string from coefficients
*/
const formatPolynomialEquation = (coeffs: number[]): string => {
const terms: string[] = [];
for (let i = coeffs.length - 1; i >= 0; i--) {
const coeff = coeffs[i];
if (Math.abs(coeff) < 1e-10) continue; // Skip near-zero coefficients
let term = '';
const absCoeff = Math.abs(coeff);
if (i === 0) {
// Constant term
term = absCoeff.toFixed(3);
} else if (i === 1) {
// Linear term
if (absCoeff === 1) {
term = 'x';
} else {
term = `${absCoeff.toFixed(3)}x`;
}
} else {
// Higher order terms
if (absCoeff === 1) {
term = `x^${i}`;
} else {
term = `${absCoeff.toFixed(3)}x^${i}`;
}
}
if (terms.length === 0) {
// First term: include negative sign directly, no plus for positive
terms.push(coeff >= 0 ? term : `-${term}`);
} else {
// Subsequent terms: always include sign with proper spacing
terms.push(coeff >= 0 ? ` + ${term}` : ` - ${term}`);
}
}
return terms.length > 0 ? `y = ${terms.join('')}` : 'y = 0';
};
/**
* Calculate trend lines for given data points, only including fits with sufficient data
*/
export const calculateTrendLines = (data: DataPoint[]): TrendLineResult[] => {
const trendLines: TrendLineResult[] = [];
// Linear regression requires at least 2 points
if (data.length >= 2) {
trendLines.push(calculateLinearRegression(data));
}
// Quadratic regression requires at least 3 points
if (data.length >= 3) {
try {
trendLines.push(calculateQuadraticRegression(data));
} catch (e) {
console.error(e);
}
}
return trendLines;
};
/**
* Calculate statistics for chart data
*/
export const calculateChartStatistics = (
data: DataPoint[],
): ChartStatistics => {
const trendLines = calculateTrendLines(data);
// Calculate favs per day using linear regression (for interpretability)
const linearTrendLine = trendLines.find((tl) => tl.degree === 1);
const linearSlope = linearTrendLine?.coeffs[1] || 0; // coefficient of x in linear equation
// Only calculate favsPerDay if we have a valid linear slope
let favsPerDay = 0;
if (linearSlope !== 0 && isFinite(linearSlope)) {
favsPerDay = 1 / linearSlope;
if (!isFinite(favsPerDay)) {
favsPerDay = 0;
}
}
return {
trendLines,
favsPerDay,
};
};

View File

@@ -17,7 +17,7 @@ export function iconClassNamesForSize(size: IconSize) {
case 'large':
return 'h-8 w-8 flex-shrink-0 rounded-md';
case 'small':
return 'h-6 w-6 flex-shrink-0 rounded-sm';
return 'h-5 w-5 flex-shrink-0 rounded-sm';
}
}
@@ -227,8 +227,7 @@ export function anchorClassNamesForVisualStyle(
visualStyle: string,
hasIcon: boolean = false,
) {
// let classNames = ['truncate', 'gap-1'];
let classNames = ['gap-1', 'min-w-0'];
let classNames = ['truncate', 'gap-1'];
if (hasIcon) {
classNames.push('flex items-center');
}

View File

@@ -1,165 +0,0 @@
/**
* User Menu Handler
* Manages the responsive user menu that works for both desktop and mobile layouts
*/
interface UserMenuElements {
button: HTMLButtonElement;
menu: HTMLDivElement;
menuIcon: SVGElement | null;
closeIcon: SVGElement | null;
chevronIcon: HTMLElement | null;
}
class UserMenu {
private elements: UserMenuElements | null = null;
constructor() {
this.initialize();
}
private initialize(): void {
const userMenuButton = document.querySelector(
'#user-menu-button',
) as HTMLButtonElement;
const userMenu = document.querySelector('#user-menu') as HTMLDivElement;
const menuIcon = document.querySelector('.menu-icon') as SVGElement | null;
const closeIcon = document.querySelector(
'.close-icon',
) as SVGElement | null;
const chevronIcon = document.querySelector(
'.chevron-icon',
) as HTMLElement | null;
if (!userMenuButton || !userMenu) {
return; // User menu not present (e.g., guest user)
}
this.elements = {
button: userMenuButton,
menu: userMenu,
menuIcon,
closeIcon,
chevronIcon,
};
this.attachEventListeners();
}
private attachEventListeners(): void {
if (!this.elements) return;
// Toggle menu on button click
this.elements.button.addEventListener(
'click',
this.handleMenuToggle.bind(this),
);
// Close menu when clicking outside
document.addEventListener('click', this.handleClickOutside.bind(this));
// Close menu on escape key
document.addEventListener('keydown', this.handleEscapeKey.bind(this));
}
private handleMenuToggle(): void {
if (!this.elements) return;
const isOpen = !this.elements.menu.classList.contains('hidden');
if (isOpen) {
this.closeMenu();
} else {
this.openMenu();
}
}
private handleClickOutside(event: MouseEvent): void {
if (!this.elements) return;
const target = event.target as Node;
if (
!this.elements.button.contains(target) &&
!this.elements.menu.contains(target)
) {
this.closeMenu();
}
}
private handleEscapeKey(event: KeyboardEvent): void {
if (!this.elements) return;
if (
event.key === 'Escape' &&
!this.elements.menu.classList.contains('hidden')
) {
this.closeMenu();
}
}
private openMenu(): void {
if (!this.elements) return;
// Show menu but keep it in initial animation state
this.elements.menu.classList.remove('hidden');
this.elements.button.setAttribute('aria-expanded', 'true');
// Ensure initial state is set
this.elements.menu.classList.add('opacity-0', '-translate-y-2');
this.elements.menu.classList.remove('opacity-100', 'translate-y-0');
// Force a reflow to ensure the initial state is applied
this.elements.menu.offsetHeight;
// Trigger animation by transitioning to visible state
requestAnimationFrame(() => {
this.elements!.menu.classList.remove('opacity-0', '-translate-y-2');
this.elements!.menu.classList.add('opacity-100', 'translate-y-0');
});
// Update mobile icons (hamburger -> close)
if (this.elements.menuIcon && this.elements.closeIcon) {
this.elements.menuIcon.classList.add('hidden');
this.elements.closeIcon.classList.remove('hidden');
}
// Update desktop chevron (rotate down)
if (this.elements.chevronIcon) {
this.elements.chevronIcon.classList.add('rotate-180');
}
}
private closeMenu(): void {
if (!this.elements) return;
// Animate out: fade and slide up
this.elements.menu.classList.remove('opacity-100', 'translate-y-0');
this.elements.menu.classList.add('opacity-0', '-translate-y-2');
// Hide menu after CSS animation completes
setTimeout(() => {
if (this.elements) {
this.elements.menu.classList.add('hidden');
}
}, 150); // Match the CSS transition duration
this.elements.button.setAttribute('aria-expanded', 'false');
// Update mobile icons (close -> hamburger)
if (this.elements.menuIcon && this.elements.closeIcon) {
this.elements.menuIcon.classList.remove('hidden');
this.elements.closeIcon.classList.add('hidden');
}
// Update desktop chevron (rotate back up)
if (this.elements.chevronIcon) {
this.elements.chevronIcon.classList.remove('rotate-180');
}
}
}
// Initialize user menu when DOM is loaded
export function initUserMenu(): void {
new UserMenu();
}

View File

@@ -1,30 +1,22 @@
import ReactOnRails from 'react-on-rails';
import UserSearchBar from '../bundles/Main/components/UserSearchBar';
import { UserMenu } from '../bundles/Main/components/UserMenu';
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
import { TrackedObjectsChart } from '../bundles/Main/components/TrackedObjectsChart';
import { PostFiles } from '../bundles/Main/components/PostFiles';
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
import { IpAddressInput } from '../bundles/UI/components';
import { StatsPage } from '../bundles/Main/components/StatsPage';
import VisualSearchForm from '../bundles/Main/components/VisualSearchForm';
import { initUserMenu } from '../bundles/UI/userMenu';
// This is how react_on_rails can see the components in the browser.
ReactOnRails.register({
UserSearchBar,
UserMenu,
PostHoverPreviewWrapper,
UserHoverPreviewWrapper,
TrackedObjectsChart,
PostFiles,
IpAddressInput,
StatsPage,
VisualSearchForm,
});
// Initialize UI components
// Initialize collapsible sections
document.addEventListener('DOMContentLoaded', function () {
initCollapsibleSections();
initUserMenu();
});

View File

@@ -1,14 +1,14 @@
import ReactOnRails from 'react-on-rails';
import UserSearchBar from '../bundles/Main/components/UserSearchBarServer';
import { UserMenu } from '../bundles/Main/components/UserMenu';
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
import { PostFiles } from '../bundles/Main/components/PostFiles';
// This is how react_on_rails can see the UserSearchBar in the browser.
ReactOnRails.register({
UserMenu,
UserSearchBar,
PostHoverPreviewWrapper,
UserHoverPreviewWrapper,
PostFiles,
});

View File

@@ -0,0 +1,140 @@
function buildUsersTrie(users) {
const rootNode = new trie();
users.forEach(([id, name]) => {
rootNode.insert(name.toLowerCase(), [id, name]);
});
return JSON.stringify(rootNode.serialize());
}
class trie_node {
constructor() {
this.terminal = false;
this.children = new Map();
}
serialize() {
const { terminal, value, children } = this;
let mapped = {};
let numChildren = 0;
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
numChildren += 1;
mapped[childKey] = children.get(childKey).serialize();
});
return {
t: this.terminal ? 1 : 0,
v: value,
c: numChildren > 0 ? mapped : undefined,
};
}
}
class trie {
constructor() {
this.root = new trie_node();
this.elements = 0;
}
serialize() {
return this.root.serialize();
}
get length() {
return this.elements;
}
get(key) {
const node = this.getNode(key);
if (node) {
return node.value;
}
return null;
}
contains(key) {
const node = this.getNode(key);
return !!node;
}
insert(key, value) {
let node = this.root;
let remaining = key;
while (remaining.length > 0) {
let child = null;
for (const childKey of node.children.keys()) {
const prefix = this.commonPrefix(remaining, childKey);
if (!prefix.length) {
continue;
}
if (prefix.length === childKey.length) {
// enter child node
child = node.children.get(childKey);
remaining = remaining.slice(childKey.length);
break;
}
else {
// split the child
child = new trie_node();
child.children.set(childKey.slice(prefix.length), node.children.get(childKey));
node.children.delete(childKey);
node.children.set(prefix, child);
remaining = remaining.slice(prefix.length);
break;
}
}
if (!child && remaining.length) {
child = new trie_node();
node.children.set(remaining, child);
remaining = "";
}
node = child;
}
if (!node.terminal) {
node.terminal = true;
this.elements += 1;
}
node.value = value;
}
remove(key) {
const node = this.getNode(key);
if (node) {
node.terminal = false;
this.elements -= 1;
}
}
map(prefix, func) {
const mapped = [];
const node = this.getNode(prefix);
const stack = [];
if (node) {
stack.push([prefix, node]);
}
while (stack.length) {
const [key, node] = stack.pop();
if (node.terminal) {
mapped.push(func(key, node.value));
}
for (const c of node.children.keys()) {
stack.push([key + c, node.children.get(c)]);
}
}
return mapped;
}
getNode(key) {
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let child = null;
for (let i = 1; i <= remaining.length; i += 1) {
child = node.children.get(remaining.slice(0, i));
if (child) {
remaining = remaining.slice(i);
break;
}
}
node = child;
}
return remaining.length === 0 && node && node.terminal ? node : null;
}
commonPrefix(a, b) {
const shortest = Math.min(a.length, b.length);
let i = 0;
for (; i < shortest; i += 1) {
if (a[i] !== b[i]) {
break;
}
}
return a.slice(0, i);
}
}

View File

@@ -0,0 +1,163 @@
type UserRow = [number, string];
function buildUsersTrie(users: UserRow[]): string {
const rootNode = new trie<[number, string]>();
users.forEach(([id, name]) => {
rootNode.insert(name.toLowerCase(), [id, name]);
});
return JSON.stringify(rootNode.serialize());
}
class trie_node<T> {
public terminal: boolean;
public value: T;
public children: Map<string, trie_node<T>>;
constructor() {
this.terminal = false;
this.children = new Map();
}
public serialize(): Object {
const { terminal, value, children } = this;
let mapped = {};
let numChildren = 0;
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
numChildren += 1;
mapped[childKey] = children.get(childKey).serialize();
});
return {
t: this.terminal ? 1 : 0,
v: value,
c: numChildren > 0 ? mapped : undefined,
};
}
}
class trie<T> {
public root: trie_node<T>;
public elements: number;
constructor() {
this.root = new trie_node<T>();
this.elements = 0;
}
public serialize(): Object {
return this.root.serialize();
}
public get length(): number {
return this.elements;
}
public get(key: string): T | null {
const node = this.getNode(key);
if (node) {
return node.value;
}
return null;
}
public contains(key: string): boolean {
const node = this.getNode(key);
return !!node;
}
public insert(key: string, value: T): void {
let node = this.root;
let remaining = key;
while (remaining.length > 0) {
let child: trie_node<T> = null;
for (const childKey of node.children.keys()) {
const prefix = this.commonPrefix(remaining, childKey);
if (!prefix.length) {
continue;
}
if (prefix.length === childKey.length) {
// enter child node
child = node.children.get(childKey);
remaining = remaining.slice(childKey.length);
break;
} else {
// split the child
child = new trie_node<T>();
child.children.set(
childKey.slice(prefix.length),
node.children.get(childKey)
);
node.children.delete(childKey);
node.children.set(prefix, child);
remaining = remaining.slice(prefix.length);
break;
}
}
if (!child && remaining.length) {
child = new trie_node<T>();
node.children.set(remaining, child);
remaining = "";
}
node = child;
}
if (!node.terminal) {
node.terminal = true;
this.elements += 1;
}
node.value = value;
}
public remove(key: string): void {
const node = this.getNode(key);
if (node) {
node.terminal = false;
this.elements -= 1;
}
}
public map<U>(prefix: string, func: (key: string, value: T) => U): U[] {
const mapped = [];
const node = this.getNode(prefix);
const stack: [string, trie_node<T>][] = [];
if (node) {
stack.push([prefix, node]);
}
while (stack.length) {
const [key, node] = stack.pop();
if (node.terminal) {
mapped.push(func(key, node.value));
}
for (const c of node.children.keys()) {
stack.push([key + c, node.children.get(c)]);
}
}
return mapped;
}
private getNode(key: string): trie_node<T> | null {
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let child = null;
for (let i = 1; i <= remaining.length; i += 1) {
child = node.children.get(remaining.slice(0, i));
if (child) {
remaining = remaining.slice(i);
break;
}
}
node = child;
}
return remaining.length === 0 && node && node.terminal ? node : null;
}
private commonPrefix(a: string, b: string): string {
const shortest = Math.min(a.length, b.length);
let i = 0;
for (; i < shortest; i += 1) {
if (a[i] !== b[i]) {
break;
}
}
return a.slice(0, i);
}
}

View File

@@ -1,90 +0,0 @@
# typed: strict
class Domain::Bluesky::Job::Base < Scraper::JobBase
abstract!
include HasBulkEnqueueJobs
queue_as :bluesky
discard_on ActiveJob::DeserializationError
sig { override.returns(Symbol) }
def self.http_factory_method
:get_generic_http_client
end
protected
sig { params(user: Domain::User::BlueskyUser).void }
def enqueue_scan_posts_job_if_due(user)
if user.posts_scan.due? || force_scan?
logger.info(
format_tags(
"enqueue posts scan",
make_tags(posts_scan: user.posts_scan.ago_in_words),
),
)
defer_job(Domain::Bluesky::Job::ScanPostsJob, { user: })
else
logger.info(
format_tags(
"skipping enqueue of posts scan",
make_tags(scanned_at: user.posts_scan.ago_in_words),
),
)
end
end
sig { params(user: Domain::User::BlueskyUser).void }
def enqueue_scan_user_job_if_due(user)
if user.profile_scan.due? || force_scan?
logger.info(
format_tags(
"enqueue user scan",
make_tags(profile_scan: user.profile_scan.ago_in_words),
),
)
defer_job(Domain::Bluesky::Job::ScanUserJob, { user: })
else
logger.info(
format_tags(
"skipping enqueue of user scan",
make_tags(scanned_at: user.profile_scan.ago_in_words),
),
)
end
end
sig { returns(T.nilable(Domain::User::BlueskyUser)) }
def user_from_args
if (user = arguments[0][:user]).is_a?(Domain::User::BlueskyUser)
user
elsif (did = arguments[0][:did]).present?
Domain::User::BlueskyUser.find_or_create_by(did:) do |user|
resolver = DIDKit::Resolver.new
if (resolved = resolver.resolve_did(did))
user.handle = resolved.get_validated_handle
end
end
elsif (handle = arguments[0][:handle]).present?
resolver = DIDKit::Resolver.new
did = resolver.resolve_handle(handle)&.did
fatal_error("failed to resolve handle: #{handle}") if did.nil?
user = Domain::User::BlueskyUser.find_or_initialize_by(did:)
user.handle = handle
user.save!
user
else
nil
end
end
sig { returns(Domain::User::BlueskyUser) }
def user_from_args!
T.must(user_from_args)
end
sig { params(user: Domain::User::BlueskyUser).returns(T::Boolean) }
def buggy_user?(user)
# Add any known problematic handles/DIDs here
false
end
end

View File

@@ -1,258 +0,0 @@
# typed: strict
class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
MEDIA_EMBED_TYPES = %w[app.bsky.embed.images app.bsky.embed.video]
self.default_priority = -10
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)
user = user_from_args!
logger.push_tags(make_arg_tag(user))
logger.info(format_tags("starting posts scan"))
return if buggy_user?(user)
unless user.state_ok?
logger.error(
format_tags("skipping posts scan", make_tags(state: user.state)),
)
return
end
if !user.posts_scan.due? && !force_scan?
logger.info(
format_tags(
"skipping posts scan",
make_tags(scanned_at: user.posts_scan.ago_in_words),
),
)
return
end
scan_user_posts(user)
user.last_posts_scan_log_entry = first_log_entry
user.touch
logger.info(format_tags("completed posts scan"))
ensure
user.save! if user
end
private
sig do
params(
user: Domain::User::BlueskyUser,
record_data: T::Hash[String, T.untyped],
).returns(T::Boolean)
end
def should_record_post?(user, record_data)
# Check for quotes first - skip quotes of other users' posts
quote_uri = extract_quote_uri(record_data)
if quote_uri
# Extract DID from the quoted post URI
quoted_did = quote_uri.split("/")[2]
return false unless quoted_did == user.did
end
# Check for replies - only record if it's a root post or reply to user's own post
return true unless record_data.dig("value", "reply")
# For replies, check if the root post is by the same user
reply_data = record_data.dig("value", "reply")
root_uri = reply_data.dig("root", "uri")
return true unless root_uri # If we can't determine root, allow it
# Extract DID from the root post URI
# AT URI format: at://did:plc:xyz/app.bsky.feed.post/rkey
root_did = root_uri.split("/")[2]
# Only record if the root post is by the same user
root_did == user.did
end
sig { params(record: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
def extract_quote_uri(record)
# Check for quote in embed data
embed = record["embed"]
return nil unless embed
case embed["$type"]
when "app.bsky.embed.record"
# Direct quote - check if it's actually a quote of a post
record_data = embed["record"]
if record_data && record_data["uri"]&.include?("app.bsky.feed.post")
record_data["uri"]
end
when "app.bsky.embed.recordWithMedia"
# Quote with media
record_data = embed.dig("record", "record")
if record_data && record_data["uri"]&.include?("app.bsky.feed.post")
record_data["uri"]
end
else
nil
end
end
sig { params(user: Domain::User::BlueskyUser).void }
def scan_user_posts(user)
# Use AT Protocol API to list user's posts
posts_url =
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100"
cursor = T.let(nil, T.nilable(String))
num_processed_posts = 0
num_posts_with_media = 0
num_filtered_posts = 0
num_created_posts = 0
num_pages = 0
posts_scan = Domain::UserJobEvent::PostsScan.create!(user:)
loop do
url = cursor ? "#{posts_url}&cursor=#{cursor}" : posts_url
response = http_client.get(url)
posts_scan.update!(log_entry: response.log_entry) if num_pages == 0
num_pages += 1
if response.status_code == 400
error = JSON.parse(response.body)["error"]
if error == "InvalidRequest"
logger.error(format_tags("account is disabled / does not exist"))
user.state = "account_disabled"
return
end
end
if response.status_code != 200
fatal_error(
format_tags(
"failed to get user posts",
make_tags(status_code: response.status_code),
),
)
end
begin
data = JSON.parse(response.body)
if data["error"]
logger.error(
format_tags("posts API error", make_tags(error: data["error"])),
)
break
end
records = data["records"] || []
records.each do |record_data|
num_processed_posts += 1
embed_type = record_data.dig("value", "embed", "$type")
unless MEDIA_EMBED_TYPES.include?(embed_type)
logger.info(
format_tags(
"skipping post, non-media embed type",
make_tags(embed_type:),
),
)
next
end
# Only process posts with media
num_posts_with_media += 1
# Skip posts that are replies to other users or quotes
unless should_record_post?(user, record_data)
num_filtered_posts += 1
next
end
if process_historical_post(user, record_data, response.log_entry)
num_created_posts += 1
end
end
cursor = data["cursor"]
break if cursor.nil? || records.empty?
rescue JSON::ParserError => e
logger.error(
format_tags(
"failed to parse posts JSON",
make_tags(error: e.message),
),
)
break
end
end
user.scanned_posts_at = Time.current
posts_scan.update!(
total_posts_seen: num_processed_posts,
new_posts_seen: num_created_posts,
)
logger.info(
format_tags(
"scanned posts",
make_tags(
num_processed_posts:,
num_posts_with_media:,
num_filtered_posts:,
num_created_posts:,
num_pages:,
),
),
)
end
sig do
params(
user: Domain::User::BlueskyUser,
record_data: T::Hash[String, T.untyped],
log_entry: HttpLogEntry,
).returns(T::Boolean)
end
def process_historical_post(user, record_data, log_entry)
at_uri = record_data["uri"]
# Check if we already have this post
existing_post = user.posts.find_by(at_uri:)
if existing_post
enqueue_pending_files_job(existing_post)
return false
end
# Extract reply and quote URIs from the raw post data
reply_to_uri = record_data.dig("value", "reply", "root", "uri")
quote_uri = extract_quote_uri(record_data)
post =
Domain::Post::BlueskyPost.build(
state: "ok",
at_uri: at_uri,
first_seen_entry: log_entry,
text: record_data.dig("value", "text") || "",
posted_at: Time.parse(record_data.dig("value", "createdAt")),
post_raw: record_data.dig("value"),
reply_to_uri: reply_to_uri,
quote_uri: quote_uri,
)
post.creator = user
post.save!
# Process media if present
embed = record_data.dig("value", "embed")
helper = Bluesky::ProcessPostHelper.new(@deferred_job_sink)
helper.process_post_media(post, embed, user.did!) if embed
logger.debug(format_tags("created post", make_tags(at_uri:)))
true
end
sig { params(post: Domain::Post::BlueskyPost).void }
def enqueue_pending_files_job(post)
post.files.each do |post_file|
if post_file.state_pending?
defer_job(Domain::StaticFileJob, { post_file: }, { queue: "bluesky" })
end
end
end
end

View File

@@ -1,266 +0,0 @@
# typed: strict
# frozen_string_literal: true
class Domain::Bluesky::Job::ScanUserFollowsJob < Domain::Bluesky::Job::Base
self.default_priority = -10
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)
user = user_from_args!
last_follows_scan = user.follows_scans.where(state: "completed").last
if (ca = last_follows_scan&.created_at) && (ca > 1.month.ago) &&
!force_scan?
logger.info(
format_tags(
"skipping user #{user.did} follows scan",
make_tags(
ago: time_ago_in_words(ca),
last_scan_id: last_follows_scan.id,
),
),
)
else
perform_scan_type(
user,
"follows",
bsky_method: "app.bsky.graph.getFollows",
bsky_field: "follows",
edge_name: :user_user_follows_from,
user_attr: :from_id,
other_attr: :to_id,
)
end
last_followed_by_scan =
user.followed_by_scans.where(state: "completed").last
if (ca = last_followed_by_scan&.created_at) && (ca > 1.month.ago) &&
!force_scan?
logger.info(
format_tags(
"skipping user #{user.did} followed by scan",
make_tags(
ago: time_ago_in_words(ca),
last_scan_id: last_followed_by_scan.id,
),
),
)
else
perform_scan_type(
user,
"followed_by",
bsky_method: "app.bsky.graph.getFollowers",
bsky_field: "followers",
edge_name: :user_user_follows_to,
user_attr: :to_id,
other_attr: :from_id,
)
end
end
private
sig do
params(
user: Domain::User::BlueskyUser,
kind: String,
bsky_method: String,
bsky_field: String,
edge_name: Symbol,
user_attr: Symbol,
other_attr: Symbol,
).void
end
def perform_scan_type(
user,
kind,
bsky_method:,
bsky_field:,
edge_name:,
user_attr:,
other_attr:
)
scan = Domain::UserJobEvent::FollowScan.create!(user:, kind:)
cursor = T.let(nil, T.nilable(String))
page = 0
subjects_data = T.let([], T::Array[Bluesky::Graph::Subject])
loop do
# get followers
xrpc_url =
"https://public.api.bsky.app/xrpc/#{bsky_method}?actor=#{user.did!}&limit=100"
xrpc_url = "#{xrpc_url}&cursor=#{cursor}" if cursor
response = http_client.get(xrpc_url)
scan.update!(log_entry: response.log_entry) if page == 0
page += 1
if response.status_code != 200
fatal_error(
format_tags(
"failed to get user #{kind}",
make_tags(status_code: response.status_code),
),
)
end
data = JSON.parse(response.body)
if data["error"]
fatal_error(
format_tags(
"failed to get user #{kind}",
make_tags(error: data["error"]),
),
)
end
subjects_data.concat(
data[bsky_field].map do |subject_data|
Bluesky::Graph::Subject.from_json(subject_data)
end,
)
cursor = data["cursor"]
break if cursor.nil?
end
handle_subjects_data(
user,
subjects_data,
scan,
edge_name:,
user_attr:,
other_attr:,
)
scan.update!(state: "completed", completed_at: Time.current)
logger.info(
format_tags(
"completed user #{kind} scan",
make_tags(num_subjects: subjects_data.size),
),
)
rescue => e
scan.update!(state: "error", completed_at: Time.current) if scan
raise e
end
sig do
params(
user: Domain::User::BlueskyUser,
subjects: T::Array[Bluesky::Graph::Subject],
scan: Domain::UserJobEvent::FollowScan,
edge_name: Symbol,
user_attr: Symbol,
other_attr: Symbol,
).void
end
def handle_subjects_data(
user,
subjects,
scan,
edge_name:,
user_attr:,
other_attr:
)
subjects_by_did =
T.cast(subjects.index_by(&:did), T::Hash[String, Bluesky::Graph::Subject])
users_by_did =
T.cast(
Domain::User::BlueskyUser.where(did: subjects_by_did.keys).index_by(
&:did
),
T::Hash[String, Domain::User::BlueskyUser],
)
missing_user_dids = subjects_by_did.keys - users_by_did.keys
missing_user_dids.each do |did|
subject = subjects_by_did[did] || next
users_by_did[did] = create_user_from_subject(subject)
end
users_by_id = users_by_did.values.map { |u| [T.must(u.id), u] }.to_h
existing_subject_ids =
T.cast(user.send(edge_name).pluck(other_attr), T::Array[Integer])
new_user_ids = users_by_did.values.map(&:id).compact - existing_subject_ids
removed_user_ids =
existing_subject_ids - users_by_did.values.map(&:id).compact
follow_upsert_attrs = []
unfollow_upsert_attrs = []
referenced_user_ids = Set.new([user.id])
new_user_ids.each do |new_user_id|
new_user_did = users_by_id[new_user_id]&.did
followed_at = new_user_did && subjects_by_did[new_user_did]&.created_at
referenced_user_ids.add(new_user_id)
follow_upsert_attrs << {
user_attr => user.id,
other_attr => new_user_id,
:followed_at => followed_at,
:removed_at => nil,
}
end
removed_at = Time.current
removed_user_ids.each do |removed_user_id|
referenced_user_ids.add(removed_user_id)
unfollow_upsert_attrs << {
user_attr => user.id,
other_attr => removed_user_id,
:removed_at => removed_at,
}
end
Domain::User.transaction do
follow_upsert_attrs.each_slice(5000) do |slice|
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
end
unfollow_upsert_attrs.each_slice(5000) do |slice|
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
end
end
# reset counter caches
Domain::User.transaction do
referenced_user_ids.each do |user_id|
Domain::User.reset_counters(
user_id,
:user_user_follows_from,
:user_user_follows_to,
)
end
end
update_attrs = {
num_created_users: missing_user_dids.size,
num_existing_assocs: existing_subject_ids.size,
num_new_assocs: new_user_ids.size,
num_removed_assocs: removed_user_ids.size,
num_total_assocs: subjects.size,
}
logger.info(
format_tags("updated user #{edge_name}", make_tags(update_attrs)),
)
scan.update_json_attributes!(update_attrs)
user.touch
end
sig do
params(subject: Bluesky::Graph::Subject).returns(Domain::User::BlueskyUser)
end
def create_user_from_subject(subject)
user =
Domain::User::BlueskyUser.create!(
did: subject.did,
handle: subject.handle,
display_name: subject.display_name,
description: subject.description,
)
avatar = user.create_avatar(url_str: subject.avatar)
defer_job(Domain::Bluesky::Job::ScanUserJob, { user: }, { priority: 0 })
defer_job(Domain::UserAvatarJob, { avatar: }, { priority: -1 })
user
end
end

View File

@@ -1,232 +0,0 @@
# typed: strict
class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
self.default_priority = -20
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)
user = user_from_args!
logger.push_tags(make_arg_tag(user))
logger.info(format_tags("starting profile scan"))
if user.state_account_disabled? && !force_scan?
logger.info(format_tags("account is disabled, skipping profile scan"))
return
end
if !user.profile_scan.due? && !force_scan?
logger.info(
format_tags(
"skipping profile scan",
make_tags(scanned_at: user.profile_scan.ago_in_words),
),
)
return
end
scan_user_profile(user)
user.scanned_profile_at = Time.zone.now
logger.info(format_tags("completed profile scan"))
ensure
user.save! if user
end
private
sig { params(user: Domain::User::BlueskyUser).void }
def scan_user_profile(user)
logger.info(format_tags("scanning user profile"))
profile_scan = Domain::UserJobEvent::ProfileScan.create!(user:)
# Use Bluesky Actor API to get user profile
profile_url =
"https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=#{user.did}"
response = http_client.get(profile_url)
user.last_scan_log_entry = response.log_entry
profile_scan.update!(log_entry: response.log_entry)
if response.status_code == 400
error = JSON.parse(response.body)["error"]
if error == "InvalidRequest"
logger.error(format_tags("account is disabled / does not exist"))
user.state = "account_disabled"
return
end
end
if response.status_code != 200
fatal_error(
format_tags(
"failed to get user profile",
make_tags(status_code: response.status_code),
),
)
end
begin
profile_data = JSON.parse(response.body)
rescue JSON::ParserError => e
fatal_error(
format_tags(
"failed to parse profile JSON",
make_tags(error: e.message),
),
)
end
if profile_data["error"]
fatal_error(
format_tags(
"profile API error",
make_tags(error: profile_data["error"]),
),
)
end
# The getProfile endpoint returns the profile data directly, not wrapped in "value"
record = profile_data
if record
# Update user profile information
user.description = record["description"]
user.display_name = record["displayName"]
user.profile_raw = record
# Set registration time from profile createdAt
if record["createdAt"]
user.registered_at = Time.parse(record["createdAt"]).in_time_zone("UTC")
logger.info(
format_tags(
"set user registration time",
make_tags(registered_at: user.registered_at),
),
)
end
# Process avatar if present
process_user_avatar_url(user, record["avatar"]) if record["avatar"]
end
end
sig do
params(
user: Domain::User::BlueskyUser,
avatar_data: T::Hash[String, T.untyped],
).void
end
def process_user_avatar(user, avatar_data)
logger.debug(format_tags("processing user avatar", make_tags(avatar_data:)))
return unless avatar_data["ref"]
user_did = user.did
return unless user_did
avatar_url =
Bluesky::ProcessPostHelper.construct_blob_url(
user_did,
avatar_data["ref"]["$link"],
)
logger.debug(format_tags("extract avatar url", make_tags(avatar_url:)))
# Check if avatar already exists and is downloaded
existing_avatar = user.avatar
if existing_avatar.present?
logger.debug(
format_tags(
"existing avatar found",
make_tags(state: existing_avatar.state),
),
)
# Only enqueue if the avatar URL has changed or it's not downloaded yet
if existing_avatar.url_str != avatar_url
avatar = user.avatars.create!(url_str: avatar_url)
logger.info(
format_tags(
"avatar url changed, creating new avatar",
make_arg_tag(avatar),
),
)
defer_job(
Domain::UserAvatarJob,
{ avatar: avatar },
{ queue: "bluesky", priority: -30 },
)
elsif existing_avatar.state_pending?
defer_job(
Domain::UserAvatarJob,
{ avatar: existing_avatar },
{ queue: "bluesky", priority: -30 },
)
logger.info(format_tags("re-enqueued pending avatar download"))
end
else
# Create new avatar and enqueue download
avatar = user.avatars.create!(url_str: avatar_url)
defer_job(
Domain::UserAvatarJob,
{ avatar: },
{ queue: "bluesky", priority: -30 },
)
logger.info(
format_tags(
"created avatar and enqueued download",
make_arg_tag(avatar),
),
)
end
end
sig { params(user: Domain::User::BlueskyUser, avatar_url: String).void }
def process_user_avatar_url(user, avatar_url)
logger.debug(
format_tags("processing user avatar url", make_tags(avatar_url:)),
)
return if avatar_url.blank?
# Check if avatar already exists and is downloaded
existing_avatar = user.avatar
if existing_avatar.present?
logger.debug(
format_tags(
"existing avatar found",
make_tags(state: existing_avatar.state),
),
)
# Only enqueue if the avatar URL has changed or it's not downloaded yet
if existing_avatar.url_str != avatar_url
avatar = user.avatars.create!(url_str: avatar_url)
logger.info(
format_tags(
"avatar url changed, creating new avatar",
make_arg_tag(avatar),
),
)
defer_job(
Domain::UserAvatarJob,
{ avatar: avatar },
{ queue: "bluesky", priority: -30 },
)
elsif existing_avatar.state_pending?
defer_job(
Domain::UserAvatarJob,
{ avatar: existing_avatar },
{ queue: "bluesky", priority: -30 },
)
logger.info(format_tags("re-enqueued pending avatar download"))
end
else
# Create new avatar and enqueue download
avatar = user.avatars.create!(url_str: avatar_url)
defer_job(
Domain::UserAvatarJob,
{ avatar: },
{ queue: "bluesky", priority: -30 },
)
logger.info(
format_tags(
"created avatar and enqueued download",
make_arg_tag(avatar),
),
)
end
end
end

View File

@@ -78,7 +78,7 @@ class Domain::E621::Job::ScanPostFavsJob < Domain::E621::Job::Base
breaker += 1
end
post.scanned_post_favs_at = Time.current
post.scanned_post_favs_at = DateTime.current
post.save!
end
end

View File

@@ -40,7 +40,7 @@ class Domain::E621::Job::ScanUserFavsJob < Domain::E621::Job::Base
if response.status_code == 403 &&
response.body.include?("This users favorites are hidden")
user.favs_are_hidden = true
user.scanned_favs_at = Time.now
user.scanned_favs_at = DateTime.current
user.save!
break
end
@@ -121,19 +121,13 @@ class Domain::E621::Job::ScanUserFavsJob < Domain::E621::Job::Base
logger.info "upserting #{post_ids.size} favs"
post_ids.each_slice(1000) do |slice|
ReduxApplicationRecord.transaction do
Domain::UserPostFav::E621UserPostFav.upsert_all(
slice.map do |post_id|
{ user_id: user.id, post_id: post_id, removed: false }
end,
unique_by: %i[user_id post_id],
Domain::UserPostFav.upsert_all(
slice.map { |post_id| { user_id: user.id, post_id: post_id } },
unique_by: :index_domain_user_post_favs_on_user_id_and_post_id,
)
end
end
# Use reset_counters to update the counter cache after using upsert_all
Domain::User.reset_counters(user.id, :user_post_favs)
logger.info("[reset user_post_favs counter cache for user: #{user.id}]")
logger.info(
[
"[favs scanned: #{post_ids.size.to_s.bold}]",
@@ -144,7 +138,7 @@ class Domain::E621::Job::ScanUserFavsJob < Domain::E621::Job::Base
)
user.scanned_favs_ok!
user.scanned_favs_at = Time.now
user.scanned_favs_at = DateTime.current
user.save!
rescue StandardError
logger.error("error scanning user favs: #{user&.e621_id}")

View File

@@ -10,6 +10,16 @@ class Domain::E621::Job::StaticFileJob < Domain::E621::Job::Base
T.cast(file, Domain::PostFile)
elsif (post = args[:post]) && post.is_a?(Domain::Post::E621Post)
T.must(post.file)
elsif (post = args[:post]) && post.is_a?(Domain::E621::Post)
post =
Domain::Post::E621Post.find_by(e621_id: post.e621_id) ||
fatal_error(
format_tags(
"post with not found",
make_tag("e621_id", post.e621_id),
),
)
T.must(post.file)
else
fatal_error(":file or :post is required")
end

View File

@@ -11,8 +11,7 @@ class Domain::Fa::Job::Base < Scraper::JobBase
protected
BUGGY_USER_URL_NAMES =
T.let(["click here", "..", ".", "<i class="], T::Array[String])
BUGGY_USER_URL_NAMES = T.let(["click here", "..", "."], T::Array[String])
sig { params(user: Domain::User::FaUser).returns(T::Boolean) }
def buggy_user?(user)
@@ -37,6 +36,8 @@ class Domain::Fa::Job::Base < Scraper::JobBase
post = args[:post]
if post.is_a?(Domain::Post::FaPost)
return post
elsif post.is_a?(Domain::Fa::Post)
return Domain::Post::FaPost.find_by!(fa_id: post.fa_id)
elsif fa_id = args[:fa_id]
if build_post
Domain::Post::FaPost.find_or_initialize_by(fa_id: fa_id)
@@ -57,9 +58,12 @@ class Domain::Fa::Job::Base < Scraper::JobBase
return avatar
elsif user.is_a?(Domain::User::FaUser)
return T.must(user.avatar)
elsif user.is_a?(Domain::Fa::User)
user = Domain::User::FaUser.find_by(url_name: user.url_name)
return T.must(user&.avatar)
else
fatal_error(
"arg 'avatar' must be a Domain::UserAvatar or user must be a Domain::User::FaUser",
"arg 'avatar' must be a Domain::UserAvatar or user must be a Domain::Fa::User",
)
end
end
@@ -70,6 +74,8 @@ class Domain::Fa::Job::Base < Scraper::JobBase
user = args[:user]
if user.is_a?(Domain::User::FaUser)
user
elsif user.is_a?(Domain::Fa::User)
Domain::User::FaUser.find_by!(url_name: user.url_name)
elsif url_name = args[:url_name]
if create_if_missing
user =
@@ -91,7 +97,7 @@ class Domain::Fa::Job::Base < Scraper::JobBase
end
else
fatal_error(
"arg 'user' must be a Domain::User::FaUser, or url_name must be provided",
"arg 'user' must be a Domain::User::FaUser or Domain::Fa::User, or url_name must be provided",
)
end
end
@@ -363,17 +369,18 @@ class Domain::Fa::Job::Base < Scraper::JobBase
).returns(T.nilable(Domain::Fa::Parser::Page))
end
def update_user_from_user_page(user, response)
disabled_or_not_found = user_disabled_or_not_found?(user, response)
user.scanned_page_at = Time.current
user.last_user_page_log_entry = response.log_entry
return nil if user_disabled_or_not_found?(user, response)
return nil if disabled_or_not_found
page = Domain::Fa::Parser::Page.from_log_entry(response.log_entry)
page = Domain::Fa::Parser::Page.new(response.body)
return nil unless page.probably_user_page?
user_page = page.user_page
user.state_ok!
user.name = user_page.name
user.registered_at = user_page.registered_since&.in_time_zone("UTC")
user.registered_at = user_page.registered_since
user.num_pageviews = user_page.num_pageviews
user.num_submissions = user_page.num_submissions
user.num_comments_recieved = user_page.num_comments_recieved
@@ -522,7 +529,6 @@ class Domain::Fa::Job::Base < Scraper::JobBase
T.let(
[
/User ".+" has voluntarily disabled access/,
/User .+ has voluntarily disabled access/,
/The page you are trying to reach is currently pending deletion/,
],
T::Array[Regexp],
@@ -559,8 +565,7 @@ class Domain::Fa::Job::Base < Scraper::JobBase
).returns(T::Boolean)
end
def user_disabled_or_not_found?(user, response)
# HTTP 400 is returned when the user is not found
if response.status_code != 200 && response.status_code != 400
if response.status_code != 200
fatal_error(
"http #{response.status_code}, log entry #{response.log_entry.id}",
)

View File

@@ -57,15 +57,15 @@ class Domain::Fa::Job::BrowsePageJob < Domain::Fa::Job::Base
)
end
page = Domain::Fa::Parser::Page.from_log_entry(response.log_entry)
enqueue_jobs_from_found_links(response.log_entry)
page = Domain::Fa::Parser::Page.new(response.body)
listing_page_stats =
update_and_enqueue_posts_from_listings_page(
ListingPageType::BrowsePage.new(page_number: @page_number),
page_parser: page,
)
enqueue_jobs_from_found_links(response.log_entry)
@total_num_new_posts_seen += listing_page_stats.new_posts.count
@total_num_posts_seen += listing_page_stats.all_posts.count
listing_page_stats.new_posts.count > 0

View File

@@ -29,11 +29,6 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
return
end
if user.state_account_disabled?
logger.warn(format_tags("user is disabled, skipping"))
return
end
faved_post_ids = T.let(Set.new, T::Set[Integer])
existing_faved_post_ids =
T.let(Set.new(user.user_post_favs.pluck(:post_id)), T::Set[Integer])
@@ -48,7 +43,7 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
while true
ret = scan_next_page(user: user)
break if ret.is_a?(ScanPageResult::Stop)
return if ret.is_a?(ScanPageResult::Stop)
faved_post_ids += ret.faved_post_ids_on_page
new_faved_post_ids_on_page =
@@ -86,16 +81,30 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
end
faved_post_ids_to_add = faved_post_ids - existing_faved_post_ids
user.upsert_new_favs(
faved_post_ids_to_add.to_a,
log_entry: causing_log_entry!,
)
upsert_faved_post_ids(user:, post_ids: faved_post_ids_to_add)
ensure
user.save! if user
end
private
sig { params(user: Domain::User::FaUser, post_ids: T::Set[Integer]).void }
def upsert_faved_post_ids(user:, post_ids:)
ReduxApplicationRecord.transaction do
if post_ids.any?
post_ids.each_slice(1000) do |slice|
Domain::UserPostFav.upsert_all(
slice.map { |id| { user_id: user.id, post_id: id } },
unique_by: %i[user_id post_id],
)
end
end
user.scanned_favs_at = Time.zone.now
end
logger.info(format_tags(make_tag("total new favs", post_ids.size)))
end
module ScanPageResult
extend T::Sig
@@ -130,9 +139,11 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
end
disabled_or_not_found = user_disabled_or_not_found?(user, response)
user.scanned_favs_at = Time.current
return ScanPageResult::Stop.new if disabled_or_not_found
page_parser = Domain::Fa::Parser::Page.from_log_entry(response.log_entry)
page_parser = Domain::Fa::Parser::Page.new(response.body)
return ScanPageResult::Stop.new unless page_parser.probably_listings_page?
listing_page_stats =
@@ -140,11 +151,6 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
ListingPageType::FavsPage.new(page_number: @page_id, user:),
page_parser:,
)
ReduxApplicationRecord.transaction do
self.class.update_favs_and_dates(user:, page_parser:)
end
@page_id = page_parser.favorites_next_button_id
ScanPageResult::Ok.new(
faved_post_ids_on_page:
@@ -153,127 +159,4 @@ class Domain::Fa::Job::FavsJob < Domain::Fa::Job::Base
keep_scanning: @page_id.present?,
)
end
class FavsAndDatesStats < T::ImmutableStruct
extend T::Sig
include T::Struct::ActsAsComparable
NH = NumberHelper
const :num_updated_with_fav_fa_id, Integer, default: 0
const :num_updated_with_date, Integer, default: 0
const :num_updated_total, Integer, default: 0
sig { params(other: FavsAndDatesStats).returns(FavsAndDatesStats) }
def +(other)
FavsAndDatesStats.new(
num_updated_with_fav_fa_id:
num_updated_with_fav_fa_id + other.num_updated_with_fav_fa_id,
num_updated_with_date:
num_updated_with_date + other.num_updated_with_date,
num_updated_total: num_updated_total + other.num_updated_total,
)
end
sig { returns(FavsAndDatesStats) }
def self.zero
FavsAndDatesStats.new()
end
sig { returns(String) }
def to_s
[
num_updated_with_fav_fa_id,
num_updated_with_date,
num_updated_total,
].map { |n| (NH.number_with_delimiter(n) || "(none)") }.join(" / ")
end
end
class FavUpsertData < T::ImmutableStruct
extend T::Sig
include T::Struct::ActsAsComparable
const :post_id, Integer
const :fav_id, Integer
end
# Creates or updates Domain::UserPostFav::FaUserPostFav records for the user's favs
# and dates. The first faved post is special-cased to be the most recent
# faved post, so we can use the date from the page parser to set its date.
# The rest of the faved posts are updated with the fav_fa_id from the
# page parser.
sig do
params(
user: Domain::User::FaUser,
page_parser: Domain::Fa::Parser::Page,
).returns(FavsAndDatesStats)
end
def self.update_favs_and_dates(user:, page_parser:)
num_updated_with_fav_fa_id = 0
num_updated_with_date = 0
num_updated_total = 0
fa_id_to_post_id =
T.let(
Domain::Post::FaPost
.where(fa_id: page_parser.submissions_parsed.map(&:id))
.pluck(:fa_id, :id)
.to_h,
T::Hash[Integer, Integer],
)
page_parser
.submissions_parsed
.filter { |sub| fa_id_to_post_id[T.must(sub.id)].nil? }
.each do |sub|
fa_id = T.must(sub.id)
model = Domain::Post::FaPost.find_or_create_by!(fa_id:)
fa_id_to_post_id[fa_id] = T.must(model.id)
end
first_faved = page_parser.submissions_parsed[0]
if (fa_id = first_faved&.id) && (post_id = fa_id_to_post_id[fa_id]) &&
(fav_id = first_faved.fav_id) &&
(explicit_time = page_parser.most_recent_faved_at_time)
num_updated_with_date += 1
num_updated_total += 1
user.update_fav_model(post_id:, fav_id:, explicit_time:)
end
user_post_favs_with_fav_id =
(page_parser.submissions_parsed[1..] || [])
.filter_map do |sub_data|
post_id = (id = sub_data.id) && fa_id_to_post_id[id]
next if post_id.nil?
fav_id = sub_data.fav_id
next if fav_id.nil?
FavUpsertData
.new(post_id:, fav_id:)
.tap do
num_updated_with_fav_fa_id += 1
num_updated_total += 1
end
end
.group_by(&:post_id)
.values
.filter_map { |data_arr| data_arr.max_by(&:fav_id) }
.map do |data|
{
user_id: T.must(user.id),
post_id: data.post_id,
fa_fav_id: data.fav_id,
}
end
Domain::UserPostFav::FaUserPostFav.upsert_all(
user_post_favs_with_fav_id,
unique_by: %i[user_id post_id],
)
FavsAndDatesStats.new(
num_updated_with_fav_fa_id:,
num_updated_with_date:,
num_updated_total:,
)
end
end

View File

@@ -19,11 +19,13 @@ class Domain::Fa::Job::ScanFileJob < Domain::Fa::Job::Base
return
end
post =
if post.is_a?(Domain::Post::FaPost)
if post.is_a?(Domain::Fa::Post)
Domain::Post::FaPost.find_by!(fa_id: post.fa_id)
elsif post.is_a?(Domain::Post::FaPost)
post
else
fatal_error(
"invalid post model: #{post.class}, expected Domain::Post::FaPost",
"invalid post model: #{post.class}, expected Domain::Fa::Post or Domain::Post::FaPost",
)
end
post.file

Some files were not shown because too many files have changed in this diff Show More