Skip to content

Commit

Permalink
feat(import): implement WIP bookmark import execution with metadata s…
Browse files Browse the repository at this point in the history
…upport

Signed-off-by: Robert Goniszewski <[email protected]>
  • Loading branch information
goniszewski committed Nov 20, 2024
1 parent 4bb9cba commit ecb7bad
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 40 deletions.
1 change: 1 addition & 0 deletions run-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

trap 'echo "Received SIGINT or SIGTERM. Exiting..." >&2; exit 1' SIGINT SIGTERM

bun --bun run run-migrations
bun --bun run dev

kill -- -$$
79 changes: 46 additions & 33 deletions src/lib/components/BulkListItem/BulkListItem.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { editBookmarkStore } from '$lib/stores/edit-bookmark.store';
import { importBookmarkStore } from '$lib/stores/import-bookmarks.store';
import { createSlug } from '$lib/utils/create-slug';
import {
IconCircleDashedCheck,
IconExclamationCircle,
Expand Down Expand Up @@ -49,49 +50,61 @@ const onRemoveItem = () => {
</label>
</th>
<td>
<div class="flex items-center gap-3">
<div class="avatar">
<div class="mask mask-squircle h-12 w-12">
{#if icon}
<img src={icon} alt="Avatar Tailwind CSS Component" />
{:else}
<IconPhotoX class="m-2 h-8 w-8 opacity-80" />
{/if}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<div class="avatar">
<div class="mask mask-squircle h-12 w-12">
{#if icon}
<img src={icon} alt="Avatar Tailwind CSS Component" />
{:else}
<IconPhotoX class="m-2 h-8 w-8 opacity-80" />
{/if}
</div>
</div>
</div>
<div class="max-w-lg">
<div class="tooltip" data-tip={url}>
<a href={url} target="_blank" class="font-bold">
{urlObj.pathname !== '/'
? `${urlObj.hostname}/.../${urlObj.pathname.slice(-5)}`
: urlObj.hostname}
</a>
</div>
<div class="flex items-center gap-1 text-sm tracking-tight text-secondary">
{new URL(url).hostname.replace(/^www\./, '')}
{#if metadataFetched}
<div class="tooltip" data-tip="Metadata fetched">
<IconCircleDashedCheck class="h-4 w-4 text-success" />
</div>
{:else if isLoading}
<div class="tooltip" data-tip="Loading metadata">
<IconStopwatch class="h-4 w-4 text-warning" />
</div>
{:else}
<div class="tooltip" data-tip="Failed to fetch metadata">
<IconExclamationCircle class="h-4 w-4 text-error" />
</div>
{/if}
<div class="max-w-lg">
<div class="tooltip" data-tip={url}>
<a href={url} target="_blank" class="font-bold">
{urlObj.pathname !== '/'
? `${urlObj.hostname}/.../${urlObj.pathname.slice(-5)}`
: urlObj.hostname}
</a>
</div>
<div class="flex items-center gap-1 text-sm tracking-tight text-secondary">
{new URL(url).hostname.replace(/^www\./, '')}
{#if metadataFetched}
<div class="tooltip" data-tip="Metadata fetched">
<IconCircleDashedCheck class="h-4 w-4 text-success" />
</div>
{:else if isLoading}
<div class="tooltip" data-tip="Loading metadata">
<IconStopwatch class="h-4 w-4 text-warning" />
</div>
{:else}
<div class="tooltip" data-tip="Failed to fetch metadata">
<IconExclamationCircle class="h-4 w-4 text-error" />
</div>
{/if}
</div>
</div>
</div>
<div class="ml-2 flex gap-1 text-sm">
{#if metadata?.bookmarkTags?.length}
<span class="font-sans text-xs">Tags: </span>
{/if}
{#each metadata?.bookmarkTags || [] as tag (tag.value)}
<a href={`/tags/${createSlug(tag.value)}`} class="link font-sans text-xs">{tag.value}</a>
{/each}
</div>
</div>
</td>
<td class="max-w-xs">
<div class="tooltip" data-tip={title}>
<span title={title} class="line-clamp-2">{title}</span>
</div>
</td>
<td><span class="link hover:link-secondary">{category}</span></td>
<td
><a class="link hover:link-secondary" href={`/categories/${createSlug(category)}`}>{category}</a
></td>
<th>
<button class="btn btn-ghost btn-xs text-secondary" on:click={onEditItem}>edit</button>
<button class="btn btn-ghost btn-xs text-error" on:click={onRemoveItem}>remove</button>
Expand Down
10 changes: 8 additions & 2 deletions src/lib/components/EditBookmarkForm/EditBookmarkForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { writable, type Writable } from 'svelte/store';
import { invalidate } from '$app/navigation';
import { editBookmarkCategoriesStore, editBookmarkStore } from '$lib/stores/edit-bookmark.store';
import { importBookmarkStore } from '$lib/stores/import-bookmarks.store';
import { searchEngine } from '$lib/stores/search.store';
import type { Bookmark, BookmarkEdit } from '$lib/types/Bookmark.type';
import { updateBookmarkInSearchIndex } from '$lib/utils/search';
Expand Down Expand Up @@ -115,9 +116,14 @@ function handleSubmit() {
if (isImportedBookmark($bookmark) && $bookmark.imported) {
const formData = new FormData(form);
let rawData = Object.fromEntries(formData as any);
console.log('rawData', rawData);
delete rawData.tags;
editBookmarkStore.set({
// editBookmarkStore.set({
// ...$bookmark,
// ...rawData,
// category: JSON.parse(rawData.category)?.label,
// bookmarkTags: $bookmarkTags
// });
importBookmarkStore.updateItem(+$bookmark.id, {
...$bookmark,
...rawData,
category: JSON.parse(rawData.category)?.label,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/stores/import-bookmarks.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export const importBookmarkStore = {
set,
update,
addItem: (item: BulkListItem) => update((items) => [...items, item]),
updateItem: (itemId: number, updatedItem: BulkListItem) =>
update((items) =>
items.map((item) => (item.id === itemId ? { ...item, ...updatedItem } : item))
),
removeItem: (itemId: number) => update((items) => items.filter((item) => item.id !== itemId)),
selectItem: (itemId: number) =>
update((items) => items.map((item) => ({ ...item, selected: item.id === itemId }))),
Expand Down
13 changes: 12 additions & 1 deletion src/lib/types/BookmarkImport.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Bookmark } from './Bookmark.type';
import type { Bookmark, BookmarkEdit } from './Bookmark.type';
import type { Category } from './Category.type';

export type ImportedBookmark = Pick<Bookmark, 'title' | 'url' | 'description'> & {
Expand All @@ -16,3 +16,14 @@ export type ImportResult = {
categories: ImportedCategory[];
tags: ImportedTag[];
};

export type ImportExecutionResult = {
total: number;
successful: number;
failed: number;
results: Array<{
success: boolean;
bookmark: Bookmark;
error?: string;
}>;
};
139 changes: 139 additions & 0 deletions src/lib/utils/bookmark-import/execute-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { BookmarkEdit } from '$lib/types/Bookmark.type';
import { db } from '$lib/database/db';
import {
bookmarkSchema, bookmarksToTagsSchema, categorySchema, tagSchema
} from '$lib/database/schema';
import { eq } from 'drizzle-orm';

import { createSlug } from '../create-slug';

import type { ImportExecutionResult } from '$lib/types/BookmarkImport.type';

export async function executeImport(
bookmarks: BookmarkEdit[],
userId: number
): Promise<ImportExecutionResult> {
const categoryCache = new Map<string, number>();
const tagCache = new Map<string, number>();

async function getOrCreateCategory(categoryPath: string, userId: number) {
if (categoryCache.has(categoryPath)) {
return categoryCache.get(categoryPath);
}

const parts = categoryPath.split('/').filter(Boolean);
let parentId: number | null = null;
let currentPath = '';

for (const part of parts) {
currentPath += `/${part}`;
const slug = createSlug(part);

let category = await db.query.categorySchema.findFirst({
where: eq(categorySchema.slug, slug)
});

if (!category) {
const [newCategory] = await db
.insert(categorySchema)
.values({
name: part,
slug,
ownerId: userId,
parentId,
created: new Date(),
updated: new Date()
})
.returning();
category = newCategory as typeof categorySchema.$inferSelect;
}

categoryCache.set(currentPath, category.id);
parentId = category.id;
}

return parentId;
}
async function getOrCreateTag(tagName: string, userId: number) {
if (tagCache.has(tagName)) {
return tagCache.get(tagName);
}

const slug = createSlug(tagName);
let tag = await db.query.tagSchema.findFirst({
where: eq(tagSchema.slug, slug)
});

if (!tag) {
const [newTag] = await db
.insert(tagSchema)
.values({
name: tagName,
slug,
ownerId: userId,
created: new Date(),
updated: new Date()
})
.returning();
tag = newTag;
}

tagCache.set(tagName, tag.id);
return tag.id;
}

const results = [];
for (const bookmark of bookmarks) {
try {
const categoryId = await getOrCreateCategory(bookmark.category, userId);

const [newBookmark] = await db
.insert(bookmarkSchema)
.values({
url: bookmark.url,
title: bookmark.title,
slug: createSlug(bookmark.title),
domain: new URL(bookmark.url).hostname,
description: bookmark.description || null,
ownerId: userId,
categoryId: categoryId ?? null,
created: new Date(),
updated: new Date()
} as typeof bookmarkSchema.$inferInsert)
.returning();

if (bookmark.bookmarkTags?.length) {
const tagIds = await Promise.all(
bookmark.bookmarkTags.map((tag) => getOrCreateTag(tag.value, userId))
);

await db.insert(bookmarksToTagsSchema).values(
tagIds
.filter((tagId): tagId is number => tagId !== undefined)
.map((tagId) => ({
bookmarkId: newBookmark.id,
tagId
}))
);
}

results.push({
success: true,
bookmark: newBookmark
});
} catch (error) {
results.push({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
bookmark
});
}
}

return {
total: bookmarks.length,
successful: results.filter((r) => r.success).length,
failed: results.filter((r) => !r.success).length,
results
} as unknown as ImportExecutionResult;
}
54 changes: 54 additions & 0 deletions src/routes/api/bookmarks/import/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { executeImport } from '$lib/utils/bookmark-import/execute-import';
import joi from 'joi';

import { json } from '@sveltejs/kit';

import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ locals, request }) => {
const ownerId = locals.user?.id;

if (!ownerId) {
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
}

const requestBody = await request.json();

const validationSchema = joi.object({
bookmarks: joi
.array()
.items(
joi.object({
url: joi.string().uri().required(),
title: joi.string().required(),
description: joi.string().allow('').optional(),
category: joi.string().required(),
bookmarkTags: joi
.array()
.items(
joi.object({
label: joi.string().required(),
value: joi.string().required()
})
)
.optional()
})
)
.required()
});

const { error } = validationSchema.validate(requestBody);

if (error) {
return json({ success: false, error: error.message }, { status: 400 });
}

try {
const result = await executeImport(requestBody.bookmarks, ownerId);

return json(result, { status: 201 });
} catch (error: any) {
console.error('Error importing bookmarks:', error?.message);

return json({ success: false, error: error?.message }, { status: 500 });
}
};
Loading

0 comments on commit ecb7bad

Please sign in to comment.