Root cause: yomitoku's -o flag received a shared directory (.ocr_cache/), causing multiple PDFs' outputs to mix. findOCROutput's fallback would return a previous PDF's result file. Changes: - Output directory is now .ocr_cache/<hash>/ (1 PDF = 1 subdirectory) - Hash matches cache key (FNV-1a 64bit) for reverse lookup - findOCROutput candidates include yomitoku's <parentdir>_<base>.md pattern - savePdftotextResult outputs to same per-hash directory - Add Delete/DeleteAll to mediacache.Cache - Add DELETE endpoints to web backend and miniapp APIs - Add delete buttons to Mini App cache page (per-entry + clear all) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
195 lines
5.2 KiB
Go
195 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/mediacache"
|
|
)
|
|
|
|
func (h *Handler) registerMediaCacheRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/api/media-cache", h.handleMediaCache)
|
|
mux.HandleFunc("/api/media-cache/", h.handleMediaCacheContent)
|
|
}
|
|
|
|
type mediaCacheEntryJSON struct {
|
|
Hash string `json:"hash"`
|
|
Type string `json:"type"`
|
|
Result string `json:"result"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
Pages int `json:"pages,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
AccessedAt string `json:"accessed_at"`
|
|
}
|
|
|
|
func (h *Handler) openMediaCache() (*mediacache.Cache, error) {
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ws := cfg.WorkspacePath()
|
|
return mediacache.Open(filepath.Join(ws, "media_cache.db"))
|
|
}
|
|
|
|
// handleMediaCache dispatches GET (list) and DELETE (clear all) for media cache.
|
|
func (h *Handler) handleMediaCache(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.listMediaCache(w, r)
|
|
case http.MethodDelete:
|
|
h.deleteAllMediaCache(w, r)
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) listMediaCache(w http.ResponseWriter, r *http.Request) {
|
|
mc, err := h.openMediaCache()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer mc.Close()
|
|
|
|
typeFilter := r.URL.Query().Get("type")
|
|
entries, err := mc.List(typeFilter)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to list cache entries"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
result := make([]mediaCacheEntryJSON, 0, len(entries))
|
|
for _, e := range entries {
|
|
result = append(result, mediaCacheEntryJSON{
|
|
Hash: e.Hash,
|
|
Type: e.Type,
|
|
Result: e.Result,
|
|
FilePath: e.FilePath,
|
|
Pages: e.Pages,
|
|
CreatedAt: e.CreatedAt,
|
|
AccessedAt: e.AccessedAt,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
func (h *Handler) deleteAllMediaCache(w http.ResponseWriter, _ *http.Request) {
|
|
mc, err := h.openMediaCache()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer mc.Close()
|
|
|
|
removed, err := mc.DeleteAll()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to delete cache"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Clean up .ocr_cache directory
|
|
cfg, _ := config.LoadConfig(h.configPath)
|
|
if cfg != nil {
|
|
ocrDir := filepath.Join(cfg.WorkspacePath(), ".ocr_cache")
|
|
os.RemoveAll(ocrDir)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"deleted": removed})
|
|
}
|
|
|
|
// handleMediaCacheContent dispatches GET (content) and DELETE (single entry).
|
|
// /api/media-cache/{hash}
|
|
func (h *Handler) handleMediaCacheContent(w http.ResponseWriter, r *http.Request) {
|
|
hash := filepath.Base(r.URL.Path)
|
|
if hash == "" || hash == "media-cache" {
|
|
http.Error(w, `{"error":"hash required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.getMediaCacheContent(w, hash)
|
|
case http.MethodDelete:
|
|
h.deleteMediaCacheEntry(w, hash)
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) getMediaCacheContent(w http.ResponseWriter, hash string) {
|
|
mc, err := h.openMediaCache()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer mc.Close()
|
|
|
|
entry, ok := mc.GetEntry(hash, mediacache.TypePDFOCR)
|
|
if !ok {
|
|
entry, ok = mc.GetEntry(hash, mediacache.TypePDFText)
|
|
}
|
|
if !ok {
|
|
result, ok := mc.Get(hash, mediacache.TypeImageDesc)
|
|
if !ok {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"hash": hash,
|
|
"type": mediacache.TypeImageDesc,
|
|
"content": result,
|
|
})
|
|
return
|
|
}
|
|
|
|
content, err := os.ReadFile(entry.FilePath)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"file not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"hash": hash,
|
|
"type": mediacache.TypePDFOCR,
|
|
"content": string(content),
|
|
"file_path": entry.FilePath,
|
|
"pages": entry.Pages,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) deleteMediaCacheEntry(w http.ResponseWriter, hash string) {
|
|
mc, err := h.openMediaCache()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer mc.Close()
|
|
|
|
// Delete all types for this hash, clean up files
|
|
for _, t := range []string{mediacache.TypePDFOCR, mediacache.TypePDFText, mediacache.TypeImageDesc} {
|
|
entry, err := mc.Delete(hash, t)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if entry.FilePath != "" {
|
|
// Remove the per-hash subdirectory if it exists
|
|
dir := filepath.Dir(entry.FilePath)
|
|
if filepath.Base(dir) == hash {
|
|
os.RemoveAll(dir)
|
|
} else {
|
|
os.Remove(entry.FilePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|