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
11 changes: 11 additions & 0 deletions .changeset/code-quality-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"think-app": patch
---

Fix code quality issues for release readiness

- Extract Poppler version to workflow variable to prevent future breakage
- Create shared useDocumentUpload hook to reduce code duplication
- Add error logging to Electron temp file cleanup
- Add empty PDF validation to reject image-only/scanned PDFs
- Simplify Vite PDF.js worker configuration
31 changes: 29 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ jobs:
virtualenvs-create: true
virtualenvs-in-project: true

- name: Install SQLCipher
run: brew install sqlcipher
- name: Install SQLCipher and Poppler
run: brew install sqlcipher poppler

- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -240,6 +240,33 @@ jobs:
}
working-directory: backend

- name: Download Poppler for Windows
shell: pwsh
env:
POPPLER_VERSION: "24.08.0-0"
run: |
# Download Poppler binaries for PDF thumbnail generation
# From: https://github.com/oschwartz10612/poppler-windows/releases
$popplerUrl = "https://github.com/oschwartz10612/poppler-windows/releases/download/v$env:POPPLER_VERSION/Release-$env:POPPLER_VERSION.zip"
$archivePath = "$env:RUNNER_TEMP\poppler.zip"
$extractPath = "backend\poppler-windows"

Write-Host "Downloading Poppler..."
Invoke-WebRequest -Uri $popplerUrl -OutFile $archivePath

Write-Host "Extracting Poppler..."
Expand-Archive -Path $archivePath -DestinationPath $extractPath -Force

# The archive extracts to poppler-24.08.0, move contents up
$extractedDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
if ($extractedDir) {
Get-ChildItem -Path $extractedDir.FullName | Move-Item -Destination $extractPath -Force
Remove-Item $extractedDir.FullName -Force
}

Write-Host "Poppler installed to $extractPath"
Get-ChildItem -Path "$extractPath\Library\bin" -ErrorAction SilentlyContinue | Select-Object -First 10 Name

- name: Build backend
run: pnpm build:backend

Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ extension/extension.pem
backend/native_host/think-native-stub

# FFmpeg (downloaded on first use, cached locally)
app/public/ffmpeg/
app/public/ffmpeg/

# PDF.js worker (copied by vite config at build time)
app/public/pdf.worker.min.mjs
40 changes: 40 additions & 0 deletions app/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -639,3 +639,43 @@ ipcMain.handle('delete-temp-file', async (_event, filePath) => {
return { success: false, error: error.message };
}
});

// Open a document with the system's default viewer
ipcMain.handle('open-document-with-system', async (_event, documentId, filename) => {
try {
// Fetch the document from the backend with authentication
const response = await fetch(`http://localhost:8765/api/document/${documentId}/file`, {
headers: { 'X-App-Token': APP_TOKEN }
});

if (!response.ok) {
throw new Error(`Failed to fetch document: ${response.status}`);
}

// Write to temp file
const tempDir = app.getPath('temp');
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
const tempPath = path.join(tempDir, `think_doc_${Date.now()}_${safeName}`);
const buffer = await response.arrayBuffer();
fs.writeFileSync(tempPath, Buffer.from(buffer));

// Open with system default application
const result = await shell.openPath(tempPath);
if (result) {
// shell.openPath returns empty string on success, error message on failure
throw new Error(result);
}

// Schedule cleanup after 60 seconds (give app time to open and load)
setTimeout(() => {
fs.unlink(tempPath, (err) => {
if (err) console.warn('Failed to cleanup temp file:', tempPath, err.message);
});
}, 60000);

return { success: true };
} catch (error) {
console.error('[IPC] open-document-with-system error:', error);
return { success: false, error: error.message };
}
});
2 changes: 2 additions & 0 deletions app/electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
removeVideoProcessListeners: () => {
ipcRenderer.removeAllListeners('video-process-progress');
},
// Document viewing
openDocumentWithSystem: (documentId, filename) => ipcRenderer.invoke('open-document-with-system', documentId, filename),
});
4 changes: 3 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
}
},
"dependencies": {
"ffmpeg-static": "^5.2.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
Expand All @@ -86,10 +85,12 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"electron-updater": "^6.6.2",
"ffmpeg-static": "^5.2.0",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-pdf": "^10.3.0",
"react-router-dom": "^7.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0"
Expand All @@ -110,6 +111,7 @@
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^6.0.1",
"vite-plugin-static-copy": "^3.1.6",
"wait-on": "^8.0.1"
}
}
251 changes: 251 additions & 0 deletions app/src/components/DocumentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
FileText,
X,
PanelRight,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getAppToken } from "@/lib/api";
import { API_BASE_URL } from "@/constants";

interface MemoryTag {
id: number;
name: string;
source: "ai" | "manual";
}

interface DocumentMemory {
id: number;
type: "document";
title: string;
content?: string;
summary: string | null;
tags: MemoryTag[];
created_at: string;
document_format?: string;
document_page_count?: number;
thumbnail_path?: string;
}

interface DocumentCardProps {
memory: DocumentMemory;
onRemoveTag: (memoryId: number, tagId: number) => void;
onExpand: (id: number) => void;
formatDate: (date: string) => string;
}

function ProcessingStatusBadge({
hasSummary,
hasContent,
}: {
hasSummary: boolean;
hasContent: boolean;
}) {
// No processing status needed if summary exists (fully processed)
if (hasSummary) return null;

// Content exists but no summary yet - AI processing in progress
if (hasContent) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] rounded-full font-medium",
"bg-muted text-muted-foreground"
)}
>
<Loader2 className="h-3 w-3 animate-spin" />
Processing...
</span>
);
}

// No content yet - still extracting text from document
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] rounded-full font-medium",
"bg-muted text-muted-foreground"
)}
>
<Loader2 className="h-3 w-3 animate-spin" />
Extracting...
</span>
);
}

export function DocumentCard({ memory, onRemoveTag, onExpand, formatDate }: DocumentCardProps) {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
const thumbnailLoadedRef = useRef(false);

// Load thumbnail on mount
useEffect(() => {
if (!memory.thumbnail_path || thumbnailLoadedRef.current) return;

thumbnailLoadedRef.current = true;
setIsLoadingThumbnail(true);

const token = getAppToken();
fetch(`${API_BASE_URL}/api/document/${memory.id}/thumbnail`, {
headers: token ? { "X-App-Token": token } : {},
})
.then((response) => {
if (response.ok) return response.blob();
throw new Error("Failed to load thumbnail");
})
.then((blob) => {
const url = URL.createObjectURL(blob);
setThumbnailUrl(url);
})
.catch((error) => {
console.error("Failed to load thumbnail:", error);
})
.finally(() => {
setIsLoadingThumbnail(false);
});

return () => {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
};
}, [memory.id, memory.thumbnail_path]);

// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
};
}, [thumbnailUrl]);

return (
<div
className={cn(
"group relative p-5 rounded-2xl",
// Glassmorphism - light mode
"bg-white/70 dark:bg-white/5 backdrop-blur-md",
"border border-white/60 dark:border-white/10",
"shadow-sm shadow-black/5 dark:shadow-black/20",
// Hover lift effect
"hover:shadow-lg hover:shadow-black/10 dark:hover:shadow-black/30",
"hover:-translate-y-0.5",
"transition-all duration-200"
)}
>
{/* Hover actions - top right */}
<div
className={cn(
"absolute top-3 right-3 flex gap-0.5 z-10",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-200"
)}
>
<Button
variant="ghost"
size="icon"
onClick={() => onExpand(memory.id)}
className="h-7 w-7 text-muted-foreground hover:text-foreground bg-background/80 backdrop-blur-sm"
title="View Details"
>
<PanelRight className="h-3.5 w-3.5" />
</Button>
</div>

{/* Thumbnail */}
<div
className="relative aspect-[4/3] rounded-lg overflow-hidden mb-3 bg-slate-100 dark:bg-slate-800 cursor-pointer"
onClick={() => onExpand(memory.id)}
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={memory.title}
className="w-full h-full object-cover"
/>
) : isLoadingThumbnail ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
</div>
)}

{/* Page count badge */}
{memory.document_page_count !== undefined && (
<div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded bg-black/70 text-white text-xs font-medium">
{memory.document_page_count} {memory.document_page_count === 1 ? "page" : "pages"}
</div>
)}
</div>

{/* Header: Document icon + Title */}
<div className="flex items-start gap-2.5 mb-2">
<div className="mt-0.5">
<FileText className="h-4 w-4 text-red-600" />
</div>
<h3 className="font-medium text-[15px] leading-snug line-clamp-2">
{memory.title || "Document"}
</h3>
</div>

{/* Processing status */}
<div className="mb-2">
<ProcessingStatusBadge
hasSummary={!!memory.summary}
hasContent={!!memory.content}
/>
</div>

{/* Summary */}
{memory.summary ? (
<p className="text-sm text-muted-foreground leading-relaxed mb-4 line-clamp-2">
{memory.summary}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic mb-4">
Generating summary...
</p>
)}

{/* Tags */}
{memory.tags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-3">
{memory.tags.map((tag) => (
<span
key={tag.id}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full",
"transition-colors",
tag.source === "ai"
? "bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"
: "bg-primary/10 text-primary"
)}
>
{tag.name}
{tag.source === "manual" && (
<button
onClick={(e) => {
e.stopPropagation();
onRemoveTag(memory.id, tag.id);
}}
className="hover:text-primary/70 -mr-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}

{/* Date */}
<p className="text-[11px] text-muted-foreground/70">{formatDate(memory.created_at)}</p>
</div>
);
}
Loading
Loading