Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Summarize-and-discard text upload

Choose one small .txt or .md file and submit it after the upload completes. The LiveView validates strict UTF-8 text, retains only byte, line, and word counts, and immediately releases the consumed bytes. It does not persist or render the file contents.

The limits make an in-memory destination appropriate for this lesson. Larger or attacker-controlled files need a streaming hosted writer or a direct external destination instead.

LiveView
scala
final class TextUploadExample extends LiveView[TextUploadExample.Msg, TextUploadExample.Model]:
  import TextUploadExample.*

  def mount(ctx: MountContext): Task[Model] =
    ctx.uploads.allow(TextFiles).map(Model(_))

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Validate | Msg.Progress => refresh(model, ctx.uploads)
    case Msg.Cancel(entry)           =>
      ctx.uploads.cancel(entry).map(upload => model.copy(upload = upload, notice = None))
    case Msg.Summarize => summarizeCompleted(model, ctx.uploads)
    case Msg.Reset     =>
      ctx.uploads.disallow(TextFiles) *>
        ctx.uploads.allow(TextFiles).map(Model(_))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val upload = model.map(_.upload)
    div(
      cls := "docs-text-upload",
      p(
        cls := "docs-example-lede",
        "Choose one small text file. The server validates UTF-8, keeps only aggregate facts, and immediately releases the uploaded bytes."
      ),
      model
        .map(_.notice).option(notice => p(role := "status", cls := "docs-upload-notice", notice)),
      form(
        dataAttr("text-upload-form") := "",
        on.change(_ => Msg.Validate),
        on.submit(Msg.Summarize),
        div(
          cls := "docs-upload-dropzone",
          upload.dropTarget,
          label(forId := upload.map(_.ref.value), "Text file"),
          liveFileInput(
            upload,
            dataAttr("text-upload-input") := "",
            upload.onProgress(Msg.Progress)
          ),
          p(cls := "docs-upload-help", "Accepted: .txt and .md. Maximum: one file, 64 KiB."),
          errorList(uploadErrors(upload)),
          div(
            cls := "docs-upload-entries",
            upload.map(_.entries).splitBy(_.ref) { (_, entry) =>
              articleTag(
                cls := "docs-upload-entry",
                div(
                  strong(entry.map(_.client.fileName)),
                  span(entry.map(entry => formatBytes(entry.client.sizeBytes)))
                ),
                progressTag(
                  value      := entry.map(_.progress.toString),
                  maxAttr    := "100",
                  aria.label := entry.map(entry => s"Upload progress for ${entry.client.fileName}")
                ),
                span(
                  entry.map(entry =>
                    if entry.status == LiveUploadEntryStatus.Completed then "Ready"
                    else s"${entry.progress}%"
                  )
                ),
                button(typ := "button", on.click(entry.map(Msg.Cancel(_))), "Cancel"),
                errorList(uploadErrors(entry))
              )
            }
          )
        ),
        div(
          cls := "docs-upload-actions",
          button(
            typ := "submit",
            submission.replaceTextWith("Summarizing..."),
            "Summarize completed file"
          ),
          button(typ := "button", on.click(Msg.Reset), "Reset upload")
        )
      ),
      div(
        cls := "docs-upload-summaries",
        model.map(_.summaries).splitBy(_.id) { (_, summary) =>
          articleTag(
            dataAttr("upload-summary") := "",
            cls                        := "docs-upload-summary",
            h3(dataAttr("summary-name") := "", summary.map(_.fileName)),
            dl(
              div(
                dt("Size"),
                dd(dataAttr("summary-bytes") := "", summary.map(value => formatBytes(value.bytes)))
              ),
              div(
                dt("Lines"),
                dd(
                  dataAttr("summary-lines") := "",
                  summary.map(value => plural(value.lines, "line"))
                )
              ),
              div(
                dt("Words"),
                dd(
                  dataAttr("summary-words") := "",
                  summary.map(value => plural(value.words, "word"))
                )
              )
            )
          )
        }
      )
    )
  end view

  private def refresh(model: Model, uploads: Uploads): Task[Model] =
    uploads.get(TextFiles).map(_.fold(model)(upload => model.copy(upload = upload, notice = None)))

  private def summarizeCompleted(model: Model, uploads: Uploads): Task[Model] =
    uploads
      .consumeCompleted(TextFiles) { completed =>
        ZIO.succeed(ConsumeDecision.Consume(summarize(completed)))
      }.map { case (results, upload) =>
        val summaries = results.collect { case Right(summary) => summary }
        val rejected  = results.count(_.isLeft)
        val notice    =
          if rejected > 0 then Some("The file was discarded because it was not valid UTF-8 text.")
          else if summaries.isEmpty then
            Some("Finish uploading a valid file before summarizing it.")
          else Some("Summary created. The uploaded bytes were discarded.")
        model.copy(upload = upload, summaries = model.summaries ++ summaries, notice = notice)
      }

  private def summarize(completed: CompletedUpload[Chunk[Byte]]): Either[Unit, Summary] =
    decodeUtf8(completed.result).map { text =>
      val lines = if text.isEmpty then 0 else text.linesIterator.size
      val words = Word.findAllIn(text).size
      Summary(
        id = completed.ref.value,
        fileName = completed.client.fileName,
        bytes = completed.result.length.toLong,
        lines = lines,
        words = words
      )
    }

  private def decodeUtf8(bytes: Chunk[Byte]): Either[Unit, String] =
    val decoder = StandardCharsets.UTF_8
      .newDecoder()
      .onMalformedInput(CodingErrorAction.REPORT)
      .onUnmappableCharacter(CodingErrorAction.REPORT)
    try Right(decoder.decode(ByteBuffer.wrap(bytes.toArray)).toString)
    catch case _: java.nio.charset.CharacterCodingException => Left(())

  private def errorList(errors: Signal[List[LiveUploadError]]): HtmlElement[Nothing] =
    div(
      errors.map(_.distinct).splitBy(identity) { (_, error) =>
        p(role := "alert", error.map(uploadErrorMessage))
      }
    )
end TextUploadExample

object TextUploadExample:
  private val MaxFileSize = 64L * 1024L
  private val Word        = raw"\S+".r

  private val TextFiles = LiveUploadDef.inMemory(
    name = "text-file",
    accept = LiveUploadAccept.only(".txt", ".md"),
    maxEntries = 1,
    maxFileSize = MaxFileSize
  )

  final case class Summary(id: String, fileName: String, bytes: Long, lines: Int, words: Int)

  final case class Model(
    upload: LiveUpload[Chunk[Byte]],
    summaries: Vector[Summary] = Vector.empty,
    notice: Option[String] = None)

  enum Msg:
    case Validate
    case Progress
    case Cancel(entry: LiveUploadEntry[Chunk[Byte]])
    case Summarize
    case Reset

  private def uploadErrorMessage(error: LiveUploadError): String = error match
    case LiveUploadError.TooManyFiles => "Choose one file at a time."
    case LiveUploadError.NotAccepted  => "Choose a .txt or .md file."
    case LiveUploadError.TooLarge     => "Choose a file no larger than 64 KiB."
    case _                            => "The upload failed. Remove the file and try again."

  private def formatBytes(bytes: Long): String =
    if bytes < 1024L then s"$bytes B" else f"${bytes.toDouble / 1024.0}%.1f KiB"

  private def plural(value: Int, unit: String): String =
    s"$value $unit${if value == 1 then "" else "s"}"
end TextUploadExample

Live result

ConnectedReconnectingRead-only

Choose one small text file. The server validates UTF-8, keeps only aggregate facts, and immediately releases the uploaded bytes.

Accepted: .txt and .md. Maximum: one file, 64 KiB.

Disconnected. Controls resume after reconnection.

LiveView observability

Interaction inspector

See how actions in the live result above travel through typed messages, server state, protocol frames, and DOM updates.

Reconnect to inspect live interactions

The live result and its inspector resume when the connection returns.

Related guidance: accept, consume, and release uploads safely.