Hi

file chunk process to this and how to send C# to api with node.js


Claude finished the response


const uploadMultipartToS3new = async (req, res) => { try { var datetime = new Date(); logToFile("uploadMultipartToS3newww body"+JSON.stringify(req.body)); logToFile(`uploadMultipartToS3new IN ${datetime.toISOString()}`); if (!req.files || req.files.length === 0) { retur


pasted


how to apply the file chunk process to this and how to send C# to api


May 11

I'll analyze your current code and show you how to add chunked file upload support — both the Node.js API changes and the C# client code.


Let me first understand what your current code does, then design the chunked approach.


Here's how chunked upload works conceptually, then the full implementation:


Current flow: Client sends all files at once → server processes them together.


Chunked flow: Client splits each file into chunks → uploads chunks one by one → server reassembles → processes the full file.


This diagram shows the full flow:


V


Connecting to visualize...

Now here's the full code — Node.js server side first, then C# client.


Node.js — two new routes + assembly logic

Add these alongside your existing uploadMultipartToS3new:


javascript

const os = require('os');

const path = require('path');

const fss = require('fs').promises;

const fs = require('fs');


// ─── POST /upload-chunk ───────────────────────────────────────────────────────

// Body fields (multipart): uploadId, chunkIndex, totalChunks, originalname, mimetype

// File field: "chunk"

const uploadChunk = async (req, res) => {

  try {

    const { uploadId, chunkIndex, totalChunks } = req.body;


    if (!uploadId || chunkIndex === undefined || !totalChunks) {

      return res.status(400).json({ ok: false, error: "Missing chunk metadata" });

    }


    if (!req.files || req.files.length === 0) {

      return res.status(400).json({ ok: false, error: "No chunk data received" });

    }


    const chunkBuffer = req.files[0].buffer;


    // Save chunk to a temp folder: /tmp/{uploadId}/{chunkIndex}.part

    const tmpDir = path.join(os.tmpdir(), 'uploads', uploadId);

    if (!fs.existsSync(tmpDir)) {

      fs.mkdirSync(tmpDir, { recursive: true });

    }


    const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(5, '0')}.part`);

    await fss.writeFile(chunkPath, chunkBuffer);


    logToFile(`✔ Saved chunk ${chunkIndex}/${totalChunks} for ${uploadId}`);


    res.json({ ok: true, received: parseInt(chunkIndex) + 1, total: parseInt(totalChunks) });


  } catch (err) {

    logToFile(`❌ uploadChunk error: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};


// ─── POST /finalize-upload ────────────────────────────────────────────────────

// Body (JSON): { uploadId, originalname, mimetype, uploadUrl, serverpath, filetype, thumbUrl }

const finalizeUpload = async (req, res) => {

  const { uploadId, originalname, mimetype, uploadUrl, serverpath, filetype, thumbUrl } = req.body;


  try {

    const tmpDir = path.join(os.tmpdir(), 'uploads', uploadId);


    if (!fs.existsSync(tmpDir)) {

      return res.status(400).json({ ok: false, error: `No chunks found for uploadId: ${uploadId}` });

    }


    // Read and sort all .part files

    const parts = (await fss.readdir(tmpDir))

      .filter(f => f.endsWith('.part'))

      .sort(); // padded names sort correctly: 00000.part, 00001.part …


    if (parts.length === 0) {

      return res.status(400).json({ ok: false, error: "Chunk folder is empty" });

    }


    logToFile(`✔ Assembling ${parts.length} chunks for ${uploadId} (${originalname})`);


    // Concatenate all chunk buffers into one

    const buffers = await Promise.all(

      parts.map(p => fss.readFile(path.join(tmpDir, p)))

    );

    const fullBuffer = Buffer.concat(buffers);


    // Build a fake "file" object matching what processSingleImage expects

    const fakeFile = {

      buffer: fullBuffer,

      originalname: originalname,

      mimetype: mimetype,

    };


    const body = { uploadUrl, serverpath, filetype, thumbUrl };


    // Reuse your existing image processing function unchanged

    const result = await processSingleImage(fakeFile, body);


    // Clean up temp folder

    await fss.rm(tmpDir, { recursive: true, force: true });


    logToFile(`✔ Finalized ${originalname}: ${result.status}`);

    res.json({ ok: true, result });


  } catch (err) {

    logToFile(`❌ finalizeUpload error: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};

Register the routes (wherever you have your Express router):


javascript

router.post('/upload-chunk',    upload.array('chunk', 1), uploadChunk);

router.post('/finalize-upload', express.json(),           finalizeUpload);

C# Client — chunked upload

csharp

using System;

using System.IO;

using System.Net.Http;

using System.Net.Http.Headers;

using System.Text;

using System.Text.Json;

using System.Threading.Tasks;

using System.Collections.Generic;


public class ChunkedUploader

{

    private readonly HttpClient _http;

    private readonly string _baseUrl;

    private const int ChunkSize = 2 * 1024 * 1024; // 2 MB per chunk


    public ChunkedUploader(string baseUrl)

    {

        _http = new HttpClient();

        _baseUrl = baseUrl.TrimEnd('/');

    }


    // ── Upload one file in chunks ─────────────────────────────────────────────

    public async Task<UploadResult> UploadFileAsync(

        string filePath,

        string serverPath,

        string uploadUrl,

        string thumbUrl,

        string fileType = "image")

    {

        var fileBytes  = await File.ReadAllBytesAsync(filePath);

        var fileName   = Path.GetFileName(filePath);

        var mimeType   = GetMimeType(fileName);

        var uploadId   = Guid.NewGuid().ToString("N"); // e.g. "a3f9…"

        var totalBytes = fileBytes.Length;

        var totalChunks = (int)Math.Ceiling((double)totalBytes / ChunkSize);


        Console.WriteLine($"Uploading {fileName} ({totalBytes:N0} bytes) in {totalChunks} chunks");


        // ── 1. Send each chunk ────────────────────────────────────────────────

        for (int i = 0; i < totalChunks; i++)

        {

            var offset    = i * ChunkSize;

            var length    = Math.Min(ChunkSize, totalBytes - offset);

            var chunkData = new byte[length];

            Buffer.BlockCopy(fileBytes, offset, chunkData, 0, length);


            var success = await SendChunkWithRetryAsync(

                uploadId, i, totalChunks, fileName, mimeType, chunkData

            );


            if (!success)

                return new UploadResult { Ok = false, Error = $"Chunk {i} failed after retries" };


            var pct = (int)((i + 1) / (double)totalChunks * 100);

            Console.WriteLine($"  [{pct,3}%] chunk {i + 1}/{totalChunks}");

        }


        // ── 2. Finalize ───────────────────────────────────────────────────────

        return await FinalizeAsync(uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType);

    }


    // ── Upload multiple files concurrently (max 3 at a time) ─────────────────

    public async Task<List<UploadResult>> UploadFilesAsync(

        IEnumerable<string> filePaths,

        string serverPath, string uploadUrl, string thumbUrl, string fileType = "image",

        int concurrency = 3)

    {

        var semaphore = new System.Threading.SemaphoreSlim(concurrency);

        var tasks = new List<Task<UploadResult>>();


        foreach (var filePath in filePaths)

        {

            await semaphore.WaitAsync();

            tasks.Add(Task.Run(async () =>

            {

                try { return await UploadFileAsync(filePath, serverPath, uploadUrl, thumbUrl, fileType); }

                finally { semaphore.Release(); }

            }));

        }


        return new List<UploadResult>(await Task.WhenAll(tasks));

    }


    // ── Send a single chunk (with up to 3 retries) ────────────────────────────

    private async Task<bool> SendChunkWithRetryAsync(

        string uploadId, int index, int total,

        string fileName, string mimeType, byte[] data,

        int maxRetries = 3)

    {

        for (int attempt = 0; attempt < maxRetries; attempt++)

        {

            try

            {

                using var form = new MultipartFormDataContent();


                // Metadata fields

                form.Add(new StringContent(uploadId),            "uploadId");

                form.Add(new StringContent(index.ToString()),    "chunkIndex");

                form.Add(new StringContent(total.ToString()),    "totalChunks");

                form.Add(new StringContent(fileName),            "originalname");

                form.Add(new StringContent(mimeType),            "mimetype");


                // Chunk bytes

                var byteContent = new ByteArrayContent(data);

                byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

                form.Add(byteContent, "chunk", fileName);


                var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);


                if (response.IsSuccessStatusCode)

                    return true;


                Console.WriteLine($"  Chunk {index} attempt {attempt + 1} failed: {response.StatusCode}");

            }

            catch (Exception ex)

            {

                Console.WriteLine($"  Chunk {index} attempt {attempt + 1} exception: {ex.Message}");

            }


            await Task.Delay(500 * (attempt + 1)); // back-off: 0.5s, 1s, 1.5s

        }


        return false;

    }


    // ── POST /finalize-upload ─────────────────────────────────────────────────

    private async Task<UploadResult> FinalizeAsync(

        string uploadId, string fileName, string mimeType,

        string serverPath, string uploadUrl, string thumbUrl, string fileType)

    {

        var payload = new

        {

            uploadId,

            originalname = fileName,

            mimetype     = mimeType,

            serverpath   = serverPath,

            uploadUrl,

            thumbUrl,

            filetype     = fileType

        };


        var json    = JsonSerializer.Serialize(payload);

        var content = new StringContent(json, Encoding.UTF8, "application/json");


        try

        {

            var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);

            var body     = await response.Content.ReadAsStringAsync();


            if (response.IsSuccessStatusCode)

            {

                var result = JsonSerializer.Deserialize<UploadResult>(body,

                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

                return result ?? new UploadResult { Ok = true };

            }


            return new UploadResult { Ok = false, Error = $"Finalize HTTP {response.StatusCode}: {body}" };

        }

        catch (Exception ex)

        {

            return new UploadResult { Ok = false, Error = ex.Message };

        }

    }


    // ── Helpers ───────────────────────────────────────────────────────────────

    private static string GetMimeType(string fileName) =>

        Path.GetExtension(fileName).ToLowerInvariant() switch

        {

            ".jpg" or ".jpeg" => "image/jpeg",

            ".png"            => "image/png",

            ".avif"           => "image/avif",

            ".gif"            => "image/gif",

            ".webp"           => "image/webp",

            ".pdf"            => "application/pdf",

            _                 => "application/octet-stream"

        };

}


// ── Result model ──────────────────────────────────────────────────────────────

public class UploadResult

{

    public bool   Ok       { get; set; }

    public string Error    { get; set; }

    public object Result   { get; set; }

}

Usage example:


csharp

var uploader = new ChunkedUploader("https://your-server.com");


// Single file

var result = await uploader.UploadFileAsync(

    filePath:   @"C:\photos\image.jpg",

    serverPath: @"\\NAS\share",

    uploadUrl:  "images/2024",

    thumbUrl:   "images/2024/thumbs"

);


Console.WriteLine(result.Ok ? "Success" : result.Error);


// Multiple files

var results = await uploader.UploadFilesAsync(

    filePaths:  new[] { "a.jpg", "b.jpg", "c.jpg" },

    serverPath: @"\\NAS\share",

    uploadUrl:  "images/2024",

    thumbUrl:   "images/2024/thumbs",

    concurrency: 3   // matches your runWithConcurrency limit

);

Key design decisions

processSingleImage is untouched — the finalize route assembles the buffer and builds a fake file object that matches exactly what your function already expects. No refactoring needed.


Chunk naming uses zero-padded numbers (00000.part, 00001.part) so a plain alphabetical sort always gives correct order — no need to parse the index back out.


Retry logic lives in C#, not the server. If a chunk fails the server never sees a partial state — it only gets a finalize call once every chunk is confirmed received.


Temp cleanup happens inside finalizeUpload after the image is processed. If you want a safety net for abandoned uploads (client crashes mid-upload), add a cron job that deletes tmp/uploads/* folders older than 1 hour.


previous one convert avif process  these sharp sharp.concurrency(4);

    sharp.cache(false);

 sharp(resizedBuffer)

      .resize(200,200)

      .avif({ quality: 40, effort: 2 })

      .toBuffer();


May 11

You want to convert those sharp calls to work with the chunked/finalized buffer. Here's the updated processSingleImage with the sharp settings applied consistently:


javascript

async function processSingleImage(req, body) {

  const datetime = new Date();

  const buffer = req.buffer;

  logToFile(`✔ buffsize ${buffer.length}`);


  const { uploadUrl, serverpath, filetype, thumbUrl } = body;

  const fileName = req.originalname

    .replace(/\.(jpg|jpeg|png|webp)$/i, '.avif');


  try {

    const SHARE_ROOT = `\\${serverpath}\\${uploadUrl}`;


    if (!fs.existsSync(SHARE_ROOT)) {

      logToFile(`SHARE_ROOT not found, creating...`);

      fs.mkdirSync(SHARE_ROOT, { recursive: true });

    }


    logToFile(`mimetype: ${req.mimetype}`);


    if (req.mimetype.includes("image")) {


      // ── Global sharp settings (set once before any pipeline) ──────────────

      sharp.concurrency(4);

      sharp.cache(false);


      // ── Read metadata ──────────────────────────────────────────────────────

      const meta = await sharp(buffer, { limitInputPixels: false }).metadata();

      const imgl = resizeByMax(

        Math.trunc(meta.width),

        Math.trunc(meta.height),

        1500

      );


      // ── Main image → avif ──────────────────────────────────────────────────

      let pipeline = sharp(buffer, { limitInputPixels: false });


      if (meta.hasAlpha) {

        pipeline = pipeline.flatten({ background: "#fff" });

      }


      const resizedBuffer = await pipeline

        .toColourspace("srgb")

        .resize(imgl.width, imgl.height, {

          fit: "inside",

          withoutEnlargement: true

        })

        .avif({ quality: 45, effort: 2 })

        .toBuffer();


      const finalPath = path.join(SHARE_ROOT, fileName);

      await fss.writeFile(finalPath, resizedBuffer);

      logToFile(`✔ Saved main image: ${finalPath}`);


      // ── Thumbnail → avif ───────────────────────────────────────────────────

      // Re-use resizedBuffer (already resized/colourspaced) — no need to

      // re-read the original large buffer

      const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })

        .resize(200, 200, {

          fit: "cover",        // fills the 200×200 box, crops edges

          position: "centre"

        })

        .avif({ quality: 40, effort: 2 })

        .toBuffer();


      const thumbDir = `\\${serverpath}\\${thumbUrl}`;

      logToFile(`thumbDir: ${thumbDir}`);


      if (!fs.existsSync(thumbDir)) {

        fs.mkdirSync(thumbDir, { recursive: true });

      }


      const thumbPath = path.join(thumbDir, fileName);

      logToFile(`thumbPath: ${thumbPath}`);

      await fss.writeFile(thumbPath, thumbBuffer);


      const datetimeOut = new Date();

      logToFile(`processSingleImage OUT ${datetimeOut.toISOString()}`);


      return { fileName, status: "success" };


    } else {

      // Non-image file — write raw buffer directly

      await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);

      return { fileName, status: "success" };

    }


  } catch (err) {

    logToFile(`❌ Image failed ${fileName}: ${err.stack || err.message}`);

    return { fileName, status: "failed", error: err.message };

  }

}

What changed and why

sharp.concurrency(4) and sharp.cache(false) placement — moved to the top of the image block, before any pipeline is created. These are global settings on the sharp module itself; calling them after you've already started a pipeline has no effect on that pipeline. Setting them once at the top of the function guarantees they're active for both the main resize and the thumbnail.


Thumbnail uses resizedBuffer not buffer — your original code already did this correctly. The thumbnail pipeline reads from the already-resized avif output, so sharp doesn't have to decode a large original JPEG twice. Kept and made explicit with a comment.


Added fit: "cover" + position: "centre" to the thumbnail resize — your original used bare .resize(200, 200) which defaults to fit: "cover" anyway, but making it explicit avoids sharp's behaviour changing between versions. If you'd prefer letterboxing instead of cropping, swap to fit: "contain" with .flatten({ background: "#fff" }) before .avif(...).


limitInputPixels: false on the thumbnail pipeline too — the resized buffer is an avif, and while it's small in pixel dimensions, sharp still validates the input. Keeping the flag consistent avoids surprises if a resized image is unusually wide.


Filename replace uses a regex — .replace(/\.(jpg|jpeg|png|webp)$/i, '.avif') instead of two chained .replace() calls, so it handles .jpeg and .webp inputs correctly too.


i need full process chunck with avif convertion


May 11

Let me give you the complete end-to-end code — Node.js server + C# client — with chunked upload and avif conversion fully integrated.



Check available skills

Check available skills

Node.js — uploadHandler.js (complete file):


javascript

const os   = require('os');

const path = require('path');

const fs   = require('fs');

const fss  = require('fs').promises;

const sharp = require('sharp');


// ─────────────────────────────────────────────────────────────────────────────

// Helpers

// ─────────────────────────────────────────────────────────────────────────────


function logToFile(msg) {

  console.log(`[${new Date().toISOString()}] ${msg}`);

}


function resizeByMax(w, h, max) {

  if (w <= max && h <= max) return { width: w, height: h };

  const ratio = Math.min(max / w, max / h);

  return { width: Math.round(w * ratio), height: Math.round(h * ratio) };

}


function getTmpDir(uploadId) {

  return path.join(os.tmpdir(), 'chunk_uploads', uploadId);

}


// ─────────────────────────────────────────────────────────────────────────────

// ROUTE 1 — POST /upload-chunk

// Multipart fields: uploadId, chunkIndex, totalChunks, originalname, mimetype

// File field:       "chunk"  (single chunk binary)

// ─────────────────────────────────────────────────────────────────────────────

const uploadChunk = async (req, res) => {

  try {

    const { uploadId, chunkIndex, totalChunks, originalname, mimetype } = req.body;


    if (!uploadId || chunkIndex === undefined || !totalChunks || !originalname) {

      return res.status(400).json({ ok: false, error: 'Missing chunk metadata' });

    }

    if (!req.files || req.files.length === 0) {

      return res.status(400).json({ ok: false, error: 'No chunk data received' });

    }


    const chunkBuffer = req.files[0].buffer;


    // ── Save chunk as zero-padded file so readdir sort = correct order ────────

    const tmpDir    = getTmpDir(uploadId);

    const metaPath  = path.join(tmpDir, '_meta.json');

    const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(6, '0')}.part`);


    if (!fs.existsSync(tmpDir)) {

      fs.mkdirSync(tmpDir, { recursive: true });

    }


    // Persist metadata on first chunk so finalize knows filename / mimetype

    if (!fs.existsSync(metaPath)) {

      await fss.writeFile(metaPath, JSON.stringify({ originalname, mimetype, totalChunks }));

    }


    await fss.writeFile(chunkPath, chunkBuffer);


    logToFile(`✔ chunk ${chunkIndex}/${parseInt(totalChunks) - 1} saved for ${uploadId}`);


    res.json({

      ok      : true,

      received: parseInt(chunkIndex) + 1,

      total   : parseInt(totalChunks),

    });


  } catch (err) {

    logToFile(`❌ uploadChunk: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};


// ─────────────────────────────────────────────────────────────────────────────

// ROUTE 2 — POST /finalize-upload

// JSON body: { uploadId, uploadUrl, serverpath, filetype, thumbUrl }

// ─────────────────────────────────────────────────────────────────────────────

const finalizeUpload = async (req, res) => {

  const { uploadId, uploadUrl, serverpath, filetype, thumbUrl } = req.body;


  if (!uploadId) {

    return res.status(400).json({ ok: false, error: 'uploadId required' });

  }


  const tmpDir   = getTmpDir(uploadId);

  const metaPath = path.join(tmpDir, '_meta.json');


  try {

    // ── Read saved metadata ───────────────────────────────────────────────────

    if (!fs.existsSync(metaPath)) {

      return res.status(400).json({ ok: false, error: `No upload found for id: ${uploadId}` });

    }


    const meta = JSON.parse(await fss.readFile(metaPath, 'utf8'));

    const { originalname, mimetype, totalChunks } = meta;


    // ── Verify all chunks arrived ─────────────────────────────────────────────

    const parts = (await fss.readdir(tmpDir))

      .filter(f => f.endsWith('.part'))

      .sort();                              // 000000.part … 000099.part


    if (parts.length !== parseInt(totalChunks)) {

      return res.status(400).json({

        ok    : false,

        error : `Expected ${totalChunks} chunks, got ${parts.length}`,

      });

    }


    logToFile(`✔ Assembling ${parts.length} chunks → ${originalname}`);


    // ── Concatenate chunks into one buffer ────────────────────────────────────

    const buffers  = await Promise.all(parts.map(p => fss.readFile(path.join(tmpDir, p))));

    const fullBuffer = Buffer.concat(buffers);


    logToFile(`✔ Assembled buffer size: ${fullBuffer.length}`);


    // ── Hand off to image processor ───────────────────────────────────────────

    const fakeFile = { buffer: fullBuffer, originalname, mimetype };

    const body     = { uploadUrl, serverpath, filetype, thumbUrl };


    const result = await processSingleImage(fakeFile, body);


    // ── Clean up temp folder ──────────────────────────────────────────────────

    await fss.rm(tmpDir, { recursive: true, force: true });


    logToFile(`✔ Finalized ${originalname}: ${result.status}`);

    res.json({ ok: true, result });


  } catch (err) {

    logToFile(`❌ finalizeUpload: ${err.stack || err.message}`);

    // Still try to clean up

    await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});

    res.status(500).json({ ok: false, error: err.message });

  }

};


// ─────────────────────────────────────────────────────────────────────────────

// ROUTE 3 — POST /upload-multipart  (original batch route, unchanged)

// ─────────────────────────────────────────────────────────────────────────────

const uploadMultipartToS3new = async (req, res) => {

  try {

    const datetime = new Date();

    logToFile(`uploadMultipartToS3new IN ${datetime.toISOString()}`);

    logToFile(`uploadMultipartToS3new body ${JSON.stringify(req.body)}`);


    if (!req.files || req.files.length === 0) {

      return res.status(400).json({ ok: false, error: 'No files received' });

    }


    const tasks   = req.files.map(file => () => processSingleImage(file, req.body));

    const results = await runWithConcurrency(tasks, 3);


    const success = results.filter(r => r.status === 'success');

    const failed  = results.filter(r => r.status === 'failed');


    logToFile(`uploadMultipartToS3new OUT ${new Date().toISOString()}`);


    res.json({

      ok          : true,

      successCount: success.length,

      failedCount : failed.length,

      failedImages: failed,

    });


  } catch (err) {

    logToFile(`❌ upload fatal: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};


// ─────────────────────────────────────────────────────────────────────────────

// Core — process one image (shared by both routes)

// ─────────────────────────────────────────────────────────────────────────────

async function processSingleImage(req, body) {

  logToFile(`processSingleImage IN  ${new Date().toISOString()}`);


  const buffer   = req.buffer;

  const { uploadUrl, serverpath, filetype, thumbUrl } = body;


  // Always output .avif regardless of input extension

  const fileName = req.originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');


  try {

    const SHARE_ROOT = `\\${serverpath}\\${uploadUrl}`;


    if (!fs.existsSync(SHARE_ROOT)) {

      logToFile(`Creating SHARE_ROOT: ${SHARE_ROOT}`);

      fs.mkdirSync(SHARE_ROOT, { recursive: true });

    }


    logToFile(`mimetype: ${req.mimetype}`);


    // ── Image path ────────────────────────────────────────────────────────────

    if (req.mimetype.includes('image')) {


      // Global sharp settings — applied before any pipeline

      sharp.concurrency(4);

      sharp.cache(false);


      // ── Read metadata ───────────────────────────────────────────────────────

      const imgMeta = await sharp(buffer, { limitInputPixels: false }).metadata();

      logToFile(`✔ Original: ${imgMeta.width}×${imgMeta.height} hasAlpha:${imgMeta.hasAlpha}`);


      const imgl = resizeByMax(

        Math.trunc(imgMeta.width),

        Math.trunc(imgMeta.height),

        1500

      );


      // ── Build main pipeline ─────────────────────────────────────────────────

      let pipeline = sharp(buffer, { limitInputPixels: false });


      if (imgMeta.hasAlpha) {

        pipeline = pipeline.flatten({ background: '#fff' });

      }


      // ── Main image → avif (quality 45, effort 2) ────────────────────────────

      const resizedBuffer = await pipeline

        .toColourspace('srgb')

        .resize(imgl.width, imgl.height, {

          fit              : 'inside',

          withoutEnlargement: true,

        })

        .avif({ quality: 45, effort: 2 })

        .toBuffer();


      logToFile(`✔ buffsize resized: ${resizedBuffer.length}`);


      const finalPath = path.join(SHARE_ROOT, fileName);

      await fss.writeFile(finalPath, resizedBuffer);

      logToFile(`✔ Saved main: ${finalPath}`);


      // ── Thumbnail → avif (200×200, quality 40, effort 2) ───────────────────

      // Re-uses resizedBuffer — avoids decoding the original large file again

      const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })

        .resize(200, 200, {

          fit     : 'cover',

          position: 'centre',

        })

        .avif({ quality: 40, effort: 2 })

        .toBuffer();


      logToFile(`✔ buffsize thumb: ${thumbBuffer.length}`);


      const thumbDir = `\\${serverpath}\\${thumbUrl}`;

      if (!fs.existsSync(thumbDir)) {

        fs.mkdirSync(thumbDir, { recursive: true });

      }


      const thumbPath = path.join(thumbDir, fileName);

      await fss.writeFile(thumbPath, thumbBuffer);

      logToFile(`✔ Saved thumb: ${thumbPath}`);


      logToFile(`processSingleImage OUT ${new Date().toISOString()}`);

      return { fileName, status: 'success' };


    // ── Non-image path (pdf, etc.) ────────────────────────────────────────────

    } else {

      const rawFileName = req.originalname; // keep original extension

      await fss.writeFile(path.join(SHARE_ROOT, rawFileName), buffer);

      logToFile(`✔ Saved non-image: ${rawFileName}`);

      return { fileName: rawFileName, status: 'success' };

    }


  } catch (err) {

    logToFile(`❌ processSingleImage ${fileName}: ${err.stack || err.message}`);

    return { fileName, status: 'failed', error: err.message };

  }

}


// ─────────────────────────────────────────────────────────────────────────────

// Concurrency helper (unchanged from original)

// ─────────────────────────────────────────────────────────────────────────────

async function runWithConcurrency(tasks, limit = 20) {

  const results  = [];

  const executing = [];


  for (const task of tasks) {

    const p = task().then(res => {

      executing.splice(executing.indexOf(p), 1);

      return res;

    });

    results.push(p);

    executing.push(p);

    if (executing.length >= limit) {

      await Promise.race(executing);

    }

  }


  return Promise.all(results);

}


module.exports = { uploadChunk, finalizeUpload, uploadMultipartToS3new };

Node.js — routes.js (register all three routes):


javascript

const express  = require('express');

const multer   = require('multer');

const router   = express.Router();


const {

  uploadChunk,

  finalizeUpload,

  uploadMultipartToS3new,

} = require('./uploadHandler');


// multer — memory storage, no size limit (adjust limit to taste)

const upload = multer({

  storage: multer.memoryStorage(),

  limits : { fileSize: 50 * 1024 * 1024 }, // 50 MB per chunk max

});


// Chunked upload (new)

router.post('/upload-chunk',    upload.array('chunk', 1),          uploadChunk);

router.post('/finalize-upload', express.json(),                     finalizeUpload);


// Original batch route (unchanged)

router.post('/upload-multipart', upload.array('files', 50),        uploadMultipartToS3new);


module.exports = router;

C# — ChunkedUploader.cs (complete, with avif-aware flow):


csharp

using System;

using System.Collections.Generic;

using System.IO;

using System.Net.Http;

using System.Net.Http.Headers;

using System.Text;

using System.Text.Json;

using System.Threading;

using System.Threading.Tasks;


public class ChunkedUploader : IDisposable

{

    // ── Config ────────────────────────────────────────────────────────────────

    private readonly HttpClient _http;

    private readonly string     _baseUrl;


    private const int ChunkSize   = 2 * 1024 * 1024;  // 2 MB per chunk

    private const int MaxRetries  = 3;

    private const int Concurrency = 3;                  // parallel files


    public ChunkedUploader(string baseUrl)

    {

        _http    = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };

        _baseUrl = baseUrl.TrimEnd('/');

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Public API

    // ─────────────────────────────────────────────────────────────────────────


    /// <summary>Upload one file in chunks then trigger avif conversion.</summary>

    public async Task<UploadResult> UploadFileAsync(

        string filePath,

        string serverPath,

        string uploadUrl,

        string thumbUrl,

        string fileType          = "image",

        IProgress<int> progress  = null)

    {

        if (!File.Exists(filePath))

            return Fail($"File not found: {filePath}");


        var fileBytes   = await File.ReadAllBytesAsync(filePath);

        var fileName    = Path.GetFileName(filePath);

        var mimeType    = GetMimeType(fileName);

        var uploadId    = Guid.NewGuid().ToString("N");

        var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);


        Console.WriteLine($"[{fileName}] {fileBytes.Length:N0} bytes → {totalChunks} chunks (id={uploadId})");


        // ── 1. Send chunks sequentially for this file ─────────────────────────

        for (int i = 0; i < totalChunks; i++)

        {

            var offset    = i * ChunkSize;

            var length    = Math.Min(ChunkSize, fileBytes.Length - offset);

            var chunk     = new byte[length];

            Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);


            var sent = await SendChunkWithRetryAsync(

                uploadId, i, totalChunks, fileName, mimeType, chunk

            );


            if (!sent)

                return Fail($"Chunk {i} failed after {MaxRetries} retries");


            var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);

            progress?.Report(pct);

            Console.WriteLine($"  [{fileName}] {pct,3}%  chunk {i + 1}/{totalChunks}");

        }


        // ── 2. Finalize → server assembles + runs avif conversion ─────────────

        Console.WriteLine($"  [{fileName}] All chunks sent — finalizing...");

        return await FinalizeAsync(uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType);

    }


    /// <summary>Upload multiple files with bounded concurrency.</summary>

    public async Task<List<UploadResult>> UploadFilesAsync(

        IEnumerable<string> filePaths,

        string serverPath,

        string uploadUrl,

        string thumbUrl,

        string fileType = "image")

    {

        var semaphore = new SemaphoreSlim(Concurrency);

        var tasks     = new List<Task<UploadResult>>();


        foreach (var fp in filePaths)

        {

            await semaphore.WaitAsync();

            var captured = fp;

            tasks.Add(Task.Run(async () =>

            {

                try   { return await UploadFileAsync(captured, serverPath, uploadUrl, thumbUrl, fileType); }

                finally { semaphore.Release(); }

            }));

        }


        return new List<UploadResult>(await Task.WhenAll(tasks));

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Private — send one chunk (with retry + back-off)

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<bool> SendChunkWithRetryAsync(

        string uploadId, int index, int total,

        string fileName, string mimeType, byte[] data)

    {

        for (int attempt = 0; attempt < MaxRetries; attempt++)

        {

            try

            {

                using var form = new MultipartFormDataContent();


                // ── Metadata fields ───────────────────────────────────────────

                form.Add(new StringContent(uploadId),           "uploadId");

                form.Add(new StringContent(index.ToString()),   "chunkIndex");

                form.Add(new StringContent(total.ToString()),   "totalChunks");

                form.Add(new StringContent(fileName),           "originalname");

                form.Add(new StringContent(mimeType),           "mimetype");


                // ── Chunk binary ──────────────────────────────────────────────

                var byteContent = new ByteArrayContent(data);

                byteContent.Headers.ContentType =

                    new MediaTypeHeaderValue("application/octet-stream");

                form.Add(byteContent, "chunk", fileName);


                var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);


                if (response.IsSuccessStatusCode) return true;


                var body = await response.Content.ReadAsStringAsync();

                Console.WriteLine($"  Chunk {index} attempt {attempt + 1} HTTP {(int)response.StatusCode}: {body}");

            }

            catch (Exception ex)

            {

                Console.WriteLine($"  Chunk {index} attempt {attempt + 1} exception: {ex.Message}");

            }


            // Exponential back-off: 500ms, 1000ms, 1500ms

            await Task.Delay(500 * (attempt + 1));

        }


        return false;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Private — finalize (triggers assembly + avif conversion on server)

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<UploadResult> FinalizeAsync(

        string uploadId, string fileName, string mimeType,

        string serverPath, string uploadUrl, string thumbUrl, string fileType)

    {

        var payload = new

        {

            uploadId,

            originalname = fileName,

            mimetype     = mimeType,

            serverpath   = serverPath,

            uploadUrl,

            thumbUrl,

            filetype     = fileType,

        };


        var json    = JsonSerializer.Serialize(payload);

        var content = new StringContent(json, Encoding.UTF8, "application/json");


        try

        {

            var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);

            var body     = await response.Content.ReadAsStringAsync();


            if (response.IsSuccessStatusCode)

            {

                var result = JsonSerializer.Deserialize<UploadResult>(

                    body,

                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }

                );

                Console.WriteLine($"  Finalize OK → {result?.Result}");

                return result ?? new UploadResult { Ok = true };

            }


            return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");

        }

        catch (Exception ex)

        {

            return Fail(ex.Message);

        }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Helpers

    // ─────────────────────────────────────────────────────────────────────────

    private static UploadResult Fail(string error)

    {

        Console.WriteLine($"  ❌ {error}");

        return new UploadResult { Ok = false, Error = error };

    }


    private static string GetMimeType(string fileName) =>

        Path.GetExtension(fileName).ToLowerInvariant() switch

        {

            ".jpg"  or ".jpeg" => "image/jpeg",

            ".png"             => "image/png",

            ".avif"            => "image/avif",

            ".webp"            => "image/webp",

            ".gif"             => "image/gif",

            ".pdf"             => "application/pdf",

            _                  => "application/octet-stream",

        };


    public void Dispose() => _http.Dispose();

}


// ─────────────────────────────────────────────────────────────────────────────

// Models

// ─────────────────────────────────────────────────────────────────────────────

public class UploadResult

{

    public bool   Ok     { get; set; }

    public string Error  { get; set; }

    public object Result { get; set; }

}

C# — Program.cs (usage example):


csharp

using System;

using System.Collections.Generic;

using System.Threading.Tasks;


class Program

{

    static async Task Main(string[] args)

    {

        using var uploader = new ChunkedUploader("https://your-server.com");


        // ── Single file with progress ─────────────────────────────────────────

        var progress = new Progress<int>(pct => Console.Write($"\r  progress: {pct}%  "));


        var single = await uploader.UploadFileAsync(

            filePath  : @"C:\photos\DSC_0001.jpg",

            serverPath: @"\\NAS\share",

            uploadUrl : "images/2024",

            thumbUrl  : "images/2024/thumbs",

            progress  : progress

        );

        Console.WriteLine();

        Console.WriteLine(single.Ok ? "✔ uploaded" : $"❌ {single.Error}");


        // ── Batch upload ──────────────────────────────────────────────────────

        var files = new List<string>

        {

            @"C:\photos\DSC_0001.jpg",

            @"C:\photos\DSC_0002.png",

            @"C:\photos\DSC_0003.jpg",

        };


        var results = await uploader.UploadFilesAsync(

            filePaths : files,

            serverPath: @"\\NAS\share",

            uploadUrl : "images/2024",

            thumbUrl  : "images/2024/thumbs"

        );


        foreach (var r in results)

            Console.WriteLine(r.Ok ? $"✔ ok" : $"❌ {r.Error}");

    }

}

How everything connects

Chunk phase — C# reads the file, slices it into 2 MB chunks, and POSTs each one to /upload-chunk with uploadId + chunkIndex + totalChunks. The server saves each chunk as 000000.part, 000001.part, … in a temp folder named after the uploadId. It also saves a _meta.json on the first chunk so the finalize route knows the original filename and mimetype without the client re-sending them.


Finalize phase — once all chunks are confirmed, C# POSTs once to /finalize-upload with just the uploadId and the destination path fields. The server reads the _meta.json, verifies the chunk count matches, concatenates all parts into one Buffer, then passes it straight into your existing processSingleImage unchanged — same sharp pipeline, same avif quality settings, same thumb generation.


avif conversion happens entirely on the server inside processSingleImage — sharp.concurrency(4) and sharp.cache(false) are set before the pipeline, the main image is resized to max 1500px and encoded at quality 45 effort 2, then the thumbnail is generated from the already-resized buffer at 200×200 quality 40 effort 2. The C# client only ever sends the original JPEG/PNG — it never touches avif.


const app = require('express')(); app.post('/finalize-upload', app.json(),finalizeUpload); app.json is not a function

May 12

The error is because app.json() doesn't exist — express.json() is a middleware on the express module itself, not on the app instance.


Fix:


javascript

// ❌ Wrong

const app = require('express')();

app.post('/finalize-upload', app.json(), finalizeUpload);


// ✅ Correct

const express = require('express');

const app = express();


app.post('/finalize-upload', express.json(), finalizeUpload);

If you're using a router instead of app directly:


javascript

const express = require('express');

const router  = express.Router();


router.post('/upload-chunk',    upload.array('chunk', 1), uploadChunk);

router.post('/finalize-upload', express.json(),           finalizeUpload);

The key point — require('express') returns the express factory function, and express.json() lives on that factory. When you call require('express')() you get the app instance which doesn't have .json() on it. Always keep a reference to express before calling it.


when uploading image totally 5 chunks


May 12

The issue is that when you have exactly 5 chunks, the chunk count verification fails because the _meta.json is written only on the first chunk — but if chunks arrive out of order (parallel uploads), totalChunks might be wrong. Also the part count check can mismatch.


Let me trace the full flow for 5 chunks:


The real bugs:


Bug 1 — chunkIndex is a string from req.body, so parts.length !== parseInt(totalChunks) can silently fail if there's a type mismatch.


Bug 2 — _meta.json written only on first chunk (chunkIndex == 0), but if chunk 0 arrives last (network reorder), finalize finds no meta.


Bug 3 — C# sends chunks sequentially but if any retry causes a duplicate .part file it still counts as one — that's fine. But the readdir count includes _meta.json if filter is wrong.


Fix all three — Node.js:


javascript

// ─── POST /upload-chunk ───────────────────────────────────────────────────

const uploadChunk = async (req, res) => {

  try {

    // ✅ Parse integers immediately — req.body fields are always strings

    const uploadId    = req.body.uploadId;

    const chunkIndex  = parseInt(req.body.chunkIndex,  10);

    const totalChunks = parseInt(req.body.totalChunks, 10);

    const originalname = req.body.originalname;

    const mimetype     = req.body.mimetype;


    if (!uploadId || isNaN(chunkIndex) || isNaN(totalChunks) || !originalname) {

      return res.status(400).json({ ok: false, error: 'Missing chunk metadata' });

    }

    if (!req.files || req.files.length === 0) {

      return res.status(400).json({ ok: false, error: 'No chunk data received' });

    }


    const chunkBuffer = req.files[0].buffer;

    const tmpDir      = getTmpDir(uploadId);

    const metaPath    = path.join(tmpDir, '_meta.json');


    if (!fs.existsSync(tmpDir)) {

      fs.mkdirSync(tmpDir, { recursive: true });

    }


    // ✅ Always write meta (overwrite is fine — values never change per upload)

    //    Fixes out-of-order arrival: chunk 3 might arrive before chunk 0

    await fss.writeFile(

      metaPath,

      JSON.stringify({ originalname, mimetype, totalChunks })

    );


    // ✅ Zero-padded so readdir alphabetical sort = correct chunk order

    const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(6, '0')}.part`);

    await fss.writeFile(chunkPath, chunkBuffer);


    logToFile(`✔ chunk ${chunkIndex + 1}/${totalChunks} saved  uploadId=${uploadId}`);


    res.json({

      ok      : true,

      received: chunkIndex + 1,

      total   : totalChunks,

    });


  } catch (err) {

    logToFile(`❌ uploadChunk: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};


// ─── POST /finalize-upload ────────────────────────────────────────────────

const finalizeUpload = async (req, res) => {

  const { uploadId, uploadUrl, serverpath, filetype, thumbUrl } = req.body;


  if (!uploadId) {

    return res.status(400).json({ ok: false, error: 'uploadId required' });

  }


  const tmpDir   = getTmpDir(uploadId);

  const metaPath = path.join(tmpDir, '_meta.json');


  try {

    if (!fs.existsSync(metaPath)) {

      return res.status(400).json({ ok: false, error: `No upload found for id: ${uploadId}` });

    }


    const meta = JSON.parse(await fss.readFile(metaPath, 'utf8'));

    const { originalname, mimetype, totalChunks } = meta;


    // ✅ Filter ONLY .part files — _meta.json must NOT be counted

    const parts = (await fss.readdir(tmpDir))

      .filter(f => f.endsWith('.part'))

      .sort();


    logToFile(`✔ found ${parts.length} parts, expected ${totalChunks}  uploadId=${uploadId}`);


    // ✅ parseInt so "5" !== 5 comparison never fails

    if (parts.length !== parseInt(totalChunks, 10)) {

      return res.status(400).json({

        ok    : false,

        error : `Chunk mismatch: expected ${totalChunks}, got ${parts.length}`,

        detail: parts   // sends back which parts arrived — useful for C# retry

      });

    }


    logToFile(`✔ Assembling ${parts.length} chunks → ${originalname}`);


    const buffers    = await Promise.all(parts.map(p => fss.readFile(path.join(tmpDir, p))));

    const fullBuffer = Buffer.concat(buffers);


    logToFile(`✔ Assembled size: ${fullBuffer.length} bytes`);


    const fakeFile = { buffer: fullBuffer, originalname, mimetype };

    const body     = { uploadUrl, serverpath, filetype, thumbUrl };


    const result = await processSingleImage(fakeFile, body);


    // Cleanup after successful processing

    await fss.rm(tmpDir, { recursive: true, force: true });


    logToFile(`✔ Done ${originalname}: ${result.status}`);

    res.json({ ok: true, result });


  } catch (err) {

    logToFile(`❌ finalizeUpload: ${err.stack || err.message}`);

    await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});

    res.status(500).json({ ok: false, error: err.message });

  }

};

C# — add chunk verification before finalize:


csharp

// After sending all chunks, verify count before finalizing

private async Task<bool> VerifyAllChunksSentAsync(

    string uploadId, int totalChunks, string fileName)

{

    // Re-send any chunk that got a non-200 response

    // (already handled by SendChunkWithRetryAsync returning false)

    // This is a final sanity log on the client side

    Console.WriteLine($"  [{fileName}] All {totalChunks} chunks confirmed → sending finalize");

    return true;

}


public async Task<UploadResult> UploadFileAsync(

    string filePath,

    string serverPath,

    string uploadUrl,

    string thumbUrl,

    string fileType         = "image",

    IProgress<int> progress = null)

{

    if (!File.Exists(filePath))

        return Fail($"File not found: {filePath}");


    var fileBytes   = await File.ReadAllBytesAsync(filePath);

    var fileName    = Path.GetFileName(filePath);

    var mimeType    = GetMimeType(fileName);

    var uploadId    = Guid.NewGuid().ToString("N");

    var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);


    Console.WriteLine($"[{fileName}] {fileBytes.Length:N0} bytes → {totalChunks} chunk(s)  id={uploadId}");


    // ── Send all chunks, track which ones failed ──────────────────────────────

    var failedChunks = new List<int>();


    for (int i = 0; i < totalChunks; i++)

    {

        var offset = i * ChunkSize;

        var length = Math.Min(ChunkSize, fileBytes.Length - offset);

        var chunk  = new byte[length];

        Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);


        var sent = await SendChunkWithRetryAsync(

            uploadId, i, totalChunks, fileName, mimeType, chunk

        );


        if (!sent)

            failedChunks.Add(i);


        var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);

        progress?.Report(pct);

        Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks}  {pct}%  {(sent ? "✔" : "❌")}");

    }


    // ── If any chunk failed, abort — do not finalize ──────────────────────────

    if (failedChunks.Count > 0)

    {

        return Fail($"Aborted: {failedChunks.Count} chunk(s) failed → [{string.Join(",", failedChunks)}]");

    }


    // ── All chunks confirmed → finalize (server assembles + avif converts) ────

    Console.WriteLine($"  [{fileName}] All {totalChunks} chunks OK → finalizing");

    return await FinalizeAsync(

        uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType

    );

}

Summary of all fixes

# Problem Fix

1 app.json() not a function Use express.json() from the module, not the app instance

2 _meta.json only written on chunk 0 Write meta on every chunk (safe overwrite, same data)

3 parts.length !== parseInt(totalChunks) string vs int parseInt(..., 10) both sides

4 _meta.json counted as a .part file .filter(f => f.endsWith('.part')) strictly

5 C# finalizes even if a chunk failed Track failedChunks[], abort if any failed before calling finalize

when uploading image totally 5 chunks 3 upload success and mobile network cut then how to continue remaining chunk and send


May 12

Good question — this is resumable upload. The client needs to ask the server "which chunks do you already have?" before resuming.


Node.js — add one new route /chunk-status

javascript

// ─── GET /chunk-status?uploadId=xxx ──────────────────────────────────────────

// Returns which chunk indexes the server already has

const chunkStatus = async (req, res) => {

  try {

    const { uploadId } = req.query;


    if (!uploadId) {

      return res.status(400).json({ ok: false, error: 'uploadId required' });

    }


    const tmpDir = getTmpDir(uploadId);


    // Upload never started or temp folder wiped

    if (!fs.existsSync(tmpDir)) {

      return res.json({ ok: true, uploadId, receivedChunks: [], canResume: false });

    }


    // Read which .part files exist

    const parts = (await fss.readdir(tmpDir))

      .filter(f => f.endsWith('.part'))

      .sort();


    // Convert filenames back to indexes: "000002.part" → 2

    const receivedIndexes = parts.map(f => parseInt(f.replace('.part', ''), 10));


    logToFile(`✔ chunkStatus ${uploadId}: ${receivedIndexes.length} chunks on disk [${receivedIndexes}]`);


    res.json({

      ok            : true,

      uploadId,

      receivedChunks: receivedIndexes,   // e.g. [0, 1, 2]

      canResume     : receivedIndexes.length > 0,

    });


  } catch (err) {

    logToFile(`❌ chunkStatus: ${err.stack || err.message}`);

    res.status(500).json({ ok: false, error: err.message });

  }

};

Register the route:


javascript

router.get('/chunk-status', chunkStatus);

C# — save uploadId locally, resume on reconnect

csharp

using System;

using System.Collections.Generic;

using System.IO;

using System.Linq;

using System.Net.Http;

using System.Net.Http.Headers;

using System.Text;

using System.Text.Json;

using System.Threading;

using System.Threading.Tasks;


public class ChunkedUploader : IDisposable

{

    private readonly HttpClient _http;

    private readonly string     _baseUrl;

    private readonly string     _resumeStorePath; // local file to persist uploadId


    private const int ChunkSize  = 2 * 1024 * 1024; // 2 MB

    private const int MaxRetries = 3;


    public ChunkedUploader(string baseUrl, string resumeStorePath = "resume_state.json")

    {

        _http             = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };

        _baseUrl          = baseUrl.TrimEnd('/');

        _resumeStorePath  = resumeStorePath;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Upload with full resume support

    // ─────────────────────────────────────────────────────────────────────────

    public async Task<UploadResult> UploadFileAsync(

        string filePath,

        string serverPath,

        string uploadUrl,

        string thumbUrl,

        string fileType         = "image",

        IProgress<int> progress = null)

    {

        if (!File.Exists(filePath))

            return Fail($"File not found: {filePath}");


        var fileBytes   = await File.ReadAllBytesAsync(filePath);

        var fileName    = Path.GetFileName(filePath);

        var mimeType    = GetMimeType(fileName);

        var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);


        // ── Check if a previous uploadId exists for this file ─────────────────

        var uploadId = await TryLoadSavedUploadIdAsync(filePath);

        var isResume = false;


        if (uploadId != null)

        {

            // Ask server which chunks it already has

            var status = await GetChunkStatusAsync(uploadId);


            if (status != null && status.CanResume)

            {

                isResume = true;

                Console.WriteLine($"[{fileName}] Resuming upload id={uploadId}  " +

                                  $"server has chunks: [{string.Join(",", status.ReceivedChunks)}]");

            }

            else

            {

                // Server has no record — start fresh

                Console.WriteLine($"[{fileName}] No resume data on server, starting fresh");

                uploadId = null;

            }

        }


        // New upload — generate a fresh uploadId and save it

        if (uploadId == null)

        {

            uploadId = Guid.NewGuid().ToString("N");

            await SaveUploadIdAsync(filePath, uploadId, totalChunks);

            Console.WriteLine($"[{fileName}] New upload id={uploadId}  chunks={totalChunks}");

        }


        // ── Get already-received chunk indexes from server ────────────────────

        var alreadyReceived = new HashSet<int>();

        if (isResume)

        {

            var status = await GetChunkStatusAsync(uploadId);

            if (status?.ReceivedChunks != null)

                foreach (var idx in status.ReceivedChunks)

                    alreadyReceived.Add(idx);

        }


        // ── Send only missing chunks ──────────────────────────────────────────

        var failedChunks = new List<int>();


        for (int i = 0; i < totalChunks; i++)

        {

            // ✅ Skip chunks the server already confirmed

            if (alreadyReceived.Contains(i))

            {

                Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks} ⏭  already on server");

                progress?.Report((int)Math.Round((i + 1) / (double)totalChunks * 100));

                continue;

            }


            var offset = i * ChunkSize;

            var length = Math.Min(ChunkSize, fileBytes.Length - offset);

            var chunk  = new byte[length];

            Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);


            var sent = await SendChunkWithRetryAsync(

                uploadId, i, totalChunks, fileName, mimeType, chunk

            );


            if (!sent)

            {

                failedChunks.Add(i);

                Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks} ❌ failed");

            }

            else

            {

                Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks} ✔");

            }


            var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);

            progress?.Report(pct);

        }


        // ── Abort if any chunk still failed ───────────────────────────────────

        if (failedChunks.Count > 0)

        {

            // Keep the saved uploadId so next call can resume again

            Console.WriteLine($"  [{fileName}] ❌ {failedChunks.Count} chunk(s) failed, " +

                              $"uploadId saved for next resume");

            return Fail($"Incomplete: chunks [{string.Join(",", failedChunks)}] failed. " +

                        $"Call UploadFileAsync again to resume.");

        }


        // ── All chunks on server — finalize ───────────────────────────────────

        Console.WriteLine($"  [{fileName}] All {totalChunks} chunks OK → finalizing");

        var result = await FinalizeAsync(

            uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType

        );


        // ── Clean up local resume state on success ────────────────────────────

        if (result.Ok)

            DeleteSavedUploadId(filePath);


        return result;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // GET /chunk-status?uploadId=xxx

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<ChunkStatusResponse> GetChunkStatusAsync(string uploadId)

    {

        try

        {

            var response = await _http.GetAsync($"{_baseUrl}/chunk-status?uploadId={uploadId}");

            if (!response.IsSuccessStatusCode) return null;


            var body = await response.Content.ReadAsStringAsync();

            return JsonSerializer.Deserialize<ChunkStatusResponse>(

                body,

                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }

            );

        }

        catch (Exception ex)

        {

            Console.WriteLine($"  GetChunkStatus failed: {ex.Message}");

            return null;

        }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Local resume state — saved as JSON next to the uploader

    // Key = filePath, Value = { uploadId, totalChunks }

    // ─────────────────────────────────────────────────────────────────────────

    private async Task SaveUploadIdAsync(string filePath, string uploadId, int totalChunks)

    {

        try

        {

            Dictionary<string, ResumeEntry> store = LoadStore();

            store[filePath] = new ResumeEntry { UploadId = uploadId, TotalChunks = totalChunks };

            await File.WriteAllTextAsync(_resumeStorePath, JsonSerializer.Serialize(store));

        }

        catch (Exception ex)

        {

            Console.WriteLine($"  SaveUploadId warning: {ex.Message}");

        }

    }


    private async Task<string> TryLoadSavedUploadIdAsync(string filePath)

    {

        try

        {

            var store = LoadStore();

            return store.TryGetValue(filePath, out var entry) ? entry.UploadId : null;

        }

        catch { return null; }

    }


    private void DeleteSavedUploadId(string filePath)

    {

        try

        {

            var store = LoadStore();

            if (store.Remove(filePath))

                File.WriteAllText(_resumeStorePath, JsonSerializer.Serialize(store));

        }

        catch { /* non-critical */ }

    }


    private Dictionary<string, ResumeEntry> LoadStore()

    {

        if (!File.Exists(_resumeStorePath)) 

            return new Dictionary<string, ResumeEntry>();


        try

        {

            var json = File.ReadAllText(_resumeStorePath);

            return JsonSerializer.Deserialize<Dictionary<string, ResumeEntry>>(json)

                   ?? new Dictionary<string, ResumeEntry>();

        }

        catch { return new Dictionary<string, ResumeEntry>(); }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Send one chunk with retry

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<bool> SendChunkWithRetryAsync(

        string uploadId, int index, int total,

        string fileName, string mimeType, byte[] data)

    {

        for (int attempt = 0; attempt < MaxRetries; attempt++)

        {

            try

            {

                using var form = new MultipartFormDataContent();

                form.Add(new StringContent(uploadId),           "uploadId");

                form.Add(new StringContent(index.ToString()),   "chunkIndex");

                form.Add(new StringContent(total.ToString()),   "totalChunks");

                form.Add(new StringContent(fileName),           "originalname");

                form.Add(new StringContent(mimeType),           "mimetype");


                var byteContent = new ByteArrayContent(data);

                byteContent.Headers.ContentType =

                    new MediaTypeHeaderValue("application/octet-stream");

                form.Add(byteContent, "chunk", fileName);


                var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);

                if (response.IsSuccessStatusCode) return true;


                Console.WriteLine($"  Chunk {index} attempt {attempt + 1} " +

                                  $"HTTP {(int)response.StatusCode}");

            }

            catch (Exception ex)

            {

                Console.WriteLine($"  Chunk {index} attempt {attempt + 1}: {ex.Message}");

            }


            await Task.Delay(500 * (attempt + 1));

        }

        return false;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Finalize

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<UploadResult> FinalizeAsync(

        string uploadId, string fileName, string mimeType,

        string serverPath, string uploadUrl, string thumbUrl, string fileType)

    {

        var payload = new

        {

            uploadId,

            originalname = fileName,

            mimetype     = mimeType,

            serverpath   = serverPath,

            uploadUrl,

            thumbUrl,

            filetype     = fileType,

        };


        var content = new StringContent(

            JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"

        );


        try

        {

            var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);

            var body     = await response.Content.ReadAsStringAsync();


            if (response.IsSuccessStatusCode)

            {

                return JsonSerializer.Deserialize<UploadResult>(

                    body,

                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }

                ) ?? new UploadResult { Ok = true };

            }


            return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");

        }

        catch (Exception ex) { return Fail(ex.Message); }

    }


    private static UploadResult Fail(string error)

    {

        Console.WriteLine($"  ❌ {error}");

        return new UploadResult { Ok = false, Error = error };

    }


    private static string GetMimeType(string fileName) =>

        Path.GetExtension(fileName).ToLowerInvariant() switch

        {

            ".jpg" or ".jpeg" => "image/jpeg",

            ".png"            => "image/png",

            ".avif"           => "image/avif",

            ".webp"           => "image/webp",

            ".gif"            => "image/gif",

            _                 => "application/octet-stream",

        };


    public void Dispose() => _http.Dispose();

}


// ─────────────────────────────────────────────────────────────────────────────

// Models

// ─────────────────────────────────────────────────────────────────────────────

public class UploadResult

{

    public bool   Ok     { get; set; }

    public string Error  { get; set; }

    public object Result { get; set; }

}


public class ChunkStatusResponse

{

    public bool   Ok             { get; set; }

    public string UploadId       { get; set; }

    public List<int> ReceivedChunks { get; set; }

    public bool   CanResume      { get; set; }

}


public class ResumeEntry

{

    public string UploadId    { get; set; }

    public int    TotalChunks { get; set; }

}

Usage — call the exact same method, it auto-resumes:


csharp

using var uploader = new ChunkedUploader("https://your-server.com");


// First call — network cuts after chunk 3

var result = await uploader.UploadFileAsync(

    filePath  : @"C:\photos\DSC_0001.jpg",

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs"

);

// result.Ok = false, uploadId saved locally


// --- network restored ---


// Second call — same method, same file path

// Automatically finds saved uploadId → asks server for status

// Server says chunks [0,1,2] received → only sends chunks [3,4]

var result2 = await uploader.UploadFileAsync(

    filePath  : @"C:\photos\DSC_0001.jpg",

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs"

);

// result2.Ok = true ✔

What happens step by step

First attempt (network cuts at chunk 3):

  Chunk 0 ✔ → saved as 000000.part

  Chunk 1 ✔ → saved as 000001.part

  Chunk 2 ✔ → saved as 000002.part

  Chunk 3 ❌ network cut

  uploadId saved to resume_state.json locally


Resume attempt:

  C# reads resume_state.json → finds uploadId for this file

  GET /chunk-status?uploadId=xxx

  Server reads tmpDir → returns { receivedChunks: [0,1,2] }

  C# skips chunks 0,1,2 ⏭

  Sends chunk 3 ✔ → saved as 000003.part

  Sends chunk 4 ✔ → saved as 000004.part

  POST /finalize-upload

  Server assembles 000000–000004.part → full buffer

  sharp converts → avif saved ✔

  resume_state.json entry deleted ✔

var result = await uploader.UploadFileAsync( filePath : @"C:\photos\DSC_0001.jpg", serverPath: @"\\NAS\share", uploadUrl : "images/2024", thumbUrl : "images/2024/thumbs" ); also passing ifromfile


May 12

Here's the updated UploadFileAsync that accepts IFormFile directly instead of a file path:


csharp

// ─────────────────────────────────────────────────────────────────────────────

// Overload — accepts IFormFile (from Ionic/MAUI file picker)

// ─────────────────────────────────────────────────────────────────────────────

public async Task<UploadResult> UploadFileAsync(

    IFormFile file,

    string serverPath,

    string uploadUrl,

    string thumbUrl,

    string fileType         = "image",

    IProgress<int> progress = null)

{

    // ── Read IFormFile into byte[] ────────────────────────────────────────────

    byte[] fileBytes;

    using (var ms = new MemoryStream())

    {

        await file.CopyToAsync(ms);

        fileBytes = ms.ToArray();

    }


    var fileName    = Path.GetFileName(file.FileName);

    var mimeType    = file.ContentType ?? GetMimeType(fileName);


    // ── Delegate to core method ───────────────────────────────────────────────

    return await UploadCoreAsync(

        fileBytes, fileName, mimeType,

        serverPath, uploadUrl, thumbUrl, fileType, progress

    );

}


// ─────────────────────────────────────────────────────────────────────────────

// Overload — accepts file path (original)

// ─────────────────────────────────────────────────────────────────────────────

public async Task<UploadResult> UploadFileAsync(

    string filePath,

    string serverPath,

    string uploadUrl,

    string thumbUrl,

    string fileType         = "image",

    IProgress<int> progress = null)

{

    if (!File.Exists(filePath))

        return Fail($"File not found: {filePath}");


    var fileBytes = await File.ReadAllBytesAsync(filePath);

    var fileName  = Path.GetFileName(filePath);

    var mimeType  = GetMimeType(fileName);


    return await UploadCoreAsync(

        fileBytes, fileName, mimeType,

        serverPath, uploadUrl, thumbUrl, fileType, progress

    );

}


// ─────────────────────────────────────────────────────────────────────────────

// Core — all logic lives here, both overloads call this

// ─────────────────────────────────────────────────────────────────────────────

private async Task<UploadResult> UploadCoreAsync(

    byte[]  fileBytes,

    string  fileName,

    string  mimeType,

    string  serverPath,

    string  uploadUrl,

    string  thumbUrl,

    string  fileType,

    IProgress<int> progress)

{

    var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);


    // ── Check for saved uploadId (resume support) ─────────────────────────────

    var uploadId = await TryLoadSavedUploadIdAsync(fileName);  // keyed by fileName

    var alreadyReceived = new HashSet<int>();


    if (uploadId != null)

    {

        var status = await GetChunkStatusAsync(uploadId);


        if (status != null && status.CanResume)

        {

            Console.WriteLine($"[{fileName}] Resuming id={uploadId}  " +

                              $"server has: [{string.Join(",", status.ReceivedChunks)}]");

            foreach (var idx in status.ReceivedChunks)

                alreadyReceived.Add(idx);

        }

        else

        {

            Console.WriteLine($"[{fileName}] No resume data on server, starting fresh");

            uploadId = null;

        }

    }


    if (uploadId == null)

    {

        uploadId = Guid.NewGuid().ToString("N");

        await SaveUploadIdAsync(fileName, uploadId, totalChunks);

        Console.WriteLine($"[{fileName}] New upload id={uploadId}  chunks={totalChunks}");

    }


    // ── Send missing chunks ───────────────────────────────────────────────────

    var failedChunks = new List<int>();


    for (int i = 0; i < totalChunks; i++)

    {

        if (alreadyReceived.Contains(i))

        {

            Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks} ⏭  already on server");

            progress?.Report((int)Math.Round((i + 1) / (double)totalChunks * 100));

            continue;

        }


        var offset = i * ChunkSize;

        var length = Math.Min(ChunkSize, fileBytes.Length - offset);

        var chunk  = new byte[length];

        Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);


        var sent = await SendChunkWithRetryAsync(

            uploadId, i, totalChunks, fileName, mimeType, chunk

        );


        if (!sent) failedChunks.Add(i);


        var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);

        progress?.Report(pct);

        Console.WriteLine($"  [{fileName}] chunk {i + 1}/{totalChunks} {(sent ? "✔" : "❌")}");

    }


    // ── Abort if chunks missing — keep uploadId for next resume ──────────────

    if (failedChunks.Count > 0)

    {

        return Fail($"Incomplete: chunks [{string.Join(",", failedChunks)}] failed. " +

                    $"Call UploadFileAsync again to resume.");

    }


    // ── Finalize → server assembles + avif converts ───────────────────────────

    Console.WriteLine($"  [{fileName}] All {totalChunks} chunks OK → finalizing");

    var result = await FinalizeAsync(

        uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType

    );


    if (result.Ok)

        DeleteSavedUploadId(fileName);


    return result;

}

Usage — both overloads side by side:


csharp

using var uploader = new ChunkedUploader("https://your-server.com");


// ── From IFormFile (Ionic / MAUI file picker) ─────────────────────────────────

IFormFile pickedFile = ...; // from file picker result


var result = await uploader.UploadFileAsync(

    file      : pickedFile,

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs"

);


// ── From file path (original) ─────────────────────────────────────────────────

var result2 = await uploader.UploadFileAsync(

    filePath  : @"C:\photos\DSC_0001.jpg",

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs"

);


// ── Batch with mixed sources ──────────────────────────────────────────────────

var tasks = pickedFiles.Select(f => uploader.UploadFileAsync(

    file      : f,

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs"

));


var results = await Task.WhenAll(tasks);

Key design points

Two public overloads, one private core — UploadFileAsync(IFormFile) and UploadFileAsync(string filePath) both read their source into a byte[] + fileName + mimeType, then call UploadCoreAsync which has all the chunk/resume/finalize logic. No duplication.


IFormFile.ContentType used directly — the mimeType comes from file.ContentType which the browser/OS sets correctly (e.g. image/jpeg). GetMimeType() is only the fallback if ContentType is null.


Resume keyed by fileName — since IFormFile has no stable file path, resume_state.json uses fileName as the key instead of the full path. If your app can have two files with the same name from different folders, use file.FileName (which includes the path on some platforms) or append a file size to the key: $"{fileName}_{fileBytes.Length}".


i need ifromfile with handling failed chunks images retry


May 18

Here's the complete implementation with failed chunk retry handling:


csharp

using System;

using System.Collections.Generic;

using System.IO;

using System.Linq;

using System.Net.Http;

using System.Net.Http.Headers;

using System.Text;

using System.Text.Json;

using System.Threading;

using System.Threading.Tasks;

using Microsoft.AspNetCore.Http;


public class ChunkedUploader : IDisposable

{

    private readonly HttpClient _http;

    private readonly string     _baseUrl;

    private readonly string     _resumeStorePath;


    private const int ChunkSize   = 2 * 1024 * 1024; // 2 MB

    private const int MaxRetries  = 3;

    private const int RetryDelay  = 500; // ms base delay


    public ChunkedUploader(string baseUrl, string resumeStorePath = "resume_state.json")

    {

        _http            = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };

        _baseUrl         = baseUrl.TrimEnd('/');

        _resumeStorePath = resumeStorePath;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // PUBLIC — IFormFile entry point

    // ─────────────────────────────────────────────────────────────────────────

    public async Task<UploadResult> UploadFileAsync(

        IFormFile      file,

        string         serverPath,

        string         uploadUrl,

        string         thumbUrl,

        string         fileType  = "image",

        IProgress<UploadProgress> progress = null)

    {

        // Read IFormFile → byte[]

        byte[] fileBytes;

        using (var ms = new MemoryStream())

        {

            await file.CopyToAsync(ms);

            fileBytes = ms.ToArray();

        }


        var fileName = Path.GetFileName(file.FileName);

        var mimeType = file.ContentType ?? GetMimeType(fileName);

        // Resume key includes size to avoid collision if same filename different file

        var resumeKey = $"{fileName}_{fileBytes.Length}";


        return await UploadCoreAsync(

            fileBytes, fileName, mimeType, resumeKey,

            serverPath, uploadUrl, thumbUrl, fileType, progress

        );

    }


    // ─────────────────────────────────────────────────────────────────────────

    // PUBLIC — Batch IFormFile with concurrency + per-file retry

    // ─────────────────────────────────────────────────────────────────────────

    public async Task<BatchUploadResult> UploadFilesAsync(

        IEnumerable<IFormFile> files,

        string serverPath,

        string uploadUrl,

        string thumbUrl,

        string fileType     = "image",

        int    concurrency  = 3,

        int    maxFileRetry = 2,   // how many times to retry a fully-failed file

        IProgress<BatchProgress> batchProgress = null)

    {

        var fileList  = files.ToList();

        var semaphore = new SemaphoreSlim(concurrency);

        var tasks     = new List<Task<FileUploadResult>>();


        for (int fi = 0; fi < fileList.Count; fi++)

        {

            var file       = fileList[fi];

            var fileIndex  = fi;

            await semaphore.WaitAsync();


            tasks.Add(Task.Run(async () =>

            {

                try

                {

                    UploadResult result = null;


                    // Per-file retry loop

                    for (int attempt = 0; attempt <= maxFileRetry; attempt++)

                    {

                        if (attempt > 0)

                        {

                            Console.WriteLine($"  [{file.FileName}] file retry {attempt}/{maxFileRetry}");

                            await Task.Delay(1000 * attempt);

                        }


                        var fileProgress = new Progress<UploadProgress>(p =>

                        {

                            batchProgress?.Report(new BatchProgress

                            {

                                FileIndex    = fileIndex,

                                TotalFiles   = fileList.Count,

                                FileName     = file.FileName,

                                FilePercent  = p.Percent,

                                ChunksDone   = p.ChunksDone,

                                TotalChunks  = p.TotalChunks,

                                FailedChunks = p.FailedChunks,

                            });

                        });


                        result = await UploadFileAsync(

                            file, serverPath, uploadUrl, thumbUrl, fileType, fileProgress

                        );


                        // Success — stop retrying this file

                        if (result.Ok) break;


                        // Only retry if failure was chunk-related (network), 

                        // not a server processing error

                        if (!result.IsChunkFailure) break;

                    }


                    return new FileUploadResult

                    {

                        FileName = file.FileName,

                        Result   = result,

                    };

                }

                finally { semaphore.Release(); }

            }));

        }


        var allResults = await Task.WhenAll(tasks);


        return new BatchUploadResult

        {

            Success      = allResults.Where(r => r.Result.Ok).ToList(),

            Failed       = allResults.Where(r => !r.Result.Ok).ToList(),

            SuccessCount = allResults.Count(r => r.Result.Ok),

            FailedCount  = allResults.Count(r => !r.Result.Ok),

        };

    }


    // ─────────────────────────────────────────────────────────────────────────

    // CORE — chunk split / resume / retry failed chunks / finalize

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<UploadResult> UploadCoreAsync(

        byte[]   fileBytes,

        string   fileName,

        string   mimeType,

        string   resumeKey,

        string   serverPath,

        string   uploadUrl,

        string   thumbUrl,

        string   fileType,

        IProgress<UploadProgress> progress)

    {

        var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);


        // ── Resume: find existing uploadId ────────────────────────────────────

        var uploadId        = await TryLoadSavedUploadIdAsync(resumeKey);

        var alreadyReceived = new HashSet<int>();


        if (uploadId != null)

        {

            var status = await GetChunkStatusAsync(uploadId);

            if (status is { CanResume: true })

            {

                Console.WriteLine($"[{fileName}] Resuming id={uploadId} " +

                                  $"server has [{string.Join(",", status.ReceivedChunks)}]");

                foreach (var idx in status.ReceivedChunks)

                    alreadyReceived.Add(idx);

            }

            else

            {

                Console.WriteLine($"[{fileName}] No resume data — starting fresh");

                uploadId = null;

                alreadyReceived.Clear();

            }

        }


        if (uploadId == null)

        {

            uploadId = Guid.NewGuid().ToString("N");

            await SaveUploadIdAsync(resumeKey, uploadId, totalChunks);

            Console.WriteLine($"[{fileName}] New upload id={uploadId} chunks={totalChunks}");

        }


        // ── Build pending chunk list (skip already received) ──────────────────

        var pendingChunks = Enumerable.Range(0, totalChunks)

            .Where(i => !alreadyReceived.Contains(i))

            .ToList();


        Console.WriteLine($"[{fileName}] Pending chunks: {pendingChunks.Count}/{totalChunks}");


        // ── Round 1: send all pending chunks ──────────────────────────────────

        var failedChunks = new List<int>();


        foreach (var i in pendingChunks)

        {

            var sent = await SendChunkWithRetryAsync(

                uploadId, i, totalChunks, fileName, mimeType,

                GetChunkBytes(fileBytes, i)

            );


            if (!sent)

                failedChunks.Add(i);


            ReportProgress(progress, fileName, i + 1, totalChunks,

                           alreadyReceived.Count + (pendingChunks.IndexOf(i) + 1),

                           failedChunks);

        }


        // ── Round 2: retry failed chunks once more (fresh HTTP call) ──────────

        if (failedChunks.Count > 0)

        {

            Console.WriteLine($"[{fileName}] ⚠ Retrying {failedChunks.Count} failed chunk(s): " +

                              $"[{string.Join(",", failedChunks)}]");


            await Task.Delay(1000); // brief pause before retry round


            var stillFailed = new List<int>();


            foreach (var i in failedChunks)

            {

                Console.WriteLine($"  [{fileName}] retry chunk {i}");


                var sent = await SendChunkWithRetryAsync(

                    uploadId, i, totalChunks, fileName, mimeType,

                    GetChunkBytes(fileBytes, i),

                    maxRetries: 5  // more retries on the retry round

                );


                if (!sent)

                    stillFailed.Add(i);

                else

                    Console.WriteLine($"  [{fileName}] chunk {i} recovered ✔");

            }


            failedChunks = stillFailed;

        }


        // ── Still failing — save state and return retryable error ─────────────

        if (failedChunks.Count > 0)

        {

            Console.WriteLine($"[{fileName}] ❌ {failedChunks.Count} chunk(s) permanently failed");

            return new UploadResult

            {

                Ok             = false,

                IsChunkFailure = true,

                Error          = $"Chunks [{string.Join(",", failedChunks)}] failed. " +

                                 $"Call UploadFileAsync again to resume.",

                FailedChunks   = failedChunks,

            };

        }


        // ── All chunks on server → finalize (assembly + avif conversion) ──────

        Console.WriteLine($"[{fileName}] All {totalChunks} chunks OK → finalizing");


        var result = await FinalizeAsync(

            uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType

        );


        // Clean up local resume state on success

        if (result.Ok)

            DeleteSavedUploadId(resumeKey);


        return result;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Send one chunk — with retry + exponential back-off

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<bool> SendChunkWithRetryAsync(

        string uploadId, int index, int total,

        string fileName, string mimeType, byte[] data,

        int maxRetries = MaxRetries)

    {

        for (int attempt = 0; attempt < maxRetries; attempt++)

        {

            try

            {

                using var form = new MultipartFormDataContent();

                form.Add(new StringContent(uploadId),         "uploadId");

                form.Add(new StringContent(index.ToString()), "chunkIndex");

                form.Add(new StringContent(total.ToString()), "totalChunks");

                form.Add(new StringContent(fileName),         "originalname");

                form.Add(new StringContent(mimeType),         "mimetype");


                var bytes = new ByteArrayContent(data);

                bytes.Headers.ContentType =

                    new MediaTypeHeaderValue("application/octet-stream");

                form.Add(bytes, "chunk", fileName);


                var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);


                if (response.IsSuccessStatusCode)

                {

                    Console.WriteLine($"  chunk {index} attempt {attempt + 1} ✔");

                    return true;

                }


                var body = await response.Content.ReadAsStringAsync();

                Console.WriteLine($"  chunk {index} attempt {attempt + 1} " +

                                  $"HTTP {(int)response.StatusCode}: {body}");

            }

            catch (Exception ex)

            {

                Console.WriteLine($"  chunk {index} attempt {attempt + 1} ex: {ex.Message}");

            }


            // Exponential back-off: 500ms → 1000ms → 1500ms …

            await Task.Delay(RetryDelay * (attempt + 1));

        }


        return false;

    }


    // ─────────────────────────────────────────────────────────────────────────

    // GET /chunk-status?uploadId=xxx

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<ChunkStatusResponse> GetChunkStatusAsync(string uploadId)

    {

        try

        {

            var response = await _http.GetAsync($"{_baseUrl}/chunk-status?uploadId={uploadId}");

            if (!response.IsSuccessStatusCode) return null;


            var body = await response.Content.ReadAsStringAsync();

            return JsonSerializer.Deserialize<ChunkStatusResponse>(

                body,

                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }

            );

        }

        catch (Exception ex)

        {

            Console.WriteLine($"  GetChunkStatus failed: {ex.Message}");

            return null;

        }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // POST /finalize-upload

    // ─────────────────────────────────────────────────────────────────────────

    private async Task<UploadResult> FinalizeAsync(

        string uploadId, string fileName, string mimeType,

        string serverPath, string uploadUrl, string thumbUrl, string fileType)

    {

        var payload = new

        {

            uploadId,

            originalname = fileName,

            mimetype     = mimeType,

            serverpath   = serverPath,

            uploadUrl,

            thumbUrl,

            filetype     = fileType,

        };


        var content = new StringContent(

            JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"

        );


        try

        {

            var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);

            var body     = await response.Content.ReadAsStringAsync();


            if (response.IsSuccessStatusCode)

            {

                return JsonSerializer.Deserialize<UploadResult>(

                    body,

                    new JsonSerializerOptions { PropertyNameCaseInsensitive = true }

                ) ?? new UploadResult { Ok = true };

            }


            return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");

        }

        catch (Exception ex) { return Fail(ex.Message); }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Resume state — persisted to resume_state.json

    // ─────────────────────────────────────────────────────────────────────────

    private async Task SaveUploadIdAsync(string key, string uploadId, int totalChunks)

    {

        try

        {

            var store = LoadStore();

            store[key] = new ResumeEntry { UploadId = uploadId, TotalChunks = totalChunks };

            await File.WriteAllTextAsync(_resumeStorePath, JsonSerializer.Serialize(store));

        }

        catch (Exception ex) { Console.WriteLine($"  SaveUploadId warn: {ex.Message}"); }

    }


    private async Task<string> TryLoadSavedUploadIdAsync(string key)

    {

        try

        {

            var store = LoadStore();

            return store.TryGetValue(key, out var entry) ? entry.UploadId : null;

        }

        catch { return null; }

    }


    private void DeleteSavedUploadId(string key)

    {

        try

        {

            var store = LoadStore();

            if (store.Remove(key))

                File.WriteAllText(_resumeStorePath, JsonSerializer.Serialize(store));

        }

        catch { }

    }


    private Dictionary<string, ResumeEntry> LoadStore()

    {

        if (!File.Exists(_resumeStorePath))

            return new Dictionary<string, ResumeEntry>();

        try

        {

            var json = File.ReadAllText(_resumeStorePath);

            return JsonSerializer.Deserialize<Dictionary<string, ResumeEntry>>(json)

                   ?? new Dictionary<string, ResumeEntry>();

        }

        catch { return new Dictionary<string, ResumeEntry>(); }

    }


    // ─────────────────────────────────────────────────────────────────────────

    // Helpers

    // ─────────────────────────────────────────────────────────────────────────

    private byte[] GetChunkBytes(byte[] fileBytes, int index)

    {

        var offset = index * ChunkSize;

        var length = Math.Min(ChunkSize, fileBytes.Length - offset);

        var chunk  = new byte[length];

        Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);

        return chunk;

    }


    private static void ReportProgress(

        IProgress<UploadProgress> progress,

        string fileName, int chunksDone, int totalChunks,

        int overallDone, List<int> failedChunks)

    {

        var pct = (int)Math.Round(overallDone / (double)totalChunks * 100);

        progress?.Report(new UploadProgress

        {

            FileName     = fileName,

            Percent      = pct,

            ChunksDone   = chunksDone,

            TotalChunks  = totalChunks,

            FailedChunks = new List<int>(failedChunks),

        });

        Console.WriteLine($"  [{fileName}] {pct,3}%  chunk {chunksDone}/{totalChunks}" +

                          (failedChunks.Count > 0 ? $"  failed:[{string.Join(",", failedChunks)}]" : ""));

    }


    private static UploadResult Fail(string error) =>

        new UploadResult { Ok = false, Error = error, IsChunkFailure = false };


    private static string GetMimeType(string fileName) =>

        Path.GetExtension(fileName).ToLowerInvariant() switch

        {

            ".jpg" or ".jpeg" => "image/jpeg",

            ".png"            => "image/png",

            ".avif"           => "image/avif",

            ".webp"           => "image/webp",

            ".gif"            => "image/gif",

            _                 => "application/octet-stream",

        };


    public void Dispose() => _http.Dispose();

}


// ─────────────────────────────────────────────────────────────────────────────

// Models

// ─────────────────────────────────────────────────────────────────────────────

public class UploadResult

{

    public bool       Ok             { get; set; }

    public string     Error          { get; set; }

    public object     Result         { get; set; }

    public bool       IsChunkFailure { get; set; }  // true = network, safe to retry

    public List<int>  FailedChunks   { get; set; } = new();

}


public class FileUploadResult

{

    public string       FileName { get; set; }

    public UploadResult Result   { get; set; }

}


public class BatchUploadResult

{

    public List<FileUploadResult> Success      { get; set; }

    public List<FileUploadResult> Failed       { get; set; }

    public int                    SuccessCount { get; set; }

    public int                    FailedCount  { get; set; }

}


public class UploadProgress

{

    public string    FileName     { get; set; }

    public int       Percent      { get; set; }

    public int       ChunksDone   { get; set; }

    public int       TotalChunks  { get; set; }

    public List<int> FailedChunks { get; set; } = new();

}


public class BatchProgress

{

    public int       FileIndex    { get; set; }

    public int       TotalFiles   { get; set; }

    public string    FileName     { get; set; }

    public int       FilePercent  { get; set; }

    public int       ChunksDone   { get; set; }

    public int       TotalChunks  { get; set; }

    public List<int> FailedChunks { get; set; } = new();

}


public class ChunkStatusResponse

{

    public bool      Ok             { get; set; }

    public string    UploadId       { get; set; }

    public List<int> ReceivedChunks { get; set; }

    public bool      CanResume      { get; set; }

}


public class ResumeEntry

{

    public string UploadId    { get; set; }

    public int    TotalChunks { get; set; }

}

Usage:


csharp

using var uploader = new ChunkedUploader("https://your-server.com");


// ── Single IFormFile with progress ────────────────────────────────────────────

var progress = new Progress<UploadProgress>(p =>

{

    Console.WriteLine($"{p.FileName}  {p.Percent}%  " +

                      $"{p.ChunksDone}/{p.TotalChunks} chunks" +

                      (p.FailedChunks.Count > 0

                          ? $"  retrying:[{string.Join(",", p.FailedChunks)}]"

                          : ""));

});


var result = await uploader.UploadFileAsync(

    file      : formFile,           // IFormFile from picker

    serverPath: @"\\NAS\share",

    uploadUrl : "images/2024",

    thumbUrl  : "images/2024/thumbs",

    progress  : progress

);


if (!result.Ok)

{

    if (result.IsChunkFailure)

        Console.WriteLine($"Network error — call again to resume. Failed: {result.Error}");

    else

        Console.WriteLine($"Server error: {result.Error}");

}


// ── Batch IFormFile with per-file retry ───────────────────────────────────────

var batchProgress = new Progress<BatchProgress>(p =>

{

    Console.WriteLine($"File {p.FileIndex + 1}/{p.TotalFiles}  " +

                      $"{p.FileName}  {p.FilePercent}%");

});


var batch = await uploader.UploadFilesAsync(

    files        : formFiles,        // IEnumerable<IFormFile>

    serverPath   : @"\\NAS\share",

    uploadUrl    : "images/2024",

    thumbUrl     : "images/2024/thumbs",

    concurrency  : 3,

    maxFileRetry : 2,               // retry each failed file up to 2 times

    batchProgress: batchProgress

);


Console.WriteLine($"Done: {batch.SuccessCount} ok, {batch.FailedCount} failed");

foreach (var f in batch.Failed)

    Console.WriteLine($"  ❌ {f.FileName}: {f.Result.Error}");

Retry flow for 5 chunks where 3 succeed and network cuts

Round 1 — initial send:

  chunk 0  ✔  (3 retries max each)

  chunk 1  ✔

  chunk 2  ✔

  chunk 3  ❌  network cut — all 3 retries exhausted

  chunk 4  ❌  network cut — all 3 retries exhausted


Round 2 — automatic retry of failed chunks (5 retries, 1s pause first):

  chunk 3  ✔  recovered

  chunk 4  ✔  recovered


Finalize → server assembles 000000–000004.part

         → sharp avif conversion runs

         → thumb generated

         → ✔ done


If Round 2 also fails:

  IsChunkFailure = true saved to resume_state.json

  Next call to UploadFileAsync same file:

    GET /chunk-status → server has [0,1,2]

    Skips 0,1,2  ⏭

    Only sends 3,4 again

my case i upload two images one by one how to call and which image is not uploded which one is suceess and how failed image to retry using previous code in  .net core


May 18

Here's the complete implementation for your exact case — two images one by one, tracking success/failed, and retrying failed ones:


Controller:


csharp

[ApiController]

[Route("api/[controller]")]

public class ImageUploadController : ControllerBase

{

    private readonly ChunkedUploader _uploader;


    public ImageUploadController(ChunkedUploader uploader)

    {

        _uploader = uploader;

    }


    // ── POST /api/imageupload/upload ──────────────────────────────────────────

    // Called once with all images — processes one by one, tracks each result

    [HttpPost("upload")]

    public async Task<IActionResult> Upload(

        [FromForm] List<IFormFile> files,

        [FromForm] string serverPath,

        [FromForm] string uploadUrl,

        [FromForm] string thumbUrl,

        [FromForm] string fileType = "image")

    {

        if (files == null || files.Count == 0)

            return BadRequest(new { ok = false, error = "No files received" });


        var successList = new List<ImageResultDto>();

        var failedList  = new List<ImageResultDto>();


        // ── Process images ONE BY ONE ─────────────────────────────────────────

        foreach (var file in files)

        {

            Console.WriteLine($"\n▶ Uploading {file.FileName} ({file.Length:N0} bytes)");


            var result = await _uploader.UploadFileAsync(

                file      : file,

                serverPath: serverPath,

                uploadUrl : uploadUrl,

                thumbUrl  : thumbUrl,

                fileType  : fileType,

                progress  : new Progress<UploadProgress>(p =>

                {

                    Console.WriteLine($"  {p.FileName}  {p.Percent}%  " +

                                      $"chunk {p.ChunksDone}/{p.TotalChunks}" +

                                      (p.FailedChunks.Count > 0

                                          ? $"  retrying:[{string.Join(",", p.FailedChunks)}]"

                                          : ""));

                })

            );


            if (result.Ok)

            {

                Console.WriteLine($"  ✔ {file.FileName} success");

                successList.Add(new ImageResultDto

                {

                    FileName = file.FileName,

                    Status   = "success",

                    Result   = result.Result,

                });

            }

            else

            {

                Console.WriteLine($"  ❌ {file.FileName} failed: {result.Error}");

                failedList.Add(new ImageResultDto

                {

                    FileName       = file.FileName,

                    Status         = "failed",

                    Error          = result.Error,

                    IsChunkFailure = result.IsChunkFailure,

                    FailedChunks   = result.FailedChunks,

                });

            }

        }


        return Ok(new UploadSummaryDto

        {

            Ok           = true,

            SuccessCount = successList.Count,

            FailedCount  = failedList.Count,

            Success      = successList,

            Failed       = failedList,

        });

    }


    // ── POST /api/imageupload/retry ───────────────────────────────────────────

    // Client sends back ONLY the failed images to retry

    [HttpPost("retry")]

    public async Task<IActionResult> Retry(

        [FromForm] List<IFormFile> files,

        [FromForm] string serverPath,

        [FromForm] string uploadUrl,

        [FromForm] string thumbUrl,

        [FromForm] string fileType = "image")

    {

        if (files == null || files.Count == 0)

            return BadRequest(new { ok = false, error = "No files to retry" });


        var successList = new List<ImageResultDto>();

        var failedList  = new List<ImageResultDto>();


        foreach (var file in files)

        {

            Console.WriteLine($"\n🔄 Retrying {file.FileName}");


            var result = await _uploader.UploadFileAsync(

                file      : file,

                serverPath: serverPath,

                uploadUrl : uploadUrl,

                thumbUrl  : thumbUrl,

                fileType  : fileType

            );


            if (result.Ok)

            {

                successList.Add(new ImageResultDto

                {

                    FileName = file.FileName,

                    Status   = "success",

                    Result   = result.Result,

                });

            }

            else

            {

                failedList.Add(new ImageResultDto

                {

                    FileName       = file.FileName,

                    Status         = "failed",

                    Error          = result.Error,

                    IsChunkFailure = result.IsChunkFailure,

                    FailedChunks   = result.FailedChunks,

                });

            }

        }


        return Ok(new UploadSummaryDto

        {

            Ok           = failedList.Count == 0,

            SuccessCount = successList.Count,

            FailedCount  = failedList.Count,

            Success      = successList,

            Failed       = failedList,

        });

    }

}

DTOs:


csharp

public class ImageResultDto

{

    public string    FileName       { get; set; }

    public string    Status         { get; set; }   // "success" | "failed"

    public string    Error          { get; set; }

    public object    Result         { get; set; }

    public bool      IsChunkFailure { get; set; }

    public List<int> FailedChunks   { get; set; } = new();

}


public class UploadSummaryDto

{

    public bool                 Ok           { get; set; }

    public int                  SuccessCount { get; set; }

    public int                  FailedCount  { get; set; }

    public List<ImageResultDto> Success      { get; set; } = new();

    public List<ImageResultDto> Failed       { get; set; } = new();

}

Register in Program.cs:


csharp

builder.Services.AddSingleton<ChunkedUploader>(sp =>

    new ChunkedUploader(

        baseUrl         : "https://your-node-server.com",

        resumeStorePath : "resume_state.json"

    )

);

Ionic/mobile client — full flow:


typescript

// upload.service.ts

export class UploadService {


  private apiUrl = 'https://your-dotnet-api.com/api/imageupload';


  // ── Step 1: Upload all images, get back success + failed lists ──────────────

  async uploadImages(

    files: File[],

    serverPath: string,

    uploadUrl: string,

    thumbUrl: string

  ): Promise<UploadSummary> {


    const form = new FormData();


    // Append all files

    files.forEach(f => form.append('files', f, f.name));

    form.append('serverPath', serverPath);

    form.append('uploadUrl',  uploadUrl);

    form.append('thumbUrl',   thumbUrl);


    const response = await fetch(`${this.apiUrl}/upload`, {

      method: 'POST',

      body  : form,

    });


    return response.json();

  }


  // ── Step 2: Retry only the failed images ────────────────────────────────────

  async retryFailedImages(

    failedFiles : File[],   // original File objects for failed ones

    serverPath  : string,

    uploadUrl   : string,

    thumbUrl    : string

  ): Promise<UploadSummary> {


    const form = new FormData();

    failedFiles.forEach(f => form.append('files', f, f.name));

    form.append('serverPath', serverPath);

    form.append('uploadUrl',  uploadUrl);

    form.append('thumbUrl',   thumbUrl);


    const response = await fetch(`${this.apiUrl}/retry`, {

      method: 'POST',

      body  : form,

    });


    return response.json();

  }

}


// ── Usage in your component ────────────────────────────────────────────────────

async handleUpload(selectedFiles: File[]) {


  // Keep a map of filename → File for retry lookup

  const fileMap = new Map<string, File>(

    selectedFiles.map(f => [f.name, f])

  );


  // First upload attempt

  const summary = await this.uploadService.uploadImages(

    selectedFiles,

    '\\\\NAS\\share',

    'images/2024',

    'images/2024/thumbs'

  );


  console.log(`✔ Success: ${summary.successCount}`);

  console.log(`❌ Failed:  ${summary.failedCount}`);


  summary.success.forEach(s => console.log(`  ✔ ${s.fileName}`));

  summary.failed.forEach(f  => console.log(`  ❌ ${f.fileName}: ${f.error}`));


  // Auto-retry failed images if any

  if (summary.failedCount > 0) {


    // Get the original File objects for failed images

    const filesToRetry = summary.failed

      .map(f => fileMap.get(f.fileName))

      .filter(Boolean);


    console.log(`\n🔄 Retrying ${filesToRetry.length} failed image(s)...`);


    const retryResult = await this.uploadService.retryFailedImages(

      filesToRetry,

      '\\\\NAS\\share',

      'images/2024',

      'images/2024/thumbs'

    );


    console.log(`Retry result: ✔ ${retryResult.successCount}  ❌ ${retryResult.failedCount}`);


    // Still failed after retry — show to user

    if (retryResult.failedCount > 0) {

      retryResult.failed.forEach(f =>

        console.log(`  Permanently failed: ${f.fileName} → ${f.error}`)

      );

    }

  }

}

What happens for your exact case (2 images, 1 fails)

POST /api/imageupload/upload

  files: [image1.jpg, image2.jpg]


  ▶ image1.jpg

    chunk 1/5 ✔

    chunk 2/5 ✔

    chunk 3/5 ✔

    chunk 4/5 ✔

    chunk 5/5 ✔

    finalize  ✔ → avif saved

    ✔ image1.jpg success


  ▶ image2.jpg

    chunk 1/5 ✔

    chunk 2/5 ✔

    chunk 3/5 ✔

    chunk 4/5 ❌ network cut → Round 2 retry

    chunk 4/5 ❌ still failing

    chunk 5/5 ❌ network cut → Round 2 retry

    chunk 5/5 ❌ still failing

    ❌ image2.jpg failed  IsChunkFailure=true


Response:

{

  "ok": true,

  "successCount": 1,

  "failedCount": 1,

  "success": [{ "fileName": "image1.jpg", "status": "success" }],

  "failed":  [{ "fileName": "image2.jpg", "status": "failed",

                "isChunkFailure": true,

                "failedChunks": [3, 4] }]

}


POST /api/imageupload/retry

  files: [image2.jpg]   ← only the failed one


  🔄 image2.jpg

    GET /chunk-status → server has [0,1,2]  ← resume kicks in

    chunk 0 ⏭ skip

    chunk 1 ⏭ skip

    chunk 2 ⏭ skip

    chunk 3 ✔ (network restored)

    chunk 4 ✔

    finalize ✔ → avif saved


Response:

{

  "ok": true,

  "successCount": 1,

  "failedCount": 0,

  "success": [{ "fileName": "image2.jpg", "status": "success" }],

  "failed":  []

}


<form action="/Home/TaskCreate" method="post" enctype="multipart/form-data" class="mt-2" id="form-create-task"> <div class="d-flex mb-4"> <h4 class="me-3">Create Work</h4> @if (Model.showofficial) { <div class="form-check align-content-center">


pasted


my index page taskattachments after selected call UploadFileAsync


May 18

You need to intercept the form submit, extract the taskattachments files, upload them via chunked uploader first, then submit the form with the returned filenames.


JavaScript — in your view (add before </form> or in a script section):


javascript

// ── Chunked upload config ─────────────────────────────────────────────────────

const CHUNK_SIZE   = 2 * 1024 * 1024; // 2 MB

const API_BASE     = 'https://your-node-server.com';

const SERVER_PATH  = '\\\\NAS\\share';

const UPLOAD_URL   = 'images/2024';

const THUMB_URL    = 'images/2024/thumbs';


// Track upload state per file

const uploadState = {

  files        : [],   // original File objects

  results      : [],   // { fileName, status, avifName, error }

  failedFiles  : [],   // File objects that failed

};


// ── File selected → show preview + auto-start upload ─────────────────────────

document.getElementById('taskattachments').addEventListener('change', async function () {

  const files = Array.from(this.files);

  if (!files.length) return;


  uploadState.files       = files;

  uploadState.results     = [];

  uploadState.failedFiles = [];


  renderPreviews(files);

  await uploadAllFiles(files);

});


// ── Upload all files one by one ───────────────────────────────────────────────

async function uploadAllFiles(files) {

  // Disable submit while uploading

  document.getElementById('btnsave').disabled = true;

  document.getElementById('btnsave').innerText = 'Uploading...';


  for (const file of files) {

    updatePreviewStatus(file.name, 'uploading', 0);


    const result = await uploadFileInChunks(file);


    if (result.ok) {

      uploadState.results.push({

        fileName: file.name,

        avifName: result.avifName,

        status  : 'success',

      });

      updatePreviewStatus(file.name, 'success', 100);

    } else {

      uploadState.results.push({

        fileName: file.name,

        status  : 'failed',

        error   : result.error,

      });

      uploadState.failedFiles.push(file);

      updatePreviewStatus(file.name, 'failed', 0, result.error);

    }

  }


  const failCount = uploadState.failedFiles.length;


  if (failCount > 0) {

    // Show retry button — don't enable submit yet

    showRetryButton(failCount);

  } else {

    // All uploaded — enable submit

    document.getElementById('btnsave').disabled = false;

    document.getElementById('btnsave').innerText = 'Send Work';

  }

}


// ── Retry only failed files ───────────────────────────────────────────────────

async function retryFailedUploads() {

  const toRetry = [...uploadState.failedFiles];

  uploadState.failedFiles = [];


  hideRetryButton();

  document.getElementById('btnsave').disabled = true;

  document.getElementById('btnsave').innerText = 'Retrying...';


  for (const file of toRetry) {

    updatePreviewStatus(file.name, 'uploading', 0);


    const result = await uploadFileInChunks(file);


    if (result.ok) {

      // Update existing result entry

      const existing = uploadState.results.find(r => r.fileName === file.name);

      if (existing) {

        existing.status   = 'success';

        existing.avifName = result.avifName;

        delete existing.error;

      }

      updatePreviewStatus(file.name, 'success', 100);

    } else {

      uploadState.failedFiles.push(file);

      updatePreviewStatus(file.name, 'failed', 0, result.error);

    }

  }


  if (uploadState.failedFiles.length > 0) {

    showRetryButton(uploadState.failedFiles.length);

  } else {

    document.getElementById('btnsave').disabled = false;

    document.getElementById('btnsave').innerText = 'Send Work';

  }

}


// ── Core chunked upload for one file ─────────────────────────────────────────

async function uploadFileInChunks(file) {

  const RESUME_KEY  = `${file.name}_${file.size}`;

  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);


  // Check if we have a saved uploadId for resume

  let uploadId       = localStorage.getItem(`upload_${RESUME_KEY}`);

  let alreadyReceived = new Set();


  if (uploadId) {

    try {

      const statusRes = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);

      const status    = await statusRes.json();


      if (status.ok && status.canResume) {

        status.receivedChunks.forEach(i => alreadyReceived.add(i));

        console.log(`[${file.name}] Resuming — server has [${[...alreadyReceived]}]`);

      } else {

        uploadId = null;

        alreadyReceived.clear();

      }

    } catch {

      uploadId = null;

    }

  }


  if (!uploadId) {

    uploadId = crypto.randomUUID().replace(/-/g, '');

    localStorage.setItem(`upload_${RESUME_KEY}`, uploadId);

  }


  // ── Round 1: send pending chunks ───────────────────────────────────────────

  const failedChunks = [];


  for (let i = 0; i < totalChunks; i++) {

    if (alreadyReceived.has(i)) {

      updatePreviewStatus(file.name, 'uploading',

        Math.round((i + 1) / totalChunks * 100));

      continue;

    }


    const start  = i * CHUNK_SIZE;

    const chunk  = file.slice(start, start + CHUNK_SIZE);

    const sent   = await sendChunkWithRetry(uploadId, i, totalChunks, file, chunk);


    if (!sent) failedChunks.push(i);


    updatePreviewStatus(file.name, 'uploading',

      Math.round((i + 1) / totalChunks * 100));

  }


  // ── Round 2: retry failed chunks ───────────────────────────────────────────

  if (failedChunks.length > 0) {

    console.log(`[${file.name}] Retrying chunks [${failedChunks}]`);

    await new Promise(r => setTimeout(r, 1000));


    const stillFailed = [];

    for (const i of failedChunks) {

      const start = i * CHUNK_SIZE;

      const chunk = file.slice(start, start + CHUNK_SIZE);

      const sent  = await sendChunkWithRetry(uploadId, i, totalChunks, file, chunk, 5);

      if (!sent) stillFailed.push(i);

    }


    if (stillFailed.length > 0) {

      // Keep uploadId in localStorage for next resume

      return {

        ok   : false,

        error: `Chunks [${stillFailed}] failed. Will resume on retry.`,

      };

    }

  }


  // ── Finalize ───────────────────────────────────────────────────────────────

  try {

    const finalRes = await fetch(`${API_BASE}/finalize-upload`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        uploadId,

        originalname: file.name,

        mimetype    : file.type,

        serverpath  : SERVER_PATH,

        uploadUrl   : UPLOAD_URL,

        thumbUrl    : THUMB_URL,

        filetype    : 'image',

      }),

    });


    const finalData = await finalRes.json();


    if (finalData.ok) {

      // Clean up localStorage on success

      localStorage.removeItem(`upload_${RESUME_KEY}`);

      return { ok: true, avifName: finalData.result?.fileName };

    }


    return { ok: false, error: finalData.error };


  } catch (err) {

    return { ok: false, error: err.message };

  }

}


// ── Send one chunk with retry ─────────────────────────────────────────────────

async function sendChunkWithRetry(uploadId, index, total, file, chunkBlob, maxRetries = 3) {

  for (let attempt = 0; attempt < maxRetries; attempt++) {

    try {

      const form = new FormData();

      form.append('uploadId',    uploadId);

      form.append('chunkIndex',  index);

      form.append('totalChunks', total);

      form.append('originalname', file.name);

      form.append('mimetype',    file.type);

      form.append('chunk',       chunkBlob, file.name);


      const res = await fetch(`${API_BASE}/upload-chunk`, {

        method: 'POST',

        body  : form,

      });


      if (res.ok) return true;

      console.warn(`chunk ${index} attempt ${attempt + 1} HTTP ${res.status}`);


    } catch (err) {

      console.warn(`chunk ${index} attempt ${attempt + 1} error: ${err.message}`);

    }


    await new Promise(r => setTimeout(r, 500 * (attempt + 1)));

  }

  return false;

}


// ── Form submit — inject uploaded filenames then post ─────────────────────────

async function savetask() {

  // Block if any uploads still pending or failed

  const failCount = uploadState.failedFiles.length;

  if (failCount > 0) {

    alert(`${failCount} file(s) not uploaded yet. Please retry first.`);

    return;

  }


  // Check all selected files have a success result

  const allDone = uploadState.results.every(r => r.status === 'success');

  if (uploadState.files.length > 0 && !allDone) {

    alert('Some files are still uploading. Please wait.');

    return;

  }


  // Inject avif filenames as hidden inputs so server knows what was uploaded

  const form = document.getElementById('form-create-task');


  uploadState.results

    .filter(r => r.status === 'success')

    .forEach(r => {

      const input = document.createElement('input');

      input.type  = 'hidden';

      input.name  = 'uploadedFiles';

      input.value = r.avifName;

      form.appendChild(input);

    });


  // Clear the file input so it doesn't re-POST the raw files

  document.getElementById('taskattachments').value = '';


  form.submit();

}


// ── Preview rendering ─────────────────────────────────────────────────────────

function renderPreviews(files) {

  const container = document.getElementById('image_preview');

  container.innerHTML = '';


  files.forEach(file => {

    const isImage = file.type.startsWith('image/');

    const col     = document.createElement('div');

    col.className = 'col-md-2 mb-3 text-center';

    col.id        = `preview_${sanitizeId(file.name)}`;


    col.innerHTML = `

      <div class="position-relative border rounded p-1">

        ${isImage

          ? `<img src="${URL.createObjectURL(file)}"

               class="img-fluid rounded mb-1"

               style="height:80px;width:100%;object-fit:cover;" />`

          : `<div class="d-flex align-items-center justify-content-center"

                  style="height:80px;">

               <i class="bi bi-file-earmark fs-2"></i>

             </div>`

        }

        <small class="d-block text-truncate" title="${file.name}">${file.name}</small>

        <div class="mt-1" id="status_${sanitizeId(file.name)}">

          <div class="progress" style="height:6px;">

            <div class="progress-bar" id="bar_${sanitizeId(file.name)}"

                 style="width:0%"></div>

          </div>

          <small class="text-muted" id="lbl_${sanitizeId(file.name)}">Pending</small>

        </div>

      </div>`;


    container.appendChild(col);

  });

}


function updatePreviewStatus(fileName, status, percent, errorMsg = '') {

  const id  = sanitizeId(fileName);

  const bar = document.getElementById(`bar_${id}`);

  const lbl = document.getElementById(`lbl_${id}`);

  if (!bar || !lbl) return;


  bar.style.width = `${percent}%`;


  switch (status) {

    case 'uploading':

      bar.className      = 'progress-bar bg-primary progress-bar-striped progress-bar-animated';

      lbl.className      = 'text-primary';

      lbl.innerText      = `${percent}%`;

      break;

    case 'success':

      bar.className      = 'progress-bar bg-success';

      lbl.className      = 'text-success';

      lbl.innerText      = '✔ Uploaded';

      break;

    case 'failed':

      bar.className      = 'progress-bar bg-danger';

      lbl.className      = 'text-danger';

      lbl.innerText      = `❌ ${errorMsg || 'Failed'}`;

      break;

  }

}


function showRetryButton(count) {

  let btn = document.getElementById('btn-retry-uploads');

  if (!btn) {

    btn            = document.createElement('button');

    btn.id         = 'btn-retry-uploads';

    btn.type       = 'button';

    btn.className  = 'btn btn-warning ms-4';

    btn.onclick    = retryFailedUploads;

    document.querySelector('.modal-submit-btn').insertAdjacentElement('beforebegin', btn);

  }

  btn.innerText  = `🔄 Retry ${count} Failed`;

  btn.style.display = '';

}


function hideRetryButton() {

  const btn = document.getElementById('btn-retry-uploads');

  if (btn) btn.style.display = 'none';

}


function sanitizeId(name) {

  return name.replace(/[^a-zA-Z0-9]/g, '_');

}

ASP.NET Controller — receive uploaded filenames:


csharp

[HttpPost]

public async Task<IActionResult> TaskCreate(

    TaskCreateViewModel model,

    [FromForm] List<string> uploadedFiles)  // ← avif names from hidden inputs

{

    // uploadedFiles = ["photo1.avif", "photo2.avif"]

    // No file processing needed — already uploaded to NAS by chunked uploader

    model.Attachments = uploadedFiles;


    // ... save task to DB

    return RedirectToAction("Index");

}

Full flow

User selects image1.jpg + image2.jpg

  ↓ change event fires immediately


image1.jpg:

  chunk 1/5 ✔  chunk 2/5 ✔  chunk 3/5 ✔

  chunk 4/5 ❌  → Round 2 retry → ✔ recovered

  chunk 5/5 ✔

  finalize  ✔  → avif saved on NAS

  preview   ✔ Uploaded  (green)


image2.jpg:

  chunk 1/3 ✔  chunk 2/3 ✔  chunk 3/3 ✔

  finalize  ✔  → avif saved on NAS

  preview   ✔ Uploaded  (green)


All done → "Send Work" button enabled


User clicks Send Work:

  hidden inputs injected: uploadedFiles=image1.avif, uploadedFiles=image2.avif

  file input cleared (no raw bytes posted)

  form.submit() → POST /Home/TaskCreate

  controller receives uploadedFiles list → saves to DB

upload cun current uploads and retry and delete image each file


May 19

Here's the complete implementation with upload progress, retry, and delete per file:


V


Connecting to visualize...

Now here's the full production code to drop into your view:


Replace <div id="image_preview"> section and add this script block:


html

<!-- Inside your existing form, replace the image_preview div -->

<div class="col-md-12">


  <!-- Summary bar -->

  <div class="d-flex align-items-center gap-3 my-2" id="upload-summary" style="display:none!important">

    <span class="text-muted" id="s-total"></span>

    <span class="text-success" id="s-ok"></span>

    <span class="text-danger" id="s-fail"></span>

    <button type="button" class="btn btn-sm btn-warning" id="btn-retry-all"

            onclick="retryAllFailed()" style="display:none">

      <i class="bi bi-arrow-repeat"></i> Retry All Failed

    </button>

  </div>


  <!-- Per-file cards -->

  <div id="file-list" class="my-2"></div>

</div>

Full JavaScript — paste before </body> or in your scripts section:


javascript

// ── Config ────────────────────────────────────────────────────────────────────

const CHUNK_SIZE  = 2 * 1024 * 1024;

const API_BASE    = 'https://your-node-server.com';

const SERVER_PATH = '\\\\NAS\\share';

const UPLOAD_URL  = 'images/2024';

const THUMB_URL   = 'images/2024/thumbs';


// ── State — keyed by fileId ───────────────────────────────────────────────────

// { id, file, status, pct, avifName, error, aborted }

const uploadRegistry = {};

let fileSeq = 0;


// ── File input change ─────────────────────────────────────────────────────────

document.getElementById('taskattachments').addEventListener('change', function () {

  Array.from(this.files).forEach(file => {

    const id = 'f' + (++fileSeq);

    uploadRegistry[id] = {

      id, file,

      status  : 'pending',

      pct     : 0,

      avifName: null,

      error   : '',

      aborted : false,

    };

    renderFileCard(id);

    startUpload(id);       // auto-start immediately

  });


  this.value = '';         // reset so same file can be re-selected

  updateSummary();

  document.getElementById('upload-summary').style.display = 'flex';

});


// ── Render one file card ──────────────────────────────────────────────────────

function renderFileCard(id) {

  const d      = uploadRegistry[id];

  const isImg  = d.file.type.startsWith('image/');

  const thumbHtml = isImg

    ? `<img src="${URL.createObjectURL(d.file)}"

            style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"

            alt="${d.file.name}">`

    : `<div style="width:54px;height:54px;background:var(--bs-secondary-bg,#f0f0f0);

                   border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0">

         <i class="bi bi-file-earmark fs-4 text-secondary"></i>

       </div>`;


  const card = document.createElement('div');

  card.id        = `card-${id}`;

  card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';

  card.style.cssText = 'background:#fff;transition:border-color .2s;';


  card.innerHTML = `

    ${thumbHtml}


    <div style="flex:1;min-width:0">

      <div class="d-flex justify-content-between align-items-center mb-1">

        <span class="text-truncate fw-500" style="font-size:13px;max-width:240px"

              title="${d.file.name}">${d.file.name}</span>

        <span class="text-muted" style="font-size:11px;flex-shrink:0;margin-left:8px">

          ${fmtSize(d.file.size)}

        </span>

      </div>


      <div class="progress mb-1" style="height:5px;">

        <div class="progress-bar" id="bar-${id}"

             style="width:0%;transition:width .3s;"></div>

      </div>


      <div class="d-flex justify-content-between">

        <span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>

        <span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>

      </div>

    </div>


    <div class="d-flex flex-column gap-1" style="flex-shrink:0">

      <button type="button" id="btn-retry-${id}"

              class="btn btn-sm btn-outline-warning py-0 px-2"

              onclick="retryFile('${id}')"

              style="display:none;font-size:11px">

        <i class="bi bi-arrow-repeat"></i> Retry

      </button>

      <button type="button"

              class="btn btn-sm btn-outline-danger py-0 px-2"

              onclick="deleteFile('${id}')"

              style="font-size:11px">

        <i class="bi bi-trash"></i>

      </button>

    </div>`;


  document.getElementById('file-list').appendChild(card);

}


// ── Update card visuals ───────────────────────────────────────────────────────

function updateCard(id) {

  const d        = uploadRegistry[id];

  const card     = document.getElementById(`card-${id}`);

  const bar      = document.getElementById(`bar-${id}`);

  const lbl      = document.getElementById(`lbl-${id}`);

  const retryBtn = document.getElementById(`btn-retry-${id}`);

  if (!card) return;


  // Progress bar color + width

  bar.style.width = d.pct + '%';

  bar.className   = 'progress-bar';

  if      (d.status === 'uploading') bar.classList.add('bg-primary', 'progress-bar-striped', 'progress-bar-animated');

  else if (d.status === 'success')   bar.classList.add('bg-success');

  else if (d.status === 'failed')    bar.classList.add('bg-danger');


  // Card border

  card.style.borderColor =

    d.status === 'success' ? '#198754' :

    d.status === 'failed'  ? '#dc3545' : '';


  // Label

  lbl.className = '';

  if      (d.status === 'uploading') { lbl.className = 'text-primary';  lbl.textContent = d.pct + '%'; }

  else if (d.status === 'success')   { lbl.className = 'text-success';  lbl.textContent = '✔ Uploaded'; }

  else if (d.status === 'failed')    { lbl.className = 'text-danger';   lbl.textContent = '✖ ' + (d.error || 'Failed'); }

  else                               { lbl.className = 'text-muted';    lbl.textContent = 'Pending'; }


  // Retry button

  retryBtn.style.display = d.status === 'failed' ? '' : 'none';

}


function updateChunkLabel(id, done, total, failedArr) {

  const el = document.getElementById(`chunk-lbl-${id}`);

  if (!el) return;

  let t = `chunk ${done}/${total}`;

  if (failedArr && failedArr.length) t += ` · retrying [${failedArr.join(',')}]`;

  el.textContent = t;

}


function clearChunkLabel(id) {

  const el = document.getElementById(`chunk-lbl-${id}`);

  if (el) el.textContent = '';

}


// ── Delete a file card ────────────────────────────────────────────────────────

function deleteFile(id) {

  if (uploadRegistry[id]) uploadRegistry[id].aborted = true;

  document.getElementById(`card-${id}`)?.remove();

  delete uploadRegistry[id];

  updateSummary();

}


// ── Retry one file ────────────────────────────────────────────────────────────

function retryFile(id) {

  const d = uploadRegistry[id];

  if (!d) return;

  d.status  = 'pending';

  d.pct     = 0;

  d.error   = '';

  d.aborted = false;

  updateCard(id);

  startUpload(id);

  updateSummary();

}


// ── Retry all failed ──────────────────────────────────────────────────────────

function retryAllFailed() {

  Object.values(uploadRegistry)

    .filter(d => d.status === 'failed')

    .forEach(d => retryFile(d.id));

}


// ── Summary bar ───────────────────────────────────────────────────────────────

function updateSummary() {

  const all  = Object.values(uploadRegistry);

  const ok   = all.filter(d => d.status === 'success').length;

  const fail = all.filter(d => d.status === 'failed').length;


  document.getElementById('s-total').textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;

  document.getElementById('s-ok').textContent    = `✔ ${ok} uploaded`;

  document.getElementById('s-fail').textContent  = fail ? `✖ ${fail} failed` : '';


  const retryAllBtn = document.getElementById('btn-retry-all');

  retryAllBtn.style.display = fail > 0 ? '' : 'none';


  if (all.length === 0)

    document.getElementById('upload-summary').style.display = 'none';

}


// ── Core upload ───────────────────────────────────────────────────────────────

async function startUpload(id) {

  const d           = uploadRegistry[id];

  const file        = d.file;

  const RESUME_KEY  = `upload_${file.name}_${file.size}`;

  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);


  d.status = 'uploading';

  updateCard(id);


  // ── Resume: check saved uploadId ─────────────────────────────────────────

  let uploadId        = localStorage.getItem(RESUME_KEY);

  let alreadyReceived = new Set();


  if (uploadId) {

    try {

      const res    = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);

      const status = await res.json();

      if (status.ok && status.canResume) {

        status.receivedChunks.forEach(i => alreadyReceived.add(i));

      } else {

        uploadId = null;

      }

    } catch {

      uploadId = null;

    }

  }


  if (!uploadId) {

    uploadId = crypto.randomUUID().replace(/-/g, '');

    localStorage.setItem(RESUME_KEY, uploadId);

  }


  // ── Round 1: send pending chunks ─────────────────────────────────────────

  const failedChunks = [];


  for (let i = 0; i < totalChunks; i++) {

    if (d.aborted) return;


    if (alreadyReceived.has(i)) {

      d.pct = Math.round((i + 1) / totalChunks * 100);

      updateChunkLabel(id, i + 1, totalChunks, []);

      updateCard(id);

      continue;

    }


    const start = i * CHUNK_SIZE;

    const blob  = file.slice(start, start + CHUNK_SIZE);

    const sent  = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);


    if (d.aborted) return;


    if (!sent) {

      failedChunks.push(i);

    }


    d.pct = Math.round((i + 1) / totalChunks * 100);

    updateChunkLabel(id, i + 1, totalChunks, failedChunks);

    updateCard(id);

  }


  // ── Round 2: retry failed chunks ─────────────────────────────────────────

  if (failedChunks.length > 0 && !d.aborted) {

    await new Promise(r => setTimeout(r, 1000));


    const stillFailed = [];

    for (const i of failedChunks) {

      if (d.aborted) return;

      const start = i * CHUNK_SIZE;

      const blob  = file.slice(start, start + CHUNK_SIZE);

      const sent  = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);

      if (!sent) stillFailed.push(i);

      updateChunkLabel(id, totalChunks, totalChunks, stillFailed);

    }


    if (stillFailed.length > 0) {

      d.status = 'failed';

      d.error  = `Chunks [${stillFailed}] failed`;

      updateCard(id);

      updateSummary();

      return;

    }

  }


  // ── Finalize ─────────────────────────────────────────────────────────────

  if (d.aborted) return;


  try {

    const res  = await fetch(`${API_BASE}/finalize-upload`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        uploadId,

        originalname: file.name,

        mimetype    : file.type,

        serverpath  : SERVER_PATH,

        uploadUrl   : UPLOAD_URL,

        thumbUrl    : THUMB_URL,

        filetype    : 'image',

      }),

    });


    const data = await res.json();


    if (data.ok) {

      d.status   = 'success';

      d.pct      = 100;

      d.avifName = data.result?.fileName;

      localStorage.removeItem(RESUME_KEY);

      clearChunkLabel(id);

    } else {

      d.status = 'failed';

      d.error  = data.error || 'Finalize failed';

    }

  } catch (err) {

    d.status = 'failed';

    d.error  = err.message;

  }


  updateCard(id);

  updateSummary();

}


// ── Send one chunk with retry + back-off ──────────────────────────────────────

async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {

  for (let attempt = 0; attempt < maxRetries; attempt++) {

    try {

      const form = new FormData();

      form.append('uploadId',    uploadId);

      form.append('chunkIndex',  index);

      form.append('totalChunks', total);

      form.append('originalname', file.name);

      form.append('mimetype',    file.type);

      form.append('chunk',       blob, file.name);


      const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });

      if (res.ok) return true;

    } catch (e) {

      console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);

    }

    await new Promise(r => setTimeout(r, 500 * (attempt + 1)));

  }

  return false;

}


// ── savetask — inject uploaded filenames then submit ─────────────────────────

async function savetask() {

  const all     = Object.values(uploadRegistry);

  const failed  = all.filter(d => d.status === 'failed');

  const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');


  if (pending.length > 0) {

    alert(`${pending.length} file(s) still uploading. Please wait.`);

    return;

  }

  if (failed.length > 0) {

    alert(`${failed.length} file(s) failed. Please retry or remove them first.`);

    return;

  }


  const form = document.getElementById('form-create-task');


  // Remove any previously injected hidden inputs

  form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());


  // Inject avif filenames

  all.filter(d => d.status === 'success' && d.avifName).forEach(d => {

    const inp   = document.createElement('input');

    inp.type    = 'hidden';

    inp.name    = 'uploadedFiles';

    inp.value   = d.avifName;

    form.appendChild(inp);

  });


  form.submit();

}


// ── Helper ────────────────────────────────────────────────────────────────────

function fmtSize(bytes) {

  if (bytes < 1024)        return bytes + ' B';

  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';

  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';

}

What each part does

Per-file card shows thumbnail, filename, filesize, animated progress bar, chunk counter (chunk 3/5), and status label. When a chunk fails mid-upload it shows retrying [3,4] inline next to the progress bar.


Delete button — available at all times including while uploading. Sets aborted = true which the upload loop checks between every chunk, so the upload stops cleanly without a hard cancel. The card and registry entry are removed immediately.


Retry button — appears only on failed cards. Resets state and calls startUpload again. Because localStorage still has the uploadId, the server's /chunk-status route returns which chunks already arrived and the uploader skips them, only sending the missing ones.


Retry All Failed button appears in the summary bar when any file has failed. Calls retryFile on every failed entry at once.


savetask() blocks submission if anything is still uploading or failed, then injects the server-returned .avif filenames as hidden uploadedFiles inputs and clears the file input before submitting, so no raw binary data is posted to your ASP.NET controller.


document.getElementById('file-list').appendChild(card); Uncaught TypeError: Cannot read properties of null (reading 'appendChild')


May 19

The error means the script runs before the DOM is ready. The file-list div doesn't exist yet when the script executes.


Fix — wrap everything in DOMContentLoaded:


javascript

document.addEventListener('DOMContentLoaded', function () {


  // ── Config ────────────────────────────────────────────────────────────────

  const CHUNK_SIZE  = 2 * 1024 * 1024;

  const API_BASE    = 'https://your-node-server.com';

  const SERVER_PATH = '\\\\NAS\\share';

  const UPLOAD_URL  = 'images/2024';

  const THUMB_URL   = 'images/2024/thumbs';


  const uploadRegistry = {};

  let fileSeq = 0;


  // ── File input change ─────────────────────────────────────────────────────

  const attachInput = document.getElementById('taskattachments');

  if (!attachInput) {

    console.warn('taskattachments input not found');

    return;

  }


  attachInput.addEventListener('change', function () {

    Array.from(this.files).forEach(file => {

      const id = 'f' + (++fileSeq);

      uploadRegistry[id] = {

        id, file,

        status  : 'pending',

        pct     : 0,

        avifName: null,

        error   : '',

        aborted : false,

      };

      renderFileCard(id);

      startUpload(id);

    });


    this.value = '';

    updateSummary();

    document.getElementById('upload-summary').style.display = 'flex';

  });


  // ── All your functions go INSIDE DOMContentLoaded ─────────────────────────


  function renderFileCard(id) {

    const container = document.getElementById('file-list');

    if (!container) {

      console.warn('file-list not found');

      return;

    }


    const d     = uploadRegistry[id];

    const isImg = d.file.type.startsWith('image/');


    const thumbHtml = isImg

      ? `<img src="${URL.createObjectURL(d.file)}"

              style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"

              alt="">`

      : `<div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;

                     display:flex;align-items:center;justify-content:center;flex-shrink:0">

           <i class="bi bi-file-earmark fs-4 text-secondary"></i>

         </div>`;


    const card = document.createElement('div');

    card.id        = `card-${id}`;

    card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';

    card.style.cssText = 'background:#fff;transition:border-color .2s;';


    card.innerHTML = `

      ${thumbHtml}

      <div style="flex:1;min-width:0">

        <div class="d-flex justify-content-between align-items-center mb-1">

          <span class="text-truncate fw-500"

                style="font-size:13px;max-width:240px"

                title="${d.file.name}">${d.file.name}</span>

          <span class="text-muted"

                style="font-size:11px;flex-shrink:0;margin-left:8px">

            ${fmtSize(d.file.size)}

          </span>

        </div>

        <div class="progress mb-1" style="height:5px;">

          <div class="progress-bar" id="bar-${id}"

               style="width:0%;transition:width .3s;"></div>

        </div>

        <div class="d-flex justify-content-between">

          <span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>

          <span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>

        </div>

      </div>

      <div class="d-flex flex-column gap-1" style="flex-shrink:0">

        <button type="button" id="btn-retry-${id}"

                class="btn btn-sm btn-outline-warning py-0 px-2"

                style="display:none;font-size:11px">

          <i class="bi bi-arrow-repeat"></i> Retry

        </button>

        <button type="button"

                class="btn btn-sm btn-outline-danger py-0 px-2"

                style="font-size:11px">

          <i class="bi bi-trash"></i>

        </button>

      </div>`;


    // ✅ Attach click handlers after innerHTML set — avoids inline onclick

    //    scope issues inside DOMContentLoaded

    card.querySelector(`#btn-retry-${id}`)

        .addEventListener('click', () => retryFile(id));

    card.querySelectorAll('.btn-outline-danger')[0]

        .addEventListener('click', () => deleteFile(id));


    container.appendChild(card);

  }


  function updateCard(id) {

    const d        = uploadRegistry[id];

    const card     = document.getElementById(`card-${id}`);

    const bar      = document.getElementById(`bar-${id}`);

    const lbl      = document.getElementById(`lbl-${id}`);

    const retryBtn = document.getElementById(`btn-retry-${id}`);

    if (!card) return;


    bar.style.width = d.pct + '%';

    bar.className   = 'progress-bar';

    if      (d.status === 'uploading') bar.classList.add('bg-primary','progress-bar-striped','progress-bar-animated');

    else if (d.status === 'success')   bar.classList.add('bg-success');

    else if (d.status === 'failed')    bar.classList.add('bg-danger');


    card.style.borderColor =

      d.status === 'success' ? '#198754' :

      d.status === 'failed'  ? '#dc3545' : '';


    lbl.className = '';

    if      (d.status === 'uploading') { lbl.className = 'text-primary'; lbl.textContent = d.pct + '%'; }

    else if (d.status === 'success')   { lbl.className = 'text-success'; lbl.textContent = '✔ Uploaded'; }

    else if (d.status === 'failed')    { lbl.className = 'text-danger';  lbl.textContent = '✖ ' + (d.error || 'Failed'); }

    else                               { lbl.className = 'text-muted';   lbl.textContent = 'Pending'; }


    if (retryBtn) retryBtn.style.display = d.status === 'failed' ? '' : 'none';

  }


  function updateChunkLabel(id, done, total, failedArr) {

    const el = document.getElementById(`chunk-lbl-${id}`);

    if (!el) return;

    let t = `chunk ${done}/${total}`;

    if (failedArr && failedArr.length) t += ` · retrying [${failedArr.join(',')}]`;

    el.textContent = t;

  }


  function clearChunkLabel(id) {

    const el = document.getElementById(`chunk-lbl-${id}`);

    if (el) el.textContent = '';

  }


  function deleteFile(id) {

    if (uploadRegistry[id]) uploadRegistry[id].aborted = true;

    document.getElementById(`card-${id}`)?.remove();

    delete uploadRegistry[id];

    updateSummary();

  }


  function retryFile(id) {

    const d = uploadRegistry[id];

    if (!d) return;

    d.status  = 'pending';

    d.pct     = 0;

    d.error   = '';

    d.aborted = false;

    updateCard(id);

    startUpload(id);

    updateSummary();

  }


  function retryAllFailed() {

    Object.values(uploadRegistry)

      .filter(d => d.status === 'failed')

      .forEach(d => retryFile(d.id));

  }


  function updateSummary() {

    const all  = Object.values(uploadRegistry);

    const ok   = all.filter(d => d.status === 'success').length;

    const fail = all.filter(d => d.status === 'failed').length;


    const elTotal = document.getElementById('s-total');

    const elOk    = document.getElementById('s-ok');

    const elFail  = document.getElementById('s-fail');

    const retryBtn = document.getElementById('btn-retry-all');

    const summary  = document.getElementById('upload-summary');


    if (elTotal) elTotal.textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;

    if (elOk)    elOk.textContent    = `✔ ${ok} uploaded`;

    if (elFail)  elFail.textContent  = fail ? `✖ ${fail} failed` : '';

    if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';

    if (summary && all.length === 0) summary.style.display = 'none';

  }


  // ── Expose savetask globally so onclick="savetask()" still works ───────────

  window.savetask = async function () {

    const all     = Object.values(uploadRegistry);

    const failed  = all.filter(d => d.status === 'failed');

    const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');


    if (pending.length > 0) {

      alert(`${pending.length} file(s) still uploading. Please wait.`);

      return;

    }

    if (failed.length > 0) {

      alert(`${failed.length} file(s) failed. Please retry or remove them.`);

      return;

    }


    const form = document.getElementById('form-create-task');

    form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());


    all.filter(d => d.status === 'success' && d.avifName).forEach(d => {

      const inp = document.createElement('input');

      inp.type  = 'hidden';

      inp.name  = 'uploadedFiles';

      inp.value = d.avifName;

      form.appendChild(inp);

    });


    form.submit();

  };


  // ── Also expose retryAllFailed for the button onclick ─────────────────────

  window.retryAllFailed = retryAllFailed;


  // ── startUpload, sendChunkWithRetry, fmtSize (unchanged from before) ───────

  async function startUpload(id) {

    const d           = uploadRegistry[id];

    const file        = d.file;

    const RESUME_KEY  = `upload_${file.name}_${file.size}`;

    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);


    d.status = 'uploading';

    updateCard(id);


    let uploadId        = localStorage.getItem(RESUME_KEY);

    let alreadyReceived = new Set();


    if (uploadId) {

      try {

        const res    = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);

        const status = await res.json();

        if (status.ok && status.canResume) {

          status.receivedChunks.forEach(i => alreadyReceived.add(i));

        } else {

          uploadId = null;

        }

      } catch { uploadId = null; }

    }


    if (!uploadId) {

      uploadId = crypto.randomUUID().replace(/-/g, '');

      localStorage.setItem(RESUME_KEY, uploadId);

    }


    const failedChunks = [];


    for (let i = 0; i < totalChunks; i++) {

      if (d.aborted) return;


      if (alreadyReceived.has(i)) {

        d.pct = Math.round((i + 1) / totalChunks * 100);

        updateChunkLabel(id, i + 1, totalChunks, []);

        updateCard(id);

        continue;

      }


      const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);

      const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);


      if (d.aborted) return;

      if (!sent) failedChunks.push(i);


      d.pct = Math.round((i + 1) / totalChunks * 100);

      updateChunkLabel(id, i + 1, totalChunks, failedChunks);

      updateCard(id);

    }


    if (failedChunks.length > 0 && !d.aborted) {

      await new Promise(r => setTimeout(r, 1000));

      const stillFailed = [];


      for (const i of failedChunks) {

        if (d.aborted) return;

        const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);

        const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);

        if (!sent) stillFailed.push(i);

        updateChunkLabel(id, totalChunks, totalChunks, stillFailed);

      }


      if (stillFailed.length > 0) {

        d.status = 'failed';

        d.error  = `Chunks [${stillFailed}] failed`;

        updateCard(id);

        updateSummary();

        return;

      }

    }


    if (d.aborted) return;


    try {

      const res = await fetch(`${API_BASE}/finalize-upload`, {

        method : 'POST',

        headers: { 'Content-Type': 'application/json' },

        body   : JSON.stringify({

          uploadId,

          originalname: file.name,

          mimetype    : file.type,

          serverpath  : SERVER_PATH,

          uploadUrl   : UPLOAD_URL,

          thumbUrl    : THUMB_URL,

          filetype    : 'image',

        }),

      });


      const data = await res.json();


      if (data.ok) {

        d.status   = 'success';

        d.pct      = 100;

        d.avifName = data.result?.fileName;

        localStorage.removeItem(RESUME_KEY);

        clearChunkLabel(id);

      } else {

        d.status = 'failed';

        d.error  = data.error || 'Finalize failed';

      }

    } catch (err) {

      d.status = 'failed';

      d.error  = err.message;

    }


    updateCard(id);

    updateSummary();

  }


  async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {

    for (let attempt = 0; attempt < maxRetries; attempt++) {

      try {

        const form = new FormData();

        form.append('uploadId',     uploadId);

        form.append('chunkIndex',   index);

        form.append('totalChunks',  total);

        form.append('originalname', file.name);

        form.append('mimetype',     file.type);

        form.append('chunk',        blob, file.name);


        const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });

        if (res.ok) return true;

      } catch (e) {

        console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);

      }

      await new Promise(r => setTimeout(r, 500 * (attempt + 1)));

    }

    return false;

  }


  function fmtSize(bytes) {

    if (bytes < 1024)        return bytes + ' B';

    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';

    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';

  }


}); // end DOMContentLoaded

Why this fixes it

The root cause is scope — when your script runs at page load, the Razor view hasn't finished rendering the file-list div yet, so getElementById returns null.


Three things the fix does together:


DOMContentLoaded — wraps everything so no code runs until the full HTML is parsed and all elements exist in the DOM.


window.savetask and window.retryAllFailed — since your button uses onclick="savetask()" in the HTML, that function must be on window (global scope). Functions declared inside DOMContentLoaded are local by default, so they're explicitly assigned to window.


addEventListener instead of onclick in innerHTML — inline onclick strings inside DOMContentLoaded lose their closure scope. Using .addEventListener('click', () => retryFile(id)) after the card is created keeps the correct id reference.


throwing task:3486 file-list not found

renderFileCard @ task:3486

(anonymous) @ task:3472

(anonymous) @ task:3462Understand this warning

task:3478 Uncaught TypeError: Cannot read properties of null (reading 'style')

    at HTMLInputElement.<anonymous> (task:3478:86)


May 19

The file-list div and upload-summary div are missing from your HTML. Add them to your form:


In your view, find this section and replace:


html

<!-- BEFORE (your current code) -->

<div class="col-md-12">

    <div id="image_preview" class="my-3 row"></div>

</div>

html

<!-- AFTER — replace with this -->

<div class="col-md-12">


    <!-- Summary bar -->

    <div class="d-flex align-items-center gap-3 my-2"

         id="upload-summary"

         style="display:none!important">

        <span class="text-muted"  id="s-total"></span>

        <span class="text-success" id="s-ok"></span>

        <span class="text-danger"  id="s-fail"></span>

        <button type="button"

                class="btn btn-sm btn-warning"

                id="btn-retry-all"

                onclick="retryAllFailed()"

                style="display:none">

            <i class="bi bi-arrow-repeat"></i> Retry All Failed

        </button>

    </div>


    <!-- Per-file cards render here -->

    <div id="file-list" class="my-2"></div>


</div>

Then fix the style null error — line 3478 is this line:


javascript

// ❌ fails because display:none!important blocks the style assignment

document.getElementById('upload-summary').style.display = 'flex';

The !important in the inline style fights the JS assignment. Fix by using a CSS class toggle instead:


html

<!-- Add this inside your <style> or <head> -->

<style>

    #upload-summary { display: none; }

    #upload-summary.visible { display: flex !important; }

</style>

Then in the JS replace every upload-summary style line:


javascript

// Find and replace these two lines in the script:


// ❌ Old — causes null crash if element missing, fights !important

document.getElementById('upload-summary').style.display = 'flex';

document.getElementById('upload-summary').style.display = 'none';  // in updateSummary


// ✅ New — safe null check + class toggle

document.getElementById('upload-summary')?.classList.add('visible');

document.getElementById('upload-summary')?.classList.remove('visible');

Exact locations in the script to update:


javascript

// 1. Inside the 'change' event listener (around line where files are added):

attachInput.addEventListener('change', function () {

    Array.from(this.files).forEach(file => {

        const id = 'f' + (++fileSeq);

        uploadRegistry[id] = {

            id, file,

            status  : 'pending',

            pct     : 0,

            avifName: null,

            error   : '',

            aborted : false,

        };

        renderFileCard(id);

        startUpload(id);

    });


    this.value = '';

    updateSummary();

    document.getElementById('upload-summary')?.classList.add('visible'); // ✅ fixed

});


// 2. Inside updateSummary() function:

function updateSummary() {

    const all  = Object.values(uploadRegistry);

    const ok   = all.filter(d => d.status === 'success').length;

    const fail = all.filter(d => d.status === 'failed').length;


    const elTotal  = document.getElementById('s-total');

    const elOk     = document.getElementById('s-ok');

    const elFail   = document.getElementById('s-fail');

    const retryBtn = document.getElementById('btn-retry-all');

    const summary  = document.getElementById('upload-summary');


    if (elTotal)  elTotal.textContent  = `${all.length} file${all.length !== 1 ? 's' : ''}`;

    if (elOk)     elOk.textContent     = `✔ ${ok} uploaded`;

    if (elFail)   elFail.textContent   = fail ? `✖ ${fail} failed` : '';

    if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';


    // ✅ fixed — toggle class instead of style

    if (summary) {

        if (all.length === 0) summary.classList.remove('visible');

        else                  summary.classList.add('visible');

    }

}

Quick checklist — verify your HTML has all required IDs

Required IDs in your Razor view:

  ✅ id="form-create-task"     ← your <form> tag

  ✅ id="taskattachments"      ← the file input

  ✅ id="upload-summary"       ← summary bar div      ← was missing

  ✅ id="file-list"            ← cards container      ← was missing

  ✅ id="s-total"              ← inside upload-summary

  ✅ id="s-ok"                 ← inside upload-summary

  ✅ id="s-fail"               ← inside upload-summary

  ✅ id="btn-retry-all"        ← inside upload-summary

  ✅ id="btnsave"              ← your Send Work button

All these must exist in the HTML before the </body> tag where your script runs. The DOMContentLoaded wrapper handles timing, but the elements still need to physically be in the Razor template.


delete file in server path also


May 19

Add a delete route in Node.js and call it from the JS delete button:


Node.js — add delete route:


javascript

// ─── POST /delete-file ────────────────────────────────────────────────────────

// JSON body: { fileName, serverpath, uploadUrl, thumbUrl }

const deleteFile = async (req, res) => {

  const { fileName, serverpath, uploadUrl, thumbUrl } = req.body;


  if (!fileName || !serverpath || !uploadUrl) {

    return res.status(400).json({ ok: false, error: 'Missing required fields' });

  }


  const results = {

    main : { deleted: false, error: null },

    thumb: { deleted: false, error: null },

  };


  // ── Delete main file ────────────────────────────────────────────────────────

  try {

    const mainPath = path.join(`\\${serverpath}\\${uploadUrl}`, fileName);

    logToFile(`🗑 Deleting main: ${mainPath}`);


    if (fs.existsSync(mainPath)) {

      await fss.unlink(mainPath);

      results.main.deleted = true;

      logToFile(`✔ Deleted main: ${mainPath}`);

    } else {

      results.main.error = 'File not found on server';

      logToFile(`⚠ Main file not found: ${mainPath}`);

    }

  } catch (err) {

    results.main.error = err.message;

    logToFile(`❌ Delete main failed: ${err.message}`);

  }


  // ── Delete thumb file ───────────────────────────────────────────────────────

  if (thumbUrl) {

    try {

      const thumbPath = path.join(`\\${serverpath}\\${thumbUrl}`, fileName);

      logToFile(`🗑 Deleting thumb: ${thumbPath}`);


      if (fs.existsSync(thumbPath)) {

        await fss.unlink(thumbPath);

        results.thumb.deleted = true;

        logToFile(`✔ Deleted thumb: ${thumbPath}`);

      } else {

        results.thumb.error = 'Thumb not found';

        logToFile(`⚠ Thumb not found: ${thumbPath}`);

      }

    } catch (err) {

      results.thumb.error = err.message;

      logToFile(`❌ Delete thumb failed: ${err.message}`);

    }

  }


  res.json({

    ok     : results.main.deleted,

    results,

  });

};

Register the route:


javascript

router.post('/delete-file', express.json(), deleteFile);

JavaScript — update deleteFile function:


javascript

function deleteFile(id) {

  const d = uploadRegistry[id];

  if (!d) return;


  // Mark aborted so any in-progress upload loop stops

  d.aborted = true;


  // Visually remove card immediately — don't wait for server

  document.getElementById(`card-${id}`)?.remove();

  delete uploadRegistry[id];

  updateSummary();


  // ── Only call server delete if file was successfully uploaded ───────────────

  // (if still pending/uploading/failed, nothing was saved on server yet)

  if (d.status === 'success' && d.avifName) {

    deleteFromServer(d.avifName);

  }

}


// ── Call Node.js delete endpoint ──────────────────────────────────────────────

async function deleteFromServer(avifName) {

  try {

    const res = await fetch(`${API_BASE}/delete-file`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        fileName  : avifName,

        serverpath: SERVER_PATH,

        uploadUrl : UPLOAD_URL,

        thumbUrl  : THUMB_URL,

      }),

    });


    const data = await res.json();


    if (data.ok) {

      console.log(`✔ Server deleted: ${avifName}`);

    } else {

      // Non-critical — file already removed from UI, just log

      console.warn(`⚠ Server delete issue for ${avifName}:`, data.results);

    }

  } catch (err) {

    console.warn(`⚠ Server delete failed for ${avifName}:`, err.message);

  }

}

What happens for each file state when delete is clicked

status = 'pending'   → upload never started    → just remove card, no server call

status = 'uploading' → set aborted=true         → upload loop stops between chunks

                                                → chunks are in /tmp only, not NAS

                                                → just remove card, no server call

status = 'failed'    → nothing saved on NAS     → just remove card, no server call

status = 'success'   → avif saved on NAS        → DELETE main + thumb from NAS

                                                → remove card

The card disappears instantly in all cases — the server delete runs silently in the background for successful uploads only, so the user never waits for it.


also need when network comes failed file chunks automatically upload


May 21

Add a network monitor that watches online/offline events and auto-resumes failed files when connection restores:


javascript

// ── Network monitor — add inside DOMContentLoaded ─────────────────────────────


let isOnline = navigator.onLine;

let autoResumeTimer = null;


// ── Show/hide network banner ──────────────────────────────────────────────────

function showNetworkBanner(online) {

  let banner = document.getElementById('network-banner');


  if (!banner) {

    banner = document.createElement('div');

    banner.id = 'network-banner';

    banner.style.cssText = `

      position  : fixed;

      bottom    : 20px;

      left      : 50%;

      transform : translateX(-50%);

      padding   : 8px 20px;

      border-radius : 99px;

      font-size : 13px;

      font-weight : 500;

      z-index   : 9999;

      transition: opacity .4s;

      display   : flex;

      align-items: center;

      gap       : 8px;

    `;

    document.body.appendChild(banner);

  }


  if (online) {

    banner.style.background  = '#198754';

    banner.style.color       = '#fff';

    banner.innerHTML = '<i class="bi bi-wifi"></i> Back online — resuming uploads...';

    banner.style.opacity = '1';


    // Auto-hide after 3s

    setTimeout(() => { banner.style.opacity = '0'; }, 3000);


  } else {

    banner.style.background  = '#dc3545';

    banner.style.color       = '#fff';

    banner.innerHTML = '<i class="bi bi-wifi-off"></i> No network — uploads paused';

    banner.style.opacity = '1';

  }

}


// ── Network went offline ──────────────────────────────────────────────────────

window.addEventListener('offline', () => {

  isOnline = false;

  showNetworkBanner(false);

  console.warn('📵 Network offline — uploads will resume when back online');


  // Mark any currently uploading files as failed so they show retry state

  Object.values(uploadRegistry).forEach(d => {

    if (d.status === 'uploading') {

      d.aborted = true;   // stop the current upload loop

      d.status  = 'failed';

      d.error   = 'Network lost — will auto-resume';

      updateCard(d.id);

    }

  });


  updateSummary();

});


// ── Network came back online ──────────────────────────────────────────────────

window.addEventListener('online', () => {

  isOnline = true;

  showNetworkBanner(true);

  console.log('✅ Network restored');


  // Small delay — let the connection stabilise before retrying

  clearTimeout(autoResumeTimer);

  autoResumeTimer = setTimeout(() => {

    autoResumeFailedUploads();

  }, 1500);

});


// ── Auto-resume all failed uploads ────────────────────────────────────────────

function autoResumeFailedUploads() {

  const failed = Object.values(uploadRegistry).filter(d => d.status === 'failed');


  if (failed.length === 0) return;


  console.log(`🔄 Auto-resuming ${failed.length} failed upload(s)`);


  failed.forEach((d, index) => {

    // Stagger restarts so they don't all hammer the server at once

    setTimeout(() => {

      if (!isOnline) return;   // went offline again during stagger

      retryFile(d.id);

    }, index * 800);

  });

}


// ── Periodic check — catches cases where online event doesn't fire ─────────────

// (Some mobile browsers don't fire 'online' reliably)

setInterval(() => {

  const currentlyOnline = navigator.onLine;


  // Transition: was offline → now online

  if (!isOnline && currentlyOnline) {

    isOnline = true;

    showNetworkBanner(true);

    clearTimeout(autoResumeTimer);

    autoResumeTimer = setTimeout(autoResumeFailedUploads, 1500);

  }


  // Transition: was online → now offline

  if (isOnline && !currentlyOnline) {

    isOnline = false;

    showNetworkBanner(false);

  }


  isOnline = currentlyOnline;

}, 3000);  // check every 3 seconds

Also update sendChunkWithRetry to respect isOnline — stops retrying immediately when offline:


javascript

async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {

  for (let attempt = 0; attempt < maxRetries; attempt++) {


    // ✅ Stop immediately if offline — no point burning retries

    if (!isOnline) {

      console.warn(`chunk ${index} skipped — offline`);

      return false;

    }


    try {

      const form = new FormData();

      form.append('uploadId',     uploadId);

      form.append('chunkIndex',   index);

      form.append('totalChunks',  total);

      form.append('originalname', file.name);

      form.append('mimetype',     file.type);

      form.append('chunk',        blob, file.name);


      const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });

      if (res.ok) return true;


      console.warn(`chunk ${index} attempt ${attempt + 1} HTTP ${res.status}`);


    } catch (e) {

      console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);


      // ✅ If fetch throws a network error, check online status immediately

      if (!navigator.onLine) {

        isOnline = false;

        return false;

      }

    }


    await new Promise(r => setTimeout(r, 500 * (attempt + 1)));

  }

  return false;

}

Also update startUpload to check isOnline before starting:


javascript

async function startUpload(id) {

  const d = uploadRegistry[id];


  // ✅ Don't even start if offline — stays as failed until network returns

  if (!isOnline) {

    d.status = 'failed';

    d.error  = 'No network — will auto-resume';

    updateCard(id);

    updateSummary();

    return;

  }


  // ... rest of startUpload unchanged

}

Full flow

User selects image1.jpg + image2.jpg

  → both start uploading


Network cuts at chunk 3/5 of image2.jpg:

  offline event fires

  → banner: "No network — uploads paused"

  → image2.jpg marked failed: "Network lost — will auto-resume"

  → upload loop aborted (aborted = true)

  → image1.jpg (still uploading) also marked failed


Network restores after 30 seconds:

  online event fires

  → banner: "Back online — resuming uploads..."

  → autoResumeFailedUploads() called after 1.5s delay


  image1.jpg resumes:

    GET /chunk-status → server has [0,1,2]

    skips 0,1,2  ⏭

    sends 3 ✔  4 ✔

    finalize ✔ → avif saved


  image2.jpg resumes (800ms later):

    GET /chunk-status → server has [0,1,2,3]

    skips 0,1,2,3  ⏭

    sends 4 ✔

    finalize ✔ → avif saved


  Both cards → green ✔ Uploaded

  "Send Work" button re-enabled

otherwise button click


May 21

Add a manual "Resume Uploads" button that appears when there are failed uploads:


Add this button next to your Send Work button in the form:


html

<div class="col-md-12 text-end">

    <a href="#" id="btnproof" value="ProofRequired" class="va-bottom">

        <i class="bi bi-file-check"></i> <span>Proof Not Required</span>

    </a>

    <a href="#" id="btnonetime" value="Onetime" class="va-bottom ms-4">

        <i class="bi bi-clock"></i> <span>One Time</span>

    </a>


    <!-- ✅ Add this resume button -->

    <button type="button"

            class="btn btn-lg btn-warning ms-4"

            id="btn-resume-uploads"

            onclick="manualResumeUploads()"

            style="display:none">

        <i class="bi bi-arrow-repeat"></i> Resume Uploads

    </button>


    <button type="button"

            class="btn btn-lg btn-theme ms-4 modal-submit-btn"

            onclick="savetask()"

            id="btnsave">

        Send Work

    </button>

</div>

Add these functions inside your DOMContentLoaded block:


javascript

// ── Show/hide resume button ───────────────────────────────────────────────────

function updateResumeButton() {

  const btn     = document.getElementById('btn-resume-uploads');

  const failed  = Object.values(uploadRegistry).filter(d => d.status === 'failed');

  const pending = Object.values(uploadRegistry).filter(d => d.status === 'uploading');


  if (!btn) return;


  // Show only when there are failed files and nothing currently uploading

  if (failed.length > 0 && pending.length === 0) {

    btn.style.display = '';

    btn.innerHTML = `<i class="bi bi-arrow-repeat"></i> Resume ${failed.length} Upload${failed.length > 1 ? 's' : ''}`;

  } else {

    btn.style.display = 'none';

  }

}


// ── Manual resume — triggered by button click ─────────────────────────────────

window.manualResumeUploads = function () {

  const failed = Object.values(uploadRegistry).filter(d => d.status === 'failed');

  if (failed.length === 0) return;


  // Check network first

  if (!navigator.onLine) {

    showNetworkBanner(false);

    alert('Still no network connection. Please check your internet and try again.');

    return;

  }


  const btn = document.getElementById('btn-resume-uploads');

  if (btn) {

    btn.disabled    = true;

    btn.innerHTML   = '<span class="spinner-border spinner-border-sm me-2"></span>Resuming...';

  }


  console.log(`▶ Manual resume: ${failed.length} file(s)`);


  // Stagger restarts

  failed.forEach((d, index) => {

    setTimeout(() => {

      retryFile(d.id);

    }, index * 800);

  });

};

Update updateSummary to also call updateResumeButton:


javascript

function updateSummary() {

  const all  = Object.values(uploadRegistry);

  const ok   = all.filter(d => d.status === 'success').length;

  const fail = all.filter(d => d.status === 'failed').length;


  const elTotal  = document.getElementById('s-total');

  const elOk     = document.getElementById('s-ok');

  const elFail   = document.getElementById('s-fail');

  const retryBtn = document.getElementById('btn-retry-all');

  const summary  = document.getElementById('upload-summary');


  if (elTotal)  elTotal.textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;

  if (elOk)     elOk.textContent    = `✔ ${ok} uploaded`;

  if (elFail)   elFail.textContent  = fail ? `✖ ${fail} failed` : '';

  if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';


  if (summary) {

    if (all.length === 0) summary.classList.remove('visible');

    else                  summary.classList.add('visible');

  }


  // ✅ sync resume button state

  updateResumeButton();

}

Update updateCard to reset the resume button when upload finishes:


javascript

function updateCard(id) {

  const d        = uploadRegistry[id];

  const card     = document.getElementById(`card-${id}`);

  const bar      = document.getElementById(`bar-${id}`);

  const lbl      = document.getElementById(`lbl-${id}`);

  const retryBtn = document.getElementById(`btn-retry-${id}`);

  if (!card) return;


  bar.style.width = d.pct + '%';

  bar.className   = 'progress-bar';

  if      (d.status === 'uploading') bar.classList.add('bg-primary','progress-bar-striped','progress-bar-animated');

  else if (d.status === 'success')   bar.classList.add('bg-success');

  else if (d.status === 'failed')    bar.classList.add('bg-danger');


  card.style.borderColor =

    d.status === 'success' ? '#198754' :

    d.status === 'failed'  ? '#dc3545' : '';


  lbl.className = '';

  if      (d.status === 'uploading') { lbl.className = 'text-primary'; lbl.textContent = d.pct + '%'; }

  else if (d.status === 'success')   { lbl.className = 'text-success'; lbl.textContent = '✔ Uploaded'; }

  else if (d.status === 'failed')    { lbl.className = 'text-danger';  lbl.textContent = '✖ ' + (d.error || 'Failed'); }

  else                               { lbl.className = 'text-muted';   lbl.textContent = 'Pending'; }


  if (retryBtn) retryBtn.style.display = d.status === 'failed' ? '' : 'none';


  // ✅ re-evaluate resume button on every card state change

  updateResumeButton();

}

Button states at a glance

All uploading normally:

  [Resume Uploads]  hidden

  [Send Work]       disabled (uploads in progress)


Network cuts — files fail:

  [Resume Uploads]  visible  "Resume 2 Uploads"

  [Send Work]       blocked  (failed files present)


User clicks Resume Uploads:

  [Resume Uploads]  "Resuming..."  disabled

  → retryFile() called for each failed file

  → chunks resume from where server left off


All recovered:

  [Resume Uploads]  hidden again

  [Send Work]       enabled  ✔

when all chunks uploaded taskattachments no files noy presnet why


May 25

The issue is this line that clears the file input after selecting:


javascript

// This runs immediately after files are selected — clears input before upload finishes

this.value = '';

The file input is empty when savetask() runs because you cleared it right away. But that's intentional — you don't want to re-POST the raw files. The real issue is the form still has taskattachments as a required file input, so the browser blocks submit when it's empty.


Fix — three things:


1. Remove required from the file input in your Razor view:


html

<!-- BEFORE -->

<input class="form-control" type="file" multiple id="taskattachments" name="taskattachments" />


<!-- AFTER — remove name so it never posts, validation handled by JS -->

<input class="form-control" type="file" multiple id="taskattachments" />

Removing name means the file input is never included in the form POST at all — the uploaded avif filenames come from the hidden inputs injected by savetask() instead.


2. Update savetask() to validate uploads before submit:


javascript

window.savetask = async function () {

  const all     = Object.values(uploadRegistry);

  const failed  = all.filter(d => d.status === 'failed');

  const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');

  const success = all.filter(d => d.status === 'success' && d.avifName);


  // ── Still uploading ───────────────────────────────────────────────────────

  if (pending.length > 0) {

    alert(`${pending.length} file(s) still uploading. Please wait.`);

    return;

  }


  // ── Some failed ───────────────────────────────────────────────────────────

  if (failed.length > 0) {

    alert(`${failed.length} file(s) not uploaded. Please retry or remove them.`);

    return;

  }


  // ── Files were selected but none succeeded ────────────────────────────────

  if (all.length > 0 && success.length === 0) {

    alert('No files uploaded successfully. Please retry.');

    return;

  }


  const form = document.getElementById('form-create-task');


  // Remove any previously injected hidden inputs from a previous submit attempt

  form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());


  // Inject one hidden input per successfully uploaded avif

  success.forEach(d => {

    const inp = document.createElement('input');

    inp.type  = 'hidden';

    inp.name  = 'uploadedFiles';

    inp.value = d.avifName;

    form.appendChild(inp);

  });


  // Log for debug — remove in production

  console.log('Submitting with files:', success.map(d => d.avifName));


  form.submit();

};

3. Update the change handler — keep files in memory, only clear input value:


javascript

attachInput.addEventListener('change', function () {

  if (!this.files || this.files.length === 0) return;


  Array.from(this.files).forEach(file => {

    // ── Skip if same file already registered (re-select guard) ───────────────

    const alreadyExists = Object.values(uploadRegistry)

      .some(d => d.file.name === file.name && d.file.size === file.size);


    if (alreadyExists) {

      console.log(`Skipped duplicate: ${file.name}`);

      return;

    }


    const id = 'f' + (++fileSeq);

    uploadRegistry[id] = {

      id,

      file,               // ✅ File object stays in uploadRegistry memory

      status  : 'pending',

      pct     : 0,

      avifName: null,

      error   : '',

      aborted : false,

    };


    renderFileCard(id);

    startUpload(id);

  });


  // ✅ Clear input VALUE only — the File objects are safe in uploadRegistry

  // This allows the same file to be re-selected if deleted from the list

  this.value = '';


  updateSummary();

  document.getElementById('upload-summary')?.classList.add('visible');

});

ASP.NET Controller — receive the hidden uploadedFiles:


csharp

[HttpPost]

public async Task<IActionResult> TaskCreate(

    TaskCreateViewModel model,

    [FromForm] List<string> uploadedFiles)

{

    // uploadedFiles = ["photo1.avif", "photo2.avif"]

    // taskattachments will be null/empty — that's expected

    model.Attachments = uploadedFiles ?? new List<string>();


    // save to DB...

    return RedirectToAction("Index");

}

Why this works

User selects image1.jpg + image2.jpg

  ↓

change fires:

  File objects stored in uploadRegistry[f1].file

                        uploadRegistry[f2].file

  input.value = ''      ← input visually cleared

                          but File objects live in memory ✔


Chunks upload using uploadRegistry[id].file.slice(...)

  ← reads directly from the File object in memory, not the input


All chunks done → finalize → avifName stored in uploadRegistry


savetask() called:

  reads uploadRegistry → finds f1.avifName = "image1.avif"

                               f2.avifName = "image2.avif"

  injects hidden inputs:

    <input type="hidden" name="uploadedFiles" value="image1.avif">

    <input type="hidden" name="uploadedFiles" value="image2.avif">

  form.submit() → POST to /Home/TaskCreate

  controller receives: uploadedFiles = ["image1.avif", "image2.avif"] ✔

[Route("/getfolderpath")]

    public async Task<IActionResult> GetFolderPath()

    {


            folderpath = $"{_profile.GetProfilePath(profile.cc, profile._id)}\\work\\{DateTime.Now.ToString("MMyyyy")}";

            folderpath_thumb = $"{_profile.GetProfilePath(profile.cc, profile._id)}\\work\\{DateTime.Now.ToString("MMyyyy")}\\thumb";

        }

        return Ok(new { folderpath, folderpath_thumb });

    } get folder and thumbbath getting here


May 25

Get the folder paths from your API before starting upload, then use them in every chunk and finalize call:


Update your JS — add getFolderPaths() and call it on file select:


javascript

// ── Folder paths — fetched once from server ───────────────────────────────────

let SERVER_PATH  = '';

let UPLOAD_URL   = '';

let THUMB_URL    = '';

let pathsLoaded  = false;


async function getFolderPaths() {

  if (pathsLoaded) return true;  // already fetched


  try {

    const res  = await fetch('/getfolderpath');

    const data = await res.json();


    if (!data.folderpath || !data.folderpath_thumb) {

      console.error('Invalid folder path response', data);

      return false;

    }


    // folderpath      = "\\NAS\share\work\052025"

    // folderpath_thumb = "\\NAS\share\work\052025\thumb"

    // Split into serverpath + uploadUrl for the Node upload API


    SERVER_PATH = '';        // not needed — full path comes from server

    UPLOAD_URL  = data.folderpath;

    THUMB_URL   = data.folderpath_thumb;

    pathsLoaded = true;


    console.log('✔ Folder paths loaded:', UPLOAD_URL, THUMB_URL);

    return true;


  } catch (err) {

    console.error('getFolderPaths failed:', err.message);

    return false;

  }

}

Update the change handler to fetch paths first:


javascript

attachInput.addEventListener('change', async function () {

  if (!this.files || this.files.length === 0) return;


  // ── Disable input while fetching paths ───────────────────────────────────

  const btn = document.getElementById('btnsave');

  if (btn) { btn.disabled = true; btn.innerText = 'Loading...'; }


  // ── Fetch folder paths before any upload starts ───────────────────────────

  const ok = await getFolderPaths();

  if (!ok) {

    alert('Could not load upload folder. Please try again.');

    if (btn) { btn.disabled = false; btn.innerText = 'Send Work'; }

    this.value = '';

    return;

  }


  if (btn) { btn.disabled = false; btn.innerText = 'Send Work'; }


  Array.from(this.files).forEach(file => {

    const alreadyExists = Object.values(uploadRegistry)

      .some(d => d.file.name === file.name && d.file.size === file.size);


    if (alreadyExists) {

      console.log(`Skipped duplicate: ${file.name}`);

      return;

    }


    const id = 'f' + (++fileSeq);

    uploadRegistry[id] = {

      id,

      file,

      status  : 'pending',

      pct     : 0,

      avifName: null,

      error   : '',

      aborted : false,

    };


    renderFileCard(id);

    startUpload(id);

  });


  this.value = '';

  updateSummary();

  document.getElementById('upload-summary')?.classList.add('visible');

});

Update startUpload — pass UPLOAD_URL and THUMB_URL into finalize:


javascript

async function startUpload(id) {

  const d = uploadRegistry[id];


  if (!isOnline) {

    d.status = 'failed';

    d.error  = 'No network — will auto-resume';

    updateCard(id);

    updateSummary();

    return;

  }


  // ── Guard: paths must be loaded before upload ─────────────────────────────

  if (!pathsLoaded) {

    const ok = await getFolderPaths();

    if (!ok) {

      d.status = 'failed';

      d.error  = 'Could not load folder path';

      updateCard(id);

      updateSummary();

      return;

    }

  }


  const file        = d.file;

  const RESUME_KEY  = `upload_${file.name}_${file.size}`;

  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);


  d.status = 'uploading';

  updateCard(id);


  // ── Resume check ──────────────────────────────────────────────────────────

  let uploadId        = localStorage.getItem(RESUME_KEY);

  let alreadyReceived = new Set();


  if (uploadId) {

    try {

      const res    = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);

      const status = await res.json();

      if (status.ok && status.canResume) {

        status.receivedChunks.forEach(i => alreadyReceived.add(i));

      } else {

        uploadId = null;

      }

    } catch { uploadId = null; }

  }


  if (!uploadId) {

    uploadId = crypto.randomUUID().replace(/-/g, '');

    localStorage.setItem(RESUME_KEY, uploadId);

  }


  // ── Round 1: send pending chunks ──────────────────────────────────────────

  const failedChunks = [];


  for (let i = 0; i < totalChunks; i++) {

    if (d.aborted) return;


    if (alreadyReceived.has(i)) {

      d.pct = Math.round((i + 1) / totalChunks * 100);

      updateChunkLabel(id, i + 1, totalChunks, []);

      updateCard(id);

      continue;

    }


    const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);

    const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);


    if (d.aborted) return;

    if (!sent) failedChunks.push(i);


    d.pct = Math.round((i + 1) / totalChunks * 100);

    updateChunkLabel(id, i + 1, totalChunks, failedChunks);

    updateCard(id);

  }


  // ── Round 2: retry failed chunks ──────────────────────────────────────────

  if (failedChunks.length > 0 && !d.aborted) {

    await new Promise(r => setTimeout(r, 1000));

    const stillFailed = [];


    for (const i of failedChunks) {

      if (d.aborted) return;

      const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);

      const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);

      if (!sent) stillFailed.push(i);

      updateChunkLabel(id, totalChunks, totalChunks, stillFailed);

    }


    if (stillFailed.length > 0) {

      d.status = 'failed';

      d.error  = `Chunks [${stillFailed}] failed`;

      updateCard(id);

      updateSummary();

      return;

    }

  }


  if (d.aborted) return;


  // ── Finalize — use paths fetched from /getfolderpath ─────────────────────

  try {

    const res = await fetch(`${API_BASE}/finalize-upload`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        uploadId,

        originalname: file.name,

        mimetype    : file.type,

        serverpath  : '',          // empty — full path is in uploadUrl

        uploadUrl   : UPLOAD_URL,  // ✅ from /getfolderpath

        thumbUrl    : THUMB_URL,   // ✅ from /getfolderpath

        filetype    : 'image',

      }),

    });


    const data = await res.json();


    if (data.ok) {

      d.status   = 'success';

      d.pct      = 100;

      d.avifName = data.result?.fileName;

      localStorage.removeItem(RESUME_KEY);

      clearChunkLabel(id);

    } else {

      d.status = 'failed';

      d.error  = data.error || 'Finalize failed';

    }

  } catch (err) {

    d.status = 'failed';

    d.error  = err.message;

  }


  updateCard(id);

  updateSummary();

}

Update deleteFromServer — use same paths:


javascript

async function deleteFromServer(avifName) {

  try {

    const res = await fetch(`${API_BASE}/delete-file`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        fileName  : avifName,

        serverpath: '',         // empty — full path in uploadUrl

        uploadUrl : UPLOAD_URL, // ✅ from /getfolderpath

        thumbUrl  : THUMB_URL,  // ✅ from /getfolderpath

      }),

    });


    const data = await res.json();

    if (data.ok) console.log(`✔ Server deleted: ${avifName}`);

    else         console.warn(`⚠ Server delete issue:`, data.results);

  } catch (err) {

    console.warn(`⚠ Server delete failed:`, err.message);

  }

}

Node.js — update processSingleImage to handle full path in uploadUrl:


javascript

async function processSingleImage(req, body) {

  const buffer = req.buffer;

  const { uploadUrl, serverpath, filetype, thumbUrl } = body;


  const fileName = req.originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');


  try {

    // ✅ If serverpath is empty, uploadUrl is already the full path

    const SHARE_ROOT = serverpath

      ? `\\${serverpath}\\${uploadUrl}`

      : uploadUrl;                    // e.g. \\NAS\share\work\052025


    const THUMB_ROOT = serverpath

      ? `\\${serverpath}\\${thumbUrl}`

      : thumbUrl;                     // e.g. \\NAS\share\work\052025\thumb


    if (!fs.existsSync(SHARE_ROOT)) {

      fs.mkdirSync(SHARE_ROOT, { recursive: true });

    }


    // ... rest of processSingleImage unchanged

Full flow

User selects files

  ↓

GET /getfolderpath

  ← { folderpath: "\\NAS\share\work\052025",

      folderpath_thumb: "\\NAS\share\work\052025\thumb" }


UPLOAD_URL = "\\NAS\share\work\052025"

THUMB_URL  = "\\NAS\share\work\052025\thumb"

pathsLoaded = true


Chunks upload to Node → POST /upload-chunk (no path needed)


POST /finalize-upload

  { uploadUrl: "\\NAS\share\work\052025",

    thumbUrl:  "\\NAS\share\work\052025\thumb" }


Node saves:

  main  → \\NAS\share\work\052025\image1.avif

  thumb → \\NAS\share\work\052025\thumb\image1.avif  ✔

async function getFolderPaths() { define with in  document.addEventListener('DOMContentLoaded', function () { or outside


May 25

Define it outside DOMContentLoaded — here's why and the correct structure:


javascript

// ── Config — only API_BASE here, paths come from server ──────────────────────

const CHUNK_SIZE = 2 * 1024 * 1024;

const API_BASE   = 'https://your-node-server.com';


// ── Folder paths — filled by getFolderPaths() ─────────────────────────────────

let SERVER_PATH = '';

let UPLOAD_URL  = '';

let THUMB_URL   = '';

let pathsLoaded = false;


// ── Upload state ──────────────────────────────────────────────────────────────

const uploadRegistry = {};

let fileSeq  = 0;

let isOnline = navigator.onLine;

let autoResumeTimer = null;


// ─────────────────────────────────────────────────────────────────────────────

// OUTSIDE DOMContentLoaded — can be called from anywhere (retry, resume, etc.)

// ─────────────────────────────────────────────────────────────────────────────

async function getFolderPaths() {

  if (pathsLoaded) return true;


  try {

    const res  = await fetch('/getfolderpath');

    const data = await res.json();


    if (!data.folderpath || !data.folderpath_thumb) {

      console.error('Invalid folder path response', data);

      return false;

    }


    UPLOAD_URL  = data.folderpath;

    THUMB_URL   = data.folderpath_thumb;

    pathsLoaded = true;


    console.log('✔ Paths loaded:', UPLOAD_URL, THUMB_URL);

    return true;


  } catch (err) {

    console.error('getFolderPaths failed:', err.message);

    return false;

  }

}


// ─────────────────────────────────────────────────────────────────────────────

// INSIDE DOMContentLoaded — only DOM-dependent code goes here

// ─────────────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', function () {


  const attachInput = document.getElementById('taskattachments');

  if (!attachInput) return;


  attachInput.addEventListener('change', async function () {

    if (!this.files || this.files.length === 0) return;


    // ✅ getFolderPaths() accessible here because it's in outer scope

    const ok = await getFolderPaths();

    if (!ok) {

      alert('Could not load upload folder. Please try again.');

      this.value = '';

      return;

    }


    Array.from(this.files).forEach(file => {

      const alreadyExists = Object.values(uploadRegistry)

        .some(d => d.file.name === file.name && d.file.size === file.size);

      if (alreadyExists) return;


      const id = 'f' + (++fileSeq);

      uploadRegistry[id] = {

        id, file,

        status  : 'pending',

        pct     : 0,

        avifName: null,

        error   : '',

        aborted : false,

      };

      renderFileCard(id);

      startUpload(id);

    });


    this.value = '';

    updateSummary();

    document.getElementById('upload-summary')?.classList.add('visible');

  });


  // all other functions: renderFileCard, updateCard,

  // startUpload, deleteFile, retryFile ... unchanged

});

The rule

Outside DOMContentLoaded:

  ✅ getFolderPaths()       — needs no DOM, called from multiple places

  ✅ uploadRegistry         — shared state

  ✅ CHUNK_SIZE, API_BASE   — constants

  ✅ isOnline, fileSeq      — shared variables

  ✅ sendChunkWithRetry()   — pure async, no DOM

  ✅ fmtSize()              — pure helper


Inside DOMContentLoaded:

  ✅ renderFileCard()       — touches getElementById

  ✅ updateCard()           — touches getElementById

  ✅ updateSummary()        — touches getElementById

  ✅ startUpload()          — calls renderFileCard / updateCard

  ✅ deleteFile()           — removes DOM element

  ✅ retryFile()            — calls updateCard

  ✅ event listeners        — need DOM elements to exist first

  ✅ window.savetask        — uses form.submit()

await is only valid in async functions and the top level bodies of modules


May 25

The error happens because DOMContentLoaded callback is not async. Fix:


javascript

// ❌ Wrong

document.addEventListener('DOMContentLoaded', function () {

  const ok = await getFolderPaths(); // ← error here

});


// ✅ Fix — add async to the callback

document.addEventListener('DOMContentLoaded', async function () {

  const ok = await getFolderPaths();

});

But in your case the await is inside the change handler, so make sure both are async:


javascript

document.addEventListener('DOMContentLoaded', async function () {  // ✅ async


  const attachInput = document.getElementById('taskattachments');

  if (!attachInput) return;


  attachInput.addEventListener('change', async function () {        // ✅ async

    if (!this.files || this.files.length === 0) return;


    const ok = await getFolderPaths();                             // ✅ valid now

    if (!ok) {

      alert('Could not load upload folder. Please try again.');

      this.value = '';

      return;

    }


    Array.from(this.files).forEach(file => {

      const alreadyExists = Object.values(uploadRegistry)

        .some(d => d.file.name === file.name && d.file.size === file.size);

      if (alreadyExists) return;


      const id = 'f' + (++fileSeq);

      uploadRegistry[id] = {

        id, file,

        status  : 'pending',

        pct     : 0,

        avifName: null,

        error   : '',

        aborted : false,

      };

      renderFileCard(id);

      startUpload(id);

    });


    this.value = '';

    updateSummary();

    document.getElementById('upload-summary')?.classList.add('visible');

  });


  // rest of your functions...

});

Quick reference — where async is needed

javascript

// ✅ Top level async functions — always fine

async function getFolderPaths() { await fetch(...) }

async function startUpload(id)  { await fetch(...) }


// ✅ Event callbacks — must add async if using await inside

document.addEventListener('DOMContentLoaded', async function () { await ... });

attachInput.addEventListener('change',         async function () { await ... });

window.addEventListener('online',              async function () { await ... });


// ✅ Arrow function version — same rule

attachInput.addEventListener('change', async (e) => { await ... });


// ✅ window.savetask — already async

window.savetask = async function () { ... };


// ✅ manualResumeUploads — add async if it awaits getFolderPaths

window.manualResumeUploads = async function () {

  const ok = await getFolderPaths();

  ...

};

[Route("/savefileuploadlog")]

   public async Task<IActionResult> SaveFileUploadLog(string folderpath, string folderpath_thumb, string filename, string type)

   {


       return Ok(new { folderpath, folderpath_thumb });

   } call each file upload success


May 25

Call /savefileuploadlog after each file successfully finalizes:


Update startUpload — add the log call after finalize success:


javascript

// ── After finalize success, save the log ─────────────────────────────────────

if (data.ok) {

  d.status   = 'success';

  d.pct      = 100;

  d.avifName = data.result?.fileName;

  localStorage.removeItem(RESUME_KEY);

  clearChunkLabel(id);


  // ✅ Call log API after each successful upload

  await saveFileUploadLog(d.avifName, file.type);

}

Add saveFileUploadLog function outside DOMContentLoaded:


javascript

async function saveFileUploadLog(fileName, fileType) {

  try {

    const params = new URLSearchParams({

      folderpath      : UPLOAD_URL,

      folderpath_thumb: THUMB_URL,

      filename        : fileName,

      type            : fileType,

    });


    const res  = await fetch(`/savefileuploadlog?${params}`);

    const data = await res.json();


    console.log(`✔ Log saved: ${fileName}`, data);

    return true;


  } catch (err) {

    // Non-critical — don't fail the upload if log fails

    console.warn(`⚠ Log failed for ${fileName}:`, err.message);

    return false;

  }

}

Full startUpload finalize block with log call:


javascript

  // ── Finalize ───────────────────────────────────────────────────────────────

  try {

    const res = await fetch(`${API_BASE}/finalize-upload`, {

      method : 'POST',

      headers: { 'Content-Type': 'application/json' },

      body   : JSON.stringify({

        uploadId,

        originalname: file.name,

        mimetype    : file.type,

        serverpath  : '',

        uploadUrl   : UPLOAD_URL,

        thumbUrl    : THUMB_URL,

        filetype    : 'image',

      }),

    });


    const data = await res.json();


    if (data.ok) {

      d.status   = 'success';

      d.pct      = 100;

      d.avifName = data.result?.fileName;

      localStorage.removeItem(RESUME_KEY);

      clearChunkLabel(id);


      // ✅ Log each successful upload individually

      await saveFileUploadLog(d.avifName, file.type);


    } else {

      d.status = 'failed';

      d.error  = data.error || 'Finalize failed';

    }


  } catch (err) {

    d.status = 'failed';

    d.error  = err.message;

  }


  updateCard(id);

  updateSummary();

ASP.NET — update the controller to actually save the log:


csharp

[Route("/savefileuploadlog")]

public async Task<IActionResult> SaveFileUploadLog(

    string folderpath,

    string folderpath_thumb,

    string filename,

    string type)

{

    // Save to DB

    var log = new FileUploadLog

    {

        FolderPath      = folderpath,

        FolderPathThumb = folderpath_thumb,

        FileName        = filename,

        FileType        = type,

        UploadedAt      = DateTime.UtcNow,

        UploadedBy      = profile._id,

    };


    await _db.FileUploadLogs.AddAsync(log);

    await _db.SaveChangesAsync();


    return Ok(new { ok = true, folderpath, folderpath_thumb, filename });

}

Flow per file

image1.jpg chunks upload → finalize ✔

  → POST /savefileuploadlog

      ?folderpath=\\NAS\share\work\052025

      &folderpath_thumb=\\NAS\share\work\052025\thumb

      &filename=image1.avif

      &type=image/jpeg

  ← { ok: true }

  card → green ✔ Uploaded


image2.jpg chunks upload → finalize ✔

  → POST /savefileuploadlog

      ?filename=image2.avif ...

  ← { ok: true }

  card → green ✔ Uploaded


savetask() called:

  hidden inputs: image1.avif, image2.avif

  form.submit() → POST /Home/TaskCreate

create thump file to pdf and video files


May 28

Update processSingleImage in Node.js to handle PDF and video thumbnails:


Install required packages:


bash

npm install sharp pdf-poppler fluent-ffmpeg

npm install @ffmpeg-installer/ffmpeg

Node.js — updated processSingleImage:


javascript

const sharp   = require('sharp');

const path    = require('path');

const fs      = require('fs');

const fss     = require('fs').promises;

const pdf     = require('pdf-poppler');

const ffmpeg  = require('fluent-ffmpeg');

const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');


// Set ffmpeg path

ffmpeg.setFfmpegPath(ffmpegInstaller.path);


async function processSingleImage(req, body) {

  logToFile(`processSingleImage IN ${new Date().toISOString()}`);


  const buffer   = req.buffer;

  const mimetype = req.mimetype;

  const { uploadUrl, serverpath, filetype, thumbUrl } = body;


  // ── Build paths ─────────────────────────────────────────────────────────────

  const SHARE_ROOT = serverpath ? `\\${serverpath}\\${uploadUrl}` : uploadUrl;

  const THUMB_ROOT = serverpath ? `\\${serverpath}\\${thumbUrl}`  : thumbUrl;


  if (!fs.existsSync(SHARE_ROOT)) fs.mkdirSync(SHARE_ROOT, { recursive: true });

  if (!fs.existsSync(THUMB_ROOT)) fs.mkdirSync(THUMB_ROOT, { recursive: true });


  logToFile(`mimetype: ${mimetype}`);


  try {


    // ────────────────────────────────────────────────────────────────────────

    // IMAGE

    // ────────────────────────────────────────────────────────────────────────

    if (mimetype.includes('image')) {

      return await processImage(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);

    }


    // ────────────────────────────────────────────────────────────────────────

    // PDF

    // ────────────────────────────────────────────────────────────────────────

    if (mimetype === 'application/pdf') {

      return await processPdf(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);

    }


    // ────────────────────────────────────────────────────────────────────────

    // VIDEO

    // ────────────────────────────────────────────────────────────────────────

    if (mimetype.startsWith('video/')) {

      return await processVideo(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);

    }


    // ────────────────────────────────────────────────────────────────────────

    // OTHER — save raw

    // ────────────────────────────────────────────────────────────────────────

    await fss.writeFile(path.join(SHARE_ROOT, req.originalname), buffer);

    return { fileName: req.originalname, status: 'success' };


  } catch (err) {

    logToFile(`❌ processSingleImage: ${err.stack || err.message}`);

    return { fileName: req.originalname, status: 'failed', error: err.message };

  }

}


// ─────────────────────────────────────────────────────────────────────────────

// IMAGE processor

// ─────────────────────────────────────────────────────────────────────────────

async function processImage(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {

  const fileName = originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');


  sharp.concurrency(4);

  sharp.cache(false);


  const meta = await sharp(buffer, { limitInputPixels: false }).metadata();

  const imgl = resizeByMax(Math.trunc(meta.width), Math.trunc(meta.height), 1500);


  let pipeline = sharp(buffer, { limitInputPixels: false });

  if (meta.hasAlpha) pipeline = pipeline.flatten({ background: '#fff' });


  // Main image → avif

  const resizedBuffer = await pipeline

    .toColourspace('srgb')

    .resize(imgl.width, imgl.height, { fit: 'inside', withoutEnlargement: true })

    .avif({ quality: 45, effort: 2 })

    .toBuffer();


  await fss.writeFile(path.join(SHARE_ROOT, fileName), resizedBuffer);

  logToFile(`✔ Image saved: ${fileName}`);


  // Thumb → avif 200×200

  const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })

    .resize(200, 200, { fit: 'cover', position: 'centre' })

    .avif({ quality: 40, effort: 2 })

    .toBuffer();


  await fss.writeFile(path.join(THUMB_ROOT, fileName), thumbBuffer);

  logToFile(`✔ Image thumb saved: ${fileName}`);


  return { fileName, status: 'success' };

}


// ─────────────────────────────────────────────────────────────────────────────

// PDF processor

// ─────────────────────────────────────────────────────────────────────────────

async function processPdf(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {

  const fileName      = originalname;                              // keep .pdf

  const thumbFileName = originalname.replace(/\.pdf$/i, '.avif'); // thumb as avif

  const tmpDir        = path.join(require('os').tmpdir(), `pdf_${Date.now()}`);


  fs.mkdirSync(tmpDir, { recursive: true });


  try {

    // Save PDF buffer to temp so pdf-poppler can read it

    const tmpPdf = path.join(tmpDir, originalname);

    await fss.writeFile(tmpPdf, buffer);


    // Save original PDF to share root

    await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);

    logToFile(`✔ PDF saved: ${fileName}`);


    // ── Convert first page to PNG using pdf-poppler ─────────────────────────

    const opts = {

      format     : 'png',

      out_dir    : tmpDir,

      out_prefix : 'page',

      page       : 1,       // first page only for thumb

      scale      : 400,     // render width

    };


    await pdf.convert(tmpPdf, opts);


    // pdf-poppler outputs: page-1.png

    const renderedPage = path.join(tmpDir, 'page-1.png');


    if (!fs.existsSync(renderedPage)) {

      throw new Error('PDF page render failed — page-1.png not found');

    }


    // ── Convert rendered PNG → avif thumb ──────────────────────────────────

    const thumbBuffer = await sharp(renderedPage)

      .resize(200, 200, { fit: 'cover', position: 'centre' })

      .avif({ quality: 40, effort: 2 })

      .toBuffer();


    await fss.writeFile(path.join(THUMB_ROOT, thumbFileName), thumbBuffer);

    logToFile(`✔ PDF thumb saved: ${thumbFileName}`);


    return { fileName, thumbFileName, status: 'success' };


  } finally {

    // Clean up temp folder

    await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});

  }

}


// ─────────────────────────────────────────────────────────────────────────────

// VIDEO processor

// ─────────────────────────────────────────────────────────────────────────────

async function processVideo(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {

  const fileName      = originalname;                                        // keep original .mp4 etc

  const thumbFileName = originalname.replace(/\.[^.]+$/, '.avif');          // thumb as avif

  const tmpDir        = path.join(require('os').tmpdir(), `vid_${Date.now()}`);


  fs.mkdirSync(tmpDir, { recursive: true });


  try {

    // Save video buffer to temp so ffmpeg can read it

    const tmpVideo = path.join(tmpDir, originalname);

    await fss.writeFile(tmpVideo, buffer);


    // Save original video to share root

    await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);

    logToFile(`✔ Video saved: ${fileName}`);


    // ── Extract frame at 1 second using ffmpeg ──────────────────────────────

    const tmpFrame = path.join(tmpDir, 'frame.png');


    await new Promise((resolve, reject) => {

      ffmpeg(tmpVideo)

        .on('end',   resolve)

        .on('error', reject)

        .screenshots({

          timestamps : ['00:00:01'],   // grab frame at 1 second

          filename   : 'frame.png',

          folder     : tmpDir,

          size       : '400x?',        // width 400, auto height

        });

    });


    if (!fs.existsSync(tmpFrame)) {

      // Fallback — try frame at 0 seconds if video is very short

      await new Promise((resolve, reject) => {

        ffmpeg(tmpVideo)

          .on('end',   resolve)

          .on('error', reject)

          .screenshots({

            timestamps : ['00:00:00'],

            filename   : 'frame.png',

            folder     : tmpDir,

            size       : '400x?',

          });

      });

    }


    if (!fs.existsSync(tmpFrame)) {

      throw new Error('Video frame extraction failed');

    }


    // ── Convert extracted frame → avif thumb ───────────────────────────────

    const thumbBuffer = await sharp(tmpFrame)

      .resize(200, 200, { fit: 'cover', position: 'centre' })

      .avif({ quality: 40, effort: 2 })

      .toBuffer();


    await fss.writeFile(path.join(THUMB_ROOT, thumbFileName), thumbBuffer);

    logToFile(`✔ Video thumb saved: ${thumbFileName}`);


    // ── Get video duration for logging ─────────────────────────────────────

    const duration = await getVideoDuration(tmpVideo);

    logToFile(`✔ Video duration: ${duration}s`);


    return { fileName, thumbFileName, duration, status: 'success' };


  } finally {

    await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});

  }

}


// ─────────────────────────────────────────────────────────────────────────────

// Get video duration helper

// ─────────────────────────────────────────────────────────────────────────────

function getVideoDuration(filePath) {

  return new Promise((resolve) => {

    ffmpeg.ffprobe(filePath, (err, metadata) => {

      if (err) { resolve(0); return; }

      resolve(Math.round(metadata.format.duration || 0));

    });

  });

}

Register routes — unchanged, processSingleImage handles all types now:


javascript

router.post('/upload-chunk',    upload.array('chunk', 1), uploadChunk);

router.post('/finalize-upload', express.json(),           finalizeUpload);

router.post('/delete-file',     express.json(),           deleteFile);

Update multer limits to allow larger video files:


javascript

const upload = multer({

  storage: multer.memoryStorage(),

  limits : { fileSize: 500 * 1024 * 1024 }, // 500 MB for videos

});

JS — update CHUNK_SIZE for video files and saveFileUploadLog to pass file type:


javascript

// ── Use larger chunks for video ───────────────────────────────────────────────

function getChunkSize(fileType) {

  if (fileType.startsWith('video/')) return 5 * 1024 * 1024;  // 5 MB for video

  if (fileType === 'application/pdf') return 2 * 1024 * 1024; // 2 MB for pdf

  return 2 * 1024 * 1024;                                      // 2 MB for image

}


// ── In startUpload — use dynamic chunk size ───────────────────────────────────

async function startUpload(id) {

  const d           = uploadRegistry[id];

  const file        = d.file;

  const CHUNK_SIZE  = getChunkSize(file.type);   // ✅ dynamic per file type

  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

  // ... rest unchanged

}

Update renderFileCard to show correct icon per file type:


javascript

function getFileIcon(file) {

  if (file.type.startsWith('image/'))       return '<i class="bi bi-image text-primary fs-4"></i>';

  if (file.type === 'application/pdf')      return '<i class="bi bi-file-pdf text-danger fs-4"></i>';

  if (file.type.startsWith('video/'))       return '<i class="bi bi-camera-video text-success fs-4"></i>';

  return '<i class="bi bi-file-earmark text-secondary fs-4"></i>';

}


function renderFileCard(id) {

  const d     = uploadRegistry[id];

  const isImg = d.file.type.startsWith('image/');


  const thumbHtml = isImg

    ? `<img src="${URL.createObjectURL(d.file)}"

            style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0" alt="">`

    : `<div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;

                   display:flex;align-items:center;justify-content:center;flex-shrink:0">

         ${getFileIcon(d.file)}

       </div>`;


  // ... rest of renderFileCard unchanged

}

What each type produces

image.jpg   → main: image.avif  (max 1500px)

              thumb: image.avif  (200×200 cover)


document.pdf → main: document.pdf  (original)

               thumb: document.avif (first page → 200×200 cover)


video.mp4   → main: video.mp4   (original)

              thumb: video.avif  (frame at 1s → 200×200 cover)

function renderFileCard(id) {

      const container = document.getElementById('file-list');

      if (!container) {

          console.warn('file-list not found');

          return;

      }

      const d = uploadRegistry[id];

      const isImg = d.file.type.startsWith('image/');

      // const thumbHtml = isImg

      //     ? <img src="${URL.createObjectURL(d.file)}"

      //       style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"

      //       alt="">

      //     : <div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;

      //              display:flex;align-items:center;justify-content:center;flex-shrink:0">

      //    <i class="bi bi-file-earmark fs-4 text-secondary"></i>

      //  </div>;

      const thumbHtml = isImg

          ? <img src="${URL.createObjectURL(d.file)}"

          style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0" alt="">

          : <div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;

                 display:flex;align-items:center;justify-content:center;flex-shrink:0">

       ${getFileIcon(d.file)}

     </div>;

      const card = document.createElement('div');

      card.id = card-${id};

      card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';

      card.style.cssText = 'background:#fff;transition:border-color .2s;';

      card.innerHTML = 

    ${thumbHtml}

    <div style="flex:1;min-width:0">

      <div class="d-flex justify-content-between align-items-center mb-1">

        <span class="text-truncate fw-500"

              style="font-size:13px;max-width:240px"

              title="${d.file.name}">${d.file.name}</span>

        <span class="text-muted"

              style="font-size:11px;flex-shrink:0;margin-left:8px">

          ${fmtSize(d.file.size)}

        </span>

      </div>

      <div class="progress mb-1" style="height:5px;">

        <div class="progress-bar" id="bar-${id}"

             style="width:0%;transition:width .3s;"></div>

      </div>

      <div class="d-flex justify-content-between">

        <span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>

        <span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>

      </div>

    </div>

    <div class="d-flex flex-column gap-1" style="flex-shrink:0">

      <button type="button" id="btn-retry-${id}"

              class="btn btn-sm btn-outline-warning py-0 px-2"

              style="display:none;font-size:11px">

        <i class="bi bi-arrow-repeat"></i> Retry

      </button>

      <button type="button"

              class="btn btn-sm btn-outline-danger py-0 px-2"

              style="font-size:11px">

        <i class="bi bi-trash"></i>

      </button>

    </div>;

      // ✅ Attach click handlers after innerHTML set — avoids inline onclick

      //    scope issues inside DOMContentLoaded

      card.querySelector(#btn-retry-${id})

          .addEventListener('click', () => retryFile(id));

      card.querySelectorAll('.btn-outline-danger')[0]

          .addEventListener('click', () => deleteFile(id));

      container.appendChild(card);

  } thum not loaded


Show more

May 28

The issue is URL.createObjectURL works only for images. For PDF and video you need different preview approaches:


javascript

function getFileIcon(file) {

  if (file.type.startsWith('image/'))  return '<i class="bi bi-image text-primary" style="font-size:24px"></i>';

  if (file.type === 'application/pdf') return '<i class="bi bi-file-pdf text-danger" style="font-size:24px"></i>';

  if (file.type.startsWith('video/'))  return '<i class="bi bi-camera-video text-success" style="font-size:24px"></i>';

  return '<i class="bi bi-file-earmark text-secondary" style="font-size:24px"></i>';

}


function getThumbBg(file) {

  if (file.type === 'application/pdf') return '#fff0f0';

  if (file.type.startsWith('video/'))  return '#f0fff4';

  return '#f0f0f0';

}


function renderFileCard(id) {

  const container = document.getElementById('file-list');

  if (!container) { console.warn('file-list not found'); return; }


  const d     = uploadRegistry[id];

  const file  = d.file;

  const isImg = file.type.startsWith('image/');

  const isVid = file.type.startsWith('video/');


  // ── Build thumb HTML based on file type ──────────────────────────────────

  let thumbHtml = '';


  if (isImg) {

    // ✅ Image — createObjectURL works fine

    thumbHtml = `

      <img src="${URL.createObjectURL(file)}"

           style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"

           alt="">`;


  } else if (isVid) {

    // ✅ Video — use canvas to extract first frame

    thumbHtml = `

      <div id="thumb-${id}"

           style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;

                  display:flex;align-items:center;justify-content:center;flex-shrink:0;

                  position:relative;overflow:hidden;">

        ${getFileIcon(file)}

      </div>`;


  } else {

    // ✅ PDF and others — icon only

    thumbHtml = `

      <div style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;

                  display:flex;align-items:center;justify-content:center;flex-shrink:0;">

        ${getFileIcon(file)}

      </div>`;

  }


  const card = document.createElement('div');

  card.id           = `card-${id}`;

  card.className    = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';

  card.style.cssText = 'background:#fff;transition:border-color .2s;';


  card.innerHTML = `

    ${thumbHtml}

    <div style="flex:1;min-width:0">

      <div class="d-flex justify-content-between align-items-center mb-1">

        <span class="text-truncate fw-500"

              style="font-size:13px;max-width:240px"

              title="${file.name}">${file.name}</span>

        <span class="text-muted" style="font-size:11px;flex-shrink:0;margin-left:8px">

          ${fmtSize(file.size)}

        </span>

      </div>

      <div class="progress mb-1" style="height:5px;">

        <div class="progress-bar" id="bar-${id}" style="width:0%;transition:width .3s;"></div>

      </div>

      <div class="d-flex justify-content-between">

        <span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>

        <span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>

      </div>

    </div>

    <div class="d-flex flex-column gap-1" style="flex-shrink:0">

      <button type="button" id="btn-retry-${id}"

              class="btn btn-sm btn-outline-warning py-0 px-2"

              style="display:none;font-size:11px">

        <i class="bi bi-arrow-repeat"></i> Retry

      </button>

      <button type="button"

              class="btn btn-sm btn-outline-danger py-0 px-2"

              style="font-size:11px">

        <i class="bi bi-trash"></i>

      </button>

    </div>`;


  card.querySelector(`#btn-retry-${id}`)

      .addEventListener('click', () => retryFile(id));

  card.querySelectorAll('.btn-outline-danger')[0]

      .addEventListener('click', () => deleteFile(id));


  container.appendChild(card);


  // ✅ Extract video frame AFTER card is in DOM

  if (isVid) {

    extractVideoFrame(file, id);

  }

}


// ── Extract first frame from video using canvas ───────────────────────────────

function extractVideoFrame(file, id) {

  const url    = URL.createObjectURL(file);

  const video  = document.createElement('video');

  video.src    = url;

  video.muted  = true;

  video.preload = 'metadata';


  video.addEventListener('loadeddata', function () {

    // Seek to 1 second or 10% of duration, whichever is smaller

    video.currentTime = Math.min(1, video.duration * 0.1);

  });


  video.addEventListener('seeked', function () {

    try {

      const canvas  = document.createElement('canvas');

      canvas.width  = 54;

      canvas.height = 54;

      const ctx = canvas.getContext('2d');


      // Crop centre of frame into 54×54

      const vw   = video.videoWidth;

      const vh   = video.videoHeight;

      const side = Math.min(vw, vh);

      const sx   = (vw - side) / 2;

      const sy   = (vh - side) / 2;


      ctx.drawImage(video, sx, sy, side, side, 0, 0, 54, 54);


      const dataUrl  = canvas.toDataURL('image/jpeg', 0.8);

      const thumbDiv = document.getElementById(`thumb-${id}`);


      if (thumbDiv) {

        thumbDiv.innerHTML = `

          <img src="${dataUrl}"

               style="width:54px;height:54px;object-fit:cover;border-radius:6px;"

               alt="">

          <div style="position:absolute;bottom:2px;right:2px;

                      background:rgba(0,0,0,0.55);border-radius:3px;

                      padding:1px 3px;font-size:9px;color:#fff;">

            <i class="bi bi-play-fill"></i>

          </div>`;

      }

    } catch (e) {

      console.warn('Frame extract failed:', e.message);

    } finally {

      URL.revokeObjectURL(url);

    }

  });


  video.addEventListener('error', function () {

    console.warn('Video load error for thumb');

    URL.revokeObjectURL(url);

  });


  // Must call load() to start loading in some browsers

  video.load();

}

What each type shows

image.jpg   → actual image preview  (URL.createObjectURL)

video.mp4   → real first frame      (canvas frame extract)

              + small ▶ play badge overlay

document.pdf → red PDF icon on light red background

other files  → grey file icon

The video frame extraction works by creating a hidden <video> element, seeking to 1 second, then painting that frame onto a <canvas> and converting it to a data: URL — no server round-trip needed.


pdf thump created but       thumbHtml = 

<div style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;

            display:flex;align-items:center;justify-content:center;flex-shrink:0;">

  ${getFileIcon(file)}

</div>; not updated


12:42 PM

The PDF thumb div has no id so you can't update it after the server returns the avif. Fix by adding an id and updating it after finalize:


Update renderFileCard — add id to PDF thumb div:


javascript

  } else {

    // ✅ PDF and others — icon with id so we can swap it later

    thumbHtml = `

      <div id="thumb-${id}"

           style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;

                  display:flex;align-items:center;justify-content:center;flex-shrink:0;">

        ${getFileIcon(file)}

      </div>`;

  }

Add updateThumb function — call after finalize success:


javascript

function updateThumb(id) {

  const d        = uploadRegistry[id];

  const thumbDiv = document.getElementById(`thumb-${id}`);

  if (!thumbDiv || !d.avifName) return;


  const isImg = d.file.type.startsWith('image/');

  const isVid = d.file.type.startsWith('video/');


  // Images already show local preview — no update needed

  if (isImg) return;


  // ── Build thumb URL pointing to Node server thumb folder ─────────────────

  // Adjust this URL pattern to match how your Node server serves static files

  const thumbUrl = `${API_BASE}/thumb/${d.avifName}`;


  thumbDiv.innerHTML = `

    <img src="${thumbUrl}"

         style="width:54px;height:54px;object-fit:cover;border-radius:6px;"

         alt=""

         onerror="this.parentElement.innerHTML='${getFileIcon(d.file).replace(/'/g, "\\'")}'">

    ${isVid

      ? `<div style="position:absolute;bottom:2px;right:2px;

                     background:rgba(0,0,0,0.55);border-radius:3px;

                     padding:1px 3px;font-size:9px;color:#fff;">

           <i class="bi bi-play-fill"></i>

         </div>`

      : ''}`;

}

Update startUpload — call updateThumb after finalize success:


javascript

    if (data.ok) {

      d.status   = 'success';

      d.pct      = 100;

      d.avifName = data.result?.fileName;

      localStorage.removeItem(RESUME_KEY);

      clearChunkLabel(id);


      await saveFileUploadLog(d.avifName, d.file.type);


      // ✅ Update thumb for PDF and video after server generates it

      updateThumb(id);

    }

Node.js — serve thumb folder as static so the browser can load it:


javascript

// In your Express app setup

const express = require('express');

const app     = express();


// Serve thumbs as static files

// THUMB_ROOT is your actual NAS thumb path

app.use('/thumb', express.static('\\\\NAS\\share\\work\\052025\\thumb'));


// OR if path is dynamic — add a dedicated thumb route

app.get('/thumb/:filename', (req, res) => {

  const thumbPath = path.join(THUMB_ROOT, req.params.filename);


  if (!fs.existsSync(thumbPath)) {

    return res.status(404).json({ error: 'Thumb not found' });

  }


  res.setHeader('Content-Type', 'image/avif');

  res.sendFile(thumbPath);

});

If your thumb path is dynamic (changes per user/date), pass thumbUrl back from finalize and store it:


javascript

// Node.js — finalizeUpload: include thumbUrl in response

res.json({

  ok: true,

  result: {

    fileName   : result.fileName,

    thumbFileName: result.thumbFileName,

    thumbUrl   : thumbUrl,   // ✅ full thumb folder path

  }

});

javascript

// JS — store thumbFileName separately

if (data.ok) {

  d.status        = 'success';

  d.pct           = 100;

  d.avifName      = data.result?.fileName;

  d.thumbFileName = data.result?.thumbFileName ?? data.result?.fileName;

  d.thumbUrl      = data.result?.thumbUrl;

  localStorage.removeItem(RESUME_KEY);

  clearChunkLabel(id);


  await saveFileUploadLog(d.avifName, d.file.type);

  updateThumb(id);

}

javascript

// Update updateThumb to use stored thumbUrl

function updateThumb(id) {

  const d        = uploadRegistry[id];

  const thumbDiv = document.getElementById(`thumb-${id}`);

  if (!thumbDiv) return;


  const isImg = d.file.type.startsWith('image/');

  const isVid = d.file.type.startsWith('video/');


  if (isImg) return;


  if (!d.avifName) return;


  // ✅ Use thumbFileName (pdf/video may differ from main fileName)

  const thumbFile = d.thumbFileName || d.avifName;

  const thumbSrc  = `${API_BASE}/thumb/${thumbFile}`;


  thumbDiv.style.position = 'relative';   // needed for video play badge

  thumbDiv.style.overflow = 'hidden';


  thumbDiv.innerHTML = `

    <img src="${thumbSrc}"

         style="width:54px;height:54px;object-fit:cover;border-radius:6px;display:block;"

         alt="${d.file.name}"

         onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">

    <div style="display:none;width:54px;height:54px;align-items:center;

                justify-content:center;border-radius:6px;

                background:${getThumbBg(d.file)}">

      ${getFileIcon(d.file)}

    </div>

    ${isVid

      ? `<div style="position:absolute;bottom:2px;right:2px;

                     background:rgba(0,0,0,0.55);border-radius:3px;

                     padding:1px 3px;font-size:9px;color:#fff;pointer-events:none;">

           <i class="bi bi-play-fill"></i>

         </div>`

      : ''}`;

}

Full flow

PDF selected:

  card renders → shows red PDF icon  (icon placeholder)


Chunks upload → finalize → Node generates thumb:

  \\NAS\share\work\052025\thumb\document.avif  ✔


JS receives finalize response:

  d.thumbFileName = "document.avif"

  updateThumb(id) called

    → thumbDiv.innerHTML updated

    → <src="/thumb/document.avif">  ← real PDF first-page preview ✔

    → if load fails → falls back to PDF icon


Video selected:

  card renders → canvas extracts frame immediately (client side)


Chunks upload → finalize → Node generates thumb from ffmpeg frame

  updateThumb(id) → swaps canvas frame with server avif thumb ✔







Previous
Next Post »

1 comments:

Write comments