v0.5.2 - UX Refresh, Custom Themes, Metadata Editing, and OPDS updates! (#1189)
* Angular Upgrade (#1059) * Upgraded to Angular 12 * Bump ng-bootstrap for upgrade * Angular 13 upgrade, ng-bootstrap bump * Angular 13 upgrade (broken) * Angular 13 upgrade. CSS is broken completely * Angular 13 upgrade is complete. * Bump versions by dotnet-bump-version. * Added beta disclaimer (#1065) * Bump versions by dotnet-bump-version. * Auto approve migration emails if the password is correct. Change Email Link dump to Critical to ensure it makes it into the logs. (#1069) * Bump versions by dotnet-bump-version. * Adding discord roles (#1070) * Adding discord roles # Added - Added: Added Discord roles to automated build discord notification. * update * Bump versions by dotnet-bump-version. * Custom Theme Support (#1077) * Started the migration to bootstrap 5. Introduced a breakpoint system that bootstrap reflects for our screens. * sr only migrated * mr/ml -> me/ms * pl/pr -> ps/pe * btn-block * removed input-group-append * Added form-label to all labels * Added some style overrides for inputs * Replaced form-group with mb-3 * Ignore journal files * Update media to d-flex/flex-grow-1 * Fixed reading list detail page * For develop builds, don't inline critical styles * Fixed some downstream security issues * Fixed a layout issue in series detail * Fixed issue with btn-light not having background color. Updated layout for series detail metadata * Cleaned up nav search * Laid out the organization for custom theme components. Update _inputs.scss with variable overrides and depending on theme, it will just work. * Lots of theming work * Added inputs to the theme page * Login and input placeholder changes - Fixed login screen centering issue on all devices - Changed the format of the login screen - Change the input placeholder color * Added checkbox styles * Refactored tagbadges and removed some ngdeep selectors * Added nav bar component and refactored some styles into event widget * Cleaned nav events again and made dedicated popover body * Finished pagination component * Fixed up some styles with buttons * refactored dropdown component * Update accordion component * Refactored breadcrumbs and rating star. Fixed a missing style for cards * Fixed some styling issues on person badge, added modal component, and some global styles * Finished moving everything within dark to component files * Fixed up filter buttons, move card styles into a component theme, fixed slider style * Refactored library card and grouped typeahead * Updated normal typeahead component and reduced amount of ngdeep selector * Refactored grid breakpoints to be available by css variable, but it's hardcoded into the app * Ensure breakpoints are defined per theme * Fixed up some styling overrides and customization for nav links and alt button * Removed some deep styles, moved css out of splash container and brough back labels for login page * Finished css variable refactor * Refactored all the theme variable definitions into files for each theme. * Added back bootstrap overrides * Added a note about bootstrap theme colors being not-possible to swap out at runtime * Cleaned up some dead code * Implemented the ability to set a custom theme on the site. Cleaned up misc code throughout. * Additional changes - Fixed nav where "kavita" was not hiding correctly on small viewports - Fixed search bar to make the behavior more consistent - Fixed accordion buttons - Changed accordion buttons to be more responsive - Added radio button colors - Fixed radios on theme test page - Changed login and reset password card layouts to be more consistent. - Added primary color shade for when darker shading is needed. * Built a basic site, allow the user to apply different themes, refactored nav service code out. * Implemented the ability update a user's theme * Added unit tests for Scan and Get Content in SiteThemeService. * Fixed a bug in the login code and Pref code which wasn't joining on SiteTheme table. Wrote Unit tests and the UI component to manage current theme. * Implemented scan so that it manages custom themes with unit tests * Component updates - Repositioning style ordering - Adding indicator override - Adding select styles * SignlaR integration, some fixes when creating custom entities, one single migration. Just login functionality left. * More ui updated - Added .no-hover to prevent hover on elements where not needed - Changed all selects I could find to appropriate class - Changed up nav tabs to work more like bootstrap tabs than pills - Added padding to top of some containers to make styles consistent - Added ability to change navbar fontawesome icon colors - removed some unecessary inline styling - Changed radio button to appropriate class - Toned down primate color, a bit too bright for dark theme. - Added ability to change button fontawesome icon color * nav-tab fix for series-detail * Added themes folder to gitignore * Adding card overlay * Fixing up light theme * Everything is done. Only bug is that color-scheme isn't being set properly from css variable. * Checkboxes have pointer by default. Confirm/Confirm email use default (dark) theme by default * Fixed an error where color-scheme wasn't reflecting correctly on themes on first load * Fixed user preferences not available on login * Changing dual radios to switches and color tweaks * disabled primary APCA fix * button APCA fixes * Fixed some timing issues with first load and image service * Fixed swiper issues from upgrade * Changed themes to be scss files again and adjusted Seed code * Migrated carousel to css variables. Fixed a broken animation for search. * Cleaned up some backend smells * Fixed white border outline on nav tabs, added some variables for header * Nav bar has been css variable-ified * Added some basic eink stuff to make the app useable Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Fix an issue for first time running theme code, theme will not be available (#1081) * Bump versions by dotnet-bump-version. * Theme Cleanup (#1089) * Fixed e-ink theme not properly applying correctly * Fixed some seed changes. Changed card checkboxes to use our themed ones * Fixed recently added carousel not going to recently-added page * Fixed an issue where no results found would show when searching for a library name * Cleaned up list a bit, typeahead dropdown still needs work * Added a TODO to streamline series-card component * Removed ng-lazyload-image module since we don't use it. We use lazysizes * Darken card on hover * Fixing accordion focus style * ux pass updates - Fixed typeahead width - Fixed changelog download buttons - Fixed a select - Fixed various input box-shadows - Fixed all anchors to only have underline on hover - Added navtab hover and active effects * more ux pass - Fixed spacing on theme cards - Fixed some light theme issues - Exposed text-muted-color for theme card subtitle color * UX pass fixes - Changed back to bright green for primary on dark theme - Changed fa icon to black on e-ink * Merged changelog component * Fixed anchor buttons text decoration * Changed nav tabs to have a background color instead of open active state * When user is not authenticated, make sure we set default theme (dark) * Cleanup on carousel * Updated Users tab to use small buttons with icons to align with Library tab * Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs * Fixed collection detail posters not rendering Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Event Widget Update (#1098) * Took care of some notes in the code * Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary * Moved Tag cleanup code into Scanner service. Added a SplitQuery to another heavy API. Refactored Scan loop to remove parallelism and use async instead. * Lots of rework on the codebase to support detailed messages and easier management of message sending. Need to take a break on this work. * Progress is being made, but slowly. Code is broken in this commit. * Progress is being made, but slowly. Code is broken in this commit. * Fixed merge issue * Fixed unit tests * CoverUpdate is now hooked into new ProgressEvent structure * Refactored code to remove custom observables and have everything use standard messages$ * Refactored a ton of instances to NotificationProgressEvent style and tons of the UI to respect that too. UI is still a bit buggy, but wholistically the work is done. * Working much better. Sometimes events come in too fast. Currently cover update progress doesn't display on UI * Fixed unit tests * Removed SignalREvent to minimize internal event types. Updated the UI to use progress bars. Finished SiteThemeService. * Merged metadata refresh progress events and changed library scan events to merge cleaner in the UI * Changed RefreshMetadataProgress to CoverUpdateProgress to reflect the event better. * Theme Cleanup (#1089) * Fixed e-ink theme not properly applying correctly * Fixed some seed changes. Changed card checkboxes to use our themed ones * Fixed recently added carousel not going to recently-added page * Fixed an issue where no results found would show when searching for a library name * Cleaned up list a bit, typeahead dropdown still needs work * Added a TODO to streamline series-card component * Removed ng-lazyload-image module since we don't use it. We use lazysizes * Darken card on hover * Fixing accordion focus style * ux pass updates - Fixed typeahead width - Fixed changelog download buttons - Fixed a select - Fixed various input box-shadows - Fixed all anchors to only have underline on hover - Added navtab hover and active effects * more ux pass - Fixed spacing on theme cards - Fixed some light theme issues - Exposed text-muted-color for theme card subtitle color * UX pass fixes - Changed back to bright green for primary on dark theme - Changed fa icon to black on e-ink * Merged changelog component * Fixed anchor buttons text decoration * Changed nav tabs to have a background color instead of open active state * When user is not authenticated, make sure we set default theme (dark) * Cleanup on carousel * Updated Users tab to use small buttons with icons to align with Library tab * Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs * Fixed collection detail posters not rendering Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Tweaked some of the emitting code * Some css, but pretty bad. Robbie please save me * Removed a todo * styling update * Only send filename on FileScanProgress * Some console.log spam cleanup * Various updates * Show events widget activity based on activeEvents * progress bar color updates * Code cleanup Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Scanner event hub fix (#1099) * Scanner event hub fix - Fixed an issue where the scanner would error when adding a new series because the series didn't have a library name yet. (develop) * Removing library.type * Bump versions by dotnet-bump-version. * Workflow update to add nightly versions (#1100) # Changed - Changed: Changed automated workflow to release individual nightly versions on dockerhub * Bump versions by dotnet-bump-version. * Updating GA to parse version (#1101) * Bump versions by dotnet-bump-version. * GA Fixes (#1103) **Strictly Repo Changes** # Fixed - Fixed: Fixed an issue where patch version was not being added to docker tag. * Bump versions by dotnet-bump-version. * Fixed specials being misaligned (#1106) # Fixed - Fixed: Fixed issue with specials not being properly aligned (develop) * Bump versions by dotnet-bump-version. * Bugfix/ux pass 2 (#1107) * Adding margin bottom to series detail tabs * Styling tag badges with green on dark - Added 3 new css vars * Removing underline from readmore * Fixing see more to be on one line * adding gutter to see more * Changing queue toasts to info * adding api key tooltip * Updating active accordion on user preference. * Fixing search bar and close btn position * Fixed a bug where entering book reader in dark mode then closing out, would leave you in a broken white state. * Fixed broken wiki links Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com> * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Series Detail Refactor (#1118) * Fixed a bug where reading list and collection's summary wouldn't render newlines * Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests. * Unit tests for CleanSpecialTitle * Laid out foundation for testing major code in SeriesController. * Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load. * Removed old property from Volume * Changed tagbadge font size to rem. * Refactored some methods from SeriesController.cs into SeriesService.cs * UpdateRating unit tested * Wrote unit tests for SeriesDetail * Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats. * Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index. * Some cleanup on the code * DeleteMultipleSeries test is hard. Going to skip. * Removed some debug code and make all tabs Books for Book library Type * Bump versions by dotnet-bump-version. * Tachiyomi Bugfix (#1119) * Updated the dependencies for .NET 6.0.2 * Fixed a bad prev chapter logic where we would bleed into chapters from last volume instead of specials. * Fixed the get prev chapter code to properly walk the order according to documentation and updated some bad test cases * Bump versions by dotnet-bump-version. * Series index fix (#1120) * Series index fix # Fixed - Fixed: Fixed an issue where epub series with index = 0 would be hidden on series detail page. * Removing unnecessary conditional * Bump versions by dotnet-bump-version. * Misc Bugfixes (#1123) * Fixed a bug where ComicInfo Count can be a float and we threw a parse error. * Fixed a bug in download bookmarks which didn't properly create the filepaths for copying. Refactored into a service with a unit test. In Scanner, repull genres, people and tags between chunk saves to ensure no unique constraint issues. * Fixed a bug where card detail layout wouldn't refresh the library name on the card between pages * Fixed an issue where a check to scrolling page back to top was missing in manga reader * Fixed a bug where cleaning up collection tags without Series was missing after editing a Series. * Cleaned up the styles for cover chooser * Added Regex support for "Series 001 (Digital) (somethingwith1234)" and removed support for "A Compendium of Ghosts - 031 - The Third Story_ Part 12" due to complexity in parsing. * Fixed a miscommunication on how Tachiyomi needs the API MarkChaptersUntilAsRead implemented. Now 0 chapter volumes will be marked. * Removed unneeded DI * Bump versions by dotnet-bump-version. * CopyFilesToDirectory will now allow for one duplicate copy over and put (2) (#1126) * Bump versions by dotnet-bump-version. * Stablize the Styles (#1128) * Fixed a bug where adding multiple series to reading list would throw an error on UI, but it was successful. * When a series has a reading list, we now show the connection on Series detail. * Removed all baseurl code from UI and not-connected component since we no longer use it. * Fixed tag badges not showing a border. Added last read time to the series detail page * Fixed up error interceptor to remove no-connection code * Changed implementation for series detail. Book libraries will never send chapters back. Volume 0 volumes will not be sent in volumes ever. Fixed up more renaming logic on books to send more accurate representations to the UI. * Cleaned up the selected tab and tab display logic * Fixed a bad where statement in reading lists for series * Fixed up tab logic again * Fixed a small margin on search backdrop * Made badge expander button smaller to align with badges * Fixed a few UIs due to .form-group and .form-row being removed * Updated Theme component page to help with style testing * Added more components to theme tester * Cleaned up some styling * Fixed opacity on search item hover * Bump versions by dotnet-bump-version. * Hacked in code so that we render an image instead of canvas for fit to screen to try out. (#1131) * Bump versions by dotnet-bump-version. * Metadata Editing from the UI! (#1135) * Added the skeleton code for layout, hooked up Age Rating, Publication Status, and Tags * Tweaked message of Scan service to Finished scan of to better indicate the total scan time * Hooked in foundation for person typeaheads * Fixed people not populating typeaheads on load * For manga/comics, when parsing, set the SeriesSort from ComicInfo if it exists. * Implemented the ability to override and create new genre tags. Code is ready to flush out the rest. * Ability to update metadata from the UI is hooked up. Next is locking. * Updated typeahead to allow for non-multiple usage. Implemented ability to update Language tag in Series Metadata. * Fixed a bug in GetContinuePoint for a case where we have Volumes, Loose Leaf chapters and no read progress. * Added ETag headers on Images to allow for better caching (bookmarks and images in manga reader) * Built out UI code to show locked indication to user * Implemented Series locking and refactored a lot of styles in typeahead to make the lock setting work, plus misc cleanup. * Added locked properties to dtos. Updated typeahead loading indicator to not interfere with close button if present * Hooked up locking flags in UI * Integrated regular field locking/unlocking * Removed some old code * Prevent input group from wrapping * Implemented some basic layout for metadata on volume/chapter card modal. Refactored out all metadata from Chapter object in terms of UI and put into a separate call to ensure speedy delivery and simplicity of code. * Refactored code to hide covers section if not an admin * Implemented ability to modify a chapter/volume cover from the detail modal * Removed a few variables and change cover image modal * Added bookmark to single chapter view * Put a temp fix in for a ngb v12 z-index bug (reported). Bumped ngb to 12.0 stable and fixed some small rendering bugs * loading buttons ftw * Lots of cleanup, looks like the story is finished * Changed action name from Info to Details * Style tweaks * Fixed an issue where Summary would assume it's locked due to a subscription firing on setting the model * Fixed some misc bugs * Code smells Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Manga Reader Refresh (#1137) * Refactored manga reader to use a regular image element for all cases except for split page rendering * Fixed a weird issue where ordering of routes broke redireciton in one case. * Added comments to a lot of the enums and refactored READER_MODE to be ReaderMode and much more clearer on function. * Added bookmark effect on image renderer * Implemented keyboard shortcut modal * Introduced the new layout mode into the manga reader, updated preferences, and updated bookmark to work for said functionality. Need to implement renderer now * Hooked in ability to show double pages but all the css is broken. Committing for help from Robbie. * Fixed an issue where Language tag in metadata edit wasn't being updated * Fixed up some styling on mobile for edit series detail * Some css fixes * Hooked in ability to set background color on reader (not implemented in reader). Optimized some code in ArchiveService to avoid extra memory allocations. * Hooked in background color, generated the migration * Fixed a bug when paging to cover images, full height would be used instead of full-width for cover images * New option in reader to show screen hints (on by default). You can disable in user preferences which will stop showing pagination overlay hints * Lots of fixes for double rendering mode * Bumped the amount of cached pages to 8 * Fixed an issue where dropdowns weren't being locked on form manipulation * Bump versions by dotnet-bump-version. * On Deck tweaks + Bugfixes (#1141) * Tweaked the On deck to only look for series that have progress in past 30 days. This number is just to test it out, it will be configurable later. Tweaked the layout of the dashboard to remove a redundant section. * Fixed a bug where archives with __MACOSX/ inside would break the reader during flattening. * Fixed a bug where confirm service rejection should have resolved as false. * Fixed an issue with checking if server is accessible with loopback and local ips * Bump versions by dotnet-bump-version. * Manga Reader Shakeout (#1142) * Fixed a unit test in ArchiveService * Image scaling fixes * removing test * Added new layout mode (enum only) and cleaned up manga reader and wrote extra documentation * Aligned code with cleanup * Adding reverse classes for manga reading * Disable options for layout modes that doesn't make sense. * Cleaned up manga reader menu items to link to preferences options directly * Work in progress, but rendering the correct page numbers for double. Need to rework caching logic so we can use existing image objects * Pagination logic is now properly increasing page number an extra when double layout mode * I can't figure out cachedImages to work properly with double pages, but doing it in a way where it handles downloading the image (and etag cache) + rendering the url, seems to work really well * Double original fix, also flex squish fix * Implemented last page on double which will load next chapter. Fixed a bug where if GetImage from ReaderController threw an error, the chapter directory would be emptied, but the folder itself wasn't deleted. * Fixed a bad if for double manga * double class fix * Cleanup up some console.logs * Adjusted the caching for images in a reading session so they cache for 2 mins * fixing webtoon image issue * Tweaked the caching of images to 10 mins for reading. Fixed a bug where after webtoon, single image layout would be selected. Tweaked logic for handling prev/next pages on chapter boundaries. * Fixed an issue where 2nd page would be skipped * Fixed an issue where 2nd page would be skipped * Fixed a skip page issue * Misc css fixes Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Misc Bugfixes and Cleanup (#1144) * Moved libraryType into chapter info * Fixed a bug where you could not reset cover on a series * Patched in relevant changes from another polish branch * Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker. * Refactored invite user to always print the url to setup a new account. * Single page renderer uses canvasImage rather than re-requesting and relying on cache * Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image * Fixed a rendering bug with split image functionality * Added title to copy button * Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter. Refactored 2 methods from controller into service and unit tested. * Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened. * Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal. * Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update. * Fixed up some poor documentation on FilterDto. * Bump versions by dotnet-bump-version. * Bugfixes and Cover Chooser Upgrades (#1146) * Fixed a bug where GetNextChapter would return a loose leaf chapter from a special when it should return nothing. * Fixed a bug in events widget when an update comes in after a user refreshes, the active event counter could get out of sync, thus showing "Nothing going on here" Refactored the events widget to be named appropriately. * Refactored code to have errors during threaded tasks propagate to the UI via events widget (css still needed). Removed ScanLibraryError in favor of generic Error event. * Fixed up some code and added ability to remove the event from events widget * Fixed a bug where modifiying certain fields, like summary, wouldn't lock the field * Fixed a few bugs where lock state was not being set in the DB correctly nor were certain combinations of locking fields and editing fields. * Removed debug code * Updated the discord alert to tag new group * Refactored cover upload to actually handle uploading a temp file via url on the backend so that users can user change cover by url. Fixed up some bugs that occured when chaning the image container in a previous PR. * Code cleanup * Cleaned up the css on the error items * Code cleanup * Bump versions by dotnet-bump-version. * Side nav (#1155) * adding back side-nav * Event Widget Update (#1098) * Took care of some notes in the code * Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary * Moved Tag cleanup code into Scanner service. Added a SplitQuery to another heavy API. Refactored Scan loop to remove parallelism and use async instead. * Lots of rework on the codebase to support detailed messages and easier management of message sending. Need to take a break on this work. * Progress is being made, but slowly. Code is broken in this commit. * Progress is being made, but slowly. Code is broken in this commit. * Fixed merge issue * Fixed unit tests * CoverUpdate is now hooked into new ProgressEvent structure * Refactored code to remove custom observables and have everything use standard messages$ * Refactored a ton of instances to NotificationProgressEvent style and tons of the UI to respect that too. UI is still a bit buggy, but wholistically the work is done. * Working much better. Sometimes events come in too fast. Currently cover update progress doesn't display on UI * Fixed unit tests * Removed SignalREvent to minimize internal event types. Updated the UI to use progress bars. Finished SiteThemeService. * Merged metadata refresh progress events and changed library scan events to merge cleaner in the UI * Changed RefreshMetadataProgress to CoverUpdateProgress to reflect the event better. * Theme Cleanup (#1089) * Fixed e-ink theme not properly applying correctly * Fixed some seed changes. Changed card checkboxes to use our themed ones * Fixed recently added carousel not going to recently-added page * Fixed an issue where no results found would show when searching for a library name * Cleaned up list a bit, typeahead dropdown still needs work * Added a TODO to streamline series-card component * Removed ng-lazyload-image module since we don't use it. We use lazysizes * Darken card on hover * Fixing accordion focus style * ux pass updates - Fixed typeahead width - Fixed changelog download buttons - Fixed a select - Fixed various input box-shadows - Fixed all anchors to only have underline on hover - Added navtab hover and active effects * more ux pass - Fixed spacing on theme cards - Fixed some light theme issues - Exposed text-muted-color for theme card subtitle color * UX pass fixes - Changed back to bright green for primary on dark theme - Changed fa icon to black on e-ink * Merged changelog component * Fixed anchor buttons text decoration * Changed nav tabs to have a background color instead of open active state * When user is not authenticated, make sure we set default theme (dark) * Cleanup on carousel * Updated Users tab to use small buttons with icons to align with Library tab * Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs * Fixed collection detail posters not rendering Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Tweaked some of the emitting code * Some css, but pretty bad. Robbie please save me * Removed a todo * styling update * Only send filename on FileScanProgress * Some console.log spam cleanup * Various updates * Show events widget activity based on activeEvents * progress bar color updates * Code cleanup Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Scanner event hub fix (#1099) * Scanner event hub fix - Fixed an issue where the scanner would error when adding a new series because the series didn't have a library name yet. (develop) * Removing library.type * Bump versions by dotnet-bump-version. * Workflow update to add nightly versions (#1100) # Changed - Changed: Changed automated workflow to release individual nightly versions on dockerhub * Bump versions by dotnet-bump-version. * Updating GA to parse version (#1101) * Bump versions by dotnet-bump-version. * GA Fixes (#1103) **Strictly Repo Changes** # Fixed - Fixed: Fixed an issue where patch version was not being added to docker tag. * Bump versions by dotnet-bump-version. * Fixed specials being misaligned (#1106) # Fixed - Fixed: Fixed issue with specials not being properly aligned (develop) * Bump versions by dotnet-bump-version. * Bugfix/ux pass 2 (#1107) * Adding margin bottom to series detail tabs * Styling tag badges with green on dark - Added 3 new css vars * Removing underline from readmore * Fixing see more to be on one line * adding gutter to see more * Changing queue toasts to info * adding api key tooltip * Updating active accordion on user preference. * Fixing search bar and close btn position * Fixed a bug where entering book reader in dark mode then closing out, would leave you in a broken white state. * Fixed broken wiki links Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com> * Bump versions by dotnet-bump-version. * Series Detail Refactor (#1118) * Fixed a bug where reading list and collection's summary wouldn't render newlines * Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests. * Unit tests for CleanSpecialTitle * Laid out foundation for testing major code in SeriesController. * Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load. * Removed old property from Volume * Changed tagbadge font size to rem. * Refactored some methods from SeriesController.cs into SeriesService.cs * UpdateRating unit tested * Wrote unit tests for SeriesDetail * Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats. * Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index. * Some cleanup on the code * DeleteMultipleSeries test is hard. Going to skip. * Removed some debug code and make all tabs Books for Book library Type * Bump versions by dotnet-bump-version. * Tachiyomi Bugfix (#1119) * Updated the dependencies for .NET 6.0.2 * Fixed a bad prev chapter logic where we would bleed into chapters from last volume instead of specials. * Fixed the get prev chapter code to properly walk the order according to documentation and updated some bad test cases * Updated side nav to float a bit and added user settings to it. * Refactored the code to hide/show sidenav to be more angular and decoupled * Moved Changelog out of admin dashboard and into a dedicated page in user menu. Added a wiki link from user menu * Introduced a side nav item for rendering each item and refactored code to use it. * Added a filter of side nav when there are more than 10 libraries. Added some themeing overrides for side nav. * Cleaned up the template code for side nav item so if there is no link, we don't generate that html directive * Refactored side nav into a module and migrated a few pipes into a pipe module for easy re-use * Added companion bar on reading list and collection. Updated modules to load pages and make side nav items clickable as anchors, so new tab works. * Moved metadata filter into separate component/module and the button in the companion bar. Needs cleanup. * Finished cleanup and refactoring of metadata filter into separate component. Removed filtering from Collections as it doesn't work and wasn't hooked up. * Tweaked the css on carousel component * Added to library detail and series-detail * Fixes and css vars * Stop destroying sidenav, animaton timing * Integrated side nav on the rest of the pages * Navbar now collapses to icons * mobile sidenav start * more mobile fixes * mobile tweaks * light and e-ink theme updates * white and eink dropdown color fixes * plex inspired side-nav * theme fixes * Making spacing more uniform across app * More fixes * fixing spacing on cards * actionable fix for sidenav * no scroll on mobile when sidenav is open * hide sidenav on pages * Adding card spacing * Adding ability to remove sidenav when in a reader * tidying up sidenav toggles * side-nav mobile updates * fixing up other themes * overlay fixes * Cleaned up the code to make the observables have better names. Removed a bunch of pointless subscriptions. Cleaned up methods that werent needed. Added jsdocs to help ensure the understandability of the 2 states for the side nav. * Integrated a highlight effect on side nav. Fixed a ton of places where the nav was being hidden when it shouldn't. * Fixed where active state wasn't working on all urls * misc fixes - smaller hamburger - z-index fixes - active fixes * Revert "Merge branch 'develop' into feature/side-nav-upgrade" This reverts commit 76b0d15a984692874e0cb57e821686ea703144cf, reversing changes made to b3ed55395473aa35577500596a211ad22a42631b. * Fixing edit-series modal spacing * Give the ability to jump to a library from admin manage libraries page * Fixed a bug with highlighting active item on side nav * Moved localized series title to companion bar via subtitle * Removed old title * Fixed a bug where clicking a link would reload the whole app, styling fixes on filter, fixed issue with initial load not setting active state, adjusted styles on active style. * code cleanup Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> * Bump versions by dotnet-bump-version. * Fixed a bug where companion bar would be pushing content to the right even when not visible. Updated nav service localstorage key. (#1159) * Bump versions by dotnet-bump-version. * Side Nav Fixes (#1161) * Fixed an issue where there was extra padding on top/bottom of readers when side nav was hidden. * Fixed a bug where fit to screen wasn't forcing width scaling * Added back total pages to many pages * Fixed the padding on series detail cards * Tweaked carousels to match series detail padding * Fixed an issue where large amount of libraries could have 2 highlighted at once due to how highlight logic works on routes. * Cleaned up some extra space in card detail layout due to moving title into compainion bar * Moved some gloabls to global and moved color-scheme to body tag * Moved scrollbar onto the body itself which helps with page jank on loading and fixes scrollbar not working with theme * Bump versions by dotnet-bump-version. * Fixed loose chapters marked as read for Tachiyomi (#1158) * Tachiyomi-related fixes * Created unit test for MarkAsReadAnythingUntil * Applied the requested changes. * More Bugfixes from Side Nav (#1162) * Fixed a bug where the bottom of the page could be cut off * Adjusted all the headings to h2, which looks better * Refactored GetSeriesDetail to actually map the names inside the code so the UI just displays. * Put in some basic improvements to OPDS by using Series Detail type layout, but this only reduces one click. * Fixed a bug where offset from scrollbar fix causes readers to be cutoff. * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * OPDS Rework (#1164) * Fixed a bug where the bottom of the page could be cut off * Adjusted all the headings to h2, which looks better * Refactored GetSeriesDetail to actually map the names inside the code so the UI just displays. * Put in some basic improvements to OPDS by using Series Detail type layout, but this only reduces one click. * Fixed a bug where offset from scrollbar fix causes readers to be cutoff. * Ensure the hamburger menu icon is aligned with side nav * Disable the image splitting dropdown in webtoon mode * Fixed broken progress/scroll code as we scroll on the body instead of window now * Fixed phone-hidden class not working due to a bad media query * Lots of changes to OPDS to provide a richer text experience. Uses Issues or Books based on library type. Cleans up the experience by providing Storyline from the get-go. * Updated OPDS-SE search description to include collections and reading lists. * Fixed up some title stuff * If a volume only has one file underneath it, flatten it and send a chapter as if it were the volume. * Code cleanup * Bump versions by dotnet-bump-version. * Feature/enhancements and more (#1166) * Moved libraryType into chapter info * Fixed a bug where you could not reset cover on a series * Patched in relevant changes from another polish branch * Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker. * Refactored invite user to always print the url to setup a new account. * Single page renderer uses canvasImage rather than re-requesting and relying on cache * Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image * Fixed a rendering bug with split image functionality * Added title to copy button * Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter. Refactored 2 methods from controller into service and unit tested. * Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened. * Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal. * Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update. * Fixed up some poor documentation on FilterDto. * Some string equals enhancements to reduce extra allocations * Fixed an issue when trying to download via a url, to remove query parameters to get the format * Made an optimization to Normalize method to reduce memory pressure by 100MB over the course of a scan (16k files) * Adjusted the styles on dashboard for first time setup and used a routerlink rather than href to avoid a fresh load. * Use framgment on router link * Hooked in the ability to search by release year (along with series optionally) and series will be returned back. * Fixed a bug in the filter format code where it was sending the wrong type * Only show clear all on typeahead when there are at least one selected item * Cleaned up the styles of the styles of the typeahead * Removed some dead code * Implemented the ability to filter against a series name. * Fixed filter top offset * Ensure that when we add or remove libraries, the side nav of users gets updated. * Tweaked the width on the mobile side nav * Close side nav on clicking overlay on mobile viewport * Don't show a pointer if the carousel section title is not actually selectable * Removed the User profile on the side nav so home is always first. Tweaked styles to match * Fixed up some poor documentation on FilterDto. * Fixed a bug where Latest read date wasn't being set due to an early short circuit. * When sending the chapter file, format the title of the FeedEntry more like Series Detail. * Removed dead code * Bump versions by dotnet-bump-version. * Bugfixes (#1177) * Fixed an underline on hover of pagination link * Ensure title of companion bar eats full width if there is no filter * If a user doesn't have the Download role, they will not be able to download over OPDS. * Fixed a bug where after going into webtoon reader mode then leaving, the bookmark effect would continue using the webtoon mode styling * Fixed a bug where continuous reader wasn't being triggered due to moving scrollbar to body and a floating point percision error on scroll top * Fixed how continuous trigger is shown so that we properly adjust scroll on the top (for prev chapter) * Fixed a bad merge that broke saving any edits to series metadata * When a epub key is not correct, even after we correct it, ignore the inlining of the style so the book is at least still readable. * Disabled double rendering (this feature is being postponed to a later release) * Disabled user setting and forced it to Single on any save * Removed cache directory from UpdateSettings validation as we don't allow changing it. * Fix security issue with url parse * After all migrations run, update the installed version in the Database. Send that installed version on the stat service. * Dependency bot to update some security stuff * Some misc code cleanup and fixes on the typeahead (still broken) * Bump versions by dotnet-bump-version. * New Feature Stats (#1179) * When searching, search against normalized names. * Added new stat fields * Bump versions by dotnet-bump-version. * Getting Ready for Release (#1180) * One more unit test for Tachiyomi * Removed some debug code in the manga reader menu * Fixed a typeahead bug where using Enter on add new item or selected options could cause items to disappear from selected state or other visual glitches * Actually fix the selection issue. We needed to filter out selected before we access element * Cleaned up collection detail page to align to new side nav design * Cleaned up some styling on the reading list page * Fixed a bug where side nav would not be visible on the main app due to some weird redirect logic * Fixed a bug where when paging to the last page, a page will be skipped and user will have to refresh manually to view * Fixed some styling bugs on drawer for light themes. Added missing pagination colors on light themes * On mobile screens, add some padding on series-detail page * Fixed a bad test case helper * Bump versions by dotnet-bump-version. * Release Shakeout Part 1 (#1184) * Have actionables on series detail action bar and in title to make it easier to use. * Fixed a bug where super long titles could render over the book content * Fixed a bug in get continue point where it wasn't working in an edge case * Bump versions by dotnet-bump-version. * Release Shakeout (#1186) * Cleaned up some styles on the progress bar in book reader * Fixed up some phone-hidden classes and added titles around the codebase. Stat reporting on first run now takes into account that admin user wont exist. * Fixed manage library page not updating last scan time when a notification event comes in. * Integrated SeriesSort ComicInfo tag (somehow it got missed) * Some minor style changes and no results found for bookmarks on chapter detail modal * Fixed the labels in action bar on book reader so Prev/Next are in same place * Cleaned up some responsive styles around images and reduced custom classes in light of new display classes on collection detail and series detail pages * Fixed an issue with webkit browsers and book reader where the scroll to would fail as the document wasn't fully rendered. A 10ms delay seems to fix the issue. * Cleaned up some code and filtering for collections. Collection detail is missing filtering functionality somehow, disabled the button and will add in future release * Correctly validate and show a message when a user is not an admin or has change password role when going through forget password flow. * Fixed a bug on manage libraries where library last scan didn't work on first scan of a library, due to there being no updated series. * Fixed a rendering issue with text being focused on confirm email page textboxes. Fixed a bug where when deleting a theme that was default, Kavita didn't reset Dark as the default theme. * Cleaned up the naming and styles for side nav active item hover * Fixed event widget to have correct styling on eink and light * Tried to fix a rendering issue on side nav for light themes, but can't figure it out * On light more, ensure switches are green * Fixed a bug where opening a page with a preselected filter, the filter toggle button would require 2 clicks to collapse * Reverted the revert of On Deck. * Improved the upload by url experience by sending a custom fail error to UI when a url returns 401. * When deleting a library, emit a series removed event for each series removed so user's dashboards/screens update. * Fixed an api throwing an error due to text being sent back instead of json. * Fixed a refresh bug with refreshing pending invites after deleting an invite. Ensure we always refresh pending invites even if user cancel's from invite, as they might invite, then hit cancel, where invite is still active. * Fixed a bug where invited users with + in the email would fail due to validation, but UI wouldn't properly inform user. * Bump versions by dotnet-bump-version. * Version bump for release Co-authored-by: ThePromidius <thepromidiusyt@gmail.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
12
.github/workflows/sonar-scan.yml
vendored
@ -183,6 +183,12 @@ jobs:
|
||||
with:
|
||||
proj-path: Kavita.Common/Kavita.Common.csproj
|
||||
|
||||
- name: Parse Version
|
||||
run: |
|
||||
version='${{steps.get-version.outputs.assembly-version}}'
|
||||
echo "::set-output name=VERSION::$version"
|
||||
id: parse-version
|
||||
|
||||
- name: Echo csproj version
|
||||
run: echo "${{steps.get-version.outputs.assembly-version}}"
|
||||
|
||||
@ -213,7 +219,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: kizaing/kavita:nightly
|
||||
tags: kizaing/kavita:nightly, kizaing/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
@ -224,7 +230,7 @@ jobs:
|
||||
severity: info
|
||||
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
||||
details: '${{ steps.parse-body.outputs.BODY }}'
|
||||
text: A new nightly build has been released for docker.
|
||||
text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker.
|
||||
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
|
||||
|
||||
stable:
|
||||
@ -338,5 +344,5 @@ jobs:
|
||||
severity: info
|
||||
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
||||
details: '${{ steps.parse-body.outputs.BODY }}'
|
||||
text: A new stable build has been released.
|
||||
text: <@&939225192553644133> A new stable build has been released.
|
||||
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
|
||||
|
2
.gitignore
vendored
@ -510,11 +510,13 @@ UI/Web/dist/
|
||||
/API/config/backups/
|
||||
/API/config/cache/
|
||||
/API/config/temp/
|
||||
/API/config/themes/
|
||||
/API/config/stats/
|
||||
/API/config/bookmarks/
|
||||
/API/config/kavita.db
|
||||
/API/config/kavita.db-shm
|
||||
/API/config/kavita.db-wal
|
||||
/API/config/kavita.db-journal
|
||||
/API/config/Hangfire.db
|
||||
/API/config/Hangfire-log.db
|
||||
API/config/covers/
|
||||
|
@ -12,7 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
|
||||
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.1" />
|
||||
<PackageReference Include="NSubstitute" Version="4.2.2" />
|
||||
<PackageReference Include="NSubstitute" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -4,6 +4,7 @@ using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.SignalR;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Order;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -28,7 +29,8 @@ namespace API.Benchmark
|
||||
_parseScannedFiles = new ParseScannedFiles(
|
||||
Substitute.For<ILogger>(),
|
||||
directoryService,
|
||||
new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService)), Substitute.For<ImageService>(), directoryService));
|
||||
new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService)), Substitute.For<ImageService>(), directoryService),
|
||||
Substitute.For<IEventHub>());
|
||||
}
|
||||
|
||||
// [Benchmark]
|
||||
@ -59,8 +61,7 @@ namespace API.Benchmark
|
||||
Title = "A Town Where You Live",
|
||||
Volumes = "1"
|
||||
};
|
||||
_parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath},
|
||||
out _, out _);
|
||||
_parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga");
|
||||
_parseScannedFiles.MergeName(p1);
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ namespace API.Benchmark
|
||||
{
|
||||
foreach (var name in _names)
|
||||
{
|
||||
if ((name + ".epub").ToLower() == ".epub")
|
||||
if ((name).ToLower() == ".epub")
|
||||
{
|
||||
/* No Operation */
|
||||
}
|
||||
@ -67,7 +67,7 @@ namespace API.Benchmark
|
||||
foreach (var name in _names)
|
||||
{
|
||||
|
||||
if (IsEpub.IsMatch((name + ".epub")))
|
||||
if (Path.GetExtension(name).Equals(".epub", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
/* No Operation */
|
||||
}
|
||||
|
@ -7,16 +7,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="NSubstitute" Version="4.2.2" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="16.1.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="NSubstitute" Version="4.3.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="16.1.15" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
17
API.Tests/Comparers/SortComparerZeroLastTests.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System.Linq;
|
||||
using API.Comparators;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Comparers;
|
||||
|
||||
public class SortComparerZeroLastTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(new[] {0, 1, 2,}, new[] {1, 2, 0})]
|
||||
[InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})]
|
||||
[InlineData(new[] {0, 0, 1}, new[] {1, 0, 0})]
|
||||
public void SortComparerZeroLastTest(int[] input, int[] expected)
|
||||
{
|
||||
Assert.Equal(expected, input.OrderBy(f => f, new SortComparerZeroLast()).ToArray());
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
@ -26,12 +26,14 @@ namespace API.Tests.Helpers
|
||||
|
||||
public static Volume CreateVolume(string volumeNumber, List<Chapter> chapters = null)
|
||||
{
|
||||
var chaps = chapters ?? new List<Chapter>();
|
||||
var pages = chaps.Count > 0 ? chaps.Max(c => c.Pages) : 0;
|
||||
return new Volume()
|
||||
{
|
||||
Name = volumeNumber,
|
||||
Number = int.Parse(volumeNumber),
|
||||
Pages = 0,
|
||||
Chapters = chapters ?? new List<Chapter>()
|
||||
Number = (int) API.Parser.Parser.MinimumNumberFromRange(volumeNumber),
|
||||
Pages = pages,
|
||||
Chapters = chaps
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ namespace API.Tests.Parser
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")]
|
||||
[InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")]
|
||||
public void ParseSeriesTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
|
||||
|
@ -159,7 +159,6 @@ namespace API.Tests.Parser
|
||||
[InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")]
|
||||
[InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo")]
|
||||
[InlineData("The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz", "The Duke of Death and His Black Maid")]
|
||||
[InlineData("A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001)", "A Compendium of Ghosts")]
|
||||
[InlineData("The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake", "The Duke of Death and His Black Maid")]
|
||||
[InlineData("Vol. 04 Ch. 054.5", "")]
|
||||
[InlineData("Great_Teacher_Onizuka_v16[TheSpectrum]", "Great Teacher Onizuka")]
|
||||
@ -168,6 +167,8 @@ namespace API.Tests.Parser
|
||||
[InlineData("Kaiju No. 8 036 (2021) (Digital)", "Kaiju No. 8")]
|
||||
[InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")]
|
||||
[InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")]
|
||||
[InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")]
|
||||
[InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")]
|
||||
public void ParseSeriesTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
|
||||
|
@ -15,6 +15,16 @@ namespace API.Tests.Parser
|
||||
Assert.Equal(expected, CleanAuthor(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
|
||||
[InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")]
|
||||
[InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")]
|
||||
public void CleanSpecialTitleTest(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CleanSpecialTitle(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Beastars - SP01", true)]
|
||||
[InlineData("Beastars SP01", true)]
|
||||
@ -153,6 +163,7 @@ namespace API.Tests.Parser
|
||||
[InlineData("Citrus+", "citrus+")]
|
||||
[InlineData("Again!!!!", "again")]
|
||||
[InlineData("카비타", "카비타")]
|
||||
[InlineData("06", "06")]
|
||||
[InlineData("", "")]
|
||||
public void NormalizeTest(string input, string expected)
|
||||
{
|
||||
@ -198,6 +209,9 @@ namespace API.Tests.Parser
|
||||
[InlineData("MACOSX/Love Hina/", false)]
|
||||
[InlineData("._Love Hina/Love Hina/", true)]
|
||||
[InlineData("@Recently-Snapshot/Love Hina/", true)]
|
||||
[InlineData("@recycle/Love Hina/", true)]
|
||||
[InlineData("@recycle/Love Hina/", true)]
|
||||
[InlineData("E:/Test/__MACOSX/Love Hina/", true)]
|
||||
public void HasBlacklistedFolderInPathTest(string inputPath, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
|
||||
|
@ -152,16 +152,14 @@ namespace API.Tests.Services
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TODO: This is broken on GA due to DirectoryService.CoverImageDirectory
|
||||
//[Theory]
|
||||
[InlineData("v10.cbz", "v10.expected.jpg")]
|
||||
[InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
|
||||
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
|
||||
[InlineData("macos_native.zip", "macos_native.jpg")]
|
||||
[InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.jpg")]
|
||||
[InlineData("sorting.zip", "sorting.expected.jpg")]
|
||||
[InlineData("test.zip", "test.expected.jpg")] // https://github.com/kleisauke/net-vips/issues/155
|
||||
[Theory]
|
||||
[InlineData("v10.cbz", "v10.expected.png")]
|
||||
[InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")]
|
||||
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")]
|
||||
[InlineData("macos_native.zip", "macos_native.png")]
|
||||
[InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")]
|
||||
[InlineData("sorting.zip", "sorting.expected.png")]
|
||||
[InlineData("test.zip", "test.expected.jpg")]
|
||||
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
|
||||
{
|
||||
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
|
||||
@ -183,33 +181,33 @@ namespace API.Tests.Services
|
||||
|
||||
|
||||
Assert.Equal(expectedBytes, actual);
|
||||
//_directoryService.ClearAndDeleteDirectory(outputDir);
|
||||
_directoryService.ClearAndDeleteDirectory(outputDir);
|
||||
}
|
||||
|
||||
|
||||
// TODO: This is broken on GA due to DirectoryService.CoverImageDirectory
|
||||
//[Theory]
|
||||
[InlineData("v10.cbz", "v10.expected.jpg")]
|
||||
[InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
|
||||
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
|
||||
[InlineData("macos_native.zip", "macos_native.jpg")]
|
||||
[InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.jpg")]
|
||||
[InlineData("sorting.zip", "sorting.expected.jpg")]
|
||||
[Theory]
|
||||
[InlineData("v10.cbz", "v10.expected.png")]
|
||||
[InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")]
|
||||
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")]
|
||||
[InlineData("macos_native.zip", "macos_native.png")]
|
||||
[InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")]
|
||||
[InlineData("sorting.zip", "sorting.expected.png")]
|
||||
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
|
||||
{
|
||||
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
|
||||
var archiveService = Substitute.For<ArchiveService>(_logger,
|
||||
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService);
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
|
||||
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
|
||||
var testDirectory = API.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")));
|
||||
|
||||
var outputDir = Path.Join(testDirectory, "output");
|
||||
_directoryService.ClearDirectory(outputDir);
|
||||
_directoryService.ExistOrCreate(outputDir);
|
||||
|
||||
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
|
||||
var actualBytes = File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
|
||||
Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir));
|
||||
var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
|
||||
Path.GetFileNameWithoutExtension(inputFile), outputDir);
|
||||
var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile));
|
||||
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
|
||||
Assert.Equal(expectedBytes, actualBytes);
|
||||
|
||||
_directoryService.ClearAndDeleteDirectory(outputDir);
|
||||
@ -310,7 +308,7 @@ namespace API.Tests.Services
|
||||
[InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")]
|
||||
public void FindCoverImageFilename(string[] filenames, string archiveName, string expected)
|
||||
{
|
||||
Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames));
|
||||
Assert.Equal(expected, ArchiveService.FindCoverImageFilename(archiveName, filenames));
|
||||
}
|
||||
|
||||
|
||||
|
@ -26,7 +26,7 @@ public class BackupServiceTests
|
||||
{
|
||||
private readonly ILogger<BackupService> _logger = Substitute.For<ILogger<BackupService>>();
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
|
||||
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
|
@ -11,6 +11,7 @@ using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -334,4 +335,65 @@ public class BookmarkServiceTests
|
||||
Assert.False(ds.FileSystem.FileInfo.FromFileName(Path.Join(BookmarkDirectory, "1/1/1/0001.jpg")).Exists);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetBookmarkFilesById
|
||||
|
||||
[Fact]
|
||||
public async Task GetBookmarkFilesById_ShouldMatchActualFiles()
|
||||
{
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123"));
|
||||
|
||||
// Delete all Series to reset state
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "Joe"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
|
||||
|
||||
await bookmarkService.BookmarkPage(user, new BookmarkDto()
|
||||
{
|
||||
ChapterId = 1,
|
||||
Page = 1,
|
||||
SeriesId = 1,
|
||||
VolumeId = 1
|
||||
}, $"{CacheDirectory}1/0001.jpg");
|
||||
|
||||
var files = await bookmarkService.GetBookmarkFilesById(1, new[] {1});
|
||||
var actualFiles = ds.GetFiles(BookmarkDirectory, searchOption: SearchOption.AllDirectories);
|
||||
Assert.Equal(files.Select(API.Parser.Parser.NormalizePath).ToList(), actualFiles.Select(API.Parser.Parser.NormalizePath).ToList());
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ public class CleanupServiceTests
|
||||
{
|
||||
private readonly ILogger<CleanupService> _logger = Substitute.For<ILogger<CleanupService>>();
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
|
||||
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly DataContext _context;
|
||||
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
@ -755,6 +755,22 @@ namespace API.Tests.Services
|
||||
Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ShouldFlatten_WithoutMacosx()
|
||||
{
|
||||
const string testDirectory = "/manga/";
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddDirectory(testDirectory);
|
||||
fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc"));
|
||||
fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc"));
|
||||
fileSystem.AddFile($"{testDirectory}__MACOSX/data-4.webp", new MockFileData("abc"));
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
ds.Flatten($"{testDirectory}");
|
||||
Assert.Equal(2, ds.GetFiles(testDirectory).Count());
|
||||
Assert.False(fileSystem.FileExists($"{testDirectory}data-4.webp"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckWriteAccess
|
||||
|
@ -11,6 +11,7 @@ using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.SignalR;
|
||||
using API.Tests.Helpers;
|
||||
using AutoMapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@ -155,7 +156,7 @@ public class ParseScannedFilesTests
|
||||
var fileSystem = new MockFileSystem();
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new DefaultParser(ds)));
|
||||
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
|
||||
|
||||
var infos = new List<ParserInfo>()
|
||||
{
|
||||
@ -200,7 +201,7 @@ public class ParseScannedFilesTests
|
||||
var fileSystem = new MockFileSystem();
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new DefaultParser(ds)));
|
||||
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
|
||||
|
||||
var infos = new List<ParserInfo>()
|
||||
{
|
||||
@ -240,7 +241,7 @@ public class ParseScannedFilesTests
|
||||
#region MergeName
|
||||
|
||||
[Fact]
|
||||
public void MergeName_ShouldMergeMatchingFormatAndName()
|
||||
public async Task MergeName_ShouldMergeMatchingFormatAndName()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddDirectory("C:/Data/");
|
||||
@ -250,10 +251,10 @@ public class ParseScannedFilesTests
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new DefaultParser(ds)));
|
||||
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
|
||||
|
||||
|
||||
psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, out _, out _);
|
||||
await psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, "libraryName");
|
||||
|
||||
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false)));
|
||||
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.cbz", false)));
|
||||
@ -261,7 +262,7 @@ public class ParseScannedFilesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeName_ShouldMerge_MismatchedFormatSameName()
|
||||
public async Task MergeName_ShouldMerge_MismatchedFormatSameName()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddDirectory("C:/Data/");
|
||||
@ -271,10 +272,10 @@ public class ParseScannedFilesTests
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new DefaultParser(ds)));
|
||||
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
|
||||
|
||||
|
||||
psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, out _, out _);
|
||||
await psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, "libraryName");
|
||||
|
||||
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.epub", false)));
|
||||
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.epub", false)));
|
||||
@ -285,7 +286,7 @@ public class ParseScannedFilesTests
|
||||
#region ScanLibrariesForSeries
|
||||
|
||||
[Fact]
|
||||
public void ScanLibrariesForSeries_ShouldFindFiles()
|
||||
public async Task ScanLibrariesForSeries_ShouldFindFiles()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddDirectory("C:/Data/");
|
||||
@ -296,10 +297,10 @@ public class ParseScannedFilesTests
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new DefaultParser(ds)));
|
||||
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
|
||||
|
||||
|
||||
var parsedSeries = psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, out _, out _);
|
||||
var parsedSeries = await psf.ScanLibrariesForSeries(LibraryType.Manga, new List<string>() {"C:/Data/"}, "libraryName");
|
||||
|
||||
Assert.Equal(3, parsedSeries.Values.Count);
|
||||
Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World")));
|
||||
|
@ -595,7 +595,7 @@ public class ReaderServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter()
|
||||
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
@ -636,7 +636,7 @@ public class ReaderServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial()
|
||||
public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
@ -678,6 +678,87 @@ public class ReaderServiceTests
|
||||
Assert.Equal("A.cbz", actualChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
|
||||
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1);
|
||||
Assert.NotEqual(-1, nextChapter);
|
||||
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
|
||||
Assert.Equal("A.cbz", actualChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
|
||||
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1);
|
||||
Assert.Equal(-1, nextChapter);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial()
|
||||
{
|
||||
@ -820,7 +901,7 @@ public class ReaderServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial()
|
||||
public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToVolume()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
@ -856,10 +937,10 @@ public class ReaderServiceTests
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
|
||||
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1);
|
||||
Assert.NotEqual(-1, prevChapter);
|
||||
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1);
|
||||
Assert.Equal(2, prevChapter);
|
||||
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
|
||||
Assert.Equal("B.cbz", actualChapter.Range);
|
||||
Assert.Equal("2", actualChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -973,6 +1054,60 @@ public class ReaderServiceTests
|
||||
Assert.Equal(-1, prevChapter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters2()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("5", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("6", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("7", false, new List<MangaFile>()),
|
||||
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("3", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("4", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,5, 1);
|
||||
var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter);
|
||||
Assert.Equal(1, float.Parse(chapterInfoDto.ChapterNumber));
|
||||
|
||||
// This is first chapter of first volume
|
||||
prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,4, 1);
|
||||
Assert.Equal(-1, prevChapter);
|
||||
//chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromChapter()
|
||||
{
|
||||
@ -1098,6 +1233,56 @@ public class ReaderServiceTests
|
||||
|
||||
#region GetContinuePoint
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress()
|
||||
{
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
|
||||
Assert.Equal("1", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
|
||||
{
|
||||
@ -1168,6 +1353,84 @@ public class ReaderServiceTests
|
||||
Assert.Equal("22", nextChapter.Range);
|
||||
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2()
|
||||
{
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
// Loose chapters
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("45", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("46", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("47", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("48", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("Some Special Title", true, new List<MangaFile>(), 1),
|
||||
}),
|
||||
|
||||
// One file volume
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1), // Read
|
||||
}),
|
||||
// Chapter-based volume
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1), // Read
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
// Chapter-based volume
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
// Save progress on first volume and 1st chapter of second volume
|
||||
await readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
PageNum = 1,
|
||||
ChapterId = 6, // Chapter 0 volume 1 id
|
||||
SeriesId = 1,
|
||||
VolumeId = 2 // Volume 1 id
|
||||
}, 1);
|
||||
|
||||
|
||||
await readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
PageNum = 1,
|
||||
ChapterId = 7, // Chapter 21 volume 2 id
|
||||
SeriesId = 1,
|
||||
VolumeId = 3 // Volume 2 id
|
||||
}, 1);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
|
||||
Assert.Equal("22", nextChapter.Range);
|
||||
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1239,6 +1502,48 @@ public class ReaderServiceTests
|
||||
Assert.Equal("31", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes()
|
||||
{
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("230", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
|
||||
Assert.Equal("1", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead()
|
||||
{
|
||||
@ -1320,6 +1625,11 @@ public class ReaderServiceTests
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("11", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
@ -1333,33 +1643,13 @@ public class ReaderServiceTests
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
// Save progress on first volume chapters and 1st of second volume
|
||||
await readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
PageNum = 1,
|
||||
ChapterId = 1,
|
||||
SeriesId = 1,
|
||||
VolumeId = 1
|
||||
}, 1);
|
||||
await readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
PageNum = 1,
|
||||
ChapterId = 2,
|
||||
SeriesId = 1,
|
||||
VolumeId = 1
|
||||
}, 1);
|
||||
await readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
PageNum = 1,
|
||||
ChapterId = 3,
|
||||
SeriesId = 1,
|
||||
VolumeId = 1
|
||||
}, 1);
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
|
||||
await readerService.MarkSeriesAsRead(user, 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
|
||||
Assert.Equal("1", nextChapter.Range);
|
||||
Assert.Equal("11", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1423,6 +1713,61 @@ public class ReaderServiceTests
|
||||
Assert.Equal("Some Special Title", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress()
|
||||
{
|
||||
var series = new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("230", false, new List<MangaFile>(), 1),
|
||||
//EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1), (added later)
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
|
||||
//EntityFactory.CreateChapter("14.9", false, new List<MangaFile>(), 1), (added later)
|
||||
}),
|
||||
}
|
||||
};
|
||||
_context.Series.Add(series);
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
|
||||
await readerService.MarkSeriesAsRead(user, 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add 2 new unread series to the Series
|
||||
series.Volumes[0].Chapters.Add(EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1));
|
||||
series.Volumes[2].Chapters.Add(EntityFactory.CreateChapter("14.9", false, new List<MangaFile>(), 1));
|
||||
_context.Series.Attach(series);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
Assert.Equal("14.9", nextChapter.Range);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MarkChaptersUntilAsRead
|
||||
@ -1514,7 +1859,7 @@ public class ReaderServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkChaptersUntilAsRead_ShouldNotReadOnlyVolumesWithChapter0()
|
||||
public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0()
|
||||
{
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
@ -1550,11 +1895,204 @@ public class ReaderServiceTests
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Validate correct chapters have read status
|
||||
Assert.False(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1));
|
||||
Assert.True(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkChaptersUntilAsRead_ShouldMarkAsReadAnythingUntil()
|
||||
{
|
||||
await ResetDB();
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("45", false, new List<MangaFile>(), 5),
|
||||
|
||||
EntityFactory.CreateChapter("46", false, new List<MangaFile>(), 46),
|
||||
EntityFactory.CreateChapter("47", false, new List<MangaFile>(), 47),
|
||||
EntityFactory.CreateChapter("48", false, new List<MangaFile>(), 48),
|
||||
EntityFactory.CreateChapter("49", false, new List<MangaFile>(), 49),
|
||||
EntityFactory.CreateChapter("50", false, new List<MangaFile>(), 50),
|
||||
EntityFactory.CreateChapter("Some Special Title", true, new List<MangaFile>(), 10),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 6),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 7),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("12", false, new List<MangaFile>(), 5),
|
||||
EntityFactory.CreateChapter("13", false, new List<MangaFile>(), 5),
|
||||
EntityFactory.CreateChapter("14", false, new List<MangaFile>(), 5),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
|
||||
const int markReadUntilNumber = 47;
|
||||
|
||||
await readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1);
|
||||
Assert.True(volumes.SelectMany(v => v.Chapters).All(c =>
|
||||
{
|
||||
// Specials are ignored.
|
||||
var notReadChapterRanges = new[] {"Some Special Title", "48", "49", "50"};
|
||||
if (notReadChapterRanges.Contains(c.Range))
|
||||
{
|
||||
return c.PagesRead == 0;
|
||||
}
|
||||
// Pages read and total pages must match -> chapter fully read
|
||||
return c.Pages == c.PagesRead;
|
||||
|
||||
}));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region MarkSeriesAsRead
|
||||
|
||||
[Fact]
|
||||
public async Task MarkSeriesAsReadTest()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
},
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region MarkSeriesAsUnread
|
||||
|
||||
[Fact]
|
||||
public async Task MarkSeriesAsUnreadTest()
|
||||
{
|
||||
await ResetDB();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList();
|
||||
readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
|
||||
|
||||
await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses;
|
||||
Assert.Equal(0, progresses.Max(p => p.PagesRead));
|
||||
Assert.Equal(2, progresses.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
572
API.Tests/Services/SeriesServiceTests.cs
Normal file
@ -0,0 +1,572 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using API.Tests.Helpers;
|
||||
using AutoMapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class SeriesServiceTests
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly DataContext _context;
|
||||
|
||||
private readonly ISeriesService _seriesService;
|
||||
|
||||
private const string CacheDirectory = "C:/kavita/config/cache/";
|
||||
private const string CoverImageDirectory = "C:/kavita/config/covers/";
|
||||
private const string BackupDirectory = "C:/kavita/config/backups/";
|
||||
private const string DataDirectory = "C:/data/";
|
||||
|
||||
public SeriesServiceTests()
|
||||
{
|
||||
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
|
||||
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
|
||||
|
||||
_context = new DataContext(contextOptions);
|
||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||
|
||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||
var mapper = config.CreateMapper();
|
||||
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||
|
||||
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>());
|
||||
}
|
||||
#region Setup
|
||||
|
||||
private static DbConnection CreateInMemoryDatabase()
|
||||
{
|
||||
var connection = new SqliteConnection("Filename=:memory:");
|
||||
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async Task<bool> SeedDb()
|
||||
{
|
||||
await _context.Database.MigrateAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
|
||||
await Seed.SeedSettings(_context,
|
||||
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
|
||||
_context.ServerSetting.Update(setting);
|
||||
|
||||
var lib = new Library()
|
||||
{
|
||||
Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
|
||||
};
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
lib
|
||||
}
|
||||
});
|
||||
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
private async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static MockFileSystem CreateFileSystem()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
|
||||
fileSystem.AddDirectory("C:/kavita/config/");
|
||||
fileSystem.AddDirectory(CacheDirectory);
|
||||
fileSystem.AddDirectory(CoverImageDirectory);
|
||||
fileSystem.AddDirectory(BackupDirectory);
|
||||
fileSystem.AddDirectory(DataDirectory);
|
||||
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SeriesDetail
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnSpecials()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("Omake", true, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("Something SP02", true, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var expectedRanges = new[] {"Omake", "Something SP02"};
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Specials);
|
||||
Assert.True(2 == detail.Specials.Count());
|
||||
Assert.All(detail.Specials, dto => Assert.Contains(dto.Range, expectedRanges));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnVolumesAndChapters()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
Assert.Equal(6, detail.Chapters.Count());
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(2, detail.Volumes.Count()); // Volume 0 shouldn't be sent in Volumes
|
||||
Assert.All(detail.Volumes, dto => Assert.Contains(dto.Name, new[] {"Volume 2", "Volume 3"})); // Volumes get names mapped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnVolumesAndChapters_ButRemove0Chapter()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
// volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
|
||||
Assert.Equal(3, detail.Chapters.Count());
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
|
||||
Assert.Empty(detail.Chapters); // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_WhenBookLibrary_ShouldReturnVolumesAndSpecial()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", true, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal("2 - Ano Orokamono ni mo Kyakkou wo! - Volume 2", detail.Volumes.ElementAt(0).Name);
|
||||
|
||||
Assert.NotEmpty(detail.Specials);
|
||||
Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", detail.Specials.ElementAt(0).Range);
|
||||
|
||||
// A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense
|
||||
Assert.Empty(detail.Chapters);
|
||||
|
||||
Assert.Equal(1, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldSortVolumesByName()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1.2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.Equal("1", detail.Volumes.ElementAt(0).Name);
|
||||
Assert.Equal("1.2", detail.Volumes.ElementAt(1).Name);
|
||||
Assert.Equal("2", detail.Volumes.ElementAt(2).Name);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region UpdateRating
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldSetRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldUpdateExistingRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
|
||||
// Update the DB again
|
||||
|
||||
var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 5,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result2);
|
||||
|
||||
var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings2);
|
||||
Assert.True(ratings2.Count == 1);
|
||||
Assert.Equal(5, ratings2.First().Rating);
|
||||
Assert.Equal("Average", ratings2.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldClampRatingAt5()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 10,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(5, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 2,
|
||||
UserRating = 5,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var ratings = user.Ratings;
|
||||
Assert.Empty(ratings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
264
API.Tests/Services/SiteThemeServiceTests.cs
Normal file
@ -0,0 +1,264 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class SiteThemeServiceTests
|
||||
{
|
||||
private readonly ILogger<SiteThemeService> _logger = Substitute.For<ILogger<SiteThemeService>>();
|
||||
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly DataContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
private const string CacheDirectory = "C:/kavita/config/cache/";
|
||||
private const string CoverImageDirectory = "C:/kavita/config/covers/";
|
||||
private const string BackupDirectory = "C:/kavita/config/backups/";
|
||||
private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
|
||||
private const string SiteThemeDirectory = "C:/kavita/config/themes/";
|
||||
|
||||
public SiteThemeServiceTests()
|
||||
{
|
||||
var contextOptions = new DbContextOptionsBuilder()
|
||||
.UseSqlite(CreateInMemoryDatabase())
|
||||
.Options;
|
||||
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
|
||||
|
||||
_context = new DataContext(contextOptions);
|
||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||
|
||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||
var mapper = config.CreateMapper();
|
||||
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||
}
|
||||
|
||||
#region Setup
|
||||
|
||||
private static DbConnection CreateInMemoryDatabase()
|
||||
{
|
||||
var connection = new SqliteConnection("Filename=:memory:");
|
||||
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async Task<bool> SeedDb()
|
||||
{
|
||||
await _context.Database.MigrateAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
|
||||
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
|
||||
setting.Value = BookmarkDirectory;
|
||||
|
||||
_context.ServerSetting.Update(setting);
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "Joe",
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = Seed.DefaultThemes[1]
|
||||
}
|
||||
});
|
||||
|
||||
_context.Library.Add(new Library()
|
||||
{
|
||||
Name = "Manga",
|
||||
Folders = new List<FolderPath>()
|
||||
{
|
||||
new FolderPath()
|
||||
{
|
||||
Path = "C:/data/"
|
||||
}
|
||||
}
|
||||
});
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
private static MockFileSystem CreateFileSystem()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
|
||||
fileSystem.AddDirectory("C:/kavita/config/");
|
||||
fileSystem.AddDirectory(CacheDirectory);
|
||||
fileSystem.AddDirectory(CoverImageDirectory);
|
||||
fileSystem.AddDirectory(BackupDirectory);
|
||||
fileSystem.AddDirectory(BookmarkDirectory);
|
||||
fileSystem.AddDirectory(SiteThemeDirectory);
|
||||
fileSystem.AddDirectory("C:/data/");
|
||||
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
private async Task ResetDb()
|
||||
{
|
||||
_context.SiteTheme.RemoveRange(_context.SiteTheme);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldFindCustomFile()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldOnlyInsertOnceOnSecondScan()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
|
||||
await siteThemeService.Scan();
|
||||
|
||||
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
|
||||
API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
|
||||
Assert.Single(customThemes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
await siteThemeService.Scan();
|
||||
|
||||
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
|
||||
|
||||
filesystem.RemoveFile($"{SiteThemeDirectory}custom.css");
|
||||
await siteThemeService.Scan();
|
||||
|
||||
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
|
||||
API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
|
||||
|
||||
Assert.Empty(customThemes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContent_ShouldReturnContent()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = API.Parser.Parser.Normalize("Custom"),
|
||||
Provider = ThemeProvider.User,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id);
|
||||
Assert.NotNull(content);
|
||||
Assert.NotEmpty(content);
|
||||
Assert.Equal("123", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDefault_ShouldHaveOneDefault()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = API.Parser.Parser.Normalize("Custom"),
|
||||
Provider = ThemeProvider.User,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom"));
|
||||
|
||||
await siteThemeService.UpdateDefault(customTheme.Id);
|
||||
|
||||
|
||||
|
||||
Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDefault_ShouldThrowOnInvalidId()
|
||||
{
|
||||
await ResetDb();
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
|
||||
|
||||
_context.SiteTheme.Add(new SiteTheme()
|
||||
{
|
||||
Name = "Custom",
|
||||
NormalizedName = API.Parser.Parser.Normalize("Custom"),
|
||||
Provider = ThemeProvider.User,
|
||||
FileName = "custom.css",
|
||||
IsDefault = false
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
var ex = await Assert.ThrowsAsync<KavitaException>(async () => await siteThemeService.UpdateDefault(10));
|
||||
Assert.Equal("Theme file missing or invalid", ex.Message);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
Before Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 122 KiB |
After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 385 KiB |
After Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 385 KiB |
After Width: | Height: | Size: 123 KiB |
After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 385 KiB |
After Width: | Height: | Size: 123 KiB |
@ -40,38 +40,40 @@
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
<PackageReference Include="Docnet.Core" Version="2.4.0-alpha.2" />
|
||||
<PackageReference Include="ExCSS" Version="4.1.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.2" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.0" />
|
||||
<PackageReference Include="Hangfire" Version="1.7.27" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.27" />
|
||||
<PackageReference Include="Flurl" Version="3.0.4" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.2" />
|
||||
<PackageReference Include="Hangfire" Version="1.7.28" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.28" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.38" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.42" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
|
||||
<PackageReference Include="NetVips" Version="2.1.0" />
|
||||
<PackageReference Include="NetVips.Native" Version="8.12.2" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.2" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.4" />
|
||||
<PackageReference Include="SharpCompress" Version="0.30.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.33.0.40503">
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.0.0" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.36.0.43782">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.15.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="16.1.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.16.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="16.1.15" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.0.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -310,4 +312,8 @@
|
||||
<Reference Include="System.Drawing.Common" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="config\themes" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -45,4 +45,18 @@ namespace API.Comparators
|
||||
return x.CompareTo(y);
|
||||
}
|
||||
}
|
||||
|
||||
public class SortComparerZeroLast : IComparer<double>
|
||||
{
|
||||
public int Compare(double x, double y)
|
||||
{
|
||||
if (x == 0.0 && y == 0.0) return 0;
|
||||
// if x is 0, it comes last
|
||||
if (x == 0.0) return 1;
|
||||
// if y is 0, it comes last
|
||||
if (y == 0.0) return -1;
|
||||
|
||||
return x.CompareTo(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,7 +106,10 @@ namespace API.Controllers
|
||||
{
|
||||
UserName = registerDto.Username,
|
||||
Email = registerDto.Email,
|
||||
UserPreferences = new AppUserPreferences(),
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
},
|
||||
ApiKey = HashUtil.ApiKey()
|
||||
};
|
||||
|
||||
@ -179,22 +182,23 @@ namespace API.Controllers
|
||||
|
||||
// Update LastActive on account
|
||||
user.LastActive = DateTime.Now;
|
||||
user.UserPreferences ??= new AppUserPreferences();
|
||||
user.UserPreferences ??= new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
};
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
|
||||
|
||||
return new UserDto
|
||||
{
|
||||
Username = user.UserName,
|
||||
Email = user.Email,
|
||||
Token = await _tokenService.CreateToken(user),
|
||||
RefreshToken = await _tokenService.CreateRefreshToken(user),
|
||||
ApiKey = user.ApiKey,
|
||||
Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences)
|
||||
};
|
||||
var dto = _mapper.Map<UserDto>(user);
|
||||
dto.Token = await _tokenService.CreateToken(user);
|
||||
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
|
||||
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName);
|
||||
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
@ -334,6 +338,12 @@ namespace API.Controllers
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no
|
||||
/// email will be sent.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("invite")]
|
||||
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
||||
@ -358,7 +368,10 @@ namespace API.Controllers
|
||||
UserName = dto.Email,
|
||||
Email = dto.Email,
|
||||
ApiKey = HashUtil.ApiKey(),
|
||||
UserPreferences = new AppUserPreferences()
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
@ -410,7 +423,10 @@ namespace API.Controllers
|
||||
|
||||
var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email);
|
||||
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
if (dto.SendEmail)
|
||||
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
var accessible = await _emailService.CheckIfAccessible(host);
|
||||
if (accessible)
|
||||
{
|
||||
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
@ -419,7 +435,11 @@ namespace API.Controllers
|
||||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
}
|
||||
return Ok(emailLink);
|
||||
return Ok(new InviteUserResponse
|
||||
{
|
||||
EmailLink = emailLink,
|
||||
EmailSent = accessible
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@ -506,6 +526,12 @@ namespace API.Controllers
|
||||
return Ok("An email will be sent to the email if it exists in our database");
|
||||
}
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
|
||||
if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
|
||||
return Unauthorized("You are not permitted to this operation.");
|
||||
|
||||
var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email);
|
||||
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
|
@ -19,13 +19,13 @@ namespace API.Controllers
|
||||
public class CollectionController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CollectionController(IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
|
||||
public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageHub = messageHub;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -156,7 +156,8 @@ namespace API.Controllers
|
||||
{
|
||||
tag.CoverImageLocked = false;
|
||||
tag.CoverImage = string.Empty;
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"));
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false);
|
||||
_unitOfWork.CollectionTagRepository.Update(tag);
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,6 @@ using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Downloads;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
@ -15,7 +14,6 @@ using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -27,21 +25,23 @@ namespace API.Controllers
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILogger<DownloadController> _logger;
|
||||
private readonly IBookmarkService _bookmarkService;
|
||||
private const string DefaultContentType = "application/octet-stream";
|
||||
|
||||
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService,
|
||||
IDownloadService downloadService, IHubContext<MessageHub> messageHub, UserManager<AppUser> userManager, ILogger<DownloadController> logger)
|
||||
IDownloadService downloadService, IEventHub eventHub, UserManager<AppUser> userManager, ILogger<DownloadController> logger, IBookmarkService bookmarkService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_archiveService = archiveService;
|
||||
_directoryService = directoryService;
|
||||
_downloadService = downloadService;
|
||||
_messageHub = messageHub;
|
||||
_eventHub = eventHub;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_bookmarkService = bookmarkService;
|
||||
}
|
||||
|
||||
[HttpGet("volume-size")]
|
||||
@ -65,6 +65,8 @@ namespace API.Controllers
|
||||
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Authorize(Policy="RequireDownloadRole")]
|
||||
[HttpGet("volume")]
|
||||
public async Task<ActionResult> DownloadVolume(int volumeId)
|
||||
@ -87,8 +89,7 @@ namespace API.Controllers
|
||||
private async Task<bool> HasDownloadPermission()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
return await _downloadService.HasDownloadPermission(user);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
@ -119,30 +120,30 @@ namespace API.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 0F));
|
||||
Path.GetFileNameWithoutExtension(downloadName), 0F, "started"));
|
||||
if (files.Count == 1)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
|
||||
return await GetFirstFileDownload(files);
|
||||
}
|
||||
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||
tempFolder);
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
|
||||
return File(fileBytes, DefaultContentType, downloadName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when trying to download files");
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -172,21 +173,17 @@ namespace API.Controllers
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
|
||||
|
||||
var bookmarkDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
|
||||
var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks
|
||||
.Select(b => b.Id)
|
||||
.ToList()))
|
||||
.Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, $"{b.ChapterId}_{b.FileName}")));
|
||||
var files = await _bookmarkService.GetBookmarkFilesById(user.Id, downloadBookmarkDto.Bookmarks.Select(b => b.Id));
|
||||
|
||||
var filename = $"{series.Name} - Bookmarks.zip";
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F));
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files,
|
||||
$"download_{user.Id}_{series.Id}_bookmarks");
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F));
|
||||
|
||||
|
||||
return File(fileBytes, DefaultContentType, filename);
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -106,7 +107,26 @@ namespace API.Controllers
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName));
|
||||
var format = Path.GetExtension(file.FullName).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(file.FullName);
|
||||
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a temp coverupload image
|
||||
/// </summary>
|
||||
/// <param name="filename">Filename of file. This is used with upload/upload-by-url</param>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("cover-upload")]
|
||||
public ActionResult GetCoverUploadImage(string filename)
|
||||
{
|
||||
var path = Path.Join(_directoryService.TempDirectory, filename);
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -26,16 +27,18 @@ namespace API.Controllers
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public LibraryController(IDirectoryService directoryService,
|
||||
ILogger<LibraryController> logger, IMapper mapper, ITaskScheduler taskScheduler,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -73,6 +76,8 @@ namespace API.Controllers
|
||||
|
||||
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
|
||||
_taskScheduler.ScanLibrary(library.Id);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -191,6 +196,15 @@ namespace API.Controllers
|
||||
await _unitOfWork.CommitAsync();
|
||||
_taskScheduler.CleanupChapters(chapterIds);
|
||||
}
|
||||
|
||||
foreach (var seriesId in seriesIds)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
|
||||
MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, libraryId), false);
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false);
|
||||
return Ok(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -123,18 +123,30 @@ public class MetadataController : BaseApiController
|
||||
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||
{
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
var englishTag = CultureInfo.GetCultureInfo("en");
|
||||
return Ok(new List<LanguageDto>()
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo("en").DisplayName,
|
||||
IsoCode = "en"
|
||||
Title = englishTag.DisplayName,
|
||||
IsoCode = englishTag.IetfLanguageTag
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("all-languages")]
|
||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||
{
|
||||
return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c =>
|
||||
new LanguageDto()
|
||||
{
|
||||
Title = c.DisplayName,
|
||||
IsoCode = c.IetfLanguageTag
|
||||
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ public class OpdsController : BaseApiController
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IReaderService _readerService;
|
||||
private readonly ISeriesService _seriesService;
|
||||
|
||||
|
||||
private readonly XmlSerializer _xmlSerializer;
|
||||
@ -61,13 +62,14 @@ public class OpdsController : BaseApiController
|
||||
|
||||
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
|
||||
IDirectoryService directoryService, ICacheService cacheService,
|
||||
IReaderService readerService)
|
||||
IReaderService readerService, ISeriesService seriesService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_downloadService = downloadService;
|
||||
_directoryService = directoryService;
|
||||
_cacheService = cacheService;
|
||||
_readerService = readerService;
|
||||
_seriesService = seriesService;
|
||||
|
||||
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
||||
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
||||
@ -314,18 +316,8 @@ public class OpdsController : BaseApiController
|
||||
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
|
||||
foreach (var item in items)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = item.ChapterId.ToString(),
|
||||
Title = $"{item.SeriesName} Chapter {item.ChapterNumber}",
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}")
|
||||
feed.Entries.Add(CreateChapter(apiKey, $"{item.SeriesName} Chapter {item.ChapterNumber}", item.ChapterId, item.VolumeId, item.SeriesId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -499,7 +491,7 @@ public class OpdsController : BaseApiController
|
||||
var feed = new OpenSearchDescription()
|
||||
{
|
||||
ShortName = "Search",
|
||||
Description = "Search for Series",
|
||||
Description = "Search for Series, Collections, or Reading Lists",
|
||||
Url = new SearchLink()
|
||||
{
|
||||
Type = FeedLinkType.AtomAcquisition,
|
||||
@ -521,13 +513,37 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
|
||||
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}");
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
|
||||
foreach (var volumeDto in volumes)
|
||||
|
||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
foreach (var volume in seriesDetail.Volumes)
|
||||
{
|
||||
feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey));
|
||||
// If there is only one chapter to the Volume, we will emulate a volume to flatten the amount of hops a user must go through
|
||||
if (volume.Chapters.Count == 1)
|
||||
{
|
||||
var firstChapter = volume.Chapters.First();
|
||||
var chapter = CreateChapter(apiKey, volume.Name, firstChapter.Id, volume.Id, seriesId);
|
||||
chapter.Id = firstChapter.Id.ToString();
|
||||
feed.Entries.Add(chapter);
|
||||
}
|
||||
else
|
||||
{
|
||||
feed.Entries.Add(CreateVolume(volume, seriesId, apiKey));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(apiKey, storylineChapter.Title, storylineChapter.Id, storylineChapter.VolumeId, seriesId));
|
||||
}
|
||||
|
||||
foreach (var special in seriesDetail.Specials)
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Id, special.VolumeId, seriesId));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
@ -541,19 +557,20 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||
var chapters =
|
||||
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
||||
_chapterSortComparer);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters");
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(libraryType)}s");
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = chapter.Id.ToString(),
|
||||
Title = "Chapter " + chapter.Number,
|
||||
Title = SeriesService.FormatChapterTitle(chapter, libraryType),
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"),
|
||||
@ -573,15 +590,16 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files");
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files");
|
||||
foreach (var mangaFile in files)
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
|
||||
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
@ -600,6 +618,12 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
|
||||
if (!await _downloadService.HasDownloadPermission(user))
|
||||
{
|
||||
return BadRequest("User does not have download permissions");
|
||||
}
|
||||
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
|
||||
return File(bytes, contentType, fileDownloadName);
|
||||
@ -688,27 +712,69 @@ public class OpdsController : BaseApiController
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = volumeDto.Id.ToString(),
|
||||
Title = "Volume " + volumeDto.Name,
|
||||
Title = volumeDto.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}")
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"/api/image/volume-cover?volumeId={volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"/api/image/volume-cover?volumeId={volumeDto.Id}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey)
|
||||
private static FeedEntry CreateChapter(string apiKey, string title, int chapterId, int volumeId, int seriesId)
|
||||
{
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = chapterId.ToString(),
|
||||
Title = title,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"/api/image/chapter-cover?chapterId={chapterId}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FeedEntry> CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey)
|
||||
{
|
||||
var fileSize =
|
||||
DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List<string>()
|
||||
{mangaFile.FilePath}));
|
||||
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
|
||||
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
|
||||
return new FeedEntry()
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
|
||||
|
||||
var title = $"{series.Name} - ";
|
||||
if (volume.Chapters.Count == 1)
|
||||
{
|
||||
SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType);
|
||||
title += $"{volume.Name}";
|
||||
}
|
||||
else
|
||||
{
|
||||
title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}";
|
||||
}
|
||||
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
var accLink =
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType,
|
||||
$"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
|
||||
filename);
|
||||
accLink.TotalPages = chapter.Pages;
|
||||
|
||||
var entry = new FeedEntry()
|
||||
{
|
||||
Id = mangaFile.Id.ToString(),
|
||||
Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}",
|
||||
Title = title,
|
||||
Extent = fileSize,
|
||||
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
|
||||
Format = mangaFile.Format.ToString(),
|
||||
@ -716,8 +782,7 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
|
||||
accLink,
|
||||
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
|
||||
},
|
||||
Content = new FeedEntryContent()
|
||||
@ -726,6 +791,16 @@ public class OpdsController : BaseApiController
|
||||
Type = "text"
|
||||
}
|
||||
};
|
||||
|
||||
// We can't not show acc link in the feed, panels wont work like that. We have to block download directly
|
||||
// var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
|
||||
// if (await _downloadService.HasDownloadPermission(user))
|
||||
// {
|
||||
// entry.Links.Add(accLink);
|
||||
// }
|
||||
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/image")]
|
||||
@ -804,13 +879,14 @@ public class OpdsController : BaseApiController
|
||||
return link;
|
||||
}
|
||||
|
||||
private static FeedLink CreateLink(string rel, string type, string href)
|
||||
private static FeedLink CreateLink(string rel, string type, string href, string title = null)
|
||||
{
|
||||
return new FeedLink()
|
||||
{
|
||||
Rel = rel,
|
||||
Href = href,
|
||||
Type = type
|
||||
Type = type,
|
||||
Title = string.IsNullOrEmpty(title) ? string.Empty : title
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -63,6 +63,7 @@ namespace API.Controllers
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
}
|
||||
catch (Exception)
|
||||
@ -108,14 +109,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
@ -136,14 +130,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
@ -393,6 +380,10 @@ namespace API.Controllers
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
// Tachiyomi sends chapter 0.0f when there's no chapters read.
|
||||
// Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it
|
||||
if (chapterNumber == 0.0f) return true;
|
||||
|
||||
if (chapterNumber < 1.0f)
|
||||
{
|
||||
// This is a hack to track volume number. We need to map it back by x100
|
||||
|
@ -49,6 +49,15 @@ namespace API.Controllers
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("lists-for-series")]
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForSeries(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true);
|
||||
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
|
||||
/// </summary>
|
||||
|
@ -11,12 +11,10 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -26,14 +24,15 @@ namespace API.Controllers
|
||||
private readonly ILogger<SeriesController> _logger;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly ISeriesService _seriesService;
|
||||
|
||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
|
||||
|
||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService)
|
||||
{
|
||||
_logger = logger;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageHub = messageHub;
|
||||
_seriesService = seriesService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -59,7 +58,7 @@ namespace API.Controllers
|
||||
/// <param name="seriesId">Series Id to fetch details for</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException">Throws an exception if the series Id does exist</exception>
|
||||
[HttpGet("{seriesId}")]
|
||||
[HttpGet("{seriesId:int}")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
@ -82,21 +81,7 @@ namespace API.Controllers
|
||||
var username = User.GetUsername();
|
||||
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
|
||||
var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId}));
|
||||
var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId);
|
||||
|
||||
if (result)
|
||||
{
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
await _unitOfWork.CommitAsync();
|
||||
_taskScheduler.CleanupChapters(chapterIds);
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved,
|
||||
MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId));
|
||||
}
|
||||
return Ok(result);
|
||||
return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId}));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
@ -106,25 +91,9 @@ namespace API.Controllers
|
||||
var username = User.GetUsername();
|
||||
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
|
||||
|
||||
var chapterMappings =
|
||||
await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray());
|
||||
if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok();
|
||||
|
||||
var allChapterIds = new List<int>();
|
||||
foreach (var mapping in chapterMappings)
|
||||
{
|
||||
allChapterIds.AddRange(mapping.Value);
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds);
|
||||
_unitOfWork.SeriesRepository.Remove(series);
|
||||
|
||||
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
||||
}
|
||||
return Ok();
|
||||
return BadRequest("There was an issue deleting the series requested");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -152,28 +121,18 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
[HttpGet("chapter-metadata")]
|
||||
public async Task<ActionResult<ChapterDto>> GetChapterMetadata(int chapterId)
|
||||
{
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("update-rating")]
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
|
||||
var userRating = await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
|
||||
userRating.Rating = updateSeriesRatingDto.UserRating;
|
||||
userRating.Review = updateSeriesRatingDto.UserReview;
|
||||
userRating.SeriesId = updateSeriesRatingDto.SeriesId;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.Ratings ??= new List<AppUserRating>();
|
||||
user.Ratings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical error.");
|
||||
|
||||
if (!await _seriesService.UpdateRating(user, updateSeriesRatingDto)) return BadRequest("There was a critical error.");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -190,10 +149,15 @@ namespace API.Controllers
|
||||
{
|
||||
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
|
||||
}
|
||||
|
||||
series.Name = updateSeries.Name.Trim();
|
||||
series.SortName = updateSeries.SortName.Trim();
|
||||
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
||||
series.SortName = updateSeries.SortName?.Trim();
|
||||
series.Metadata.Summary = updateSeries.Summary?.Trim();
|
||||
|
||||
series.NameLocked = updateSeries.NameLocked;
|
||||
series.SortNameLocked = updateSeries.SortNameLocked;
|
||||
series.LocalizedNameLocked = updateSeries.LocalizedNameLocked;
|
||||
|
||||
|
||||
var needsRefreshMetadata = false;
|
||||
// This is when you hit Reset
|
||||
@ -281,8 +245,7 @@ namespace API.Controllers
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto);
|
||||
|
||||
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
|
||||
.Take(userParams.PageSize).ToList();
|
||||
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize).Take(userParams.PageSize).ToList();
|
||||
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
|
||||
|
||||
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
|
||||
@ -318,78 +281,10 @@ namespace API.Controllers
|
||||
[HttpPost("metadata")]
|
||||
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||
{
|
||||
try
|
||||
if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
|
||||
{
|
||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
|
||||
if (series.Metadata == null)
|
||||
{
|
||||
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
|
||||
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
||||
// TODO: Move this merging logic into a reusable code as it can be used for any Tag
|
||||
var newTags = new List<CollectionTag>();
|
||||
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.CollectionTags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.CollectionTags.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
|
||||
{
|
||||
newTags.Add(existingTag);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in newTags)
|
||||
{
|
||||
series.Metadata.CollectionTags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
{
|
||||
return Ok("No changes to save");
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAddedToCollection,
|
||||
MessageFactory.SeriesAddedToCollection(tag.Id,
|
||||
updateSeriesMetadataDto.SeriesMetadata.SeriesId));
|
||||
}
|
||||
return Ok("Successfully updated");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when updating metadata");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return BadRequest("Could not update metadata");
|
||||
}
|
||||
@ -437,5 +332,12 @@ namespace API.Controllers
|
||||
|
||||
return Ok(val.ToDescription());
|
||||
}
|
||||
|
||||
[HttpGet("series-detail")]
|
||||
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ using API.DTOs.Update;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -105,16 +105,6 @@ namespace API.Controllers
|
||||
{
|
||||
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
|
||||
|
||||
if (updateSettingsDto.CacheDirectory.Equals(string.Empty))
|
||||
{
|
||||
return BadRequest("Cache Directory cannot be empty");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(updateSettingsDto.CacheDirectory))
|
||||
{
|
||||
return BadRequest("Directory does not exist or is not accessible.");
|
||||
}
|
||||
|
||||
// We do not allow CacheDirectory changes, so we will ignore.
|
||||
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
|
||||
var updateBookmarks = false;
|
||||
|
64
API/Controllers/ThemeController.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Theme;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class ThemeController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ISiteThemeService _siteThemeService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
|
||||
public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_siteThemeService = siteThemeService;
|
||||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()
|
||||
{
|
||||
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("scan")]
|
||||
public ActionResult Scan()
|
||||
{
|
||||
_taskScheduler.ScanSiteThemes();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("update-default")]
|
||||
public async Task<ActionResult> UpdateDefault(UpdateDefaultSiteThemeDto dto)
|
||||
{
|
||||
await _siteThemeService.UpdateDefault(dto.ThemeId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns css content to the UI. UI is expected to escape the content
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("download-content")]
|
||||
public async Task<ActionResult<string>> GetThemeContent(int themeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _siteThemeService.GetContent(themeId));
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Uploads;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using Flurl.Http;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -20,14 +23,49 @@ namespace API.Controllers
|
||||
private readonly IImageService _imageService;
|
||||
private readonly ILogger<UploadController> _logger;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger, ITaskScheduler taskScheduler)
|
||||
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
|
||||
ITaskScheduler taskScheduler, IDirectoryService directoryService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_imageService = imageService;
|
||||
_logger = logger;
|
||||
_taskScheduler = taskScheduler;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This stores a file (image) in temp directory for use in a cover image replacement flow.
|
||||
/// This is automatically cleaned up.
|
||||
/// </summary>
|
||||
/// <param name="dto">Escaped url to download from</param>
|
||||
/// <returns>filename</returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("upload-by-url")]
|
||||
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
|
||||
{
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", "");
|
||||
try
|
||||
{
|
||||
var path = await dto.Url
|
||||
.DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
|
||||
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
|
||||
return BadRequest($"Could not download file");
|
||||
|
||||
return $"coverupload_{dateString}.{format}";
|
||||
}
|
||||
catch (FlurlHttpException ex)
|
||||
{
|
||||
// Unauthorized
|
||||
if (ex.StatusCode == 401)
|
||||
return BadRequest("The server requires authentication to load the url externally");
|
||||
}
|
||||
|
||||
return BadRequest("Unable to download image, please use another url or upload by file");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -4,7 +4,9 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -14,10 +16,12 @@ namespace API.Controllers
|
||||
public class UsersController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public UsersController(IUnitOfWork unitOfWork)
|
||||
public UsersController(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
@ -71,14 +75,21 @@ namespace API.Controllers
|
||||
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
|
||||
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
|
||||
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
|
||||
existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
|
||||
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
|
||||
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
||||
existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
|
||||
existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin;
|
||||
existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing;
|
||||
existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily;
|
||||
existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode;
|
||||
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
|
||||
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
|
||||
existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode;
|
||||
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
|
||||
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
|
||||
|
||||
// TODO: Remove this code - this overrides layout mode to be single until the mode is released
|
||||
existingPreferences.LayoutMode = LayoutMode.Single;
|
||||
|
||||
_unitOfWork.UserRepository.Update(existingPreferences);
|
||||
|
||||
@ -89,5 +100,13 @@ namespace API.Controllers
|
||||
|
||||
return BadRequest("There was an issue saving preferences.");
|
||||
}
|
||||
|
||||
[HttpGet("get-preferences")]
|
||||
public async Task<ActionResult<UserPreferencesDto>> GetPreferences()
|
||||
{
|
||||
return _mapper.Map<UserPreferencesDto>(
|
||||
await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername()));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,4 @@ public class InviteUserDto
|
||||
/// A list of libraries to grant access to
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
|
||||
public bool SendEmail { get; init; } = true;
|
||||
}
|
||||
|
13
API/DTOs/Account/InviteUserResponse.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
public class InviteUserResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Email link used to setup the user account
|
||||
/// </summary>
|
||||
public string EmailLink { get; set; }
|
||||
/// <summary>
|
||||
/// Was an email sent (ie is this server accessible)
|
||||
/// </summary>
|
||||
public bool EmailSent { get; set; }
|
||||
}
|
@ -30,7 +30,7 @@ namespace API.DTOs
|
||||
/// <summary>
|
||||
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
|
||||
/// </summary>
|
||||
public string Title { get; init; }
|
||||
public string Title { get; set; }
|
||||
/// <summary>
|
||||
/// The files that represent this Chapter
|
||||
/// </summary>
|
||||
@ -61,31 +61,5 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
/// <remarks>Metadata field</remarks>
|
||||
public string TitleName { get; set; }
|
||||
/// <summary>
|
||||
/// Summary for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Summary { get; set; }
|
||||
/// <summary>
|
||||
/// Language for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Language { get; set; }
|
||||
/// <summary>
|
||||
/// Number in the TotalCount of issues
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Total number of issues for the series
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterer { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
@ -25,51 +26,51 @@ namespace API.DTOs.Filtering
|
||||
/// </summary>
|
||||
public IList<int> Genres { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Writers to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Writers { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Penciller { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Inker { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Colorist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Letterer { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CoverArtist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Editor { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Publisher { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Character { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Translators { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CollectionTags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Tags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
@ -94,5 +95,10 @@ namespace API.DTOs.Filtering
|
||||
/// </summary>
|
||||
public IList<PublicationStatus> PublicationStatus { get; init; } = new List<PublicationStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// An optional name string to filter by. Empty string will ignore.
|
||||
/// </summary>
|
||||
public string SeriesNameQuery { get; init; } = string.Empty;
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Exclusively metadata about a given chapter
|
||||
/// </summary>
|
||||
public class ChapterMetadataDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterer { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
||||
public int ChapterId { get; set; }
|
||||
public ICollection<PersonDto> CoverArtists { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||
|
||||
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Collection of all Tags from underlying chapters for a Series
|
||||
/// </summary>
|
||||
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||
public AgeRating AgeRating { get; set; }
|
||||
public string ReleaseDate { get; set; }
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
/// <summary>
|
||||
/// Summary for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Summary { get; set; }
|
||||
/// <summary>
|
||||
/// Language for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Language { get; set; }
|
||||
/// <summary>
|
||||
/// Number in the TotalCount of issues
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Total number of issues for the series
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ namespace API.DTOs.Reader
|
||||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
public LibraryType LibraryType { get; set; }
|
||||
public string ChapterTitle { get; set; } = string.Empty;
|
||||
public int Pages { get; set; }
|
||||
public string FileName { get; set; }
|
||||
|
28
API/DTOs/SeriesDetailDto.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
|
||||
/// This is subject to change, do not rely on this Data model.
|
||||
/// </summary>
|
||||
public class SeriesDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> Specials { get; set; }
|
||||
/// <summary>
|
||||
/// All Chapters, excluding Specials and single chapters (0 chapter) for a volume
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> Chapters { get; set; }
|
||||
/// <summary>
|
||||
/// Just the Volumes for the Series (Excludes Volume 0)
|
||||
/// </summary>
|
||||
public IEnumerable<VolumeDto> Volumes { get; set; }
|
||||
/// <summary>
|
||||
/// These are chapters that are in Volume 0 and should be read AFTER the volumes
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> StorylineChapters { get; set; }
|
||||
|
||||
}
|
@ -18,6 +18,10 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
public int PagesRead { get; set; }
|
||||
/// <summary>
|
||||
/// DateTime representing last time the series was Read. Calculated at API-time.
|
||||
/// </summary>
|
||||
public DateTime LatestReadDate { get; set; }
|
||||
/// <summary>
|
||||
/// Rating from logged in user. Calculated at API-time.
|
||||
/// </summary>
|
||||
public int UserRating { get; set; }
|
||||
@ -29,6 +33,10 @@ namespace API.DTOs
|
||||
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
public int LibraryId { get; set; }
|
||||
public string LibraryName { get; set; }
|
||||
}
|
||||
|
@ -56,6 +56,30 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override AgeRating
|
||||
/// </summary>
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
|
||||
/// </summary>
|
||||
public bool PublicationStatusLocked { get; set; }
|
||||
public bool GenresLocked { get; set; }
|
||||
public bool TagsLocked { get; set; }
|
||||
public bool WriterLocked { get; set; }
|
||||
public bool CharacterLocked { get; set; }
|
||||
public bool ColoristLocked { get; set; }
|
||||
public bool EditorLocked { get; set; }
|
||||
public bool InkerLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
|
||||
|
||||
public int SeriesId { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -37,5 +37,6 @@ namespace API.DTOs.Settings
|
||||
/// </summary>
|
||||
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
|
||||
public string EmailServiceUrl { get; set; }
|
||||
public string InstallVersion { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
namespace API.DTOs.Stats
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Stats
|
||||
{
|
||||
public class ServerInfoDto
|
||||
{
|
||||
@ -10,5 +12,39 @@
|
||||
public int NumOfCores { get; set; }
|
||||
public int NumberOfLibraries { get; set; }
|
||||
public bool HasBookmarks { get; set; }
|
||||
/// <summary>
|
||||
/// The site theme the install is using
|
||||
/// </summary>
|
||||
public string ActiveSiteTheme { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The reading mode the main user has as a preference
|
||||
/// </summary>
|
||||
public ReaderMode MangaReaderMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of users on the install
|
||||
/// </summary>
|
||||
public int NumberOfUsers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of collections on the install
|
||||
/// </summary>
|
||||
public int NumberOfCollections { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of reading lists on the install (Sum of all users)
|
||||
/// </summary>
|
||||
public int NumberOfReadingLists { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Is OPDS enabled
|
||||
/// </summary>
|
||||
public bool OPDSEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of files in the instance
|
||||
/// </summary>
|
||||
public int TotalFiles { get; set; }
|
||||
}
|
||||
}
|
||||
|
30
API/DTOs/Theme/SiteThemeDto.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Services;
|
||||
|
||||
namespace API.DTOs.Theme;
|
||||
|
||||
public class SiteThemeDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name of the Theme
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
|
||||
/// Must be a .css file
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Only one theme can have this. Will auto-set this as default for new user accounts
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
/// <summary>
|
||||
/// Where did the theme come from
|
||||
/// </summary>
|
||||
public ThemeProvider Provider { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public string Selector => "bg-" + Name.ToLower();
|
||||
}
|
6
API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace API.DTOs.Theme;
|
||||
|
||||
public class UpdateDefaultSiteThemeDto
|
||||
{
|
||||
public int ThemeId { get; set; }
|
||||
}
|
@ -6,10 +6,10 @@
|
||||
public string Name { get; init; }
|
||||
public string LocalizedName { get; init; }
|
||||
public string SortName { get; init; }
|
||||
public string Summary { get; init; }
|
||||
public byte[] CoverImage { get; init; }
|
||||
public int UserRating { get; set; }
|
||||
public string UserReview { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,6 @@ namespace API.DTOs
|
||||
public class UpdateSeriesMetadataDto
|
||||
{
|
||||
public SeriesMetadataDto SeriesMetadata { get; set; }
|
||||
public ICollection<CollectionTagDto> Tags { get; set; }
|
||||
public ICollection<CollectionTagDto> CollectionTags { get; set; }
|
||||
}
|
||||
}
|
10
API/DTOs/UpdateUserRole.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public class UpdateUserRole : IRequest<bool>
|
||||
{
|
||||
public string Username { get; init; }
|
||||
public IList<string> Roles { get; init; }
|
||||
}
|
9
API/DTOs/Uploads/UploadUrlDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace API.DTOs.Uploads;
|
||||
|
||||
public class UploadUrlDto
|
||||
{
|
||||
/// <summary>
|
||||
/// External url
|
||||
/// </summary>
|
||||
public string Url { get; set; }
|
||||
}
|
@ -5,8 +5,8 @@ namespace API.DTOs
|
||||
{
|
||||
public string Username { get; init; }
|
||||
public string Email { get; init; }
|
||||
public string Token { get; init; }
|
||||
public string RefreshToken { get; init; }
|
||||
public string Token { get; set; }
|
||||
public string RefreshToken { get; set; }
|
||||
public string ApiKey { get; init; }
|
||||
public UserPreferencesDto Preferences { get; set; }
|
||||
}
|
||||
|
@ -1,21 +1,78 @@
|
||||
using API.Entities.Enums;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class UserPreferencesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Manga Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection ReadingDirection { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How should the image be scaled to screen
|
||||
/// </summary>
|
||||
public ScalingOption ScalingOption { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Which side of a split image should we show first
|
||||
/// </summary>
|
||||
public PageSplitOption PageSplitOption { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How the manga reader should perform paging or reading of the file
|
||||
/// <example>
|
||||
/// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging
|
||||
/// by clicking top/bottom sides of reader.
|
||||
/// </example>
|
||||
/// </summary>
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How many pages to display in the reader at once
|
||||
/// </summary>
|
||||
public LayoutMode LayoutMode { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Background color of the reader
|
||||
/// </summary>
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||
/// </summary>
|
||||
public bool AutoCloseMenu { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
|
||||
/// </summary>
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Should the background color be dark
|
||||
/// </summary>
|
||||
public bool BookReaderDarkMode { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override extra Margin
|
||||
/// </summary>
|
||||
public int BookReaderMargin { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override line-height
|
||||
/// </summary>
|
||||
public int BookReaderLineSpacing { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override font size
|
||||
/// </summary>
|
||||
public int BookReaderFontSize { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
|
||||
/// </summary>
|
||||
public string BookReaderFontFamily { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Allows tapping on side of screens to paginate
|
||||
/// </summary>
|
||||
public bool BookReaderTapToPaginate { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; }
|
||||
public bool SiteDarkMode { get; set; }
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: The UI theme the user should use.
|
||||
/// </summary>
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
public SiteTheme Theme { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ namespace API.Data
|
||||
public DbSet<Person> Person { get; set; }
|
||||
public DbSet<Genre> Genre { get; set; }
|
||||
public DbSet<Tag> Tag { get; set; }
|
||||
public DbSet<SiteTheme> SiteTheme { get; set; }
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
@ -14,6 +14,7 @@ namespace API.Data.Metadata
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Series { get; set; } = string.Empty;
|
||||
public string SeriesSort { get; set; } = string.Empty;
|
||||
public string Number { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The total number of items in the series.
|
||||
@ -25,7 +26,7 @@ namespace API.Data.Metadata
|
||||
public int PageCount { get; set; }
|
||||
// ReSharper disable once InconsistentNaming
|
||||
/// <summary>
|
||||
/// ISO 639-1 Code to represent the language of the content
|
||||
/// IETF BCP 47 Code to represent the language of the content
|
||||
/// </summary>
|
||||
public string LanguageISO { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
|
1391
API/Data/Migrations/20220215163317_SiteTheme.Designer.cs
generated
Normal file
79
API/Data/Migrations/20220215163317_SiteTheme.cs
Normal file
@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class SiteTheme : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SiteDarkMode",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteTheme",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FileName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Provider = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteTheme", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserPreferences_ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
column: "ThemeId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
|
||||
table: "AppUserPreferences",
|
||||
column: "ThemeId",
|
||||
principalTable: "SiteTheme",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_AppUserPreferences_SiteTheme_ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteTheme");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AppUserPreferences_ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ThemeId",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SiteDarkMode",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
1448
API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs
generated
Normal file
224
API/Data/Migrations/20220303205301_SeriesLockedFields.cs
Normal file
@ -0,0 +1,224 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class SeriesLockedFields : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AgeRatingLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CharacterLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ColoristLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CoverArtistLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EditorLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "GenresLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "InkerLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LanguageLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LettererLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PencillerLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PublicationStatusLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PublisherLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SummaryLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TagsLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TranslatorLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WriterLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LocalizedNameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SortNameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRatingLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ColoristLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverArtistLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EditorLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GenresLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InkerLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LanguageLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LettererLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PencillerLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublicationStatusLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublisherLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SummaryLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TagsLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TranslatorLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WriterLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LocalizedNameLocked",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameLocked",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortNameLocked",
|
||||
table: "Series");
|
||||
}
|
||||
}
|
||||
}
|
1454
API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.Designer.cs
generated
Normal file
@ -0,0 +1,38 @@
|
||||
using API.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class MangaReaderBackgroundAndLayoutMode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BackgroundColor",
|
||||
table: "AppUserPreferences",
|
||||
type: "TEXT",
|
||||
defaultValue: "#000000",
|
||||
nullable: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: LayoutMode.Single);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BackgroundColor",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LayoutMode",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
1457
API/Data/Migrations/20220307153053_ScreenHints.Designer.cs
generated
Normal file
26
API/Data/Migrations/20220307153053_ScreenHints.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class ScreenHints : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShowScreenHints",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowScreenHints",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -165,6 +165,9 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("AutoCloseMenu")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackgroundColor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BookReaderDarkMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -186,6 +189,9 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("BookReaderTapToPaginate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -198,7 +204,10 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("ScalingOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SiteDarkMode")
|
||||
b.Property<bool>("ShowScreenHints")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
@ -206,6 +215,8 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("AppUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences");
|
||||
});
|
||||
|
||||
@ -500,15 +511,51 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AgeRatingLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CharacterLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ColoristLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CoverArtistLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("GenresLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InkerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LanguageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LettererLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PencillerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PublicationStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PublicationStatusLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PublisherLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReleaseYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -522,6 +569,18 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SummaryLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TagsLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TranslatorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WriterLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId")
|
||||
@ -645,9 +704,15 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("LocalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LocalizedNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("NameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -660,6 +725,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("SortName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SortNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
@ -687,6 +755,38 @@ namespace API.Data.Migrations
|
||||
b.ToTable("ServerSetting");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -967,7 +1067,13 @@ namespace API.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.SiteTheme", "Theme")
|
||||
.WithMany()
|
||||
.HasForeignKey("ThemeId");
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -13,6 +14,7 @@ public interface IAppUserProgressRepository
|
||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
|
||||
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
|
||||
Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId);
|
||||
}
|
||||
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
@ -83,6 +85,19 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
.AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This will return any user progress. This filters out progress rows that have no pages read.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.Where(p => p.SeriesId == seriesId && p.AppUserId == userId && p.PagesRead > 0)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
@ -18,6 +19,7 @@ public interface IChapterRepository
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
@ -46,6 +48,7 @@ public class ChapterRepository : IChapterRepository
|
||||
return await _context.Chapter
|
||||
.Where(c => chapterIds.Contains(c.Id))
|
||||
.Include(c => c.Volume)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@ -78,7 +81,8 @@ public class ChapterRepository : IChapterRepository
|
||||
data.TitleName,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesName = series.Name,
|
||||
series.LibraryId
|
||||
series.LibraryId,
|
||||
LibraryType = series.Library.Type
|
||||
})
|
||||
.Select(data => new ChapterInfoDto()
|
||||
{
|
||||
@ -91,7 +95,8 @@ public class ChapterRepository : IChapterRepository
|
||||
SeriesName = data.SeriesName,
|
||||
LibraryId = data.LibraryId,
|
||||
Pages = data.Pages,
|
||||
ChapterTitle = data.TitleName
|
||||
ChapterTitle = data.TitleName,
|
||||
LibraryType = data.LibraryType
|
||||
})
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
@ -113,6 +118,19 @@ public class ChapterRepository : IChapterRepository
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public async Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterMetadataDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
@ -140,6 +158,7 @@ public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,7 @@ public interface ILibraryRepository
|
||||
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
|
||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
|
||||
Task<int> GetTotalFiles();
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
@ -88,6 +89,7 @@ public class LibraryRepository : ILibraryRepository
|
||||
{
|
||||
var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series);
|
||||
_context.Library.Remove(library);
|
||||
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
@ -116,6 +118,11 @@ public class LibraryRepository : ILibraryRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalFiles()
|
||||
{
|
||||
return await _context.MangaFile.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
|
@ -19,9 +19,13 @@ public interface IReadingListRepository
|
||||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
|
||||
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
|
||||
|
||||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
|
||||
bool includePromoted);
|
||||
void Remove(ReadingListItem item);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
Task<int> Count();
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
@ -40,6 +44,11 @@ public class ReadingListRepository : IReadingListRepository
|
||||
_context.Entry(list).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<int> Count()
|
||||
{
|
||||
return await _context.ReadingList.CountAsync();
|
||||
}
|
||||
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
@ -62,6 +71,18 @@ public class ReadingListRepository : IReadingListRepository
|
||||
return await PagedList<ReadingListDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted)
|
||||
{
|
||||
var query = _context.ReadingList
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.Where(l => l.Items.Any(i => i.SeriesId == seriesId))
|
||||
.OrderBy(l => l.LastModified)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ReadingList> GetReadingListByIdAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
@ -79,8 +80,8 @@ public interface ISeriesRepository
|
||||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
@ -275,6 +276,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
{
|
||||
|
||||
var result = new SearchResultGroupDto();
|
||||
var searchQueryNormalized = Parser.Parser.Normalize(searchQuery);
|
||||
|
||||
var seriesIds = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
@ -283,17 +285,23 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
result.Libraries = await _context.Library
|
||||
.Where(l => libraryIds.Contains(l.Id))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%"))
|
||||
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
|
||||
.OrderBy(l => l.Name)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
var justYear = Regex.Match(searchQuery, @"\d{4}").Value;
|
||||
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
||||
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
||||
|
||||
result.Series = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")
|
||||
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
|
||||
.Include(s => s.Library)
|
||||
.OrderBy(s => s.SortName)
|
||||
.AsNoTracking()
|
||||
@ -301,6 +309,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
result.ReadingLists = await _context.ReadingList
|
||||
.Where(rl => rl.AppUserId == userId || rl.Promoted)
|
||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
||||
@ -310,7 +319,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
result.Collections = await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQueryNormalized}%"))
|
||||
.Where(s => s.Promoted || isAdmin)
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
@ -466,10 +475,17 @@ public class SeriesRepository : ISeriesRepository
|
||||
{
|
||||
s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead);
|
||||
var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id);
|
||||
if (rating == null) continue;
|
||||
if (rating != null)
|
||||
{
|
||||
s.UserRating = rating.Rating;
|
||||
s.UserReview = rating.Review;
|
||||
}
|
||||
|
||||
if (userProgress.Count > 0)
|
||||
{
|
||||
s.LatestReadDate = userProgress.Max(p => p.LastModified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetSeriesCoverImageAsync(int seriesId)
|
||||
@ -507,7 +523,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
|
||||
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
|
||||
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
|
||||
out bool hasLanguageFilter, out bool hasPublicationFilter)
|
||||
out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter)
|
||||
{
|
||||
var formats = filter.GetSqlFilter();
|
||||
|
||||
@ -580,6 +596,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ToList();
|
||||
}
|
||||
|
||||
hasSeriesNameFilter = !string.IsNullOrEmpty(filter.SeriesNameQuery);
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
@ -592,11 +610,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <param name="userParams">Pagination information</param>
|
||||
/// <param name="filter">Optional (default null) filter on query</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true)
|
||||
{
|
||||
//var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync();
|
||||
//var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress);
|
||||
|
||||
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter))
|
||||
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
|
||||
new
|
||||
@ -605,12 +621,18 @@ public class SeriesRepository : ISeriesRepository
|
||||
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId)
|
||||
.Sum(s1 => s1.PagesRead),
|
||||
progress.AppUserId,
|
||||
LastReadingProgress = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId)
|
||||
LastReadingProgress = _context.AppUserProgresses
|
||||
.Where(p => p.Id == progress.Id && p.AppUserId == userId)
|
||||
.Max(p => p.LastModified),
|
||||
// This is only taking into account chapters that have progress on them, not all chapters in said series
|
||||
LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created)
|
||||
// BUG: This is only taking into account chapters that have progress on them, not all chapters in said series
|
||||
LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created),
|
||||
//LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created)
|
||||
});
|
||||
if (cutoffOnDate)
|
||||
{
|
||||
query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint);
|
||||
}
|
||||
|
||||
// I think I need another Join statement. The problem is the chapters are still limited to progress
|
||||
|
||||
|
||||
@ -635,7 +657,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
||||
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
||||
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
|
||||
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter);
|
||||
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter);
|
||||
|
||||
var query = _context.Series
|
||||
.Where(s => userLibraries.Contains(s.LibraryId)
|
||||
@ -649,8 +671,11 @@ public class SeriesRepository : ISeriesRepository
|
||||
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
|
||||
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
|
||||
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
|
||||
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
|
||||
)
|
||||
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
|
||||
.Where(s => !hasSeriesNameFilter ||
|
||||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|
||||
.AsNoTracking();
|
||||
|
||||
// If no sort options, default to using SortName
|
||||
|
107
API/Data/Repositories/SiteThemeRepository.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface ISiteThemeRepository
|
||||
{
|
||||
void Add(SiteTheme theme);
|
||||
void Remove(SiteTheme theme);
|
||||
void Update(SiteTheme siteTheme);
|
||||
Task<IEnumerable<SiteThemeDto>> GetThemeDtos();
|
||||
Task<SiteThemeDto> GetThemeDto(int themeId);
|
||||
Task<SiteThemeDto> GetThemeDtoByName(string themeName);
|
||||
Task<SiteTheme> GetDefaultTheme();
|
||||
Task<IEnumerable<SiteTheme>> GetThemes();
|
||||
|
||||
Task<SiteTheme> GetThemeById(int themeId);
|
||||
}
|
||||
|
||||
public class SiteThemeRepository : ISiteThemeRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public SiteThemeRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Add(SiteTheme theme)
|
||||
{
|
||||
_context.Add(theme);
|
||||
}
|
||||
|
||||
public void Remove(SiteTheme theme)
|
||||
{
|
||||
_context.Remove(theme);
|
||||
}
|
||||
|
||||
public void Update(SiteTheme siteTheme)
|
||||
{
|
||||
_context.Entry(siteTheme).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SiteThemeDto>> GetThemeDtos()
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteThemeDto> GetThemeDtoByName(string themeName)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Name.Equals(themeName))
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default theme, if the default theme is not available, returns the dark theme
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<SiteTheme> GetDefaultTheme()
|
||||
{
|
||||
var result = await _context.SiteTheme
|
||||
.Where(t => t.IsDefault)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.NormalizedName == "dark")
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SiteTheme>> GetThemes()
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteTheme> GetThemeById(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Id == themeId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<SiteThemeDto> GetThemeDto(int themeId)
|
||||
{
|
||||
return await _context.SiteTheme
|
||||
.Where(t => t.Id == themeId)
|
||||
.ProjectTo<SiteThemeDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
@ -55,6 +55,7 @@ public interface IUserRepository
|
||||
Task<AppUser> GetUserByEmailAsync(string email);
|
||||
Task<IEnumerable<AppUser>> GetAllUsers();
|
||||
|
||||
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -219,7 +220,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<AppUser> GetUserByEmailAsync(string email)
|
||||
{
|
||||
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower()));
|
||||
var lowerEmail = email.ToLower();
|
||||
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(lowerEmail));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAllUsers()
|
||||
@ -227,6 +229,15 @@ public class UserRepository : IUserRepository
|
||||
return await _context.AppUser.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.Theme)
|
||||
.Where(p => p.Theme.Id == themeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
@ -244,7 +255,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<AppUserRating> GetUserRatingAsync(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
return await _context.AppUserRating
|
||||
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
@ -252,6 +264,8 @@ public class UserRepository : IUserRepository
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
.Include(p => p.AppUser)
|
||||
.Include(p => p.Theme)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@ -6,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
@ -21,6 +23,34 @@ namespace API.Data
|
||||
/// </summary>
|
||||
public static IList<ServerSetting> DefaultSettings;
|
||||
|
||||
public static readonly IList<SiteTheme> DefaultThemes = new List<SiteTheme>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "Dark",
|
||||
NormalizedName = Parser.Parser.Normalize("Dark"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "dark.scss",
|
||||
IsDefault = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Light",
|
||||
NormalizedName = Parser.Parser.Normalize("Light"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "light.scss",
|
||||
IsDefault = false,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "E-Ink",
|
||||
NormalizedName = Parser.Parser.Normalize("E-Ink"),
|
||||
Provider = ThemeProvider.System,
|
||||
FileName = "e-ink.scss",
|
||||
IsDefault = false,
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
var roles = typeof(PolicyConstants)
|
||||
@ -41,6 +71,22 @@ namespace API.Data
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SeedThemes(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
foreach (var theme in DefaultThemes)
|
||||
{
|
||||
var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name));
|
||||
if (existing == null)
|
||||
{
|
||||
await context.SiteTheme.AddAsync(theme);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
@ -21,6 +21,7 @@ public interface IUnitOfWork
|
||||
IPersonRepository PersonRepository { get; }
|
||||
IGenreRepository GenreRepository { get; }
|
||||
ITagRepository TagRepository { get; }
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -56,6 +57,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper);
|
||||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
@ -25,12 +25,23 @@ namespace API.Entities
|
||||
/// </example>
|
||||
/// </summary>
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||
/// </summary>
|
||||
public bool AutoCloseMenu { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
|
||||
/// </summary>
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How many pages to display in the reader at once
|
||||
/// </summary>
|
||||
public LayoutMode LayoutMode { get; set; } = LayoutMode.Single;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Background color of the reader
|
||||
/// </summary>
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
/// <summary>
|
||||
/// Book Reader Option: Should the background color be dark
|
||||
/// </summary>
|
||||
public bool BookReaderDarkMode { get; set; } = true;
|
||||
@ -58,11 +69,11 @@ namespace API.Entities
|
||||
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
||||
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Whether the UI should render in Dark mode or not.
|
||||
/// UI Site Global Setting: The UI theme the user should use.
|
||||
/// </summary>
|
||||
public bool SiteDarkMode { get; set; } = true;
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
public SiteTheme Theme { get; set; }
|
||||
|
||||
|
||||
|
||||
|
@ -35,6 +35,14 @@ namespace API.Entities
|
||||
/// on next load
|
||||
/// </summary>
|
||||
public string BookScrollId { get; set; }
|
||||
/// <summary>
|
||||
/// When this was first created
|
||||
/// </summary>
|
||||
public DateTime Created { get; set; }
|
||||
/// <summary>
|
||||
/// Last date this was updated
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
// Relationships
|
||||
/// <summary>
|
||||
@ -45,14 +53,5 @@ namespace API.Entities
|
||||
/// User this progress belongs to
|
||||
/// </summary>
|
||||
public int AppUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this was first created
|
||||
/// </summary>
|
||||
public DateTime Created { get; set; }
|
||||
/// <summary>
|
||||
/// Last date this was updated
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
}
|
||||
|
13
API/Entities/Enums/LayoutMode.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
public enum LayoutMode
|
||||
{
|
||||
[Description("Single")]
|
||||
Single = 1,
|
||||
[Description("Double")]
|
||||
Double = 2,
|
||||
[Description("Double (manga)")]
|
||||
DoubleReversed = 3
|
||||
}
|
@ -5,13 +5,10 @@ namespace API.Entities.Enums
|
||||
public enum ReaderMode
|
||||
{
|
||||
[Description("Left and Right")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
MANGA_LR = 0,
|
||||
LeftRight = 0,
|
||||
[Description("Up and Down")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
MANGA_UP = 1,
|
||||
UpDown = 1,
|
||||
[Description("Webtoon")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
WEBTOON = 2
|
||||
Webtoon = 2
|
||||
}
|
||||
}
|
||||
|
17
API/Entities/Enums/Theme/ThemeProvider.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.Theme;
|
||||
|
||||
public enum ThemeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Theme is provided by System
|
||||
/// </summary>
|
||||
[Description("System")]
|
||||
System = 1,
|
||||
/// <summary>
|
||||
/// Theme is provided by the User (ie it's custom)
|
||||
/// </summary>
|
||||
[Description("User")]
|
||||
User = 2
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.Entities.Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Has a 1-to-1 relationship with a Chapter. Represents metadata about a chapter.
|
||||
/// </summary>
|
||||
public class ChapterMetadata
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Chapter title
|
||||
/// </summary>
|
||||
/// <remarks>This should not be confused with Chapter.Title which is used for special filenames.</remarks>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread
|
||||
public string StoryArc { get; set; } // This might be a list
|
||||
|
||||
/// <summary>
|
||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||
/// </summary>
|
||||
public ICollection<Person> People { get; set; } = new List<Person>();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Relationships
|
||||
public Chapter Chapter { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
|
||||
}
|
||||
}
|
@ -40,6 +40,31 @@ namespace API.Entities.Metadata
|
||||
public int Count { get; set; } = 0;
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
|
||||
// Locks
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override AgeRating
|
||||
/// </summary>
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
|
||||
/// </summary>
|
||||
public bool PublicationStatusLocked { get; set; }
|
||||
public bool GenresLocked { get; set; }
|
||||
public bool TagsLocked { get; set; }
|
||||
public bool WriterLocked { get; set; }
|
||||
public bool CharacterLocked { get; set; }
|
||||
public bool ColoristLocked { get; set; }
|
||||
public bool EditorLocked { get; set; }
|
||||
public bool InkerLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
|
||||
|
||||
// Relationship
|
||||
public Series Series { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
@ -48,6 +73,7 @@ namespace API.Entities.Metadata
|
||||
[ConcurrencyCheck]
|
||||
public uint RowVersion { get; private set; }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnSavingChanges()
|
||||
{
|
||||
|
@ -58,6 +58,10 @@ namespace API.Entities
|
||||
/// </summary>
|
||||
public MangaFormat Format { get; set; } = MangaFormat.Unknown;
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
public SeriesMetadata Metadata { get; set; }
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
|
||||
|
37
API/Entities/SiteTheme.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Entities;
|
||||
/// <summary>
|
||||
/// Represents a set of css overrides the user can upload to Kavita and will load into webui
|
||||
/// </summary>
|
||||
public class SiteTheme : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Name of the Theme
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Normalized name for lookups
|
||||
/// </summary>
|
||||
public string NormalizedName { get; set; }
|
||||
/// <summary>
|
||||
/// File path to the content. Stored under <see cref="DirectoryService.SiteThemeDirectory"/>.
|
||||
/// Must be a .css file
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Only one theme can have this. Will auto-set this as default for new user accounts
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
/// <summary>
|
||||
/// Where did the theme come from
|
||||
/// </summary>
|
||||
public ThemeProvider Provider { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
@ -9,9 +9,13 @@ namespace API.Entities
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// A String representation of the volume number. Allows for floats
|
||||
/// A String representation of the volume number. Allows for floats.
|
||||
/// </summary>
|
||||
/// <remarks>For Books with Series_index, this will map to the Series Index.</remarks>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// The minimum number in the Name field in Int form
|
||||
/// </summary>
|
||||
public int Number { get; set; }
|
||||
public IList<Chapter> Chapters { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
|
@ -3,6 +3,7 @@ using API.Data;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR;
|
||||
using API.SignalR.Presence;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@ -39,6 +40,9 @@ namespace API.Extensions
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<IEmailService, EmailService>();
|
||||
services.AddScoped<IBookmarkService, BookmarkService>();
|
||||
services.AddScoped<ISiteThemeService, SiteThemeService>();
|
||||
services.AddScoped<ISeriesService, SeriesService>();
|
||||
|
||||
|
||||
services.AddScoped<IFileSystem, FileSystem>();
|
||||
services.AddScoped<IFileService, FileService>();
|
||||
@ -50,6 +54,7 @@ namespace API.Extensions
|
||||
services.AddScoped<ICacheHelper, CacheHelper>();
|
||||
|
||||
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
||||
services.AddScoped<IEventHub, EventHub>();
|
||||
|
||||
services.AddSqLite(config, env);
|
||||
services.AddLogging(config);
|
||||
|
@ -19,12 +19,13 @@ namespace API.Extensions
|
||||
/// <returns>Sorted Enumerable</returns>
|
||||
public static IEnumerable<T> OrderByNatural<T>(this IEnumerable<T> items, Func<T, string> selector, StringComparer stringComparer = null)
|
||||
{
|
||||
var maxDigits = items
|
||||
var list = items.ToList();
|
||||
var maxDigits = list
|
||||
.SelectMany(i => Regex.Matches(selector(i))
|
||||
.Select(digitChunk => (int?)digitChunk.Value.Length))
|
||||
.Max() ?? 0;
|
||||
|
||||
return items.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
|
||||
return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|