Skip to content

Commit 8795442

Browse files
committed
Added json and markdown book detail export
1 parent c5f61f7 commit 8795442

File tree

10 files changed

+318
-23
lines changed

10 files changed

+318
-23
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "koshelf"
3-
version = "1.0.16"
3+
version = "1.0.17"
44
description = "Transform your KOReader library into a beautiful reading dashboard with statistics."
55
repository = "https://github.com/paviro/KOShelf"
66
license = "EUPL-1.2 license"

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,15 @@ site/
191191
├── calendar/
192192
│ └── index.html # Reading calendar view
193193
├── books/ # Individual book pages
194+
│ ├── list.json # Manifest of all books (convenience only; not used by frontend)
194195
│ ├── book-id1/
195-
│ │ └── index.html # Book detail page with annotations
196+
│ │ ├── index.html # Book detail page with annotations
197+
│ │ ├── details.md # Markdown export (human-readable)
198+
│ │ └── details.json # JSON export (machine-readable)
196199
│ └── book-id2/
197-
│ └── index.html
200+
│ ├── index.html
201+
│ ├── details.md
202+
│ └── details.json
198203
└── assets/
199204
├── covers/ # Optimized book covers
200205
│ ├── book-id1.webp
@@ -209,7 +214,7 @@ site/
209214
│ ├── calendar.js # Calendar functionality
210215
│ ├── heatmap.js # Activity heatmap visualization
211216
│ └── event-calendar.min.js # Event calendar library
212-
└── json/ # Data files (when available)
217+
└── json/ # Data files used by the frontend (when available)
213218
├── calendar/ # Calendar data split by month
214219
│ ├── available_months.json # List of months with calendar data
215220
│ ├── 2024-01.json # January 2024 events and book data

assets/book_detail.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,18 @@ document.addEventListener('DOMContentLoaded', function() {
77
// Initialize section toggles using the module
88
const sectionToggle = new SectionToggle();
99

10+
// Share dropdown toggle logic
11+
const shareDropdownButton = document.getElementById('shareDropdownButton');
12+
const shareDropdownMenu = document.getElementById('shareDropdownMenu');
13+
14+
shareDropdownButton?.addEventListener('click', () => {
15+
shareDropdownMenu?.classList.toggle('hidden');
16+
});
17+
18+
// Close dropdown when clicking outside
19+
document.addEventListener('click', (e) => {
20+
if (!shareDropdownButton?.contains(e.target) && !shareDropdownMenu?.contains(e.target)) {
21+
shareDropdownMenu?.classList.add('hidden');
22+
}
23+
});
1024
});

src/lua_parser.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,7 @@ impl LuaParser {
7575
fn parse_annotation(&self, table: Table) -> Result<Annotation> {
7676
Ok(Annotation {
7777
chapter: self.get_optional_string(&table, "chapter")?,
78-
color: self.get_optional_string(&table, "color")?,
7978
datetime: self.get_optional_string(&table, "datetime")?,
80-
drawer: self.get_optional_string(&table, "drawer")?,
81-
page: self.get_optional_string(&table, "page")?,
8279
pageno: self.get_optional_u32(&table, "pageno")?,
8380
pos0: self.get_optional_string(&table, "pos0")?,
8481
pos1: self.get_optional_string(&table, "pos1")?,

src/models.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,11 @@ pub struct KoReaderMetadata {
238238
#[derive(Debug, Clone, Serialize, Deserialize)]
239239
pub struct Annotation {
240240
pub chapter: Option<String>,
241-
pub color: Option<String>,
242241
pub datetime: Option<String>,
243-
pub drawer: Option<String>,
244-
pub page: Option<String>,
245242
pub pageno: Option<u32>,
243+
#[serde(skip_serializing)]
246244
pub pos0: Option<String>,
245+
#[serde(skip_serializing)]
247246
pub pos1: Option<String>,
248247
pub text: String,
249248
pub note: Option<String>,
@@ -321,23 +320,25 @@ impl std::fmt::Display for BookStatus {
321320
/// Data structure representing a book entry from the statistics database
322321
#[derive(Debug, Clone, Serialize, Deserialize)]
323322
pub struct StatBook {
323+
#[serde(skip_serializing)]
324324
pub id: i64,
325+
#[serde(skip_serializing)]
325326
pub title: String,
327+
#[serde(skip_serializing)]
326328
pub authors: String,
327329
pub notes: Option<i64>,
328330
pub last_open: Option<i64>,
329331
pub highlights: Option<i64>,
330332
pub pages: Option<i64>,
331-
pub series: String,
332-
pub language: String,
333333
#[serde(skip_serializing)]
334334
pub md5: String,
335335
pub total_read_time: Option<i64>,
336+
#[serde(skip_serializing)]
336337
pub total_read_pages: Option<i64>,
337338
}
338339

339340
/// Additional statistics calculated for a book from its reading sessions
340-
#[derive(Debug, Clone)]
341+
#[derive(Debug, Clone, Serialize)]
341342
pub struct BookSessionStats {
342343
pub session_count: i64,
343344
pub average_session_duration: Option<i64>, // in seconds

src/site_generator.rs

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,45 @@ impl SiteGenerator {
285285
completed_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
286286
unread_books.sort_by(|a, b| a.epub_info.title.cmp(&b.epub_info.title));
287287

288+
// ------------------------------------------------------------------
289+
// Generate books manifest JSON categorized by reading status.
290+
// NOTE: This manifest is not consumed by the frontend code – it is
291+
// generated purely for the convenience of users who may want a
292+
// machine-readable list of all books and their export paths.
293+
// ------------------------------------------------------------------
294+
295+
use serde_json::json;
296+
297+
let to_manifest_entry = |b: &Book| {
298+
json!({
299+
"id": b.id.clone(),
300+
"title": b.epub_info.title.clone(),
301+
"authors": b.epub_info.authors.clone(),
302+
"json_path": format!("/books/{}/details.json", b.id),
303+
"html_path": format!("/books/{}/index.html", b.id),
304+
})
305+
};
306+
307+
let reading_json: Vec<_> = reading_books.iter().map(to_manifest_entry).collect();
308+
let completed_json: Vec<_> = completed_books.iter().map(to_manifest_entry).collect();
309+
let new_json: Vec<_> = unread_books.iter().map(to_manifest_entry).collect();
310+
311+
let manifest = json!({
312+
"reading": reading_json,
313+
"completed": completed_json,
314+
"new": new_json,
315+
"generated_at": self.get_last_updated(),
316+
});
317+
318+
fs::write(
319+
self.books_dir().join("list.json"),
320+
serde_json::to_string_pretty(&manifest)?,
321+
)?;
322+
323+
// ------------------------------------------------------------------
324+
// Render book list HTML
325+
// ------------------------------------------------------------------
326+
288327
let template = IndexTemplate {
289328
site_title: self.site_title.clone(),
290329
reading_books,
@@ -294,7 +333,7 @@ impl SiteGenerator {
294333
last_updated: self.get_last_updated(),
295334
navbar_items: self.create_navbar_items("books"),
296335
};
297-
336+
298337
let html = template.render()?;
299338
fs::write(self.output_dir.join("index.html"), html)?;
300339

@@ -327,8 +366,8 @@ impl SiteGenerator {
327366
let template = BookTemplate {
328367
site_title: self.site_title.clone(),
329368
book: book.clone(),
330-
book_stats,
331-
session_stats,
369+
book_stats: book_stats.clone(),
370+
session_stats: session_stats.clone(),
332371
version: self.get_version(),
333372
last_updated: self.get_last_updated(),
334373
navbar_items: self.create_navbar_items("books"),
@@ -339,6 +378,54 @@ impl SiteGenerator {
339378
fs::create_dir_all(&book_dir)?;
340379
let book_path = book_dir.join("index.html");
341380
fs::write(book_path, html)?;
381+
382+
// Generate Markdown export
383+
let md_template = BookMarkdownTemplate {
384+
book: book.clone(),
385+
book_stats: book_stats.clone(),
386+
session_stats: session_stats.clone(),
387+
version: self.get_version(),
388+
last_updated: self.get_last_updated(),
389+
};
390+
let markdown = md_template.render()?;
391+
fs::write(book_dir.join("details.md"), markdown)?;
392+
393+
// Generate JSON export / not used by the frontend code - only for the user's convenience
394+
let json_data = serde_json::json!({
395+
"book": {
396+
"title": book.epub_info.title,
397+
"authors": book.epub_info.authors,
398+
"series": book.series_display(),
399+
"language": book.language(),
400+
"publisher": book.publisher(),
401+
"description": book.epub_info.sanitized_description(),
402+
"rating": book.rating(),
403+
"review_note": book.review_note(),
404+
"status": book.status().to_string(),
405+
"progress_percentage": book.progress_percentage(),
406+
"subjects": book.subjects(),
407+
"identifiers": book.identifiers().iter().map(|id| {
408+
serde_json::json!({
409+
"scheme": id.scheme,
410+
"value": id.value,
411+
"display_scheme": id.display_scheme(),
412+
"url": id.url()
413+
})
414+
}).collect::<Vec<_>>()
415+
},
416+
"annotations": book.koreader_metadata.as_ref().map(|m| &m.annotations).unwrap_or(&vec![]),
417+
"statistics": {
418+
"book_stats": book_stats,
419+
"session_stats": session_stats
420+
},
421+
"export_info": {
422+
"generated_by": "KoShelf",
423+
"version": self.get_version(),
424+
"generated_at": self.get_last_updated()
425+
}
426+
});
427+
let json_str = serde_json::to_string_pretty(&json_data)?;
428+
fs::write(book_dir.join("details.json"), json_str)?;
342429
}
343430

344431
Ok(())

src/statistics_parser.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ impl StatisticsParser {
5858

5959
/// Parse book entries from the database
6060
fn parse_books(conn: &Connection) -> Result<Vec<StatBook>> {
61-
let mut stmt = conn.prepare("SELECT id, title, authors, notes, last_open, highlights, pages, series, language, md5, total_read_time, total_read_pages FROM book")?;
61+
let mut stmt = conn.prepare("SELECT id, title, authors, notes, last_open, highlights, pages, md5, total_read_time, total_read_pages FROM book")?;
6262

6363
let book_iter = stmt.query_map([], |row| {
6464
Ok(StatBook {
@@ -69,11 +69,9 @@ impl StatisticsParser {
6969
last_open: row.get(4)?,
7070
highlights: row.get(5)?,
7171
pages: row.get(6)?,
72-
series: row.get(7)?,
73-
language: row.get(8)?,
74-
md5: row.get(9)?,
75-
total_read_time: row.get(10)?,
76-
total_read_pages: row.get(11)?,
72+
md5: row.get(7)?,
73+
total_read_time: row.get(8)?,
74+
total_read_pages: row.get(9)?,
7775
})
7876
})?;
7977

src/templates.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ pub struct BookTemplate {
3333
pub navbar_items: Vec<NavItem>,
3434
}
3535

36+
#[derive(Template)]
37+
#[template(path = "book_details/book_details.md", escape = "none")]
38+
pub struct BookMarkdownTemplate {
39+
pub book: Book,
40+
pub book_stats: Option<StatBook>,
41+
pub session_stats: Option<BookSessionStats>,
42+
pub version: String,
43+
pub last_updated: String,
44+
}
45+
3646
#[derive(Template)]
3747
#[template(path = "statistics/statistics.html")]
3848
pub struct StatsTemplate {

templates/book_details/book_details.html

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
{% include "sidebar.html" %}
1212

1313
<!-- Header - Fixed at top -->
14-
<header class="fixed top-0 left-0 right-0 md:left-64 bg-white/90 dark:bg-dark-850/75 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40">
14+
<header class="fixed top-0 left-0 right-0 md:left-64 bg-white/90 dark:bg-dark-850/75 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40 flex items-center justify-between">
1515
<div class="flex items-center h-full min-w-0 flex-1">
1616
<!-- Mobile Title & Back Button -->
1717
<div class="md:hidden flex items-center space-x-3 min-w-0 flex-1">
@@ -37,6 +37,25 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white truncate">{{ book.ep
3737
{% endif %}
3838
</div>
3939
</div>
40+
41+
<!-- Action Buttons -->
42+
<div class="flex items-center space-x-2">
43+
<!-- Share Dropdown -->
44+
<div class="relative" id="shareDropdownWrapper">
45+
<button id="shareDropdownButton" class="p-2 bg-gray-100/50 dark:bg-dark-800/50 border border-gray-300/50 dark:border-dark-700/50 rounded-lg hover:bg-gray-200/50 dark:hover:bg-dark-700/50 transition-colors" type="button" aria-label="Share">
46+
<!-- Download Icon (Heroicons) -->
47+
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
48+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
49+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10l5 5 5-5"/>
50+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v12"/>
51+
</svg>
52+
</button>
53+
<div id="shareDropdownMenu" class="hidden absolute right-0 mt-2 w-40 bg-white dark:bg-dark-800/90 border border-gray-200/50 dark:border-dark-700/50 rounded-lg shadow-xl z-20 overflow-hidden">
54+
<a href="/books/{{ book.id }}/details.md" download class="block px-4 py-2 hover:bg-gray-100/50 dark:hover:bg-dark-700/50 text-sm transition-colors duration-200">Markdown</a>
55+
<a href="/books/{{ book.id }}/details.json" download class="block px-4 py-2 hover:bg-gray-100/50 dark:hover:bg-dark-700/50 text-sm transition-colors duration-200">JSON</a>
56+
</div>
57+
</div>
58+
</div>
4059
</header>
4160

4261
<div class="min-h-full md:ml-64">

0 commit comments

Comments
 (0)