feat: enhance Shiori import with progress indicator and error handling

This commit is contained in:
Nicola Zangrandi 2025-03-04 17:54:49 +01:00
parent b1ac38d4ab
commit c3b15a14d2
Signed by: wasp
GPG key ID: 43C1470D890F23ED
13 changed files with 1015 additions and 143 deletions

View file

@ -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)

View file

@ -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=="],

View file

@ -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"

View file

@ -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<ReadLaterItem[]>([]);
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();
}
};
}

View file

@ -1,36 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { readlist } from '$lib/readlist';
import CardList from '$lib/components/CardList.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import type { ReadLaterItem } from '$lib/types';
let items: ReadLaterItem[] = [];
let url = '';
let isLoading = false;
let error: string | null = null;
let showArchived = false;
let archivingItems: Record<string, boolean> = {};
let importCount = 0;
let searchQuery = '';
let showArchived = false;
onMount(() => {
loadItems();
// Shiori import
let showShioriImport = false;
let shioriUrl = '';
let shioriUsername = '';
let shioriPassword = '';
let isImportingShiori = false;
let shioriImportCount = 0;
let shioriError: string | null = null;
let currentImportId: string | null = null;
let importProgress = {
totalBookmarks: 0,
importedCount: 0,
failedCount: 0,
inProgress: false
};
let statusCheckInterval: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await loadItems();
});
async function loadItems() {
await readlist.load(showArchived);
isLoading = true;
error = null;
try {
await readlist.load(showArchived);
const unsubscribe = readlist.subscribe((value) => {
items = value;
});
return () => {
unsubscribe();
};
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load items';
} finally {
isLoading = false;
}
}
async function toggleArchived() {
showArchived = !showArchived;
await loadItems();
}
async function handleSubmit() {
if (!url) return;
async function handleAddUrl() {
if (!url.trim()) {
error = 'Please enter a URL';
return;
}
isLoading = true;
error = null;
try {
await readlist.add(url);
url = '';
@ -41,12 +70,7 @@
}
}
function handleDelete(item: ReadLaterItem) {
readlist.delete(item.id);
}
async function handleToggleArchive(item: ReadLaterItem) {
archivingItems[item.id] = true;
try {
if (item.archivedAt) {
await readlist.unarchive(item.id);
@ -55,109 +79,333 @@
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update archive status';
} finally {
archivingItems[item.id] = false;
}
}
async function handleDelete(item: ReadLaterItem) {
if (!confirm(`Are you sure you want to delete "${item.title}"?`)) {
return;
}
try {
await readlist.delete(item.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete item';
}
}
async function handleToggleShowArchived() {
showArchived = readlist.toggleShowArchived();
await loadItems();
}
function getFilteredItems() {
if (!searchQuery) {
return $readlist;
return items;
}
return $readlist.filter(
const query = searchQuery.toLowerCase();
return items.filter(
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.url.toLowerCase().includes(searchQuery.toLowerCase())
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.url.toLowerCase().includes(query)
);
}
async function handleImportFromShiori() {
if (!shioriUrl || !shioriUsername || !shioriPassword) {
shioriError = 'Please fill in all fields';
return;
}
isImportingShiori = true;
shioriError = null;
try {
// Start the import process - this returns an import ID
const result = await readlist.importFromShiori({
url: shioriUrl,
username: shioriUsername,
password: shioriPassword
});
// Store the import ID
currentImportId = result.id;
// Start polling for status updates
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
statusCheckInterval = setInterval(async () => {
if (currentImportId) {
try {
const status = await readlist.getImportStatus(currentImportId);
importProgress = {
totalBookmarks: status.totalBookmarks,
importedCount: status.importedCount,
failedCount: status.failedCount,
inProgress: status.inProgress
};
// If import is complete, stop polling and update count
if (!status.inProgress) {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
shioriImportCount = status.importedCount;
isImportingShiori = false;
// Reload the list to show new items
await readlist.load(showArchived);
}
} catch (e) {
console.error('Error checking import status:', e);
}
}
}, 1000); // Check every second
// Clear form
shioriUrl = '';
shioriUsername = '';
shioriPassword = '';
showShioriImport = false;
} catch (e) {
shioriError = e instanceof Error ? e.message : 'Failed to import from Shiori';
isImportingShiori = false;
}
}
// Clean up interval on component destroy
onDestroy(() => {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
});
</script>
<div class="container">
<h1 class="title">Read Later</h1>
<section class="section">
<h1 class="title">Read Later</h1>
<form onsubmit={handleSubmit} class="mb-4">
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
type="url"
placeholder="Enter a URL to save"
bind:value={url}
disabled={isLoading}
/>
</div>
<div class="control">
<button type="submit" class="button is-primary" disabled={isLoading || !url}>
{#if isLoading}
<span class="icon">
<i class="fas fa-spinner fa-spin"></i>
</span>
{:else}
Add Link
{/if}
</button>
</div>
</div>
{#if error}
<p class="help is-danger">{error}</p>
<div class="notification is-danger">
<p>{error}</p>
</div>
{/if}
</form>
<div class="level mb-4">
<div class="level-left">
<div class="level-item">
<button class="button is-small" onclick={toggleArchived}>
<span class="icon">
<i class="fas fa-archive"></i>
</span>
<span>{showArchived ? 'Hide Archived' : 'Show Archived'}</span>
</button>
{#if importCount > 0}
<div class="notification is-success">
<p>Successfully added {importCount} URLs.</p>
</div>
{/if}
{#if shioriImportCount > 0}
<div class="notification is-success">
<p>Successfully imported {shioriImportCount} bookmarks from Shiori.</p>
</div>
{/if}
<!-- Import progress indicator -->
{#if isImportingShiori}
<div class="notification is-warning">
{#if importProgress.totalBookmarks === 0}
<strong>Connecting to Shiori and preparing for import...</strong>
{:else}
<strong>Import in progress:</strong>
{importProgress.importedCount} imported, {importProgress.failedCount} failed out of {importProgress.totalBookmarks}
<progress
class="progress is-primary"
value={importProgress.importedCount + importProgress.failedCount}
max={importProgress.totalBookmarks}
></progress>
{/if}
</div>
{/if}
<!-- End of import progress indicator -->
<div class="level">
<div class="level-left">
<div class="level-item">
<div class="field has-addons">
<div class="control">
<input
class="input"
type="url"
placeholder="Enter URL to save"
bind:value={url}
on:keydown={(e) => {
if (e.key === 'Enter') handleAddUrl();
}}
/>
</div>
<div class="control">
<button
class="button is-primary"
on:click={handleAddUrl}
disabled={isLoading || !url}
>
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>Add</span>
</button>
</div>
</div>
</div>
<div class="level-item">
<button class="button is-info" on:click={() => (showShioriImport = !showShioriImport)}>
<span class="icon">
<i class="fas fa-file-import"></i>
</span>
<span>Import from Shiori</span>
</button>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button class="button" class:is-info={showArchived} on:click={handleToggleShowArchived}>
<span class="icon">
<i class="fas fa-archive"></i>
</span>
<span>{showArchived ? 'Hide Archived' : 'Show Archived'}</span>
</button>
</div>
</div>
</div>
</div>
<div class="mb-4">
<SearchBar
placeholder="Search saved links by title, description, or URL..."
bind:value={searchQuery}
{#if showShioriImport}
<div class="box mb-4">
<h2 class="title is-5">Import from Shiori</h2>
{#if shioriError}
<div class="notification is-danger">
<p>{shioriError}</p>
</div>
{/if}
<div class="field">
<label class="label" for="shiori-url">Shiori URL</label>
<div class="control">
<input
id="shiori-url"
class="input"
type="url"
placeholder="https://shiori.example.com"
bind:value={shioriUrl}
/>
</div>
<p class="help">The URL of your Shiori instance</p>
</div>
<div class="field">
<label class="label" for="shiori-username">Username</label>
<div class="control">
<input
id="shiori-username"
class="input"
type="text"
placeholder="Username"
bind:value={shioriUsername}
/>
</div>
</div>
<div class="field">
<label class="label" for="shiori-password">Password</label>
<div class="control">
<input
id="shiori-password"
class="input"
type="password"
placeholder="Password"
bind:value={shioriPassword}
/>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button
class="button is-primary"
on:click={handleImportFromShiori}
disabled={isImportingShiori || !shioriUrl || !shioriUsername || !shioriPassword}
>
<span class="icon">
<i class="fas fa-file-import" class:fa-spin={isImportingShiori}></i>
</span>
<span>{isImportingShiori ? 'Importing...' : 'Import'}</span>
</button>
</div>
<div class="control">
<button
class="button"
on:click={() => (showShioriImport = false)}
disabled={isImportingShiori}
>
Cancel
</button>
</div>
</div>
<!-- Add status message when import is in progress -->
{#if isImportingShiori}
<div class="notification is-warning mt-3">
<p class="has-text-weight-bold">
<i class="fas fa-spinner fa-spin mr-2"></i>
Import is running in the background
</p>
<p>
You can close this form and the import will continue. Progress will be shown at the
top of the page.
</p>
</div>
{/if}
</div>
{/if}
<div class="mb-4">
<SearchBar placeholder="Search saved links..." bind:value={searchQuery} />
</div>
<CardList
{items}
renderCard={(item) => {
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."
/>
</div>
<CardList
items={getFilteredItems()}
renderCard={(item) => {
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."
/>
</section>
</div>

5
go.mod
View file

@ -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

9
go.sum
View file

@ -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=

40
main.go
View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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."
}

View file

@ -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
- 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.