diff --git a/Dockerfile b/Dockerfile index 2d8a771..45d5a81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,47 @@ -FROM oven/bun AS base +FROM oven/bun AS builder LABEL maintainer="Grimoire Developers " LABEL description="Bookmark manager for the wizards" LABEL org.opencontainers.image.source="https://github.com/goniszewski/grimoire" -RUN apt-get update && apt-get install -y python3 python3-pip wget build-essential && \ +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + xz-utils python3 python3-pip wget build-essential && \ + dpkg --configure -a && \ rm -rf /var/lib/apt/lists/* && \ - bun i -g svelte-kit@latest + mkdir -p /etc/s6-overlay/s6-rc.d/grimoire /etc/s6-overlay/s6-rc.d/user/contents.d + +RUN mkdir -p /app/data + +ARG S6_OVERLAY_VERSION=3.1.6.2 +ARG TARGETARCH=x86_64 +ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp +ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${TARGETARCH}.tar.xz /tmp +RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \ + tar -C / -Jxpf /tmp/s6-overlay-${TARGETARCH}.tar.xz && \ + rm /tmp/s6-overlay-*xz + +COPY docker/etc/s6-overlay /etc/s6-overlay/ +RUN chmod +x /etc/s6-overlay/s6-rc.d/grimoire/run + +ENV S6_KEEP_ENV=1 \ + S6_SERVICES_GRACETIME=15000 \ + S6_KILL_GRACETIME=10000 \ + S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 \ + S6_SYNC_DISKS=1 + +RUN bun i -g svelte-kit@latest RUN adduser --disabled-password --gecos '' grimoire RUN mkdir -p /app/data && chown -R grimoire:grimoire /app/data && chmod 766 /app/data WORKDIR /app -FROM base AS dependencies +FROM builder AS dependencies COPY package.json bun.lockb ./ -RUN bun install --frozen-lockfile -RUN bun install --frozen-lockfile --production +RUN bun install --frozen-lockfile && \ + bun install --frozen-lockfile --production -FROM base AS build +FROM builder AS build COPY --from=dependencies /app/node_modules ./node_modules COPY . . RUN bun run svelte-kit sync @@ -29,7 +54,8 @@ ENV NODE_ENV=production \ NODE_OPTIONS="--max-old-space-size=4096" RUN bun --bun run build -FROM base AS release +FROM builder AS release + COPY --from=dependencies /app/node_modules ./node_modules COPY --from=build /app/build ./build COPY --from=build /app/migrations ./migrations diff --git a/bun.lockb b/bun.lockb index 1a5dcc6..1578df8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker/etc/s6-overlay/s6-rc.d/grimoire/run b/docker/etc/s6-overlay/s6-rc.d/grimoire/run new file mode 100644 index 0000000..45d1335 --- /dev/null +++ b/docker/etc/s6-overlay/s6-rc.d/grimoire/run @@ -0,0 +1,9 @@ +#!/command/with-contenv bash + +cd /app + +# Run migrations first +/usr/local/bin/bun run run-migrations + +# Start the application +exec /usr/local/bin/bun ./build/index.js diff --git a/docker/etc/s6-overlay/s6-rc.d/grimoire/type b/docker/etc/s6-overlay/s6-rc.d/grimoire/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/docker/etc/s6-overlay/s6-rc.d/grimoire/type @@ -0,0 +1 @@ +longrun diff --git a/docker/etc/s6-overlay/s6-rc.d/user/contents.d/grimoire b/docker/etc/s6-overlay/s6-rc.d/user/contents.d/grimoire new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docker/etc/s6-overlay/s6-rc.d/user/contents.d/grimoire @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index c3ef897..165c430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grimoire", - "version": "0.4.4", + "version": "0.5.0", "description": "Bookmark manager for the wizards ๐Ÿง™", "author": "Robert Goniszewski ", "main": "./build/index.js", @@ -78,20 +78,20 @@ "@tabler/icons-svelte": "^3.16.0", "@tailwindcss/line-clamp": "^0.4.4", "@types/html-to-text": "^9.0.4", - "@types/lodash": "^4.17.7", "@types/sanitize-html": "^2.13.0", "adm-zip": "^0.5.16", "chalk": "^5.3.0", "daisyui": "^4.12.10", "dotenv": "^16.4.5", "drizzle-orm": "^0.33.0", + "es-toolkit": "^1.31.0", "eslint-plugin-drizzle": "^0.2.3", "express": "^4.21.0", "fuse.js": "^7.0.0", "html-to-text": "^9.0.5", "joi": "^17.13.3", - "lodash": "^4.17.21", "lucia": "^3.2.0", + "node-parse-bookmarks": "^1.0.3", "sanitize-html": "^2.13.0", "svelte-french-toast": "^1.2.0", "svelte-select": "^5.8.3", diff --git a/run-dev.sh b/run-dev.sh index 0a3a9a5..c1663c9 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -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 -- -$$ \ No newline at end of file diff --git a/src/lib/components/AddBookmarkForm/AddBookmarkForm.svelte b/src/lib/components/AddBookmarkForm/AddBookmarkForm.svelte index 83df3a7..d868831 100644 --- a/src/lib/components/AddBookmarkForm/AddBookmarkForm.svelte +++ b/src/lib/components/AddBookmarkForm/AddBookmarkForm.svelte @@ -11,7 +11,7 @@ import { addBookmarkToSearchIndex } from '$lib/utils/search'; import { showToast } from '$lib/utils/show-toast'; import { IconInfoCircle } from '@tabler/icons-svelte'; -import _ from 'lodash'; +import { debounce } from 'es-toolkit'; import { onDestroy } from 'svelte'; import Select from 'svelte-select'; import { writable, type Writable } from 'svelte/store'; @@ -112,7 +112,7 @@ let error = ''; let warning = ''; const loading = writable(false); -const onGetMetadata = _.debounce( +const onGetMetadata = debounce( async (event: Event) => { const target = event.target as HTMLButtonElement; const url = target.value; @@ -188,9 +188,8 @@ const onGetMetadata = _.debounce( }, 1000, { - leading: false, - trailing: true, - maxWait: 1000 + signal: AbortSignal.timeout(5000), + edges: ['trailing'] } ); diff --git a/src/lib/components/BulkList/BulkList.svelte b/src/lib/components/BulkList/BulkList.svelte new file mode 100644 index 0000000..eee3a29 --- /dev/null +++ b/src/lib/components/BulkList/BulkList.svelte @@ -0,0 +1,60 @@ + + +
+
+ + + + + + + + + + + + + {#each $itemList as item (item.id)} + + {/each} + + + + + + + + + + + +
+ + URLTitleCategory
URLTitleCategory
+
+
diff --git a/src/lib/components/BulkListItem/BulkListItem.svelte b/src/lib/components/BulkListItem/BulkListItem.svelte new file mode 100644 index 0000000..643fa2a --- /dev/null +++ b/src/lib/components/BulkListItem/BulkListItem.svelte @@ -0,0 +1,117 @@ + + + + + + + +
+
+
+
+ {#if icon} + Icon + {:else} + + {/if} +
+
+
+ +
+ {new URL(url).hostname.replace(/^www\./, '')} + {#if metadataFetched} +
+ +
+ {:else if isLoading} +
+ +
+ {:else} +
+ +
+ {/if} +
+
+
+
+ {#if metadata?.bookmarkTags?.length} + Tags: + {/if} + {#each metadata?.bookmarkTags || [] as tag (tag.value)} + {tag.value} + {/each} +
+
+ + +
+ {title} +
+ + {category.name} + + + + + diff --git a/src/lib/components/EditBookmarkForm/EditBookmarkForm.svelte b/src/lib/components/EditBookmarkForm/EditBookmarkForm.svelte index 3c05057..5468840 100644 --- a/src/lib/components/EditBookmarkForm/EditBookmarkForm.svelte +++ b/src/lib/components/EditBookmarkForm/EditBookmarkForm.svelte @@ -1,31 +1,46 @@ @@ -198,20 +258,21 @@ const onGetMetadata = _.debounce(
- {#if $bookmark.category?.id} + {#if $bookmark.category?.name} - +
@@ -373,6 +434,7 @@ const onGetMetadata = _.debounce(
diff --git a/src/lib/components/Pagination/Pagination.svelte b/src/lib/components/Pagination/Pagination.svelte index 79ffb95..589179f 100644 --- a/src/lib/components/Pagination/Pagination.svelte +++ b/src/lib/components/Pagination/Pagination.svelte @@ -1,44 +1,51 @@ -
+
{#if pagesCount > 0} {#each Array(pagesCount) as _, i} + }}>{i + 1} {/each} {/if}
@@ -47,10 +54,11 @@ item.group : undefined} + value={value} + filterText={filterText} + class={className} + listAutoWidth={listAutoWidth} + multiple={multiple} + on:change={onSelect} + on:filter={onFilter} + on:input={onInput}> + {children} + diff --git a/src/lib/database/repositories/Category.repository.ts b/src/lib/database/repositories/Category.repository.ts index a47023e..fef80bb 100644 --- a/src/lib/database/repositories/Category.repository.ts +++ b/src/lib/database/repositories/Category.repository.ts @@ -107,3 +107,21 @@ export const getInitialCategory = async (userId: number): Promise +): Promise => { + const category = (await db.query.categorySchema.findFirst({ + where: and(eq(categorySchema.ownerId, ownerId), eq(categorySchema.slug, categoryData.slug)), + with: mapRelationsToWithStatements([CategoryRelations.OWNER]) + })) as CategoryDbo | undefined; + + if (category) { + return serializeCategory(category); + } + + const newCategory = await createCategory(ownerId, categoryData); + + return newCategory; +}; diff --git a/src/lib/database/repositories/Tag.repository.ts b/src/lib/database/repositories/Tag.repository.ts index 74d2280..d34391f 100644 --- a/src/lib/database/repositories/Tag.repository.ts +++ b/src/lib/database/repositories/Tag.repository.ts @@ -113,3 +113,21 @@ export const getTagCountForUser = async (userId: number): Promise => { return tagCount; }; + +export const getOrCreateTag = async ( + ownerId: number, + tagData: typeof tagSchema.$inferInsert +): Promise => { + const tag = (await db.query.tagSchema.findFirst({ + where: and(eq(tagSchema.ownerId, ownerId), eq(tagSchema.name, tagData.name)), + with: mapRelationsToWithStatements([TagRelations.OWNER]) + })) as TagDbo | undefined; + + if (tag) { + return serializeTag(tag); + } + + const newTag = await createTag(ownerId, tagData); + + return newTag; +}; diff --git a/src/lib/stores/edit-bookmark.store.ts b/src/lib/stores/edit-bookmark.store.ts index b1c2b02..ae8c244 100644 --- a/src/lib/stores/edit-bookmark.store.ts +++ b/src/lib/stores/edit-bookmark.store.ts @@ -1,4 +1,5 @@ -import type { Bookmark } from '$lib/types/Bookmark.type'; +import type { Bookmark, BookmarkEdit } from '$lib/types/Bookmark.type'; import { writable } from 'svelte/store'; -export const editBookmarkStore = writable>({}); +export const editBookmarkStore = writable | BookmarkEdit>({}); +export const editBookmarkCategoriesStore = writable([]); diff --git a/src/lib/stores/import-bookmarks.store.ts b/src/lib/stores/import-bookmarks.store.ts new file mode 100644 index 0000000..5eee7ec --- /dev/null +++ b/src/lib/stores/import-bookmarks.store.ts @@ -0,0 +1,33 @@ +import { derived, get, writable } from 'svelte/store'; + +import type { BulkListItem } from '$lib/types/common/BulkList.type'; + +const store = writable([]); +const { subscribe, set, update } = store; + +export const importBookmarkStore = { + subscribe, + set, + update, + getById: (id: number) => get(derived(store, (items) => items.find((item) => item.id === id))), + 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 }))), + isAnySelected: derived(store, (items) => items.some((item) => item.selected)), + toggleSelectionForItem: (itemId: number) => + update((items) => + items.map((item) => ({ + ...item, + selected: item.id === itemId ? !item.selected : item.selected + })) + ), + setSelectStatusForAll: (selected: boolean) => + update((items) => items.map((item) => ({ ...item, selected }))), + removeSelected: () => update((items) => items.filter((item) => !item.selected)), + clear: () => set([]), + importedCategories: get(derived(store, (items) => items.map((item) => item.category))), + length: derived(store, (items) => items.length) +}; diff --git a/src/lib/types/Bookmark.type.ts b/src/lib/types/Bookmark.type.ts index ed87c80..b86b0db 100644 --- a/src/lib/types/Bookmark.type.ts +++ b/src/lib/types/Bookmark.type.ts @@ -1,24 +1,14 @@ import type { Category } from './Category.type'; +import type { Metadata } from './Metadata.type'; import type { Tag } from './Tag.type'; import type { User } from './User.type'; -export type Bookmark = { +export type Bookmark = Metadata & { id: number; - url: string; - domain: string; - title: string; - description: string | null; - author: string | null; - contentText: string | null; - contentHtml: string | null; - contentType: string | null; - contentPublishedDate: string | null; - note: string | null; mainImage: string | null; mainImageId: number | null; - mainImageUrl: string | null; icon: string | null; - iconUrl: string | null; + note: string | null; importance: number | null; flagged: null | Date; read: null | Date; @@ -38,3 +28,24 @@ export type BookmarkForIndex = Omit; }; + +export type BookmarkEdit = Metadata & { + id: number; + icon: string | null; + url: string; + title: string; + category: { + id?: number; + name: string; + }; + selected: boolean; + importance: number | null; + flagged: Date | null; + note: string | null; + imported?: boolean; + bookmarkTags?: { + value: string; + label: string; + created?: boolean; + }[]; +}; diff --git a/src/lib/types/BookmarkImport.type.ts b/src/lib/types/BookmarkImport.type.ts new file mode 100644 index 0000000..32a3dbf --- /dev/null +++ b/src/lib/types/BookmarkImport.type.ts @@ -0,0 +1,42 @@ +import type { Bookmark, BookmarkEdit } from './Bookmark.type'; +import type { Category } from './Category.type'; + +export type ImportedBookmark = Pick & { + categorySlug?: string; + createdAt?: Date; + icon?: string; +}; +export type ImportedCategory = Pick & { + createdAt?: Date; + parentSlug?: string; +}; +export type ImportedTag = string; +export type ImportResult = { + bookmarks: ImportedBookmark[]; + categories: ImportedCategory[]; + tags: ImportedTag[]; +}; + +export type ImportProgress = { + processed: number; + total: number; + successful: number; + failed: number; +}; + +export type ImportExecutionResult = { + total: number; + successful: number; + failed: number; + results: Array<{ + success: boolean; + bookmark: { + id: number; + url: string; + title: string; + category: string; + success: boolean + }; + error?: string; + }>; +}; diff --git a/src/lib/types/Metadata.type.ts b/src/lib/types/Metadata.type.ts index 696ed43..2295ca0 100644 --- a/src/lib/types/Metadata.type.ts +++ b/src/lib/types/Metadata.type.ts @@ -2,12 +2,12 @@ export type Metadata = { url: string; domain: string; title: string; - description: string; - author: string; - contentText: string; - contentHtml: string; - contentType: string; - contentPublishedDate: Date | null; - mainImageUrl: string; - iconUrl: string; + description: string | null; + author: string | null; + contentText: string | null; + contentHtml: string | null; + contentType: string | null; + contentPublishedDate: string | null; + mainImageUrl: string | null; + iconUrl: string | null; }; diff --git a/src/lib/types/common/BulkList.type.ts b/src/lib/types/common/BulkList.type.ts new file mode 100644 index 0000000..13b55dc --- /dev/null +++ b/src/lib/types/common/BulkList.type.ts @@ -0,0 +1,3 @@ +import type { BookmarkEdit } from '../Bookmark.type'; + +export type BulkListItem = BookmarkEdit; diff --git a/src/lib/utils/bookmark-import/execute-import.ts b/src/lib/utils/bookmark-import/execute-import.ts new file mode 100644 index 0000000..c725618 --- /dev/null +++ b/src/lib/utils/bookmark-import/execute-import.ts @@ -0,0 +1,162 @@ +import type { Bookmark, BookmarkEdit } from '$lib/types/Bookmark.type'; +import { db } from '$lib/database/db'; +import { getOrCreateCategory } from '$lib/database/repositories/Category.repository'; +import { getOrCreateTag } from '$lib/database/repositories/Tag.repository'; +import { bookmarkSchema, bookmarksToTagsSchema } from '$lib/database/schema'; +import { createSlug } from '../create-slug'; +import type { ImportExecutionResult, ImportProgress } from '$lib/types/BookmarkImport.type'; +import { Storage } from '$lib/storage/storage'; +import { eq } from 'drizzle-orm'; + +const BATCH_SIZE = 50; + +const storage = new Storage(); + +export async function executeImport( + bookmarks: BookmarkEdit[], + userId: number, + onProgress?: (progress: ImportProgress) => void +): Promise { + const results: ImportExecutionResult['results'] = []; + let processedCount = 0; + + const batches = Array.from({ length: Math.ceil(bookmarks.length / BATCH_SIZE) }, (_, i) => + bookmarks.slice(i * BATCH_SIZE, (i + 1) * BATCH_SIZE) + ); + + for (const batch of batches) { + await db.transaction(async (tx) => { + for (const bookmark of batch) { + try { + if (!bookmark.url || !bookmark.title || !bookmark.category?.name) { + throw new Error('Missing required fields'); + } + + const category = await getOrCreateCategory(userId, { + name: bookmark.category.name, + slug: createSlug(bookmark.category.name) + }); + + const [newBookmark] = await tx + .insert(bookmarkSchema) + .values({ + url: bookmark.url.trim(), + domain: bookmark.domain, + title: bookmark.title.trim(), + description: bookmark.description?.trim() || null, + iconUrl: bookmark.iconUrl, + mainImageUrl: bookmark.mainImageUrl, + importance: bookmark.importance, + flagged: bookmark.flagged, + contentHtml: bookmark.contentHtml, + contentText: bookmark.contentText, + contentType: bookmark.contentType, + author: bookmark.author, + contentPublishedDate: bookmark.contentPublishedDate, + slug: createSlug(bookmark.title), + ownerId: userId, + categoryId: category.id, + created: new Date(), + updated: new Date() + } as typeof bookmarkSchema.$inferInsert) + .returning(); + + let iconId: number | null = null; + let mainImageId: number | null = null; + + if (bookmark.mainImageUrl) { + ({ id: mainImageId } = await storage.storeImage( + bookmark.mainImageUrl, + bookmark.title, + userId, + newBookmark.id + )); + } + + if (bookmark.iconUrl) { + ({ id: iconId } = await storage.storeImage( + bookmark.iconUrl, + bookmark.title, + userId, + newBookmark.id + )); + } + + if (iconId || mainImageId) { + await tx + .update(bookmarkSchema) + .set({ + iconId, + mainImageId, + updated: new Date() + }) + .where(eq(bookmarkSchema.id, newBookmark.id)); + } + + if (bookmark.bookmarkTags?.length) { + const tags = await Promise.all( + bookmark.bookmarkTags.map((tag) => + getOrCreateTag(userId, { + name: tag.label.trim(), + slug: createSlug(tag.label), + ownerId: userId + }) + ) + ); + + await tx.insert(bookmarksToTagsSchema).values( + tags.map((tag) => ({ + bookmarkId: newBookmark.id, + tagId: tag.id + })) + ); + } + + results.push({ + success: true, + bookmark: { + id: bookmark.id, + url: bookmark.url, + title: bookmark.title, + category: bookmark.category.name, + success: true + } + }); + } catch (error) { + console.error('Failed to import bookmark:', bookmark.url, error); + results.push({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + bookmark: { + id: bookmark.id, + url: bookmark.url, + title: bookmark.title, + category: bookmark.category.name, + success: false + } + }); + } + } + }); + + processedCount += batch.length; + + if (onProgress) { + onProgress({ + processed: processedCount, + total: bookmarks.length, + successful: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length + }); + } + } + + const result: ImportExecutionResult = { + total: bookmarks.length, + successful: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + results + }; + + return result; +} diff --git a/src/lib/utils/bookmark-import/netscape.importer.ts b/src/lib/utils/bookmark-import/netscape.importer.ts new file mode 100644 index 0000000..3e3e7e5 --- /dev/null +++ b/src/lib/utils/bookmark-import/netscape.importer.ts @@ -0,0 +1,64 @@ +import parse from 'node-parse-bookmarks'; + +import { createSlug } from '../create-slug'; + +import type { Bookmark as ParserBookmark } from 'node-parse-bookmarks/build/interfaces/bookmark'; +import type { + ImportedBookmark, + ImportedCategory, + ImportResult +} from '$lib/types/BookmarkImport.type'; + +async function parseNetscapeBackupFile(content: string): Promise { + try { + const bookmarks: ParserBookmark[] = await new Promise((resolve, _reject) => { + resolve(parse(content)); + }); + return bookmarks; + } catch (error) { + console.error('Error importing Netscape backup:', error); + throw new Error('Failed to import Netscape backup'); + } +} + +function translateNetscapeBookmarks(bookmarks: ParserBookmark[]): ImportResult { + const result: ImportResult = { + bookmarks: [], + categories: [], + tags: [] + }; + + function processBookmark(item: ParserBookmark, parentSlug?: string) { + if (item.type === 'folder' && item.children?.length) { + const category: ImportedCategory = { + name: item.title!, + slug: createSlug(item.title!), + parentSlug, + createdAt: item.addDate ? new Date(item.addDate) : undefined + }; + result.categories.push(category); + + item.children?.forEach((child) => processBookmark(child, category.slug)); + } else { + const bookmark: ImportedBookmark = { + title: item.title || item.url!, + url: item.url!, + description: item.description || '', + createdAt: item.addDate ? new Date(item.addDate) : undefined, + icon: item.icon || undefined, + categorySlug: parentSlug + }; + result.bookmarks.push(bookmark); + } + } + + bookmarks.forEach((bookmark) => processBookmark(bookmark)); + + return result; +} + +export async function importNetscapeBackup(content: string): Promise { + const bookmarks = await parseNetscapeBackupFile(content); + + return translateNetscapeBookmarks(bookmarks); +} diff --git a/src/lib/utils/get-metadata.ts b/src/lib/utils/get-metadata.ts index 433c8ba..0d34afc 100644 --- a/src/lib/utils/get-metadata.ts +++ b/src/lib/utils/get-metadata.ts @@ -61,8 +61,6 @@ const articleExtractorScraper = async (html: string, url: string): Promise Promise; +}; + +export async function importBookmarks( + content: string, + provider: keyof ImportProviders +): Promise { + const providers: ImportProviders = { + netscape: importNetscapeBackup + }; + + return providers[provider](content); +} diff --git a/src/lib/utils/sort-bookmarks.ts b/src/lib/utils/sort-bookmarks.ts index 7096a61..2da7daf 100644 --- a/src/lib/utils/sort-bookmarks.ts +++ b/src/lib/utils/sort-bookmarks.ts @@ -1,5 +1,5 @@ import type { Bookmark } from '$lib/types/Bookmark.type'; -import _ from 'lodash'; +import { sortBy } from 'es-toolkit'; export type sortByType = | 'created_asc' @@ -14,9 +14,11 @@ export function sortBookmarks(bookmarks: Bookmark[], sortString: sortByType) { keyof Bookmark ]; const field = fieldNameParts.reverse().join('_') as keyof Bookmark; - let result = _.sortBy(bookmarks, (b) => { - return typeof b[field] === 'string' ? (b[field] as string).toLowerCase() : b[field]; - }); + let result = sortBy(bookmarks, [ + (b) => { + return typeof b[field] === 'string' ? (b[field] as string).toLowerCase() : b[field]; + } + ]); if (order === 'desc') { result = result.reverse(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a73cd63..4c67afc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -99,6 +99,9 @@ $: {
  • Profile
  • +
  • + Import +
  • Settings
  • diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fe1eca8..9874c4a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,7 +20,7 @@ import { IconSortAscending, IconSortDescending } from '@tabler/icons-svelte'; -import _ from 'lodash'; +import { throttle } from 'es-toolkit'; import Select from 'svelte-select'; import { writable } from 'svelte/store'; @@ -52,7 +52,7 @@ const bookmarksToDisplay = writable($page.data.bookmarks); $: { if ($searchedValue.trim()) { const searchedBookmarksIds = $searchEngine.search($searchedValue).map((b) => b.item.id); - _.throttle(() => { + throttle(() => { fetch(`/api/bookmarks?ids=${searchedBookmarksIds.join(',')}`) .then((r) => r.json()) .then((r) => { diff --git a/src/routes/import/+page.svelte b/src/routes/import/+page.svelte new file mode 100644 index 0000000..2a572ec --- /dev/null +++ b/src/routes/import/+page.svelte @@ -0,0 +1,34 @@ + + +{#if !user} +

    Not logged in.

    +{:else} +
    +
    +

    Import Bookmarks

    + +

    Choose a method to import your bookmarks:

    +
    + +
    +{/if} diff --git a/src/routes/import/html/+page.server.ts b/src/routes/import/html/+page.server.ts new file mode 100644 index 0000000..4887d3d --- /dev/null +++ b/src/routes/import/html/+page.server.ts @@ -0,0 +1,76 @@ +import { executeImport } from '$lib/utils/bookmark-import/execute-import'; +import joi from 'joi'; + +import type { Actions } from './$types'; +import type { BookmarkEdit } from '$lib/types/Bookmark.type'; + +export const actions: Actions = { + default: async ({ locals, request }) => { + const ownerId = locals.user?.id; + + if (!ownerId) { + return { + success: false, + error: 'Unauthorized' + }; + } + + const requestBody = await request.formData(); + const bookmarks:BookmarkEdit[] = JSON.parse(requestBody.get('bookmarks') as string); + + const validationSchema = joi + .array() + .items( + joi.object({ + url: joi.string().uri().required(), + domain: joi.string().allow('').optional(), + title: joi.string().required(), + description: joi.string().allow(null, '').optional(), + category: joi.object({ + id: joi.number(), + name: joi.string().required(), + }).required(), + mainImageUrl: joi.string().allow(null, '').optional(), + iconUrl: joi.string().allow(null, '').optional(), + author: joi.string().allow(null, '').optional(), + contentText: joi.string().allow(null, '').optional(), + contentHtml: joi.string().allow(null, '').optional(), + contentType: joi.string().allow(null, '').optional(), + contentPublishedDate: joi.date().allow(null).optional(), + importance: joi.number().allow(null).required(), + flagged: joi.boolean().allow(null).required(), + note: joi.string().allow(null, '').optional(), + bookmarkTags: joi + .array() + .items( + joi.object({ + label: joi.string().required(), + value: joi.string().required() + }) + ) + .optional() + }) + ) + .required(); + + const { error } = validationSchema.validate(bookmarks); + + console.log('Validation error:', error); + + if (error) { + return { success: false, error: error.message, status: 400 }; + } + + try { + const result = await executeImport(bookmarks, ownerId); + + console.log('executeImport result:', JSON.stringify(result, null, 2)); + + return { success: true, data: result, status: 201 }; + } catch (error: any) { + console.error('Error importing bookmarks:', error?.message); + + return { success: false, error: error?.message, status: 500 }; + } + } +}; diff --git a/src/routes/import/html/+page.svelte b/src/routes/import/html/+page.svelte new file mode 100644 index 0000000..07f9b09 --- /dev/null +++ b/src/routes/import/html/+page.svelte @@ -0,0 +1,383 @@ + + +{#if !user} +

    Not logged in.

    +{:else if $step === 1} +
    +
    + +
    +

    Import bookmarks from HTML file

    +

    + Use this tool to import your bookmarks exported as HTML file (from browser or any + compatible tool) +

    + +
    +
    +
    +{:else if $step === 2} +
    + { + formData.set( + 'bookmarks', + JSON.stringify( + $importBookmarkStore.map((bookmark) => ({ + url: bookmark.url, + domain: bookmark.domain, + title: bookmark.title, + description: bookmark.description, + category: bookmark.category, + mainImageUrl: bookmark.mainImageUrl, + iconUrl: bookmark.iconUrl, + author: bookmark.author, + contentText: bookmark.contentText, + contentHtml: bookmark.contentHtml, + contentType: bookmark.contentType, + contentPublishedDate: bookmark.contentPublishedDate, + importance: bookmark.importance, + flagged: bookmark.flagged, + note: bookmark.note, + bookmarkTags: bookmark.bookmarkTags + })) + ) + ); + + return async ({ update, result }) => { + if (result.type === 'success' && result?.data?.data) { + showToast.success('Bookmarks imported successfully'); + const { data } = result.data; + if (data) { + // @ts-ignore-next-line + importResult.set(data); + step.set(3); + } + } else { + showToast.error('Failed to import bookmarks'); + } + update(); + }; + }}> +
    + + + {#if $isAnySelected && !$isFetchingMetadata} + +
    Click here to see failed items
    +
    +
    +
    + + + + + + + + + + + + {#each $importResult.results.filter((item) => !item.success) as { bookmark, error }, i (bookmark.id)} + + + + + + + + {/each} + +
    #TitleCategoryURLError
    {i + 1}{bookmark.title}{bookmark.category}{bookmark.url.slice(0, 10)}{bookmark.url.length > 10 ? '...' : ''} + {error} +
    +
    +
    +
    +
    + {/if} + +
    +
    +
    +{:else} +
    +

    Import failed to complete

    +

    Unexpected error occurred while importing bookmarks ๐Ÿ”ฅ

    +

    (check your console for more details)

    +
    +
    +
    + try again +
    +
    +
    +
    +{/if}