diff --git a/src/IPA.Bcfier.App/Controllers/BcfConversionController.cs b/src/IPA.Bcfier.App/Controllers/BcfConversionController.cs index d546e4c4..8b519645 100644 --- a/src/IPA.Bcfier.App/Controllers/BcfConversionController.cs +++ b/src/IPA.Bcfier.App/Controllers/BcfConversionController.cs @@ -24,39 +24,44 @@ public BcfConversionController(ElectronWindowProvider electronWindowProvider) [ProducesResponseType((int)HttpStatusCode.NoContent)] [ProducesResponseType(typeof(ApiError), (int)HttpStatusCode.BadRequest)] [ProducesResponseType(typeof(BcfFileWrapper), (int)HttpStatusCode.OK)] - public async Task ImportBcfFileAsync() + public async Task ImportBcfFileAsync([FromQuery] string? filePath) { - var electronWindow = _electronWindowProvider.BrowserWindow; - if (electronWindow == null) + if (string.IsNullOrWhiteSpace(filePath)) { - return BadRequest(); - } + var electronWindow = _electronWindowProvider.BrowserWindow; + if (electronWindow == null) + { + return BadRequest(); + } - var fileSelectionResult = await Electron.Dialog.ShowOpenDialogAsync(electronWindow, new OpenDialogOptions - { - Filters = new [] + var fileSelectionResult = await Electron.Dialog.ShowOpenDialogAsync(electronWindow, new OpenDialogOptions { + Filters = new[] + { new FileFilter { Name = "BCF File", Extensions = new string[] { "bcf", "bcfzip" } } } - }); + }); - if (fileSelectionResult == null) - { - return NoContent(); + if (fileSelectionResult == null) + { + return NoContent(); + } + + filePath = fileSelectionResult.First(); } try { - using var bcfFileStream = System.IO.File.OpenRead(fileSelectionResult.First()); - var bcfFileName = Path.GetFileName(fileSelectionResult.FirstOrDefault()); + using var bcfFileStream = System.IO.File.OpenRead(filePath); + var bcfFileName = Path.GetFileName(filePath); var bcfResult = await new BcfImportService().ImportBcfFileAsync(bcfFileStream, bcfFileName ?? "issue.bcf"); return Ok(new BcfFileWrapper { - FileName = fileSelectionResult.First(), + FileName = filePath, BcfFile = bcfResult }); } diff --git a/src/IPA.Bcfier.App/Controllers/LastOpenedFilesController.cs b/src/IPA.Bcfier.App/Controllers/LastOpenedFilesController.cs new file mode 100644 index 00000000..a5bc8f4d --- /dev/null +++ b/src/IPA.Bcfier.App/Controllers/LastOpenedFilesController.cs @@ -0,0 +1,76 @@ +using IPA.Bcfier.App.Data; +using IPA.Bcfier.App.Models.Controllers.LastOpenedFiles; +using IPA.Bcfier.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.Net; + +namespace IPA.Bcfier.App.Controllers +{ + [ApiController] + [Route("api/last-opened-files")] + public class LastOpenedFilesController : ControllerBase + { + private readonly BcfierDbContext _context; + private readonly SettingsService _settingsService; + + public LastOpenedFilesController(BcfierDbContext context, + SettingsService settingsService) + { + _context = context; + _settingsService = settingsService; + } + + [HttpGet("")] + [ProducesResponseType(typeof(LastOpenedFilesWrapperGet), (int)HttpStatusCode.OK)] + public async Task GetLastOpenedFilesAsync([FromQuery]Guid? projectId) + { + var userName = (await _settingsService.LoadSettingsAsync()).Username; + + var lastOpenedFiles = await _context + .LastOpenedUserFiles + .Where(louf => louf.UserName == userName && louf.ProjectId == projectId) + .OrderByDescending(louf => louf.OpenedAtAtUtc) + .Select(louf => new LastOpenedFileGet + { + FileName = louf.FilePath, + OpenedAtUtc = louf.OpenedAtAtUtc + }) + .Take(10) + .ToListAsync(); + + return Ok(new LastOpenedFilesWrapperGet + { + LastOpenedFiles = lastOpenedFiles + }); + } + + [HttpPut("")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + public async Task SetFileAsLastOpened([FromQuery]Guid? projectId, [FromQuery, Required]string filePath) + { + var userName = (await _settingsService.LoadSettingsAsync()).Username; + var existingEntry = await _context.LastOpenedUserFiles + .FirstOrDefaultAsync(louf => louf.ProjectId == projectId + && louf.UserName == userName + && louf.FilePath == filePath); + if (existingEntry != null) + { + existingEntry.OpenedAtAtUtc = DateTimeOffset.UtcNow; + } + else + { + _context.LastOpenedUserFiles.Add(new Data.Models.LastOpenedUserFile + { + ProjectId = projectId, + UserName = userName, + FilePath = filePath + }); + } + + await _context.SaveChangesAsync(); + return NoContent(); + } + } +} diff --git a/src/IPA.Bcfier.App/Data/BcfierDbContext.cs b/src/IPA.Bcfier.App/Data/BcfierDbContext.cs index b6787ae2..104e145c 100644 --- a/src/IPA.Bcfier.App/Data/BcfierDbContext.cs +++ b/src/IPA.Bcfier.App/Data/BcfierDbContext.cs @@ -1,6 +1,7 @@ using IPA.Bcfier.App.Data.Models; using Microsoft.EntityFrameworkCore; -using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace IPA.Bcfier.App.Data { @@ -13,5 +14,36 @@ public BcfierDbContext(DbContextOptions options) : base(options public DbSet Projects { get; set; } public DbSet ProjectUsers { get; set; } + + public DbSet LastOpenedUserFiles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + LastOpenedUserFile.OnModelCreating(modelBuilder); + + if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations + // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations + // To work around this, when the Sqlite database provider is used, all model properties of type DateTimeOffset + // use the DateTimeOffsetToBinaryConverter + // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754 + // This only supports millisecond precision, but should be sufficient for most use cases. + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + var properties = entityType.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset) + || p.PropertyType == typeof(DateTimeOffset?)); + foreach (var property in properties) + { + modelBuilder + .Entity(entityType.Name) + .Property(property.Name) + .HasConversion(new DateTimeOffsetToBinaryConverter()); + } + } + } + } } -} +} \ No newline at end of file diff --git a/src/IPA.Bcfier.App/Data/Models/LastOpenedUserFile.cs b/src/IPA.Bcfier.App/Data/Models/LastOpenedUserFile.cs new file mode 100644 index 00000000..8d25bfb6 --- /dev/null +++ b/src/IPA.Bcfier.App/Data/Models/LastOpenedUserFile.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace IPA.Bcfier.App.Data.Models +{ + public class LastOpenedUserFile + { + public Guid Id { get; set; } + + public Guid? ProjectId { get; set; } + + public Project? Project { get; set; } + + public DateTimeOffset OpenedAtAtUtc { get; set; } = DateTimeOffset.UtcNow; + + [Required] + public string FilePath { get; set; } = string.Empty; + + [Required] + public string UserName { get; set; } = string.Empty; + + public static void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(x => x.UserName); + } + } +} diff --git a/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.Designer.cs b/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.Designer.cs new file mode 100644 index 00000000..bd40be60 --- /dev/null +++ b/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.Designer.cs @@ -0,0 +1,119 @@ +// +using System; +using IPA.Bcfier.App.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IPA.Bcfier.App.Migrations +{ + [DbContext(typeof(BcfierDbContext))] + [Migration("20240502190902_LastOpenedFiles")] + partial class LastOpenedFiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.LastOpenedUserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OpenedAtAtUtc") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserName"); + + b.ToTable("LastOpenedUserFiles"); + }); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevitIdentifer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeamsWebhook") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.ProjectUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.LastOpenedUserFile", b => + { + b.HasOne("IPA.Bcfier.App.Data.Models.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.ProjectUser", b => + { + b.HasOne("IPA.Bcfier.App.Data.Models.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.cs b/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.cs new file mode 100644 index 00000000..f7f6e6d4 --- /dev/null +++ b/src/IPA.Bcfier.App/Migrations/20240502190902_LastOpenedFiles.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IPA.Bcfier.App.Migrations +{ + /// + public partial class LastOpenedFiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CreatedAtUtc", + table: "Projects", + type: "INTEGER", + nullable: false, + oldClrType: typeof(DateTimeOffset), + oldType: "TEXT"); + + migrationBuilder.CreateTable( + name: "LastOpenedUserFiles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: true), + OpenedAtAtUtc = table.Column(type: "INTEGER", nullable: false), + FilePath = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LastOpenedUserFiles", x => x.Id); + table.ForeignKey( + name: "FK_LastOpenedUserFiles_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_LastOpenedUserFiles_ProjectId", + table: "LastOpenedUserFiles", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_LastOpenedUserFiles_UserName", + table: "LastOpenedUserFiles", + column: "UserName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LastOpenedUserFiles"); + + migrationBuilder.AlterColumn( + name: "CreatedAtUtc", + table: "Projects", + type: "TEXT", + nullable: false, + oldClrType: typeof(long), + oldType: "INTEGER"); + } + } +} diff --git a/src/IPA.Bcfier.App/Migrations/BcfierDbContextModelSnapshot.cs b/src/IPA.Bcfier.App/Migrations/BcfierDbContextModelSnapshot.cs index 5ef66d79..801633dd 100644 --- a/src/IPA.Bcfier.App/Migrations/BcfierDbContextModelSnapshot.cs +++ b/src/IPA.Bcfier.App/Migrations/BcfierDbContextModelSnapshot.cs @@ -17,15 +17,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); - modelBuilder.Entity("IPA.Bcfier.App.Data.Models.Project", b => + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.LastOpenedUserFile", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CreatedAtUtc") + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OpenedAtAtUtc") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() .HasColumnType("TEXT"); + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserName"); + + b.ToTable("LastOpenedUserFiles"); + }); + + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -62,6 +91,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectUsers"); }); + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.LastOpenedUserFile", b => + { + b.HasOne("IPA.Bcfier.App.Data.Models.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId"); + + b.Navigation("Project"); + }); + modelBuilder.Entity("IPA.Bcfier.App.Data.Models.ProjectUser", b => { b.HasOne("IPA.Bcfier.App.Data.Models.Project", "Project") diff --git a/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFileGet.cs b/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFileGet.cs new file mode 100644 index 00000000..b0ef8636 --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFileGet.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace IPA.Bcfier.App.Models.Controllers.LastOpenedFiles +{ + public class LastOpenedFileGet + { + [Required] + public string FileName { get; set; } = string.Empty; + + [Required] + public DateTimeOffset OpenedAtUtc { get; set; } + } +} diff --git a/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFilesWrapperGet.cs b/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFilesWrapperGet.cs new file mode 100644 index 00000000..5b8c7bd3 --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Controllers/LastOpenedFiles/LastOpenedFilesWrapperGet.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace IPA.Bcfier.App.Models.Controllers.LastOpenedFiles +{ + public class LastOpenedFilesWrapperGet + { + [Required] + public List LastOpenedFiles { get; set; } = new List(); + } +} diff --git a/src/ipa-bcfier-ui/src/app/app.component.ts b/src/ipa-bcfier-ui/src/app/app.component.ts index 8ac9c812..75fefcf5 100644 --- a/src/ipa-bcfier-ui/src/app/app.component.ts +++ b/src/ipa-bcfier-ui/src/app/app.component.ts @@ -170,13 +170,16 @@ export class AppComponent implements OnDestroy { closeBcfFile(bcfFile: BcfFile): void { this.bcfFilesMessengerService.closeBcfFile(bcfFile); + if (this.tabGroup && this.tabGroup.selectedIndex !== null) { + this.changeSelectedTabIndex(this.tabGroup.selectedIndex); + } } changeSelectedTabIndex(index: number): void { this.bcfFilesMessengerService.bcfFiles .pipe(take(1)) .subscribe((bcfFiles) => { - if (bcfFiles.length) { + if (bcfFiles.length && bcfFiles[index] !== undefined) { this.bcfFilesMessengerService.setBcfFileSelected(bcfFiles[index]); } }); diff --git a/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.html b/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.html new file mode 100644 index 00000000..732e8346 --- /dev/null +++ b/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.html @@ -0,0 +1,5 @@ +@for(file of lastOpenedFiles() ;track file.fileName) { + +} diff --git a/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.scss b/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.ts b/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.ts new file mode 100644 index 00000000..37f87b7a --- /dev/null +++ b/src/ipa-bcfier-ui/src/app/components/last-opened-files/last-opened-files.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; +import { LastOpenedFileGet } from '../../generated-client/generated-client'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FileNamePipe } from '../../pipes/file-name.pipe'; +import { BcfFilesMessengerService } from '../../services/bcf-files-messenger.service'; +import { take } from 'rxjs'; +import { mapToCanActivate } from '@angular/router'; +import { BackendService } from '../../services/BackendService'; +import { NotificationsService } from '../../services/notifications.service'; + +@Component({ + selector: 'bcfier-last-opened-files', + standalone: true, + imports: [MatMenuModule, MatButtonModule, MatTooltipModule, FileNamePipe], + templateUrl: './last-opened-files.component.html', + styleUrl: './last-opened-files.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LastOpenedFilesComponent { + isOpen = input(false); + btnWidth = input(0); + lastOpenedFiles = input([]); + private bcfFilesMessengerService = inject(BcfFilesMessengerService); + private backendService = inject(BackendService); + private notificationsService = inject(NotificationsService); + + constructor() { + effect(() => { + this.setButtonWidth(); + }); + } + + setButtonWidth(): void { + if (this.isOpen()) { + const panel = ( + document.getElementsByClassName('mat-mdc-menu-panel')[0] + ); + if (panel) { + panel.style.width = this.btnWidth() + 'px'; + } + } + } + + openFile(file: LastOpenedFileGet): void { + this.bcfFilesMessengerService.bcfFiles + .pipe(take(1)) + .subscribe((bcfFiles) => { + const matchingFile = bcfFiles.find((f) => f.fileName === file.fileName); + if (matchingFile) { + this.bcfFilesMessengerService.setBcfFileSelected(matchingFile); + } else { + this.backendService.importBcfFile(file.fileName).subscribe({ + next: (bcfFileWrapper) => { + this.bcfFilesMessengerService.openBcfFile(bcfFileWrapper); + }, + error: () => { + this.notificationsService.error( + file.fileName, + 'Could not open file' + ); + }, + }); + } + }); + } +} diff --git a/src/ipa-bcfier-ui/src/app/components/top-menu/top-menu.component.html b/src/ipa-bcfier-ui/src/app/components/top-menu/top-menu.component.html index c7c3c4fc..f19c1637 100644 --- a/src/ipa-bcfier-ui/src/app/components/top-menu/top-menu.component.html +++ b/src/ipa-bcfier-ui/src/app/components/top-menu/top-menu.component.html @@ -16,6 +16,17 @@ settings Settings +
+ +