From c3b15a14d20e77b1a0bde482d33daf75924ceea7 Mon Sep 17 00:00:00 2001 From: Nicola Zangrandi Date: Tue, 4 Mar 2025 17:54:49 +0100 Subject: [PATCH] feat: enhance Shiori import with progress indicator and error handling --- feeds/handler.go | 12 +- frontend/bun.lock | 16 +- frontend/package.json | 8 +- frontend/src/lib/readlist.ts | 26 +- frontend/src/routes/readlist/+page.svelte | 454 ++++++++++++++----- go.mod | 5 + go.sum | 9 + main.go | 40 +- readlist/model.go | 21 +- readlist/routes.go | 46 ++ readlist/service.go | 502 +++++++++++++++++++++- scripts/build.sh | 4 +- specs/readlist.md | 15 +- 13 files changed, 1015 insertions(+), 143 deletions(-) diff --git a/feeds/handler.go b/feeds/handler.go index 780674d..b5fc25a 100644 --- a/feeds/handler.go +++ b/feeds/handler.go @@ -140,7 +140,7 @@ func (h *Handler) handleImportOPML(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { fmt.Printf("OPML Import - Error getting form file: %v\n", err) - + // Check if URL is provided instead url := c.PostForm("url") if url != "" { @@ -177,7 +177,7 @@ func (h *Handler) handleImportOPML(c *gin.Context) { }() // Log file information - fmt.Printf("OPML Import - File: %s, Size: %d, Content-Type: %s\n", + fmt.Printf("OPML Import - File: %s, Size: %d, Content-Type: %s\n", header.Filename, header.Size, header.Header.Get("Content-Type")) // Check file size @@ -188,8 +188,8 @@ func (h *Handler) handleImportOPML(c *gin.Context) { } // Check file extension - if !strings.HasSuffix(strings.ToLower(header.Filename), ".opml") && - !strings.HasSuffix(strings.ToLower(header.Filename), ".xml") { + if !strings.HasSuffix(strings.ToLower(header.Filename), ".opml") && + !strings.HasSuffix(strings.ToLower(header.Filename), ".xml") { fmt.Printf("OPML Import - Invalid file extension: %s\n", header.Filename) c.JSON(http.StatusBadRequest, gin.H{"error": "File must be an OPML or XML file"}) return @@ -203,11 +203,11 @@ func (h *Handler) handleImportOPML(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Error reading file: %v", err)}) return } - + // Log the first few bytes of the file sample := string(sampleBuf[:n]) fmt.Printf("OPML Import - File sample: %s\n", sample[:min(100, len(sample))]) - + // Reset the file pointer to the beginning if _, err := file.Seek(0, 0); err != nil { fmt.Printf("OPML Import - Error resetting file pointer: %v\n", err) diff --git a/frontend/bun.lock b/frontend/bun.lock index b0c8e8e..ac5b28d 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -13,8 +13,8 @@ "@eslint/js": "^9.18.0", "@playwright/test": "1.50.1", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.5.27", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/kit": "^2.18.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/node": "^22.13.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -22,8 +22,8 @@ "globals": "^15.14.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", + "svelte": "^5.22.1", + "svelte-check": "^4.1.0", "typescript": "^5.5.0", "typescript-eslint": "^8.20.0", "vite": "^6.0.0", @@ -169,9 +169,11 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.34.8", "", { "os": "win32", "cpu": "x64" }, "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="], - "@sveltejs/kit": ["@sveltejs/kit@2.17.2", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vypk02baf7qd3SOB1uUwUC/3Oka+srPo2J0a8YN3EfJypRshDkNx9HzNKjSmhOnGWwT+SSO06+N0mAb8iVTmTQ=="], + "@sveltejs/kit": ["@sveltejs/kit@2.18.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-4DGCGiwNzgnPJySlMe/Qi6rKMK3ntphJaV95BTW+aggaTIAVZ5x3Bp+LURVLMxAEAtWAI5U449NafVxTS+kXbQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="], @@ -209,8 +211,6 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-typescript": ["acorn-typescript@1.4.13", "", { "peerDependencies": { "acorn": ">=8.9.0" } }, "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q=="], - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -451,7 +451,7 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "svelte": ["svelte@5.20.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "acorn-typescript": "^1.4.13", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.3", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-aCARru2WTdzJl55Ws8SK27+kvQwd8tijl4kY7NoDUXUHtTHhxMa8Lf6QNZKmU7cuPu3jjFloDO1j5HgYJNIIWg=="], + "svelte": ["svelte@5.22.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.3", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-b9DQGnfrZc+km4u3j6qkEY87pYe23yfgBT03CkeBlYST+Wzij7ut1o0BSoQ+UmiyAO1nPh9DMJJCoGDdUOrunw=="], "svelte-check": ["svelte-check@4.1.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-v0j7yLbT29MezzaQJPEDwksybTE2Ups9rUxEXy92T06TiA0cbqcO8wAOwNUVkFW6B0hsYHA+oAX3BS8b/2oHtw=="], diff --git a/frontend/package.json b/frontend/package.json index 1e6b6ec..a860c28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,8 +22,8 @@ "@eslint/js": "^9.18.0", "@playwright/test": "1.50.1", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.5.27", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/kit": "^2.18.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/node": "^22.13.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -31,8 +31,8 @@ "globals": "^15.14.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", + "svelte": "^5.22.1", + "svelte-check": "^4.1.0", "typescript": "^5.5.0", "typescript-eslint": "^8.20.0", "vite": "^6.0.0" diff --git a/frontend/src/lib/readlist.ts b/frontend/src/lib/readlist.ts index fa72dd5..a31cde9 100644 --- a/frontend/src/lib/readlist.ts +++ b/frontend/src/lib/readlist.ts @@ -1,6 +1,13 @@ import { writable } from 'svelte/store'; import type { ReadLaterItem } from './types'; +// Shiori credentials interface +interface ShioriCredentials { + url: string; + username: string; + password: string; +} + function createReadLaterStore() { const { subscribe, set, update } = writable([]); let showArchived = false; @@ -31,7 +38,6 @@ function createReadLaterStore() { ...item, createdAt: new Date(item.createdAt), updatedAt: new Date(item.updatedAt), - savedAt: new Date(item.savedAt), readAt: item.readAt ? new Date(item.readAt) : undefined, archivedAt: item.archivedAt ? new Date(item.archivedAt) : undefined }, @@ -121,6 +127,24 @@ function createReadLaterStore() { console.error('Failed to load read later items:', error); set([]); } + }, + importFromShiori: async (creds: ShioriCredentials) => { + const response = await fetch('/api/readlist/import/shiori', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(creds) + }); + if (!response.ok) { + throw new Error('Failed to import from Shiori'); + } + return await response.json(); + }, + getImportStatus: async (importId: string) => { + const response = await fetch(`/api/readlist/import/status/${importId}`); + if (!response.ok) { + throw new Error('Failed to get import status'); + } + return await response.json(); } }; } diff --git a/frontend/src/routes/readlist/+page.svelte b/frontend/src/routes/readlist/+page.svelte index ce16faa..34acb84 100644 --- a/frontend/src/routes/readlist/+page.svelte +++ b/frontend/src/routes/readlist/+page.svelte @@ -1,36 +1,65 @@
-

Read Later

+
+

Read Later

-
-
-
- -
-
- -
-
{#if error} -

{error}

+
+

{error}

+
{/if} -
-
-
-
- + {#if importCount > 0} +
+

Successfully added {importCount} URLs.

+
+ {/if} + + {#if shioriImportCount > 0} +
+

Successfully imported {shioriImportCount} bookmarks from Shiori.

+
+ {/if} + + + {#if isImportingShiori} +
+ {#if importProgress.totalBookmarks === 0} + Connecting to Shiori and preparing for import... + {:else} + Import in progress: + {importProgress.importedCount} imported, {importProgress.failedCount} failed out of {importProgress.totalBookmarks} + + {/if} +
+ {/if} + + +
+
+
+
+
+ { + if (e.key === 'Enter') handleAddUrl(); + }} + /> +
+
+ +
+
+
+ +
+ +
+
+ +
+
+ +
-
-
- +

Import from Shiori

+ + {#if shioriError} +
+

{shioriError}

+
+ {/if} + +
+ +
+ +
+

The URL of your Shiori instance

+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ + + {#if isImportingShiori} +
+

+ + Import is running in the background +

+

+ You can close this form and the import will continue. Progress will be shown at the + top of the page. +

+
+ {/if} +
+ {/if} + +
+ +
+ + { + const readLaterItem = item as ReadLaterItem; + return { + title: readLaterItem.title, + description: readLaterItem.description, + timestamp: readLaterItem.createdAt, + actions: [ + { + icon: 'fas fa-book-reader', + label: 'Read', + href: `/readlist/${readLaterItem.id}` + }, + { + icon: 'fas fa-external-link-alt', + label: 'Original', + href: readLaterItem.url, + target: '_blank' + }, + { + icon: readLaterItem.archivedAt ? 'fas fa-box-open' : 'fas fa-archive', + label: readLaterItem.archivedAt ? 'Unarchive' : 'Archive', + onClick: () => handleToggleArchive(readLaterItem) + }, + { + icon: 'fas fa-trash', + label: 'Delete', + onClick: () => handleDelete(readLaterItem) + } + ] + }; + }} + emptyMessage="No saved links yet." /> -
- - { - const readLaterItem = item as ReadLaterItem; - return { - title: readLaterItem.title, - description: readLaterItem.description, - timestamp: readLaterItem.createdAt, - actions: [ - { - icon: 'fas fa-book-reader', - label: 'Read', - href: `/readlist/${readLaterItem.id}` - }, - { - icon: 'fas fa-external-link-alt', - label: 'Original', - href: readLaterItem.url, - target: '_blank' - }, - { - icon: readLaterItem.archivedAt ? 'fas fa-box-open' : 'fas fa-archive', - label: readLaterItem.archivedAt ? 'Unarchive' : 'Archive', - onClick: () => handleToggleArchive(readLaterItem) - }, - { - icon: 'fas fa-trash', - label: 'Delete', - onClick: () => handleDelete(readLaterItem) - } - ] - }; - }} - emptyMessage="No saved links yet." - /> +
diff --git a/go.mod b/go.mod index bb18715..7cd6bc4 100644 --- a/go.mod +++ b/go.mod @@ -35,13 +35,17 @@ require ( github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.10 // indirect github.com/mmcdole/gofeed v1.3.0 // indirect github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.1.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/atomic v1.9.0 // indirect @@ -52,6 +56,7 @@ require ( golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 5930884..95dbe31 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= @@ -85,6 +87,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -93,6 +97,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= @@ -101,6 +106,8 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4 github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -212,6 +219,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g= +jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= diff --git a/main.go b/main.go index 1e92dd1..d9d0d3c 100644 --- a/main.go +++ b/main.go @@ -166,14 +166,42 @@ func parseConfig() Configuration { } func handleFrontend(c *gin.Context) { - // Don't serve API routes - if path.Dir(c.Request.URL.Path) == "/api" { - c.Status(http.StatusNotFound) + requestPath := c.Request.URL.Path + + // Check if the path is a direct file request (CSS, JS, etc.) + if isStaticFileRequest(requestPath) { + // Use the embedded filesystem instead of reading from disk + if err := serveStaticFile(c, "frontend/build"); err != nil { + // If not found in build, try static folder + if err := serveStaticFile(c, "frontend/static"); err != nil { + // Return 404 for static files that should exist + c.Status(http.StatusNotFound) + return + } + } return } - err := serveStaticFile(c, "frontend/build") - if err != nil { // if serveStaticFile returns an error, it has already tried to serve index.html as fallback - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // For SPA navigation - serve index.html for all non-API routes + // Use the embedded filesystem + if err := serveStaticFile(c, "frontend/build"); err != nil { + c.String(http.StatusInternalServerError, "Error reading index.html") + return } } + +// isStaticFileRequest determines if a path is for a static file (CSS, JS, image, etc.) +func isStaticFileRequest(path string) bool { + staticExtensions := []string{ + ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", + ".json", ".woff", ".woff2", ".ttf", ".eot", + } + + for _, ext := range staticExtensions { + if strings.HasSuffix(path, ext) { + return true + } + } + + return false +} diff --git a/readlist/model.go b/readlist/model.go index a2a1752..8e92ef5 100644 --- a/readlist/model.go +++ b/readlist/model.go @@ -9,15 +9,18 @@ import ( // ReadLaterItem represents a saved link with its reader mode content type ReadLaterItem struct { - ID string `json:"id" gorm:"primaryKey"` - URL string `json:"url" gorm:"not null"` - Title string `json:"title" gorm:"not null"` - Content string `json:"content" gorm:"type:text"` - Description string `json:"description"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - ReadAt *time.Time `json:"readAt"` - ArchivedAt *time.Time `json:"archivedAt"` + ID string `json:"id" gorm:"primaryKey"` + URL string `json:"url" gorm:"not null"` + Title string `json:"title" gorm:"not null"` + Content string `json:"content" gorm:"type:text"` + Description string `json:"description"` + Image string `json:"image"` + Status string `json:"status" gorm:"default:unread"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastModified time.Time `json:"lastModified"` + ReadAt *time.Time `json:"readAt"` + ArchivedAt *time.Time `json:"archivedAt"` } // ParseURL fetches the URL and extracts readable content diff --git a/readlist/routes.go b/readlist/routes.go index b3033c2..e64b72d 100644 --- a/readlist/routes.go +++ b/readlist/routes.go @@ -27,6 +27,8 @@ func (h *Handler) RegisterRoutes(group *gin.RouterGroup) { readlist.POST("/:id/archive", h.handleArchive) readlist.POST("/:id/unarchive", h.handleUnarchive) readlist.DELETE("/:id", h.handleDelete) + readlist.POST("/import/shiori", h.handleImportFromShiori) + readlist.GET("/import/status/:id", h.handleImportStatus) } // Test endpoint @@ -124,3 +126,47 @@ func (h *Handler) handleUnarchive(c *gin.Context) { c.Status(http.StatusOK) } + +// handleImportFromShiori handles importing bookmarks from a Shiori instance +func (h *Handler) handleImportFromShiori(c *gin.Context) { + var creds ShioriCredentials + if err := c.ShouldBindJSON(&creds); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate required fields + if creds.URL == "" || creds.Username == "" || creds.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL, username, and password are required"}) + return + } + + // Start the import process + importID, err := h.service.ImportFromShiori(creds) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Return the import ID + c.JSON(http.StatusAccepted, gin.H{"importId": importID}) +} + +// handleImportStatus handles getting the status of an import operation +func (h *Handler) handleImportStatus(c *gin.Context) { + importID := c.Param("id") + if importID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Import ID is required"}) + return + } + + // Get import status + status, exists := h.service.GetImportStatus(importID) + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "Import not found"}) + return + } + + // Return the status + c.JSON(http.StatusOK, status) +} diff --git a/readlist/service.go b/readlist/service.go index e6aa644..16a6465 100644 --- a/readlist/service.go +++ b/readlist/service.go @@ -1,21 +1,49 @@ package readlist import ( + "bytes" + "encoding/json" "fmt" + "io" + "net/http" + "strings" + "sync" "time" + "github.com/go-shiori/go-readability" "github.com/google/uuid" "gorm.io/gorm" + "jaytaylor.com/html2text" ) +// ImportStatus represents the status of a Shiori import operation +type ImportStatus struct { + ID string `json:"id"` + TotalBookmarks int `json:"totalBookmarks"` + ImportedCount int `json:"importedCount"` + FailedCount int `json:"failedCount"` + InProgress bool `json:"inProgress"` + StartedAt time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + Error string `json:"error,omitempty"` +} + // Service handles read later operations type Service struct { - db *gorm.DB + db *gorm.DB + imports map[string]*ImportStatus + importsMutex sync.RWMutex + rateLimiter *time.Ticker // For rate limiting API calls } // NewService creates a new read later service func NewService(db *gorm.DB) *Service { - return &Service{db: db} + return &Service{ + db: db, + imports: make(map[string]*ImportStatus), + importsMutex: sync.RWMutex{}, + rateLimiter: time.NewTicker(500 * time.Millisecond), // 500ms delay between requests + } } // Create adds a new URL to read later @@ -123,3 +151,473 @@ func (s *Service) Reset() error { } return nil } + +// ShioriCredentials contains the credentials for connecting to a Shiori instance +type ShioriCredentials struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` +} + +// ShioriBookmark represents a bookmark in Shiori +type ShioriBookmark struct { + ID int `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Author string `json:"author"` + Public int `json:"public"` + CreatedAt string `json:"createdAt"` + ModifiedAt string `json:"modifiedAt"` + Modified time.Time `json:"modified,omitempty"` + Content string `json:"content,omitempty"` + HTML string `json:"html,omitempty"` + ImageURL string `json:"imageURL,omitempty"` + HasContent bool `json:"hasContent"` + HasImage bool `json:"hasImage"` + HasArchive bool `json:"hasArchive,omitempty"` + HasEbook bool `json:"hasEbook,omitempty"` + CreateArchive bool `json:"create_archive,omitempty"` + CreateEbook bool `json:"create_ebook,omitempty"` + Tags json.RawMessage `json:"tags,omitempty"` +} + +// ShioriLoginResponse represents the response from Shiori login API +type ShioriLoginResponse struct { + OK bool `json:"ok"` + Message struct { + Token string `json:"token"` // JWT token for bearer auth + Session string `json:"session"` // Session ID + Expires int64 `json:"expires"` + } `json:"message"` +} + +// ShioriBookmarksResponse represents the response from Shiori bookmarks API +type ShioriBookmarksResponse struct { + Bookmarks []ShioriBookmark `json:"bookmarks"` + MaxPage int `json:"maxPage"` + Count int `json:"count"` +} + +// ImportFromShiori imports bookmarks from a Shiori instance +// Returns the import ID which can be used to check the status +func (s *Service) ImportFromShiori(creds ShioriCredentials) (string, error) { + // For debugging + fmt.Printf("[DEBUG] Starting import from Shiori URL: %s\n", creds.URL) + + // Create a new import status + importID := s.CreateImportStatus() + fmt.Printf("[DEBUG] Created import status with ID: %s\n", importID) + + // Start the import process in a goroutine + go s.runShioriImport(importID, creds) + + // Return the import ID so the caller can check the status + return importID, nil +} + +// runShioriImport performs the actual import process in the background +func (s *Service) runShioriImport(importID string, creds ShioriCredentials) { + fmt.Printf("[DEBUG] Starting runShioriImport for ID: %s\n", importID) + + // Define a helper function to mark import as failed + markFailed := func(err error) { + fmt.Printf("[DEBUG] Import failed: %v\n", err) + s.UpdateImportStatus(importID, func(status *ImportStatus) { + status.InProgress = false + status.Error = err.Error() + }) + } + + // Login to Shiori + fmt.Printf("[DEBUG] Attempting to login to Shiori at %s\n", creds.URL) + token, err := s.loginToShiori(creds) + if err != nil { + fmt.Printf("[DEBUG] Login failed: %v\n", err) + markFailed(fmt.Errorf("failed to login to Shiori: %v", err)) + return + } + fmt.Printf("[DEBUG] Login successful, token: %s...\n", token[:min(len(token), 10)]) + + // Fetch all bookmarks from Shiori with pagination + fmt.Printf("[DEBUG] Fetching bookmarks from Shiori\n") + allBookmarks, err := s.fetchShioriBookmarks(creds.URL, token) + if err != nil { + fmt.Printf("[DEBUG] Fetching bookmarks failed: %v\n", err) + markFailed(fmt.Errorf("failed to fetch bookmarks: %v", err)) + return + } + fmt.Printf("[DEBUG] Fetched %d bookmarks\n", len(allBookmarks)) + + // Setup counters + importedCount := 0 + failedCount := 0 + + // Update status with total count + s.UpdateImportStatus(importID, func(status *ImportStatus) { + status.TotalBookmarks = len(allBookmarks) + }) + fmt.Printf("[DEBUG] Updated import status with total count: %d\n", len(allBookmarks)) + + // Process each bookmark + for i, bookmark := range allBookmarks { + fmt.Printf("[DEBUG] Processing bookmark %d/%d: %s\n", i+1, len(allBookmarks), bookmark.URL) + <-s.rateLimiter.C // Apply rate limiting for processing each bookmark + + // Create a ReadLaterItem from the bookmark + item := ReadLaterItem{ + ID: uuid.New().String(), + URL: bookmark.URL, + Title: bookmark.Title, + Image: bookmark.ImageURL, + Description: bookmark.Excerpt, + Status: "unread", + Content: bookmark.Content, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // If content is empty, try to fetch it + if item.Content == "" { + fmt.Printf("[DEBUG] Content empty, fetching from URL: %s\n", bookmark.URL) + article, err := readability.FromURL(bookmark.URL, 30*time.Second) + if err == nil && article.Content != "" { + item.Content = article.Content + + // If description/excerpt is empty, use the beginning of the content + if item.Description == "" { + plainText, err := s.ConvertHTMLToText(article.Content) + if err == nil && plainText != "" { + // Use the first 150 characters as description + if len(plainText) > 150 { + item.Description = plainText[:150] + "..." + } else { + item.Description = plainText + } + } + } + + // If title is empty, use article title + if item.Title == "" && article.Title != "" { + item.Title = article.Title + } + + // If image is empty, use article image + if item.Image == "" && article.Image != "" { + item.Image = article.Image + } + } else if err != nil { + fmt.Printf("[DEBUG] Error fetching article content: %v\n", err) + } + } else if item.Description == "" { // Convert any existing HTML content to plain text for the description if needed + plainText, err := s.ConvertHTMLToText(item.Content) + if err == nil && plainText != "" { + // Use the first 150 characters as description + if len(plainText) > 150 { + item.Description = plainText[:150] + "..." + } else { + item.Description = plainText + } + } else if err != nil { + fmt.Printf("[DEBUG] Error converting HTML to text: %v\n", err) + } + } + + // Ensure we have at least a basic title if it's still empty + if item.Title == "" { + item.Title = bookmark.URL + } + + // Save the item to the database + fmt.Printf("[DEBUG] Saving item to database: %s\n", item.URL) + err = s.db.Create(&item).Error + if err != nil { + fmt.Printf("[DEBUG] Error saving item: %v\n", err) + failedCount++ + } else { + importedCount++ + } + + // Update import status + s.UpdateImportStatus(importID, func(status *ImportStatus) { + status.ImportedCount = importedCount + status.FailedCount = failedCount + }) + } + + // Mark import as complete + fmt.Printf("[DEBUG] Import complete. Imported: %d, Failed: %d\n", importedCount, failedCount) + s.UpdateImportStatus(importID, func(status *ImportStatus) { + status.InProgress = false + completedAt := time.Now() + status.CompletedAt = &completedAt + }) +} + +// loginToShiori logs in to a Shiori instance and returns the session token +func (s *Service) loginToShiori(creds ShioriCredentials) (string, error) { + // Apply rate limiting + <-s.rateLimiter.C + + fmt.Printf("[DEBUG] loginToShiori: Starting login to %s\n", creds.URL) + + // Construct login URL + loginURL := fmt.Sprintf("%s/api/v1/auth/login", strings.TrimSuffix(creds.URL, "/")) + fmt.Printf("[DEBUG] loginToShiori: Login URL: %s\n", loginURL) + + // Prepare login data + loginData := map[string]string{ + "username": creds.Username, + "password": creds.Password, + } + + // Convert login data to JSON + jsonData, err := json.Marshal(loginData) + if err != nil { + fmt.Printf("[DEBUG] loginToShiori: Error marshaling login data: %v\n", err) + return "", fmt.Errorf("failed to marshal login data: %v", err) + } + + // Create request + req, err := http.NewRequest("POST", loginURL, bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Printf("[DEBUG] loginToShiori: Error creating request: %v\n", err) + return "", fmt.Errorf("failed to create login request: %v", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + + // Send request + fmt.Printf("[DEBUG] loginToShiori: Sending login request\n") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("[DEBUG] loginToShiori: Error sending request: %v\n", err) + return "", fmt.Errorf("failed to send login request: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err) + } + }() + + // Check response status + fmt.Printf("[DEBUG] loginToShiori: Response status: %s\n", resp.Status) + if resp.StatusCode != http.StatusOK { + // Read the error response body + errBody, _ := io.ReadAll(resp.Body) + fmt.Printf("[DEBUG] loginToShiori: Error response body: %s\n", string(errBody)) + return "", fmt.Errorf("login failed with status code: %d", resp.StatusCode) + } + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("[DEBUG] loginToShiori: Error reading response body: %v\n", err) + return "", fmt.Errorf("failed to read login response: %v", err) + } + + // Print response body for debugging + fmt.Printf("[DEBUG] loginToShiori: Response body: %s\n", string(bodyBytes)) + + // We need to re-create the reader since we consumed it + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Parse response + var loginResp ShioriLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + fmt.Printf("[DEBUG] loginToShiori: Error parsing response: %v\n", err) + return "", fmt.Errorf("failed to parse login response: %v", err) + } + + // Extract token + token := loginResp.Message.Token + fmt.Printf("[DEBUG] loginToShiori: Successfully logged in, token length: %d\n", len(token)) + + return token, nil +} + +// fetchShioriBookmarks retrieves bookmarks from a Shiori instance using the session token +func (s *Service) fetchShioriBookmarks(baseURL, session string) ([]ShioriBookmark, error) { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Starting fetching bookmarks from %s\n", baseURL) + // Apply rate limiting + <-s.rateLimiter.C + + // Ensure baseURL doesn't have a trailing slash + baseURL = strings.TrimSuffix(baseURL, "/") + + // Initialize results + allBookmarks := []ShioriBookmark{} + + // Start with page 1 + page := 1 + maxPage := 1 // Will be updated from the first response + + for page <= maxPage { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Fetching page %d of %d\n", page, maxPage) + + // Construct URL for the current page - fixing the API endpoint path + url := fmt.Sprintf("%s/api/bookmarks?page=%d", baseURL, page) + fmt.Printf("[DEBUG] fetchShioriBookmarks: Request URL for page %d: %s\n", page, url) + + // Create request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error creating request for page %d: %v\n", page, err) + return nil, fmt.Errorf("failed to create request: %v", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session)) + + // Send request + fmt.Printf("[DEBUG] fetchShioriBookmarks: Sending request for page %d\n", page) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error sending request for page %d: %v\n", page, err) + return nil, fmt.Errorf("failed to send request: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err) + } + }() + + // Check response + fmt.Printf("[DEBUG] fetchShioriBookmarks: Response status for page %d: %s\n", page, resp.Status) + if resp.StatusCode != http.StatusOK { + // Read the error response body + errBody, _ := io.ReadAll(resp.Body) + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error response body for page %d: %s\n", page, string(errBody)) + return nil, fmt.Errorf("request failed with status code: %d", resp.StatusCode) + } + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error reading response body for page %d: %v\n", page, err) + return nil, fmt.Errorf("failed to read response: %v", err) + } + + // Print the first 200 characters of response for debugging + previewText := string(bodyBytes) + if len(previewText) > 200 { + previewText = previewText[:200] + "..." + } + fmt.Printf("[DEBUG] fetchShioriBookmarks: Response body preview for page %d: %s\n", page, previewText) + + // We need to re-create the reader since we consumed it + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Parse response + var bookmarksResp ShioriBookmarksResponse + if err := json.NewDecoder(resp.Body).Decode(&bookmarksResp); err != nil { + fmt.Printf("[DEBUG] fetchShioriBookmarks: Error parsing response for page %d: %v\n", page, err) + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + // Update maxPage from the response (only on first page) + if page == 1 { + maxPage = bookmarksResp.MaxPage + fmt.Printf("[DEBUG] fetchShioriBookmarks: Updated max page to %d\n", maxPage) + } + + // Add bookmarks from this page to results + allBookmarks = append(allBookmarks, bookmarksResp.Bookmarks...) + fmt.Printf("[DEBUG] fetchShioriBookmarks: Added %d bookmarks from page %d, total so far: %d\n", + len(bookmarksResp.Bookmarks), page, len(allBookmarks)) + + // Move to next page + page++ + + // Apply rate limiting before next request (if there is one) + if page <= maxPage { + <-s.rateLimiter.C + } + } + + fmt.Printf("[DEBUG] fetchShioriBookmarks: Completed, fetched %d bookmarks total\n", len(allBookmarks)) + return allBookmarks, nil +} + +// CreateImportStatus initializes a new import status and returns its ID +func (s *Service) CreateImportStatus() string { + id := uuid.New().String() + status := &ImportStatus{ + ID: id, + InProgress: true, + StartedAt: time.Now(), + ImportedCount: 0, + FailedCount: 0, + } + + s.importsMutex.Lock() + s.imports[id] = status + s.importsMutex.Unlock() + + return id +} + +// UpdateImportStatus updates the status of an import operation +func (s *Service) UpdateImportStatus(id string, update func(*ImportStatus)) { + s.importsMutex.Lock() + defer s.importsMutex.Unlock() + + if status, exists := s.imports[id]; exists { + update(status) + } +} + +// GetImportStatus retrieves the status of an import operation +func (s *Service) GetImportStatus(id string) (*ImportStatus, bool) { + s.importsMutex.RLock() + defer s.importsMutex.RUnlock() + + status, exists := s.imports[id] + return status, exists +} + +// CleanupOldImports removes completed import statuses older than a day +func (s *Service) CleanupOldImports() { + s.importsMutex.Lock() + defer s.importsMutex.Unlock() + + cutoff := time.Now().Add(-24 * time.Hour) + + for id, status := range s.imports { + if !status.InProgress && status.CompletedAt != nil && status.CompletedAt.Before(cutoff) { + delete(s.imports, id) + } + } +} + +// ConvertHTMLToText converts HTML content to plain text +// This is useful when we need to extract text from HTML for searching or displaying in non-HTML contexts +func (s *Service) ConvertHTMLToText(htmlContent string) (string, error) { + if htmlContent == "" { + return "", nil + } + + // Convert HTML to plain text + text, err := html2text.FromString(htmlContent, html2text.Options{ + PrettyTables: true, + OmitLinks: false, + }) + + if err != nil { + return "", err + } + + return text, nil +} + +// Add helper function for safe string slicing +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/scripts/build.sh b/scripts/build.sh index 5d4b022..5975e37 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -25,9 +25,7 @@ cp static/css/bulma.min.css build/css/ # Build backend (without CGO) popd -echo "Building Go backend..." CGO_ENABLED=0 $GO_CMD build -v -o notes-app || { echo "Go build failed!" exit 1 -} -echo "Go build completed successfully." +} \ No newline at end of file diff --git a/specs/readlist.md b/specs/readlist.md index 2a8696a..cfb0329 100644 --- a/specs/readlist.md +++ b/specs/readlist.md @@ -120,4 +120,17 @@ The system tracks the status of articles using nullable timestamp fields: - Input field for the URL to save - Automatic extraction of content after submission -- Preview of the extracted content before saving \ No newline at end of file +- Preview of the extracted content before saving + +## Shiori Import + +The Readlist module now supports importing bookmarks from a Shiori instance. This feature allows users to migrate or synchronize their bookmarks by connecting to a Shiori service using their credentials. + +### API Endpoint +- POST /api/readlist/import/shiori: Accepts a JSON payload containing `url`, `username`, and `password`. The backend authenticates with the Shiori instance, fetches bookmarks, and creates corresponding read later items. + +### Frontend Integration +- A form in the readlist UI accepts Shiori credentials. The readlist store includes an `importFromShiori` method that sends a request to the endpoint and processes the response, updating the list of saved articles accordingly. + +### Error Handling +- Both the backend and frontend provide clear error messages if authentication or bookmark retrieval fails. \ No newline at end of file