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
4 changes: 4 additions & 0 deletions backend/models/data_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class DataDocumentsFilter(BaseModel):
key: str
value: Any = "" # Can be str, int, etc. depending on the field type
operator: Optional[str] = "equals" # 'equals', 'exists', 'not_exists'
type: Optional[str] = "string"


class DataDocumentsRequest(BaseModel):
Expand All @@ -17,6 +18,9 @@ class DataDocumentsRequest(BaseModel):
filter: Optional[DataDocumentsFilter] = None
filters: Optional[List[DataDocumentsFilter]] = None

class DataDocumentsQueryResponse(BaseModel):
query_code: str


class DataDocumentsResponse(BaseModel):
documents: List[Dict[str, Any]]
Expand Down
28 changes: 28 additions & 0 deletions backend/routes/data_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from models.data_documents import (
DataDocumentsRequest,
DataDocumentsResponse,
DataDocumentsQueryResponse,
FindByIdRequest,
FindByIdResponse,
UpdateDocumentRequest,
Expand All @@ -17,6 +18,8 @@
from services.data_documents_service import (
find_document_by_id,
fetch_documents,
build_mongo_query,
generate_mongo_query_string,
update_document,
get_single_document,
insert_document,
Expand Down Expand Up @@ -49,6 +52,31 @@ def get_documents(
filters=[f.model_dump() for f in body.filters] if body.filters else None,
)

@router.post("/documents/query_code", response_model=DataDocumentsQueryResponse)
def get_documents_query_code(
body: DataDocumentsRequest = Body(...), authorization: str = Header(...)
):
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid token format")
user_token = authorization.replace("Bearer ", "")
access_token = exchange_token_obo(user_token)
connection_string = get_connection_string(body.account_id, access_token)

# Needs to initialize MongoClient briefly to access find_one if 'all' keys are used in the algorithm
from pymongo import MongoClient
client = MongoClient(connection_string)
db = client[body.database_name]
collection = db[body.collection_name]

query = build_mongo_query(
collection=collection,
filter=body.filter.model_dump() if body.filter else None,
filters=[f.model_dump() for f in body.filters] if body.filters else None,
)

query_str = generate_mongo_query_string(body.collection_name, query)
return DataDocumentsQueryResponse(query_code=query_str)


@router.put("/documents", response_model=dict)
def put_update_document(
Expand Down
84 changes: 70 additions & 14 deletions backend/services/data_documents_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,41 @@

ALL_DOCUMENTS_CACHES = [_find_by_id_cache]

def _format_value_for_pymongo(v: any) -> str:
if isinstance(v, dict):
items = []
for key, val in v.items():
items.append(f"'{key}': {_format_value_for_pymongo(val)}")
return "{" + ", ".join(items) + "}"
elif isinstance(v, list):
items = [_format_value_for_pymongo(x) for x in v]
return "[" + ", ".join(items) + "]"
elif isinstance(v, datetime):
# E.g. datetime(2026, 3, 12, 10, 0, tzinfo=timezone.utc)
return repr(v)
elif isinstance(v, ObjectId):
return f"ObjectId('{str(v)}')"
elif isinstance(v, str):
# simple escape
if v.startswith("ObjectId(") or v.startswith("datetime("):
return v # fallback if it was already stringified somehow

escaped = v.replace("'", "\\'")
return f"'{escaped}'"
elif isinstance(v, bool):
return "True" if v else "False"
elif v is None:
return "None"
elif hasattr(v, "pattern"): # Regex object
return f"re.compile(r'{v.pattern}', {v.flags})"
else:
return repr(v)

def generate_mongo_query_string(collection_name: str, query: dict) -> str:
query_str = _format_value_for_pymongo(query)
# The user specifically requested exactly the db['project'].find({......}) format
return f"db['{collection_name}'].find({query_str})"


def dict_diff(before, after):
"""
Expand Down Expand Up @@ -82,26 +117,15 @@ def json_dumps_safe(obj):
return None


def fetch_documents(
connection_string: str,
database_name: str,
collection_name: str,
page: int,
limit: int,
filter: dict = None,
filters: list = None,
) -> DataDocumentsResponse:
client = MongoClient(connection_string)
db = client[database_name]
collection = db[collection_name]

def build_mongo_query(collection, filter: dict = None, filters: list = None) -> dict:
query = {}
and_clauses = []

def get_query_for_filter(f: dict):
key = f.get("key")
value = f.get("value")
operator = f.get("operator", "equals")
val_type = f.get("type", "string")

if not key:
return {}
Expand All @@ -127,7 +151,21 @@ def get_query_for_filter(f: dict):
return {"$or": or_clauses}
return {}
else:
if key == "_id":
if val_type == "date" and isinstance(value, str):
try:
dt_str = value
if len(dt_str) == 10:
dt_str += "T00:00:00"
if (
"Z" not in dt_str
and "+" not in dt_str[-6:]
and "-" not in dt_str[-6:]
):
dt_str += "Z"
query_val = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
except Exception:
query_val = value
elif key == "_id":
try:
query_val = ObjectId(value)
except Exception:
Expand Down Expand Up @@ -167,6 +205,24 @@ def get_query_for_filter(f: dict):
query = and_clauses[0]
else:
query = {"$and": and_clauses}

return query


def fetch_documents(
connection_string: str,
database_name: str,
collection_name: str,
page: int,
limit: int,
filter: dict = None,
filters: list = None,
) -> DataDocumentsResponse:
client = MongoClient(connection_string)
db = client[database_name]
collection = db[collection_name]

query = build_mongo_query(collection, filter, filters)

total_documents = collection.count_documents(query)
total_pages = max(1, (total_documents + limit - 1) // limit)
Expand Down
10 changes: 10 additions & 0 deletions backend/test_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from datetime import datetime

dt_str = "2026-03-12T10:00"
if len(dt_str) == 10:
dt_str += "T00:00:00"
if "Z" not in dt_str and "+" not in dt_str[-6:] and "-" not in dt_str[-6:]:
dt_str += "Z"

print(dt_str)
print(datetime.fromisoformat(dt_str.replace("Z", "+00:00")))
46 changes: 44 additions & 2 deletions frontend/components/DocumentDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useTheme } from '../contexts/ThemeContext';
import { Button, CircularProgress } from '@mui/material';
import MonacoEditor from '@monaco-editor/react';
import { updateDocument, getSingleDocument } from '../services/dbService';
import { isEqual, omit } from 'lodash';
import SaveConflictDialog from './SaveConflictDialog';


interface DocumentEditViewProps {
Expand All @@ -26,17 +28,47 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const { theme } = useTheme();
const [isSaving, setIsSaving] = useState(false);
const [isConflictDialogOpen, setIsConflictDialogOpen] = useState(false);
const [conflictServerDocStr, setConflictServerDocStr] = useState<string>('');

useImperativeHandle(ref, () => ({
getCurrentValue: () => jsonValue,
setCurrentValue: (val: string) => setJsonValue(val)
}));

const handleSave = async () => {
const handleSave = async (forceSave = false) => {
setIsSaving(true);
try {
const parsed = JSON.parse(jsonValue);
if (!accountId || !databaseName || !collection || !docId) throw new Error('Missing DB info');

if (!forceSave) {
// Fetch the latest document from DB
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);

// Compare with the original document prop
const ignoredKeys = ['_id', 'datetime_creation', 'datetime_last_modified'];
const oldWithoutIgnored = omit(document, ignoredKeys);
const newWithoutIgnored = omit(refreshed, ignoredKeys);

if (!isEqual(oldWithoutIgnored, newWithoutIgnored)) {
// Sync ignored fields to match user's expected view without highlighting them
const displayServerDoc = { ...refreshed };
ignoredKeys.forEach(key => {
if (key in parsed) {
displayServerDoc[key] = parsed[key];
} else {
delete displayServerDoc[key];
}
});

setConflictServerDocStr(JSON.stringify(displayServerDoc, null, 2));
setIsConflictDialogOpen(true);
setIsSaving(false);
return;
}
}

await updateDocument(accountId, databaseName, collection, docId, parsed);
// Fetch the latest document after update
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);
Expand All @@ -56,7 +88,7 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100">Edit Document</h3>
<div className="flex gap-2">
<Button variant="contained" color="success" size="small" onClick={handleSave} disabled={loading || isSaving} startIcon={isSaving ? <CircularProgress size={18} color="inherit" /> : undefined}>
<Button variant="contained" color="success" size="small" onClick={() => handleSave(false)} disabled={loading || isSaving} startIcon={isSaving ? <CircularProgress size={18} color="inherit" /> : undefined}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
Expand Down Expand Up @@ -96,6 +128,16 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
{feedback.message}
</div>
)}
<SaveConflictDialog
open={isConflictDialogOpen}
serverValue={conflictServerDocStr}
localValue={jsonValue}
onClose={() => setIsConflictDialogOpen(false)}
onOverwrite={() => {
setIsConflictDialogOpen(false);
handleSave(true);
}}
/>
</div>
);
});
Expand Down
40 changes: 23 additions & 17 deletions frontend/components/JsonDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { CheckIcon, ChevronDownIcon, ClipboardIcon, SearchIcon, XIcon, ArrowUpwardIcon, ArrowDownwardIcon, CaseSensitiveIcon, WholeWordIcon, RegexIcon, ImageIcon } from './icons/material-icons-imports';

// Search context for highlighting and navigation
interface SearchState {
query: string;
currentMatchIndex: number;
totalMatches: number;
isActive: boolean;
}
// Removed unused SearchState interface

// Helper function to highlight search matches in text
// Helper function to highlight search matches in text
Expand Down Expand Up @@ -65,7 +59,7 @@ const highlightText = (text: string, searchRegex: RegExp | null): React.ReactNod
// Component to render ObjectId with both click navigation and copy functionality
const ObjectIdDisplay: React.FC<{
objectId: string;
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean) => void;
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean, openToSide?: boolean) => void;
keyContext?: string;
showAsLink?: boolean;
}> = ({ objectId, onObjectIdClick, keyContext, showAsLink = true }) => {
Expand Down Expand Up @@ -175,6 +169,15 @@ const ObjectIdDisplay: React.FC<{
>
<span>Open in New Tab</span>
</button>
<button
onClick={() => {
setShowContextMenu(false);
onObjectIdClick?.(objectId, keyContext, false, true);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center gap-2"
>
<span>Open to Side</span>
</button>
<div className="border-t border-slate-200 dark:border-slate-600 my-1"></div>
<button
onClick={handleCopyFromMenu}
Expand All @@ -201,10 +204,10 @@ const ObjectIdDisplay: React.FC<{
);
};

const Base64ImagePreview: React.FC<{
base64String: string;
const ImagePreview: React.FC<{
imageUrl: string;
searchRegex: RegExp | null;
}> = ({ base64String, searchRegex }) => {
}> = ({ imageUrl, searchRegex }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [previewPos, setPreviewPos] = useState<{ x: number; y: number } | null>(null);
const iconRef = useRef<HTMLSpanElement>(null);
Expand Down Expand Up @@ -248,7 +251,7 @@ const Base64ImagePreview: React.FC<{
title="Click to collapse back to icon"
>
<span className="text-emerald-700 dark:text-emerald-300">"
{searchRegex ? highlightText(base64String, searchRegex) : base64String}"
{searchRegex ? highlightText(imageUrl, searchRegex) : imageUrl}"
</span>
</span>
);
Expand Down Expand Up @@ -281,7 +284,7 @@ const Base64ImagePreview: React.FC<{
}}
>
<img
src={base64String}
src={imageUrl}
alt="Preview"
className="max-w-[200px] max-h-[200px] object-contain rounded bg-slate-100 dark:bg-slate-900"
/>
Expand All @@ -296,7 +299,7 @@ const JsonNode: React.FC<{
nodeValue: any;
nodeKey?: string; // The key of this node, if it's in an object
isRoot?: boolean; // The top-level object is not collapsible
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean) => void;
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean, openToSide?: boolean) => void;
parentKeyContext?: string; // The key of the parent, used for context in clicks
searchRegex?: RegExp | null; // Regex for highlighting
currentMatchIndex?: number; // Current match index for highlighting
Expand Down Expand Up @@ -475,8 +478,11 @@ const JsonNode: React.FC<{
);
}

if (nodeValue.startsWith("data:image/png;base64,")) {
return <Base64ImagePreview base64String={nodeValue} searchRegex={searchRegex} />;
const isBase64Image = nodeValue.startsWith("data:image/");
const isUrlImage = /^https?:\/\/.+\.(png|jpe?g|gif|svg|webp)(\?.*)?$/i.test(nodeValue);

if (isBase64Image || isUrlImage) {
return <ImagePreview imageUrl={nodeValue} searchRegex={searchRegex} />;
}

return <span className="text-emerald-700 dark:text-emerald-300">"
Expand Down Expand Up @@ -513,7 +519,7 @@ const JsonNode: React.FC<{

interface JsonDisplayProps {
data: any;
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean) => void;
onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean, openToSide?: boolean) => void;
}

const JsonDisplay: React.FC<JsonDisplayProps> = ({ data, onObjectIdClick }) => {
Expand Down
Loading
Loading