Make reading profile buttons more user-friendly

Updating a profile no longer deletes all implicit profiles
This commit is contained in:
Amelia 2025-06-06 13:24:25 +02:00
parent 45a44480e1
commit 82f557490a
9 changed files with 96 additions and 87 deletions

View file

@ -67,13 +67,8 @@ public class ReadingProfileServiceTest: AbstractDbTest
Assert.NotNull(seriesProfile); Assert.NotNull(seriesProfile);
Assert.Equal("Implicit Profile", seriesProfile.Name); Assert.Equal("Implicit Profile", seriesProfile.Name);
await rps.UpdateReadingProfile(user.Id, new UserReadingProfileDto // Find parent
{ seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true);
Id = profile2.Id,
WidthOverride = 23,
});
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile); Assert.NotNull(seriesProfile);
Assert.Equal("Non-implicit Profile", seriesProfile.Name); Assert.Equal("Non-implicit Profile", seriesProfile.Name);
} }
@ -260,7 +255,7 @@ public class ReadingProfileServiceTest: AbstractDbTest
} }
[Fact] [Fact]
public async Task BatchAddReadingProfiles() public async Task BulkAddReadingProfiles()
{ {
await ResetDb(); await ResetDb();
var (rps, user, lib, series) = await Setup(); var (rps, user, lib, series) = await Setup();
@ -309,43 +304,7 @@ public class ReadingProfileServiceTest: AbstractDbTest
} }
[Fact] [Fact]
public async Task UpdateDeletesImplicit() public async Task BulkAssignDeletesImplicit()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
.Build());
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
var profileDto = Mapper.Map<UserReadingProfileDto>(profile);
await rps.UpdateReadingProfile(user.Id, profileDto);
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(0, implicitCount);
}
[Fact]
public async Task BatchUpdateDeletesImplicit()
{ {
await ResetDb(); await ResetDb();
var (rps, user, lib, series) = await Setup(); var (rps, user, lib, series) = await Setup();
@ -574,18 +533,22 @@ public class ReadingProfileServiceTest: AbstractDbTest
[Fact] [Fact]
public void UpdateFields_UpdatesAll() public void UpdateFields_UpdatesAll()
{ {
var profile = new AppUserReadingProfile(); // Repeat to ensure booleans are flipped and actually tested
var dto = new UserReadingProfileDto(); for (int i = 0; i < 10; i++)
{
var profile = new AppUserReadingProfile();
var dto = new UserReadingProfileDto();
RandfHelper.SetRandomValues(profile); RandfHelper.SetRandomValues(profile);
RandfHelper.SetRandomValues(dto); RandfHelper.SetRandomValues(dto);
ReadingProfileService.UpdateReaderProfileFields(profile, dto); ReadingProfileService.UpdateReaderProfileFields(profile, dto);
var newDto = Mapper.Map<UserReadingProfileDto>(profile); var newDto = Mapper.Map<UserReadingProfileDto>(profile);
Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto, Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto,
["<Id>k__BackingField", "<AppUserId>k__BackingField", "<Implicit>k__BackingField"])); ["<Id>k__BackingField", "<UserId>k__BackingField"]));
}
} }

View file

@ -35,11 +35,12 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
/// Series -> Library -> Default /// Series -> Library -> Default
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="skipImplicit"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("{seriesId}")] [HttpGet("{seriesId:int}")]
public async Task<ActionResult<UserReadingProfileDto>> GetProfileForSeries(int seriesId) public async Task<ActionResult<UserReadingProfileDto>> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit)
{ {
return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId)); return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit));
} }
/// <summary> /// <summary>
@ -126,7 +127,7 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="profileId"></param> /// <param name="profileId"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("series/{seriesId}")] [HttpPost("series/{seriesId:int}")]
public async Task<IActionResult> AddProfileToSeries(int seriesId, [FromQuery] int profileId) public async Task<IActionResult> AddProfileToSeries(int seriesId, [FromQuery] int profileId)
{ {
await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId); await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId);
@ -138,7 +139,7 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <returns></returns> /// <returns></returns>
[HttpDelete("series/{seriesId}")] [HttpDelete("series/{seriesId:int}")]
public async Task<IActionResult> ClearSeriesProfile(int seriesId) public async Task<IActionResult> ClearSeriesProfile(int seriesId)
{ {
await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId); await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId);
@ -151,7 +152,7 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="profileId"></param> /// <param name="profileId"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("library/{libraryId}")] [HttpPost("library/{libraryId:int}")]
public async Task<IActionResult> AddProfileToLibrary(int libraryId, [FromQuery] int profileId) public async Task<IActionResult> AddProfileToLibrary(int libraryId, [FromQuery] int profileId)
{ {
await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId); await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId);
@ -164,7 +165,7 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="profileId"></param> /// <param name="profileId"></param>
/// <returns></returns> /// <returns></returns>
[HttpDelete("library/{libraryId}")] [HttpDelete("library/{libraryId:int}")]
public async Task<IActionResult> ClearLibraryProfile(int libraryId) public async Task<IActionResult> ClearLibraryProfile(int libraryId)
{ {
await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId); await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId);

View file

@ -22,8 +22,9 @@ public interface IReadingProfileService
/// </summary> /// </summary>
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="skipImplicit"></param>
/// <returns></returns> /// <returns></returns>
Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId); Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false);
/// <summary> /// <summary>
/// Creates a new reading profile for a user. Name must be unique per user /// Creates a new reading profile for a user. Name must be unique per user
@ -60,7 +61,7 @@ public interface IReadingProfileService
Task<UserReadingProfileDto> UpdateParent(int userId, int seriesId, UserReadingProfileDto dto); Task<UserReadingProfileDto> UpdateParent(int userId, int seriesId, UserReadingProfileDto dto);
/// <summary> /// <summary>
/// Updates a given reading profile for a user, and deletes all implicit profiles /// Updates a given reading profile for a user
/// </summary> /// </summary>
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="dto"></param> /// <param name="dto"></param>
@ -123,9 +124,9 @@ public interface IReadingProfileService
public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService
{ {
public async Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId) public async Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false)
{ {
return mapper.Map<UserReadingProfileDto>(await GetReadingProfileForSeries(userId, seriesId)); return mapper.Map<UserReadingProfileDto>(await GetReadingProfileForSeries(userId, seriesId, skipImplicit));
} }
public async Task<AppUserReadingProfile> GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false) public async Task<AppUserReadingProfile> GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false)
@ -175,7 +176,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
UpdateReaderProfileFields(profile, dto); UpdateReaderProfileFields(profile, dto);
unitOfWork.AppUserReadingProfileRepository.Update(profile); unitOfWork.AppUserReadingProfileRepository.Update(profile);
await DeleteImplicateReadingProfilesForSeries(userId, profile.SeriesIds); // await DeleteImplicateReadingProfilesForSeries(userId, profile.SeriesIds);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
return mapper.Map<UserReadingProfileDto>(profile); return mapper.Map<UserReadingProfileDto>(profile);

View file

@ -12,8 +12,8 @@ export class ReadingProfileService {
constructor(private httpClient: HttpClient) { } constructor(private httpClient: HttpClient) { }
getForSeries(seriesId: number) { getForSeries(seriesId: number, skipImplicit: boolean = false) {
return this.httpClient.get<ReadingProfile>(this.baseUrl + "ReadingProfile/"+seriesId); return this.httpClient.get<ReadingProfile>(this.baseUrl + `ReadingProfile/${seriesId}?skipImplicit=${skipImplicit}`);
} }
updateProfile(profile: ReadingProfile) { updateProfile(profile: ReadingProfile) {

View file

@ -170,9 +170,9 @@
<div class="d-flex gap-2 mt-2 flex-wrap mb-2"> <div class="d-flex gap-2 mt-2 flex-wrap mb-2">
<button class="btn btn-primary" <button class="btn btn-primary"
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit" [disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
(click)="updateParentPref()"> (click)="updateParentPref()">
{{ t('update-parent') }} {{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
</button> </button>
<button class="btn btn-primary" <button class="btn btn-primary"
[ngbTooltip]="t('create-new-tooltip')" [ngbTooltip]="t('create-new-tooltip')"

View file

@ -165,6 +165,11 @@ export class ReaderSettingsComponent implements OnInit {
settingsForm: FormGroup = new FormGroup({}); settingsForm: FormGroup = new FormGroup({});
/**
* The reading profile itself, unless readingProfile is implicit
*/
parentReadingProfile: ReadingProfile | null = null;
/** /**
* System provided themes * System provided themes
*/ */
@ -190,6 +195,14 @@ export class ReaderSettingsComponent implements OnInit {
private toastr: ToastrService) {} private toastr: ToastrService) {}
ngOnInit(): void { ngOnInit(): void {
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
this.parentReadingProfile = parent;
this.cdRef.markForCheck();
})
} else {
this.parentReadingProfile = this.readingProfile;
}
this.fontFamilies = this.bookService.getFontFamilies(); this.fontFamilies = this.bookService.getFontFamilies();
this.fontOptions = this.fontFamilies.map(f => f.title); this.fontOptions = this.fontFamilies.map(f => f.title);
@ -325,6 +338,10 @@ export class ReaderSettingsComponent implements OnInit {
updateImplicit() { updateImplicit() {
this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({ this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({
next: newProfile => {
this.readingProfile = newProfile;
this.cdRef.markForCheck();
},
error: err => { error: err => {
console.error(err); console.error(err);
} }
@ -414,6 +431,7 @@ export class ReaderSettingsComponent implements OnInit {
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
this.readingProfile = newProfile; this.readingProfile = newProfile;
this.parentReadingProfile = newProfile; // profile is no longer implicit
this.toastr.success(translate("manga-reader.reading-profile-promoted")); this.toastr.success(translate("manga-reader.reading-profile-promoted"));
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });

View file

@ -310,8 +310,17 @@
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<button class="btn btn-primary" [disabled]="readingProfile.kind !== ReadingProfileKind.Implicit" (click)="updateParentPref()">{{t('update-parent')}}</button> <button class="btn btn-primary"
<button class="btn btn-primary ms-2" [ngbTooltip]="t('create-new-tooltip')" [disabled]="readingProfile.kind !== ReadingProfileKind.Implicit" (click)="createNewProfileFromImplicit()">{{t('create-new')}}</button> [disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
(click)="updateParentPref()">
{{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
</button>
<button class="btn btn-primary ms-2"
[ngbTooltip]="t('create-new-tooltip')"
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit"
(click)="createNewProfileFromImplicit()">
{{ t('create-new') }}
</button>
</div> </div>
</div> </div>
</form> </form>

View file

@ -205,6 +205,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
totalSeriesPagesRead = 0; totalSeriesPagesRead = 0;
user!: User; user!: User;
readingProfile!: ReadingProfile; readingProfile!: ReadingProfile;
/**
* The reading profile itself, unless readingProfile is implicit
*/
parentReadingProfile: ReadingProfile | null = null;
generalSettingsForm!: FormGroup; generalSettingsForm!: FormGroup;
readingDirection = ReadingDirection.LeftToRight; readingDirection = ReadingDirection.LeftToRight;
@ -491,17 +495,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.readingProfile = data['readingProfile'];
if (this.readingProfile == null) {
this.router.navigateByUrl('/home');
return;
}
this.setupReaderSettings();
this.cdRef.markForCheck();
});
this.getPageFn = this.getPage.bind(this); this.getPageFn = this.getPage.bind(this);
this.libraryId = parseInt(libraryId, 10); this.libraryId = parseInt(libraryId, 10);
@ -510,6 +503,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'; this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true'; this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true';
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.readingProfile = data['readingProfile'];
if (this.readingProfile == null) {
this.router.navigateByUrl('/home');
return;
}
// Requires seriesId to be set
this.setupReaderSettings();
this.cdRef.markForCheck();
});
const readingListId = this.route.snapshot.queryParamMap.get('readingListId'); const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
if (readingListId != null) { if (readingListId != null) {
this.readingListMode = true; this.readingListMode = true;
@ -644,6 +648,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
setupReaderSettings() { setupReaderSettings() {
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
this.parentReadingProfile = parent;
this.cdRef.markForCheck();
})
} else {
this.parentReadingProfile = this.readingProfile;
}
this.readingDirection = this.readingProfile.readingDirection; this.readingDirection = this.readingProfile.readingDirection;
this.scalingOption = this.readingProfile.scalingOption; this.scalingOption = this.readingProfile.scalingOption;
this.pageSplitOption = this.readingProfile.pageSplitOption; this.pageSplitOption = this.readingProfile.pageSplitOption;
@ -1805,6 +1819,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
this.readingProfile = newProfile; this.readingProfile = newProfile;
this.parentReadingProfile = newProfile; // Profile is no longer implicit
this.toastr.success(translate("manga-reader.reading-profile-promoted")); this.toastr.success(translate("manga-reader.reading-profile-promoted"));
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });

View file

@ -1132,8 +1132,9 @@
"line-spacing-label": "{{manage-reading-profiles.line-height-book-label}}", "line-spacing-label": "{{manage-reading-profiles.line-height-book-label}}",
"margin-label": "{{manage-reading-profiles.margin-book-label}}", "margin-label": "{{manage-reading-profiles.margin-book-label}}",
"reset-to-defaults": "Reset to Defaults", "reset-to-defaults": "Reset to Defaults",
"update-parent": "Update parent profile", "update-parent": "Save to {{name}}",
"create-new": "Promote profile", "loading": "loading",
"create-new": "New profile from implicit",
"create-new-tooltip": "Create a new manageable profile from your current implicit one", "create-new-tooltip": "Create a new manageable profile from your current implicit one",
"reading-profile-updated": "Reading profile updated", "reading-profile-updated": "Reading profile updated",
"reading-profile-promoted": "Reading profile promoted", "reading-profile-promoted": "Reading profile promoted",
@ -1952,9 +1953,10 @@
"manga-reader": { "manga-reader": {
"back": "Back", "back": "Back",
"update-parent": "Update parent profile", "update-parent": "{{reader-settings.update-parent}}",
"create-new": "Promote profile", "loading": "{{reader-settings.loading}}",
"create-new-tooltip": "Create a new manageable profile from your current implicit one", "create-new": "{{reader-settings.create-new}}",
"create-new-tooltip": "{{reader-settings.create-new-tooltip}}",
"incognito-alt": "Incognito mode is on. Toggle to turn off.", "incognito-alt": "Incognito mode is on. Toggle to turn off.",
"incognito-title": "Incognito Mode:", "incognito-title": "Incognito Mode:",
"shortcuts-menu-alt": "Keyboard Shortcuts Modal", "shortcuts-menu-alt": "Keyboard Shortcuts Modal",