Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(node --input-type=module --check)",
"Bash(bundle exec *)",
"Bash(git stash *)",
"Bash(bundle exec rails runner ' *)"
]
}
}
84 changes: 29 additions & 55 deletions app/controllers/ocr_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,19 @@ class OcrController < ApplicationController
include Secured

def scan
# Process only the first uploaded file
file = params[:files].first
files = Array(params[:files])
ai_method = params[:ai_method] || 'mistral_only'
ocr_flags = build_ocr_flags(files)
ocr_files = files.select.with_index { |_, index| ocr_flags[index] }

begin
# Process based on selected AI method
magic_data_json = if ai_method == 'mistral_only'
# Two-phase: Mistral OCR -> Mistral parsing
markdown = mistral_service.ocr_to_markdown(file.tempfile, file.content_type)

if markdown.blank?
raise "Mistral OCR returned empty markdown"
end

mistral_service.parse_markdown_to_recipes(markdown)
elsif ai_method == 'mistral_openai'
# Two-phase: Mistral OCR -> OpenAI parsing
markdown = mistral_service.ocr_to_markdown(file.tempfile, file.content_type)

if markdown.blank?
raise "Mistral OCR returned empty markdown"
end
if ocr_files.empty?
render json: { success: false, error: I18n.t('ocr.errors.no_images_selected') }
return
end

openai_service.parse_markdown_to_recipes(markdown)
else
# Direct OpenAI OCR
openai_service.ocr(file.tempfile, file.content_type)
end
begin
files_with_types = ocr_files.map { |f| [f.tempfile, f.content_type] }
magic_data_json = ocr_pipeline.extract_recipes(files_with_types, ai_method)

if magic_data_json.empty?
raise "No recipes extracted from image"
Expand All @@ -39,11 +24,11 @@ def scan

# Save full OCR result array to database and store id in flash to avoid flash size limits
ocrresult = OcrResult.create(result: magic_data_json.to_json, ai_method: ai_method)
ocrresult.image.attach(file)
ocrresult.image.attach(files.first)
ocrresult.save

if params[:files].length > 1
params[:files][1..].each { |f| ocrresult.extra_images.attach(f) }
if files.length > 1
files[1..].each { |f| ocrresult.extra_images.attach(f) }
end

logger.debug "OCR data id stored in flash: #{ocrresult.id}"
Expand Down Expand Up @@ -149,29 +134,7 @@ def reparse_image
temp_file.write(image_file)
temp_file.rewind

# Process based on selected AI method
magic_data_json = if ai_method == 'mistral_only'
# Two-phase: Mistral OCR -> Mistral parsing
markdown = mistral_service.ocr_to_markdown(temp_file, content_type)

if markdown.blank?
raise "Mistral OCR returned empty markdown"
end

mistral_service.parse_markdown_to_recipes(markdown)
elsif ai_method == 'mistral_openai'
# Two-phase: Mistral OCR -> OpenAI parsing
markdown = mistral_service.ocr_to_markdown(temp_file, content_type)

if markdown.blank?
raise "Mistral OCR returned empty markdown"
end

openai_service.parse_markdown_to_recipes(markdown)
else
# Direct OpenAI OCR
openai_service.ocr(temp_file, content_type)
end
magic_data_json = ocr_pipeline.extract_recipes([[temp_file, content_type]], ai_method)

if magic_data_json.empty?
raise "No recipes extracted from image"
Expand Down Expand Up @@ -210,11 +173,22 @@ def reparse_image

private

def openai_service
@openai_service ||= OpenaiService.new
BOOLEAN_TYPE = ActiveModel::Type::Boolean.new

# Builds a boolean array (same length/order as files) indicating which files should be sent for OCR.
# Missing, blank, or unparseable ocr_flags default to true (include all); only an explicit false excludes.
def build_ocr_flags(files)
raw_flags = Array(params[:ocr_flags])
return Array.new(files.length, true) if raw_flags.empty?

files.each_index.map { |index| BOOLEAN_TYPE.cast(raw_flags[index]) != false }
end

def ocr_pipeline
@ocr_pipeline ||= OcrPipeline.new
end

def mistral_service
@mistral_service ||= MistralaiService.new
def openai_service
@openai_service ||= OpenaiService.new
end
end
45 changes: 0 additions & 45 deletions app/controllers/recipes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,51 +121,6 @@ def favorite
render json: { redirect_url: recipe_path(recipe.id) }
end

def reparse_image
@recipe = Recipe.find(params[:id])
attachment_id = params[:attachment_id]

begin
# Find the selected image attachment
attachment = @recipe.recipe_images.find(attachment_id)

# Download the image blob and get content type
blob = attachment.blob
image_file = blob.download
content_type = blob.content_type

# Create a temporary file for the OpenAI service
temp_file = Tempfile.new(['recipe_image', File.extname(blob.filename.to_s)])
temp_file.binmode
temp_file.write(image_file)
temp_file.rewind

# Call OpenAI service to parse the image
openai = OpenaiService.new
magic_data_json = openai.ocr(temp_file, content_type)

# Save OCR result to database
ocrresult = OcrResult.create(result: magic_data_json.to_s)
ocrresult.image.attach(blob)
ocrresult.save
flash[:ocr_data] = ocrresult.id

logger.debug "Reparse OCR data id stored in flash: #{flash[:ocr_data]}"

redirect_to edit_recipe_path(@recipe)
rescue JSON::ParserError => e
logger.error "Reparse JSON parse error: #{e.message}"
flash[:error] = I18n.t('ocr.errors.parse_failed')
redirect_to recipe_path(@recipe)
rescue => e
logger.error "Reparse error: #{e.message}"
flash[:error] = I18n.t('ocr.errors.processing_failed')
redirect_to recipe_path(@recipe)
ensure
temp_file&.close
temp_file&.unlink
end
end

def destroy
@recipe = Recipe.find(params[:id])
Expand Down
40 changes: 36 additions & 4 deletions app/javascript/controllers/dropzone_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { Utils } from "../src/utils"

export default class extends Controller {
static targets = ["dropzone", "fileInput", "spinner", "successMessage", "errorMessage", "resetButton", "previewContainer", "uploadButton"]
static values = { url: String, previewMode: Boolean }
static values = { url: String, previewMode: Boolean, ocrBadgeTitle: String, noImagesSelectedError: String }

connect() {
this.maxFiles = 10
this.acceptedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'image/heif']
this.acceptedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']
this.pendingFiles = []
this.pendingObjectUrls = []
this.pendingOcrFlags = []

// Initialize button state in preview mode
if (this.previewModeValue && this.hasUploadButtonTarget) {
Expand Down Expand Up @@ -92,6 +93,7 @@ export default class extends Controller {
if (this.previewModeValue) {
for (let i = 0; i < files.length; i++) {
this.pendingFiles.push(files[i])
this.pendingOcrFlags.push(true)
}
this.renderPreviews()
this.uploadButtonTarget.disabled = false
Expand Down Expand Up @@ -121,6 +123,11 @@ export default class extends Controller {
const objectUrl = URL.createObjectURL(file)
this.pendingObjectUrls.push(objectUrl)

const included = this.pendingOcrFlags[index]
const badgeClass = included
? 'bg-blue-500 text-white'
: 'bg-gray-300 text-gray-500 opacity-60'

const card = document.createElement('div')
card.className = 'relative rounded-lg overflow-hidden border border-gray-200 bg-gray-50'
card.innerHTML = `
Expand All @@ -133,7 +140,13 @@ export default class extends Controller {
aria-label="Remove">
<i class="fas fa-times text-xs pointer-events-none"></i>
</button>
${index === 0 ? '<div class="absolute top-1 left-1 bg-blue-500 text-white text-xs rounded px-1 leading-5">AI</div>' : ''}
<button type="button"
class="absolute top-1 left-1 text-xs rounded px-1 leading-5 cursor-pointer ${badgeClass}"
data-action="click->dropzone#toggleOcr"
data-index="${index}"
title="${this.escapeHtml(this.ocrBadgeTitleValue)}">
AI
</button>
`
grid.appendChild(card)
})
Expand All @@ -145,6 +158,7 @@ export default class extends Controller {
removeFile(event) {
const index = parseInt(event.currentTarget.dataset.index, 10)
this.pendingFiles.splice(index, 1)
this.pendingOcrFlags.splice(index, 1)

if (this.pendingFiles.length === 0) {
this.uploadButtonTarget.disabled = true
Expand All @@ -157,19 +171,33 @@ export default class extends Controller {
}
}

toggleOcr(event) {
event.stopPropagation()
const index = parseInt(event.currentTarget.dataset.index, 10)
this.pendingOcrFlags[index] = !this.pendingOcrFlags[index]
this.renderPreviews()
}

submit() {
if (this.pendingOcrFlags.every(flag => !flag)) {
this.showError(this.noImagesSelectedErrorValue)
return
}

const filesToUpload = this.pendingFiles.slice()
const ocrFlagsToUpload = this.pendingOcrFlags.slice()
this.pendingFiles = []
this.pendingOcrFlags = []
this.uploadButtonTarget.disabled = true
this.previewContainerTarget.classList.add('hidden')
this.uploadFiles(filesToUpload)
this.uploadFiles(filesToUpload, ocrFlagsToUpload)
}

escapeHtml(str) {
return str.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}

uploadFiles(files) {
uploadFiles(files, ocrFlags = null) {
// Hide dropzone
this.dropzoneTarget.classList.add('hidden')

Expand All @@ -180,6 +208,9 @@ export default class extends Controller {
const formData = new FormData()
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i])
if (ocrFlags) {
formData.append('ocr_flags[]', ocrFlags[i] ? 'true' : 'false')
}
}

// Add AI method selection if present
Expand Down Expand Up @@ -240,6 +271,7 @@ export default class extends Controller {

// Clear any pending preview state
this.pendingFiles = []
this.pendingOcrFlags = []
this.pendingObjectUrls.forEach(url => URL.revokeObjectURL(url))
this.pendingObjectUrls = []
if (this.hasPreviewContainerTarget) {
Expand Down
24 changes: 24 additions & 0 deletions app/services/image_data_uri.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module ImageDataUri
def build_data_uri(image_file, content_type)
image_data = if image_file.respond_to?(:read)
image_file.rewind if image_file.respond_to?(:rewind)
data = Base64.strict_encode64(image_file.read)
image_file.rewind if image_file.respond_to?(:rewind)
data
elsif image_file.is_a?(String)
File.open(image_file, 'rb') { |f| Base64.strict_encode64(f.read) }
else
raise ArgumentError, "Invalid image_file type"
end

image_format = case content_type
when /jpeg|jpg/ then 'jpeg'
when /png/ then 'png'
when /webp/ then 'webp'
when /heic|heif/ then 'heic'
else 'jpeg' # default
end

"data:image/#{image_format};base64,#{image_data}"
end
end
Loading
Loading