Compare commits

..
5 Commits
Author SHA1 Message Date
ajandClaude Fable 5 7f3beebe85 ci: add Gitea workflow to build and push image on main
Build PepApi image / build-and-push (push) Successful in 21s
Same auto-build setup as CncApi and LaserQuote.Web so all forge
services publish to the Gitea registry on push instead of the
manual docker save/load flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:20:18 -04:00
aj 1d4f64726b fix: resolve 500s on nest-details endpoints under Docker deployment
NestReader opened .pep files with FileStream(path, FileMode.Open), which
defaults to FileAccess.ReadWrite. The forge deployment bind-mounts
/mnt/pep-nest read-only, so every read-only open was rejected with
"Read-only file system" (EROFS) even though the code never writes.
Pass FileAccess.Read/FileShare.Read explicitly, matching DrawingReader
and ZipHelper elsewhere in PepLib.Core.

Once that was fixed, a second pre-existing bug surfaced: GetNestDetailsAsync
matched DB rows by NestName AND a literal Path comparison built from the
container's local mount path. NestHeader.Path stores the original Windows
UNC share path (e.g. \REMCOSRV0\pep nest\) from when PepApi ran directly
against the network share, so the comparison can never match post-Docker.
Match by NestName alone (ordered by most recent), consistent with the
fallback lookup already used elsewhere in NestsController.
2026-06-30 15:00:22 -04:00
ajandClaude Sonnet 4.6 85f6a50f1a feat: improve DXF export and cross-platform file handling
- PepSettings: add DrawingsDirectory property for DXF endpoint config
- DrawingDxfExporter: support subprogram calls, skip non-cut linear moves,
  remove spurious radians-to-degrees conversion on arc angles, set AC1018 DXF version
- DrawingReader: handle .loop-info/.cadsnapshot entries gracefully, derive
  DrawingInfo from loop names when .info entry is absent, use FileShare.Read
- DrawingInfoReader: use TryParse instead of Parse for material number
- Drawing: make GetLoopName public (used by DrawingDxfExporter)
- ZipHelper: use FileShare.Read to allow concurrent readers on Windows/Linux
- NestFilterData: skip status filter when value is "All"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 07:23:12 -04:00
ajandClaude Sonnet 4.6 5e0856b5d1 fix: resolve API route name collisions
- DrawingsController: rename 'name/{name}' to 'by-name/{name}' to
  eliminate ambiguous match with '{name}/dxf' on path /drawings/name/dxf
- NestsController: add regex constraint to '{nestName}/download' and
  '{nestName}/plates' to prevent collision with year-parameterized routes
  when nestName is a 4-digit integer
- PepMcp/PepTools.cs: update GetDrawingByName URL to match renamed route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 07:20:53 -04:00
aj 9fcc9df209 feat: add Dockerfile for Linux container build 2026-06-25 22:17:43 -04:00
15 changed files with 270 additions and 95 deletions
+4
View File
@@ -0,0 +1,4 @@
**/.git
**/obj
**/bin
*.md
+28
View File
@@ -0,0 +1,28 @@
name: Build PepApi image
on:
push:
branches: [main]
paths:
- "PepApi.Core/**"
- "PepLib.Core/**"
- "Dockerfile"
- ".gitea/workflows/build-pepapi.yml"
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
- name: Build image
run: docker build -t git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:${{ gitea.sha }} .
- name: Push image
run: |
docker push git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:latest
docker push git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:${{ gitea.sha }}
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY PepApi.Core/PepApi.Core.csproj PepApi.Core/
COPY PepLib.Core/PepLib.Core.csproj PepLib.Core/
RUN dotnet restore PepApi.Core/PepApi.Core.csproj
COPY PepApi.Core/ PepApi.Core/
COPY PepLib.Core/ PepLib.Core/
RUN dotnet publish PepApi.Core/PepApi.Core.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8085
ENTRYPOINT ["dotnet", "PepApi.Core.dll"]
+1
View File
@@ -4,4 +4,5 @@ public class PepSettings
{
public string NestDirectory { get; set; } = string.Empty;
public string MaterialsFile { get; set; } = string.Empty;
public string DrawingsDirectory { get; set; } = string.Empty;
}
+11 -4
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PepApi.Core.Configuration;
using PepApi.Core.Models;
using PepLib.Data;
using PepLib.IO;
@@ -11,10 +13,12 @@ namespace PepApi.Core.Controllers;
public class DrawingsController : ControllerBase
{
private readonly PepDB _db;
private readonly PepSettings _settings;
public DrawingsController(PepDB db)
public DrawingsController(PepDB db, IOptions<PepSettings> settings)
{
_db = db;
_settings = settings.Value;
}
/// <summary>
@@ -81,7 +85,7 @@ public class DrawingsController : ControllerBase
/// <summary>
/// Get a drawing by exact name.
/// </summary>
[HttpGet("name/{name}")]
[HttpGet("by-name/{name}")]
public async Task<ActionResult<DrawingDetails>> GetDrawingByName(string name)
{
var drawing = await _db.Drawings
@@ -161,10 +165,13 @@ public class DrawingsController : ControllerBase
if (drawing == null)
return NotFound(new { message = "Drawing not found in database" });
var filePath = drawing.Path + drawing.File;
if (string.IsNullOrEmpty(_settings.DrawingsDirectory))
return StatusCode(503, new { message = "DrawingsDirectory is not configured" });
var filePath = Path.Combine(_settings.DrawingsDirectory, drawing.File);
if (!System.IO.File.Exists(filePath))
return NotFound(new { message = "Drawing file not found on disk" });
return NotFound(new { message = $"Drawing file not found: {drawing.File}" });
PepLib.Models.Drawing pep;
try
+34 -7
View File
@@ -101,7 +101,7 @@ public class NestsController : ControllerBase
return File(bytes, mimeType, fileName);
}
[HttpGet("{nestName}/download")]
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/download")]
public async Task<IActionResult> DownloadFile(string nestName)
{
var filePath = await GetNestPathAsync(nestName);
@@ -131,7 +131,7 @@ public class NestsController : ControllerBase
return Ok(combined);
}
[HttpGet("{nestName}/plates")]
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/plates")]
public async Task<ActionResult<List<Plate>>> GetPlates(string nestName)
{
var nestFile = await GetNestPathAsync(nestName);
@@ -346,13 +346,22 @@ public class NestsController : ControllerBase
var pepPath = Path.Combine(_nestDirectory, nestName + ".pep");
if (System.IO.File.Exists(pepPath))
return pepPath;
var pepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (pepMatch != null)
return pepMatch;
}
// Older nests use .zip in year subdirectory
var zipPath = Path.Combine(_nestDirectory, year.Value.ToString(), nestName + ".zip");
var yearDir = Path.Combine(_nestDirectory, year.Value.ToString());
var zipPath = Path.Combine(yearDir, nestName + ".zip");
if (System.IO.File.Exists(zipPath))
return zipPath;
var zipMatch = FindFileByPrefix(yearDir, nestName, ".zip");
if (zipMatch != null)
return zipMatch;
return null;
}
@@ -361,33 +370,51 @@ public class NestsController : ControllerBase
if (System.IO.File.Exists(flatPepPath))
return flatPepPath;
var flatPepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (flatPepMatch != null)
return flatPepMatch;
// Fall back to year-based directory lookup for older nests
var upperName = nestName.ToUpper();
var nestHeader = await _db.NestHeaders
.Where(n => n.NestName.ToUpper() == nestName.ToUpper() && n.DateProgrammed != null)
.Where(n => (n.NestName.ToUpper() == upperName || n.NestName.ToUpper().StartsWith(upperName + "-"))
&& n.DateProgrammed != null)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (nestHeader == null)
return null;
var fullName = nestHeader.NestName;
var dbYear = nestHeader.DateProgrammed!.Value.Year;
// Older nests used .zip extension
var yearZipPath = Path.Combine(_nestDirectory, dbYear.ToString(), nestName + ".zip");
var yearZipPath = Path.Combine(_nestDirectory, dbYear.ToString(), fullName + ".zip");
if (System.IO.File.Exists(yearZipPath))
return yearZipPath;
return null;
}
private static string? FindFileByPrefix(string directory, string prefix, string extension)
{
if (!Directory.Exists(directory))
return null;
var pattern = prefix + "-*" + extension;
var matches = Directory.GetFiles(directory, pattern);
return matches.Length > 0 ? matches.Order().First() : null;
}
private async Task<NestDetails> GetNestDetailsAsync(string nestFilePath)
{
var nest = Nest.Load(nestFilePath);
var dir = Path.GetDirectoryName(nestFilePath) + "\\";
var name = Path.GetFileNameWithoutExtension(nestFilePath).ToUpper();
var info = await _db.NestHeaders
.FirstOrDefaultAsync(n => n.NestName.ToUpper() == name && dir == n.Path);
.Where(n => n.NestName.ToUpper() == name)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (info == null)
throw new Exception("Nest header not found in database");
+1 -1
View File
@@ -77,7 +77,7 @@ namespace PepApi.Core.Models
nests = nests.Where(n => n.DateCreated <= this.EndDate);
}
if (this.Status != null)
if (this.Status != null && !this.Status.Equals("All", StringComparison.OrdinalIgnoreCase))
{
var x = this.Status.ToUpper();
nests = nests.Where(n => n.Status.ToUpper() == x);
+33
View File
@@ -0,0 +1,33 @@
using PepLib.IO;
using Xunit;
namespace PepLib.Core.Tests.IO;
public class NestReaderTests
{
[Fact]
public void Read_ReadOnlyFile_DoesNotRequestWriteAccess()
{
// Arrange: a file on a read-only mount denies write-access opens (EROFS on Linux,
// UnauthorizedAccessException on Windows when the read-only attribute is set).
// NestReader only ever reads nest files, so it must never request FileAccess.Write.
var path = Path.GetTempFileName();
try
{
File.SetAttributes(path, FileAttributes.ReadOnly);
var reader = new NestReader();
// Act / Assert: opening must succeed (i.e. not throw because of the read-only
// attribute). Parsing the empty/invalid content is expected to fail separately.
var ex = Record.Exception(() => reader.Read(path));
Assert.IsNotType<UnauthorizedAccessException>(ex);
}
finally
{
File.SetAttributes(path, FileAttributes.Normal);
File.Delete(path);
}
}
}
+57 -16
View File
@@ -1,4 +1,4 @@
using ACadSharp;
using ACadSharp;
using ACadSharp.IO;
using ACadSharp.Tables;
using CSMath;
@@ -17,13 +17,43 @@ public static class DrawingDxfExporter
public static Stream Export(Drawing drawing)
{
var doc = new CadDocument();
doc.Header.Version = ACadVersion.AC1018;
// Collect loop IDs that are called as subprograms so we skip drawing them as top-level
var subprogramIds = new HashSet<int>();
foreach (var loop in drawing.Loops)
{
foreach (var code in loop)
{
if (code.CodeType() == CodeType.SubProgramCall)
subprogramIds.Add(((SubProgramCall)code).LoopId);
}
}
foreach (var loop in drawing.Loops)
{
var loopNumber = GetLoopNumber(loop.Name);
if (subprogramIds.Contains(loopNumber))
continue; // drawn inline at SubProgramCall positions
var layer = new Layer(loop.Name);
doc.Layers.Add(layer);
var pos = new Vector();
DrawLoop(doc, drawing, loop, layer, new Vector());
}
var buffer = new MemoryStream();
using (var writer = new DxfWriter(buffer, doc, false))
{
writer.Write();
}
return new MemoryStream(buffer.ToArray());
}
private static Vector DrawLoop(CadDocument doc, Drawing drawing, Loop loop, Layer layer, Vector startPos)
{
var pos = startPos;
foreach (var code in loop)
{
@@ -35,6 +65,8 @@ public static class DrawingDxfExporter
case CodeType.LinearMove:
var lm = (LinearMove)code;
if (lm.Type == EntityType.Cut)
{
var lineEnd = Advance(pos, lm.EndPoint, loop.Mode);
doc.Entities.Add(new AcadLine
{
@@ -43,6 +75,11 @@ public static class DrawingDxfExporter
Layer = layer
});
pos = lineEnd;
}
else
{
pos = Advance(pos, lm.EndPoint, loop.Mode);
}
break;
case CodeType.CircularMove:
@@ -58,11 +95,9 @@ public static class DrawingDxfExporter
var endAngle = Math.Atan2(arcEnd.Y - arcCenter.Y, arcEnd.X - arcCenter.X);
if (cm.Rotation == RotationType.CW)
{
(startAngle, endAngle) = (endAngle, startAngle);
}
if (Math.Abs(startAngle - endAngle) < 1e-10) // full circle
if (Math.Abs(startAngle - endAngle) < 1e-10)
{
doc.Entities.Add(new AcadCircle
{
@@ -77,26 +112,33 @@ public static class DrawingDxfExporter
{
Center = ToXYZ(arcCenter),
Radius = radius,
StartAngle = startAngle * (180.0 / Math.PI),
EndAngle = endAngle * (180.0 / Math.PI),
StartAngle = startAngle,
EndAngle = endAngle,
Layer = layer
});
}
pos = arcEnd;
break;
}
case CodeType.SubProgramCall:
var call = (SubProgramCall)code;
var subLoop = drawing.Loops.FirstOrDefault(l => l.Name == drawing.GetLoopName(call.LoopId));
if (subLoop != null)
DrawLoop(doc, drawing, subLoop, layer, pos);
// pos unchanged — subprograms are closed shapes that return to start
break;
}
}
var buffer = new MemoryStream();
using (var writer = new DxfWriter(buffer, doc, false))
return pos;
}
private static int GetLoopNumber(string loopName)
{
writer.Write();
}
var stream = new MemoryStream(buffer.ToArray());
return stream;
var idx = loopName.LastIndexOf(".loop-", StringComparison.OrdinalIgnoreCase);
if (idx < 0) return -1;
return int.TryParse(loopName.Substring(idx + 6), out var n) ? n : -1;
}
private static Vector Advance(Vector current, Vector offset, ProgrammingMode mode) =>
@@ -104,4 +146,3 @@ public static class DrawingDxfExporter
private static XYZ ToXYZ(Vector v) => new XYZ(v.X, v.Y, 0);
}
+2 -1
View File
@@ -34,7 +34,8 @@ namespace PepLib.IO
stream.Seek(0x9, SeekOrigin.Current);
Info.MaterialNumber = int.Parse(ReadString(0x40, ref stream));
int.TryParse(ReadString(0x40, ref stream), out var materialNumber);
Info.MaterialNumber = materialNumber;
Info.MaterialGrade = ReadString(0x10, ref stream);
Info.ProgrammedBy = ReadString(0x40, ref stream);
Info.CreatedBy = ReadString(0x40, ref stream);
+21 -7
View File
@@ -1,5 +1,4 @@
using PepLib.Models;
using System.Diagnostics;
using System.IO.Compression;
using System.Text.RegularExpressions;
@@ -38,6 +37,11 @@ namespace PepLib.IO
LoadInfo(memstream);
memstream.Close();
continue;
case ".loop-info":
case ".cadsnapshot":
memstream.Close();
continue;
}
if (Regex.IsMatch(extension, "loop-\\d\\d\\d"))
@@ -47,6 +51,9 @@ namespace PepLib.IO
}
}
if (Drawing.Info == null)
Drawing.Info = DeriveInfoFromLoops();
Drawing.ResolveLoops();
}
@@ -62,7 +69,7 @@ namespace PepLib.IO
try
{
stream = new FileStream(nestFile, FileMode.Open);
stream = new FileStream(nestFile, FileMode.Open, FileAccess.Read, FileShare.Read);
Read(stream);
}
finally
@@ -73,16 +80,23 @@ namespace PepLib.IO
}
private void LoadInfo(Stream stream)
{
try
{
Drawing.Info = DrawingInfo.Load(stream);
}
catch (Exception exception)
private DrawingInfo DeriveInfoFromLoops()
{
Debug.WriteLine(exception.Message);
Debug.WriteLine(exception.StackTrace);
var info = new DrawingInfo();
if (Drawing.Loops.Count > 0)
{
// Loop names follow the pattern "{DrawingName}.loop-XXX"
var loopName = Drawing.Loops[0].Name;
var suffix = loopName.LastIndexOf(".loop-", StringComparison.OrdinalIgnoreCase);
info.Name = suffix >= 0 ? loopName.Substring(0, suffix) : loopName;
}
return info;
}
private Loop ReadLoop(string name, Stream stream)
+1 -1
View File
@@ -86,7 +86,7 @@ namespace PepLib.IO
try
{
stream = new FileStream(nestFile, FileMode.Open);
stream = new FileStream(nestFile, FileMode.Open, FileAccess.Read, FileShare.Read);
Read(stream);
}
finally
+1 -1
View File
@@ -76,7 +76,7 @@ namespace PepLib.Models
return null;
}
private string GetLoopName(int loopId)
public string GetLoopName(int loopId)
{
return string.Format("{0}.loop-{1}", Info.Name, loopId.ToString().PadLeft(3, '0'));
}
+2 -2
View File
@@ -18,7 +18,7 @@ namespace PepLib.Utilities
var nameList = new List<string>();
var streamList = new List<Stream>();
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read))
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Read))
{
foreach (var entry in zip.Entries)
@@ -52,7 +52,7 @@ namespace PepLib.Utilities
/// <returns></returns>
public static bool ExtractByExtension(string file, string extension, out string name, out Stream stream)
{
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read))
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Read))
{
foreach (var entry in zip.Entries)
+1 -1
View File
@@ -533,7 +533,7 @@ public class PepTools
{
try
{
var response = await _httpClient.GetAsync($"/drawings/name/{Uri.EscapeDataString(name)}");
var response = await _httpClient.GetAsync($"/drawings/by-name/{Uri.EscapeDataString(name)}");
if (!response.IsSuccessStatusCode)
return $"Error: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}";