Compare commits
3 commits
c5380c0661
...
dfad78a0ca
Author | SHA1 | Date | |
---|---|---|---|
dfad78a0ca | |||
887815c7da | |||
d480495715 |
7 changed files with 455 additions and 176 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -52,3 +52,4 @@ prisma/*.sqlite3
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
/.qodo
|
/.qodo
|
||||||
|
settings.json
|
||||||
|
|
|
@ -1,6 +1,62 @@
|
||||||
//Handles DELETE requests for specific items
|
//Handles DELETE requests for specific items
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { deleteItemByUrl } from '@/lib/database';
|
import { deleteItemByUrl, insertItemByUrl } from '@/lib/database';
|
||||||
|
import { Item } from '@/types/database';
|
||||||
|
|
||||||
|
// PUT /api/urls/[encodedUrl]/items/[itemId] - Update a specific item
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { encodedUrl: string; itemId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { encodedUrl, itemId } = params;
|
||||||
|
const url = decodeURIComponent(encodedUrl);
|
||||||
|
const item: Item = await request.json();
|
||||||
|
|
||||||
|
console.log('[API] Updating item', itemId, 'for URL', url);
|
||||||
|
|
||||||
|
// Validate that the item ID matches the URL parameter
|
||||||
|
if (item.id !== itemId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Item ID mismatch' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!item.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Item ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure name and description are strings
|
||||||
|
const sanitizedItem: Item = {
|
||||||
|
...item,
|
||||||
|
name: item.name || '',
|
||||||
|
description: item.description || '',
|
||||||
|
customProperties: item.customProperties || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertItemByUrl(url, sanitizedItem);
|
||||||
|
|
||||||
|
console.log('[API] Successfully updated item:', itemId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, itemId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] Update error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Database error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
itemId: params.itemId,
|
||||||
|
url: decodeURIComponent(params.encodedUrl)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/urls/[encodedUrl]/items/[itemId] - Delete a specific item
|
// DELETE /api/urls/[encodedUrl]/items/[itemId] - Delete a specific item
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
54
src/app/api/wikidata/labels/route.ts
Normal file
54
src/app/api/wikidata/labels/route.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { propertyIds } = await request.json();
|
||||||
|
|
||||||
|
if (!propertyIds || !Array.isArray(propertyIds)) {
|
||||||
|
return NextResponse.json({ error: 'propertyIds array is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanIds = propertyIds.map(id => id.replace('http://www.wikidata.org/prop/', ''));
|
||||||
|
const propertyIdsStr = cleanIds.join(' wd:');
|
||||||
|
|
||||||
|
const sparqlQuery = `
|
||||||
|
SELECT ?prop ?propLabel WHERE {
|
||||||
|
VALUES ?prop { wd:${propertyIdsStr} }
|
||||||
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'WikidataApp/1.0 (https://example.com/contact)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Wikidata SPARQL error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data.results?.bindings) {
|
||||||
|
for (const binding of data.results.bindings) {
|
||||||
|
const propUri = binding.prop?.value;
|
||||||
|
const label = binding.propLabel?.value || '';
|
||||||
|
|
||||||
|
if (propUri) {
|
||||||
|
const propId = propUri.split('/').pop() || '';
|
||||||
|
result[propId] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching property labels:', error);
|
||||||
|
return NextResponse.json({});
|
||||||
|
}
|
||||||
|
}
|
59
src/app/api/wikidata/properties/route.ts
Normal file
59
src/app/api/wikidata/properties/route.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const wikidataId = searchParams.get('wikidataId');
|
||||||
|
|
||||||
|
if (!wikidataId || typeof wikidataId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'wikidataId parameter is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sparqlQuery = `
|
||||||
|
SELECT ?prop ?propLabel ?value ?valueLabel WHERE {
|
||||||
|
wd:${wikidataId} ?prop ?statement.
|
||||||
|
?statement ?ps ?value.
|
||||||
|
?property wikibase:claim ?prop.
|
||||||
|
?property wikibase:statementProperty ?ps.
|
||||||
|
SERVICE wikibase:label {
|
||||||
|
bd:serviceParam wikibase:language "en".
|
||||||
|
?prop rdfs:label ?propLabel.
|
||||||
|
?value rdfs:label ?valueLabel.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'WikidataApp/1.0 (https://example.com/contact)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Wikidata SPARQL error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data.results?.bindings) {
|
||||||
|
for (const binding of data.results.bindings) {
|
||||||
|
const propUri = binding.prop?.value;
|
||||||
|
const value = binding.valueLabel?.value || binding.value?.value || '';
|
||||||
|
|
||||||
|
if (propUri) {
|
||||||
|
const propId = propUri.split('/').pop() || '';
|
||||||
|
result[propId] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Wikidata properties:', error);
|
||||||
|
return NextResponse.json({});
|
||||||
|
}
|
||||||
|
}
|
56
src/app/api/wikidata/suggestions/route.ts
Normal file
56
src/app/api/wikidata/suggestions/route.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Define interfaces for Wikidata API response
|
||||||
|
interface WikidataSearchItem {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
concepturi?: string;
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WikidataSearchResponse {
|
||||||
|
search?: WikidataSearchItem[];
|
||||||
|
searchinfo?: {
|
||||||
|
search: string;
|
||||||
|
};
|
||||||
|
success?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('query');
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Query parameter is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wikidataUrl = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(query)}&language=en&limit=5&format=json&origin=*`;
|
||||||
|
|
||||||
|
const response = await fetch(wikidataUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'WikidataApp/1.0 (https://example.com/contact)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Wikidata API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: WikidataSearchResponse = await response.json();
|
||||||
|
|
||||||
|
// Transform the response to match our WikidataSuggestion interface
|
||||||
|
const suggestions = (data.search || []).map((item: WikidataSearchItem) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label || '',
|
||||||
|
description: item.description || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(suggestions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Wikidata suggestions:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch suggestions' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,15 +60,58 @@ async function loadItemsFromDb(url: string): Promise<ItemsResponse> {
|
||||||
async function saveItemToDb(url: string, item: Item): Promise<void> {
|
async function saveItemToDb(url: string, item: Item): Promise<void> {
|
||||||
console.log('[FRONTEND] Saving item - ID:', item.id, 'Name:', item.name);
|
console.log('[FRONTEND] Saving item - ID:', item.id, 'Name:', item.name);
|
||||||
|
|
||||||
|
// Validate item before saving
|
||||||
|
if (!item.id) {
|
||||||
|
throw new Error('Item ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure name and description are strings
|
||||||
|
const sanitizedItem = {
|
||||||
|
...item,
|
||||||
|
name: item.name || '',
|
||||||
|
description: item.description || '',
|
||||||
|
customProperties: item.customProperties || {}
|
||||||
|
};
|
||||||
|
|
||||||
const encodedUrl = encodeURIComponent(url);
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch(`/api/urls/${encodedUrl}/items`, {
|
const response = await fetch(`/api/urls/${encodedUrl}/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(item)
|
body: JSON.stringify(sanitizedItem)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to save item: ${response.status}`);
|
const errorText = await response.text();
|
||||||
|
console.error('[ERROR] Failed to save item:', response.status, errorText);
|
||||||
|
|
||||||
|
// Handle specific database constraint errors
|
||||||
|
if (response.status === 400 && errorText.includes('Unique constraint failed')) {
|
||||||
|
console.log('[INFO] Item already exists, attempting update...');
|
||||||
|
// Try to update instead of create
|
||||||
|
const updateResponse = await fetch(`/api/urls/${encodedUrl}/items/${item.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(sanitizedItem)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
const updateErrorText = await updateResponse.text();
|
||||||
|
throw new Error(`Failed to update item (${updateResponse.status}): ${updateErrorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SUCCESS] Item updated successfully');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to save item (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SUCCESS] Item saved successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] Error in saveItemToDb:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,44 +152,15 @@ async function addPropertyToDb(url: string, property: string): Promise<void> {
|
||||||
|
|
||||||
// Wikidata API functions
|
// Wikidata API functions
|
||||||
async function fetchWikidataProperties(wikidataId: string): Promise<Record<string, string>> {
|
async function fetchWikidataProperties(wikidataId: string): Promise<Record<string, string>> {
|
||||||
const sparqlQuery = `
|
|
||||||
SELECT ?prop ?propLabel ?value ?valueLabel WHERE {
|
|
||||||
wd:${wikidataId} ?prop ?statement.
|
|
||||||
?statement ?ps ?value.
|
|
||||||
?property wikibase:claim ?prop.
|
|
||||||
?property wikibase:statementProperty ?ps.
|
|
||||||
SERVICE wikibase:label {
|
|
||||||
bd:serviceParam wikibase:language "en".
|
|
||||||
?prop rdfs:label ?propLabel.
|
|
||||||
?value rdfs:label ?valueLabel.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(`/api/wikidata/properties?wikidataId=${encodeURIComponent(wikidataId)}`);
|
||||||
headers: { 'Accept': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) {
|
||||||
|
console.error('Wikidata properties API error:', response.status);
|
||||||
const data = await response.json();
|
return {};
|
||||||
const result: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (data.results?.bindings) {
|
|
||||||
for (const binding of data.results.bindings) {
|
|
||||||
const propUri = binding.prop?.value;
|
|
||||||
const value = binding.valueLabel?.value || binding.value?.value || '';
|
|
||||||
|
|
||||||
if (propUri) {
|
|
||||||
const propId = propUri.split('/').pop() || '';
|
|
||||||
result[propId] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Wikidata properties:', error);
|
console.error('Error fetching Wikidata properties:', error);
|
||||||
|
@ -155,40 +169,21 @@ async function fetchWikidataProperties(wikidataId: string): Promise<Record<strin
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPropertyLabels(propertyIds: string[]): Promise<Record<string, string>> {
|
async function fetchPropertyLabels(propertyIds: string[]): Promise<Record<string, string>> {
|
||||||
const cleanIds = propertyIds.map(id => id.replace('http://www.wikidata.org/prop/', ''));
|
if (propertyIds.length === 0) return {};
|
||||||
const propertyIdsStr = cleanIds.join(' wd:');
|
|
||||||
|
|
||||||
const sparqlQuery = `
|
|
||||||
SELECT ?prop ?propLabel WHERE {
|
|
||||||
VALUES ?prop { wd:${propertyIdsStr} }
|
|
||||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch('/api/wikidata/labels', {
|
||||||
headers: { 'Accept': 'application/json' }
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ propertyIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) {
|
||||||
|
console.error('Property labels API error:', response.status);
|
||||||
const data = await response.json();
|
return {};
|
||||||
const result: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (data.results?.bindings) {
|
|
||||||
for (const binding of data.results.bindings) {
|
|
||||||
const propUri = binding.prop?.value;
|
|
||||||
const label = binding.propLabel?.value || '';
|
|
||||||
|
|
||||||
if (propUri) {
|
|
||||||
const propId = propUri.split('/').pop() || '';
|
|
||||||
result[propId] = label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching property labels:', error);
|
console.error('Error fetching property labels:', error);
|
||||||
|
@ -199,14 +194,16 @@ async function fetchPropertyLabels(propertyIds: string[]): Promise<Record<string
|
||||||
async function fetchWikidataSuggestions(query: string): Promise<WikidataSuggestion[]> {
|
async function fetchWikidataSuggestions(query: string): Promise<WikidataSuggestion[]> {
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(query)}&language=en&limit=5&format=json&origin=*`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(`/api/wikidata/suggestions?query=${encodeURIComponent(query)}`);
|
||||||
if (!response.ok) return [];
|
|
||||||
|
|
||||||
const data = await response.json();
|
if (!response.ok) {
|
||||||
return data.search || [];
|
console.error('Wikidata suggestions API error:', response.status);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await response.json();
|
||||||
|
return suggestions;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Wikidata suggestions:', error);
|
console.error('Error fetching Wikidata suggestions:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -317,14 +314,27 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
|
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
item.name = value;
|
item.name = value;
|
||||||
// Fetch Wikidata suggestions
|
// Fetch Wikidata suggestions only if value is not empty
|
||||||
if (value.trim()) {
|
if (value.trim().length >= 2) {
|
||||||
fetchWikidataSuggestions(value).then(suggestions => {
|
fetchWikidataSuggestions(value).then(suggestions => {
|
||||||
setWikidataSuggestions(prev => ({
|
setWikidataSuggestions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[`name-${index}`]: suggestions
|
[`name-${index}`]: suggestions
|
||||||
}));
|
}));
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error fetching Wikidata suggestions:', error);
|
||||||
|
// Clear suggestions on error
|
||||||
|
setWikidataSuggestions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[`name-${index}`]: []
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Clear suggestions if value is too short
|
||||||
|
setWikidataSuggestions(prev => ({
|
||||||
|
...prev,
|
||||||
|
[`name-${index}`]: []
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} else if (field === 'description') {
|
} else if (field === 'description') {
|
||||||
item.description = value;
|
item.description = value;
|
||||||
|
@ -334,11 +344,36 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
|
|
||||||
newItems[index] = item;
|
newItems[index] = item;
|
||||||
|
|
||||||
// Auto-save
|
// Only save if the item has some content
|
||||||
saveItemMutation.mutate(item);
|
const hasContent = item.name.trim() ||
|
||||||
|
item.description.trim() ||
|
||||||
|
Object.values(item.customProperties).some(prop => prop.trim());
|
||||||
|
|
||||||
|
if (hasContent) {
|
||||||
|
// Auto-save with error handling
|
||||||
|
saveItemMutation.mutate(item, {
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to save item:', error);
|
||||||
|
// You could add a toast notification here
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// After successful save, reload items to get any synchronized properties
|
||||||
|
// This ensures that if another item with the same name exists,
|
||||||
|
// its properties are updated in the UI
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', url] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add new row if editing last row and value is not empty
|
// Add new row if editing last row and value is not empty
|
||||||
|
// Only add if the last item doesn't already have content and we're not already at the end
|
||||||
if (index === newItems.length - 1 && value.trim()) {
|
if (index === newItems.length - 1 && value.trim()) {
|
||||||
|
const lastItem = newItems[newItems.length - 1];
|
||||||
|
const lastItemHasContent = lastItem.name.trim() ||
|
||||||
|
lastItem.description.trim() ||
|
||||||
|
Object.values(lastItem.customProperties).some(prop => prop.trim());
|
||||||
|
|
||||||
|
if (lastItemHasContent) {
|
||||||
const newItem: Item = {
|
const newItem: Item = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: '',
|
name: '',
|
||||||
|
@ -347,12 +382,15 @@ export function ItemsList({ url }: ItemsListProps) {
|
||||||
customProperties: {}
|
customProperties: {}
|
||||||
};
|
};
|
||||||
newItems.push(newItem);
|
newItems.push(newItem);
|
||||||
saveItemMutation.mutate(newItem);
|
|
||||||
|
// Don't auto-save empty items immediately
|
||||||
|
// They will be saved when user starts typing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newItems;
|
return newItems;
|
||||||
});
|
});
|
||||||
}, [saveItemMutation]);
|
}, [saveItemMutation, queryClient, url]);
|
||||||
|
|
||||||
const removeItem = useCallback((index: number) => {
|
const removeItem = useCallback((index: number) => {
|
||||||
const itemId = items[index].id;
|
const itemId = items[index].id;
|
||||||
|
|
|
@ -39,85 +39,76 @@ async function getOrCreateProperty(tx: TransactionClient, propertyName: string):
|
||||||
|
|
||||||
// Get items by URL
|
// Get items by URL
|
||||||
export async function getItemsByUrl(url: string): Promise<Item[]> {
|
export async function getItemsByUrl(url: string): Promise<Item[]> {
|
||||||
console.log(`[DB] Fetching items for URL: ${url}`);
|
console.log(`[DB] Getting items for URL: ${url}`);
|
||||||
|
|
||||||
// First check if URL exists
|
return await withTransaction(async (tx) => {
|
||||||
const urlRecord = await prisma.url.findUnique({
|
// Get URL record
|
||||||
where: { url }
|
const urlRecord = await tx.url.findUnique({
|
||||||
|
where: { url },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { itemOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
// We don't include itemProperties here since we'll fetch them by globalItemId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!urlRecord) {
|
if (!urlRecord) {
|
||||||
console.log(`[DB] URL not found: ${url}`);
|
console.log(`[DB] No URL record found for: ${url}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DB] Found URL ID: ${urlRecord.id}`);
|
console.log(`[DB] Found ${urlRecord.items.length} items for URL: ${url}`);
|
||||||
|
|
||||||
// Get items with their properties, excluding deleted ones
|
// For each item, get properties from globalItemId
|
||||||
const items = await prisma.item.findMany({
|
const items: Item[] = [];
|
||||||
where: { urlId: urlRecord.id },
|
|
||||||
include: {
|
for (const dbItem of urlRecord.items) {
|
||||||
itemProperties: {
|
// Get all properties for this globalItemId
|
||||||
include: { property: true },
|
const itemProperties = await tx.itemProperty.findMany({
|
||||||
where: {
|
where: { globalItemId: dbItem.globalItemId },
|
||||||
NOT: {
|
include: { property: true }
|
||||||
AND: [
|
|
||||||
{ globalItemId: { in: [] } }, // This will be populated below
|
|
||||||
{ propertyId: { in: [] } } // This will be populated below
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { itemOrder: 'asc' }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get deleted properties for this URL to filter them out
|
// Separate core properties from custom properties
|
||||||
const deletedProps = await prisma.deletedProperty.findMany({
|
|
||||||
where: { urlId: urlRecord.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
const deletedPropsSet = new Set(
|
|
||||||
deletedProps.map(dp => `${dp.globalItemId}-${dp.propertyId}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Transform to Item format
|
|
||||||
const result: Item[] = items.map(item => {
|
|
||||||
const customProperties: Record<string, string> = {};
|
const customProperties: Record<string, string> = {};
|
||||||
let name = '';
|
let name = '';
|
||||||
let description = '';
|
let description = '';
|
||||||
|
|
||||||
item.itemProperties.forEach(ip => {
|
for (const prop of itemProperties) {
|
||||||
const key = `${ip.globalItemId}-${ip.propertyId}`;
|
const propName = prop.property.name;
|
||||||
if (deletedPropsSet.has(key)) {
|
const propValue = prop.value;
|
||||||
return; // Skip deleted properties
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.property.name === 'name') {
|
if (propName === 'name') {
|
||||||
name = ip.value;
|
name = propValue;
|
||||||
} else if (ip.property.name === 'description') {
|
} else if (propName === 'description') {
|
||||||
description = ip.value;
|
description = propValue;
|
||||||
} else {
|
} else {
|
||||||
customProperties[ip.property.name] = ip.value;
|
customProperties[propName] = propValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const item: Item = {
|
||||||
id: item.id,
|
id: dbItem.id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
wikidataId: item.wikidataId || undefined,
|
wikidataId: dbItem.wikidataId || undefined,
|
||||||
customProperties
|
customProperties
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[DB] Fetched ${result.length} items`);
|
items.push(item);
|
||||||
return result;
|
}
|
||||||
|
|
||||||
|
console.log(`[DB] Returning ${items.length} items with properties`);
|
||||||
|
return items;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert item by URL
|
// Insert item by URL
|
||||||
export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
||||||
console.log(`[DB] Starting insert for URL: ${url}, Item: ${item.id}`);
|
console.log(`[DB] Starting insert for URL: ${url}, Item: ${item.id}, Name: ${item.name}`);
|
||||||
|
|
||||||
await withTransaction(async (tx) => {
|
await withTransaction(async (tx) => {
|
||||||
// 1. Get or create URL
|
// 1. Get or create URL
|
||||||
|
@ -129,27 +120,50 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
||||||
|
|
||||||
console.log(`[DB] URL ID: ${urlRecord.id}`);
|
console.log(`[DB] URL ID: ${urlRecord.id}`);
|
||||||
|
|
||||||
// 2. Get max order for new items
|
// 2. Check if this specific item already exists for this URL
|
||||||
|
const existingItem = await tx.item.findUnique({
|
||||||
|
where: { id: item.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Find or create globalItemId based on item name
|
||||||
|
let globalItemId: string;
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
// Use existing globalItemId if item already exists
|
||||||
|
globalItemId = existingItem.globalItemId;
|
||||||
|
console.log(`[DB] Using existing global item ID: ${globalItemId}`);
|
||||||
|
} else {
|
||||||
|
// For new items, find existing globalItemId by name or create new one
|
||||||
|
const namePropertyId = await getOrCreateProperty(tx, 'name');
|
||||||
|
|
||||||
|
// Look for existing global item with the same name
|
||||||
|
const existingGlobalItem = await tx.itemProperty.findFirst({
|
||||||
|
where: {
|
||||||
|
propertyId: namePropertyId,
|
||||||
|
value: item.name.trim()
|
||||||
|
},
|
||||||
|
select: { globalItemId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGlobalItem && item.name.trim()) {
|
||||||
|
// Use existing globalItemId for items with the same name
|
||||||
|
globalItemId = existingGlobalItem.globalItemId;
|
||||||
|
console.log(`[DB] Found existing global item ID for name "${item.name}": ${globalItemId}`);
|
||||||
|
} else {
|
||||||
|
// Create new globalItemId for new names or empty names
|
||||||
|
globalItemId = uuidv4();
|
||||||
|
console.log(`[DB] Created new global item ID: ${globalItemId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get max order for new items
|
||||||
const maxOrder = await tx.item.findFirst({
|
const maxOrder = await tx.item.findFirst({
|
||||||
where: { urlId: urlRecord.id },
|
where: { urlId: urlRecord.id },
|
||||||
orderBy: { itemOrder: 'desc' },
|
orderBy: { itemOrder: 'desc' },
|
||||||
select: { itemOrder: true }
|
select: { itemOrder: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Check if global item exists (by name)
|
// 5. Upsert the item record (unique by item.id)
|
||||||
const namePropertyId = await getOrCreateProperty(tx, 'name');
|
|
||||||
const existingGlobalItem = await tx.itemProperty.findFirst({
|
|
||||||
where: {
|
|
||||||
propertyId: namePropertyId,
|
|
||||||
value: item.name
|
|
||||||
},
|
|
||||||
select: { globalItemId: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
const globalItemId = existingGlobalItem?.globalItemId || uuidv4();
|
|
||||||
console.log(`[DB] Using global item ID: ${globalItemId}`);
|
|
||||||
|
|
||||||
// 4. Upsert item
|
|
||||||
await tx.item.upsert({
|
await tx.item.upsert({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
update: {
|
update: {
|
||||||
|
@ -166,7 +180,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Handle all properties (core + custom)
|
// 6. Handle all properties (core + custom)
|
||||||
const allProperties = [
|
const allProperties = [
|
||||||
['name', item.name],
|
['name', item.name],
|
||||||
['description', item.description],
|
['description', item.description],
|
||||||
|
@ -176,6 +190,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
||||||
for (const [propName, propValue] of allProperties) {
|
for (const [propName, propValue] of allProperties) {
|
||||||
const propertyId = await getOrCreateProperty(tx, propName);
|
const propertyId = await getOrCreateProperty(tx, propName);
|
||||||
|
|
||||||
|
// Update properties for the globalItemId (this affects all items with same name)
|
||||||
await tx.itemProperty.upsert({
|
await tx.itemProperty.upsert({
|
||||||
where: {
|
where: {
|
||||||
globalItemId_propertyId: {
|
globalItemId_propertyId: {
|
||||||
|
@ -192,7 +207,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DB] Item ${item.id} inserted/updated successfully`);
|
console.log(`[DB] Item ${item.id} with global ID ${globalItemId} inserted/updated successfully`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue