ebk 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ebk might be problematic. Click here for more details.

Files changed (61) hide show
  1. ebk/ai/__init__.py +23 -0
  2. ebk/ai/knowledge_graph.py +443 -0
  3. ebk/ai/llm_providers/__init__.py +21 -0
  4. ebk/ai/llm_providers/base.py +230 -0
  5. ebk/ai/llm_providers/ollama.py +362 -0
  6. ebk/ai/metadata_enrichment.py +396 -0
  7. ebk/ai/question_generator.py +328 -0
  8. ebk/ai/reading_companion.py +224 -0
  9. ebk/ai/semantic_search.py +434 -0
  10. ebk/ai/text_extractor.py +394 -0
  11. ebk/cli.py +1097 -9
  12. ebk/db/__init__.py +37 -0
  13. ebk/db/migrations.py +180 -0
  14. ebk/db/models.py +526 -0
  15. ebk/db/session.py +144 -0
  16. ebk/exports/__init__.py +0 -0
  17. ebk/exports/base_exporter.py +218 -0
  18. ebk/exports/html_library.py +1390 -0
  19. ebk/exports/html_utils.py +117 -0
  20. ebk/exports/hugo.py +59 -0
  21. ebk/exports/jinja_export.py +287 -0
  22. ebk/exports/multi_facet_export.py +164 -0
  23. ebk/exports/symlink_dag.py +479 -0
  24. ebk/exports/zip.py +25 -0
  25. ebk/library_db.py +155 -0
  26. ebk/repl/__init__.py +9 -0
  27. ebk/repl/find.py +126 -0
  28. ebk/repl/grep.py +174 -0
  29. ebk/repl/shell.py +1677 -0
  30. ebk/repl/text_utils.py +320 -0
  31. ebk/services/__init__.py +11 -0
  32. ebk/services/import_service.py +442 -0
  33. ebk/services/tag_service.py +282 -0
  34. ebk/services/text_extraction.py +317 -0
  35. ebk/similarity/__init__.py +77 -0
  36. ebk/similarity/base.py +154 -0
  37. ebk/similarity/core.py +445 -0
  38. ebk/similarity/extractors.py +168 -0
  39. ebk/similarity/metrics.py +376 -0
  40. ebk/vfs/__init__.py +101 -0
  41. ebk/vfs/base.py +301 -0
  42. ebk/vfs/library_vfs.py +124 -0
  43. ebk/vfs/nodes/__init__.py +54 -0
  44. ebk/vfs/nodes/authors.py +196 -0
  45. ebk/vfs/nodes/books.py +480 -0
  46. ebk/vfs/nodes/files.py +155 -0
  47. ebk/vfs/nodes/metadata.py +385 -0
  48. ebk/vfs/nodes/root.py +100 -0
  49. ebk/vfs/nodes/similar.py +165 -0
  50. ebk/vfs/nodes/subjects.py +184 -0
  51. ebk/vfs/nodes/tags.py +371 -0
  52. ebk/vfs/resolver.py +228 -0
  53. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/METADATA +1 -1
  54. ebk-0.3.2.dist-info/RECORD +69 -0
  55. ebk-0.3.2.dist-info/entry_points.txt +2 -0
  56. ebk-0.3.2.dist-info/top_level.txt +1 -0
  57. ebk-0.3.1.dist-info/RECORD +0 -19
  58. ebk-0.3.1.dist-info/entry_points.txt +0 -6
  59. ebk-0.3.1.dist-info/top_level.txt +0 -2
  60. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/WHEEL +0 -0
  61. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/licenses/LICENSE +0 -0
ebk/vfs/base.py ADDED
@@ -0,0 +1,301 @@
1
+ """Base classes for the Virtual File System.
2
+
3
+ The VFS maps the library database to a filesystem-like structure
4
+ that can be navigated with shell commands (cd, ls, cat, etc.).
5
+
6
+ Architecture:
7
+ - Node: Base class for all VFS nodes
8
+ - DirectoryNode: Nodes that can contain children (cd into them)
9
+ - FileNode: Leaf nodes with content (cat them)
10
+ - VirtualNode: Dynamically computed nodes (e.g., similar books)
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+ from enum import Enum
15
+ from typing import Dict, List, Optional, Any
16
+ from datetime import datetime
17
+
18
+
19
+ class NodeType(Enum):
20
+ """Type of VFS node."""
21
+ DIRECTORY = "directory"
22
+ FILE = "file"
23
+ VIRTUAL = "virtual"
24
+ SYMLINK = "symlink"
25
+
26
+
27
+ class Node(ABC):
28
+ """Base class for all VFS nodes.
29
+
30
+ A Node represents an entry in the virtual filesystem. It can be
31
+ a directory (navigable), a file (readable), or something virtual
32
+ (dynamically computed).
33
+
34
+ Attributes:
35
+ name: The name of this node (e.g., "title", "books", "42")
36
+ parent: Parent directory node (None for root)
37
+ node_type: Type of node (directory, file, virtual, symlink)
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ name: str,
43
+ parent: Optional['DirectoryNode'] = None,
44
+ node_type: NodeType = NodeType.FILE,
45
+ ):
46
+ """Initialize a VFS node.
47
+
48
+ Args:
49
+ name: Name of this node
50
+ parent: Parent directory (None for root)
51
+ node_type: Type of node
52
+ """
53
+ self.name = name
54
+ self.parent = parent
55
+ self.node_type = node_type
56
+ self._metadata: Dict[str, Any] = {}
57
+
58
+ @abstractmethod
59
+ def get_info(self) -> Dict[str, Any]:
60
+ """Get metadata about this node for display.
61
+
62
+ Returns:
63
+ Dict with keys like: size, modified, type, description
64
+ """
65
+ pass
66
+
67
+ def get_path(self) -> str:
68
+ """Get absolute path to this node.
69
+
70
+ Returns:
71
+ Path like /books/42/title
72
+ """
73
+ if self.parent is None:
74
+ return "/"
75
+
76
+ parts = []
77
+ node = self
78
+ while node.parent is not None:
79
+ parts.append(node.name)
80
+ node = node.parent
81
+
82
+ if not parts:
83
+ return "/"
84
+
85
+ return "/" + "/".join(reversed(parts))
86
+
87
+ def __repr__(self) -> str:
88
+ return f"{self.__class__.__name__}(name='{self.name}', path='{self.get_path()}')"
89
+
90
+
91
+ class DirectoryNode(Node):
92
+ """A directory node that can contain children.
93
+
94
+ Directory nodes can be navigated into with `cd` and their
95
+ children can be listed with `ls`.
96
+
97
+ Children can be:
98
+ - Static: Fixed set of children
99
+ - Dynamic: Children computed on-demand (e.g., book list from DB)
100
+ """
101
+
102
+ def __init__(self, name: str, parent: Optional['DirectoryNode'] = None):
103
+ """Initialize a directory node.
104
+
105
+ Args:
106
+ name: Name of this directory
107
+ parent: Parent directory
108
+ """
109
+ super().__init__(name, parent, NodeType.DIRECTORY)
110
+ self._children: Dict[str, Node] = {}
111
+
112
+ @abstractmethod
113
+ def list_children(self) -> List[Node]:
114
+ """List all children of this directory.
115
+
116
+ This may compute children dynamically from the database.
117
+
118
+ Returns:
119
+ List of child nodes
120
+ """
121
+ pass
122
+
123
+ @abstractmethod
124
+ def get_child(self, name: str) -> Optional[Node]:
125
+ """Get a child node by name.
126
+
127
+ Args:
128
+ name: Name of child node
129
+
130
+ Returns:
131
+ Child node or None if not found
132
+ """
133
+ pass
134
+
135
+ def get_info(self) -> Dict[str, Any]:
136
+ """Get directory metadata.
137
+
138
+ Returns:
139
+ Dict with directory information
140
+ """
141
+ children = self.list_children()
142
+ return {
143
+ "type": "directory",
144
+ "name": self.name,
145
+ "children_count": len(children),
146
+ "path": self.get_path(),
147
+ }
148
+
149
+
150
+ class FileNode(Node):
151
+ """A file node with readable content.
152
+
153
+ File nodes represent data that can be read with `cat`.
154
+ Examples: book title, description, full text, etc.
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ name: str,
160
+ parent: Optional[DirectoryNode] = None,
161
+ size: Optional[int] = None,
162
+ ):
163
+ """Initialize a file node.
164
+
165
+ Args:
166
+ name: Name of this file
167
+ parent: Parent directory
168
+ size: Size in bytes (if known)
169
+ """
170
+ super().__init__(name, parent, NodeType.FILE)
171
+ self._size = size
172
+ self._content_cache: Optional[str] = None
173
+
174
+ @abstractmethod
175
+ def read_content(self) -> str:
176
+ """Read the content of this file.
177
+
178
+ Returns:
179
+ File content as string
180
+ """
181
+ pass
182
+
183
+ def write_content(self, content: str) -> None:
184
+ """Write content to this file.
185
+
186
+ Args:
187
+ content: Content to write
188
+
189
+ Raises:
190
+ NotImplementedError: If the file is read-only
191
+ """
192
+ raise NotImplementedError(f"File '{self.name}' is read-only")
193
+
194
+ def is_writable(self) -> bool:
195
+ """Check if this file is writable.
196
+
197
+ Returns:
198
+ True if file supports writing, False otherwise
199
+ """
200
+ # By default, files are read-only unless they override write_content
201
+ try:
202
+ # Try calling write_content with empty string to see if it raises NotImplementedError
203
+ # This is a bit hacky but works
204
+ return hasattr(self.__class__, 'write_content') and \
205
+ self.__class__.write_content != FileNode.write_content
206
+ except:
207
+ return False
208
+
209
+ def get_info(self) -> Dict[str, Any]:
210
+ """Get file metadata.
211
+
212
+ Returns:
213
+ Dict with file information
214
+ """
215
+ return {
216
+ "type": "file",
217
+ "name": self.name,
218
+ "size": self._size,
219
+ "path": self.get_path(),
220
+ "writable": self.is_writable(),
221
+ }
222
+
223
+
224
+ class VirtualNode(DirectoryNode):
225
+ """A virtual directory with dynamically computed children.
226
+
227
+ Virtual nodes don't have a fixed set of children - they compute
228
+ them on-demand from the database or other sources.
229
+
230
+ Examples:
231
+ - /books/ - Lists all books from DB
232
+ - /books/42/similar/ - Computes similar books on-demand
233
+ - /authors/ - Lists all authors from DB
234
+ """
235
+
236
+ def __init__(self, name: str, parent: Optional[DirectoryNode] = None):
237
+ """Initialize a virtual directory node.
238
+
239
+ Args:
240
+ name: Name of this directory
241
+ parent: Parent directory
242
+ """
243
+ super().__init__(name, parent)
244
+ self.node_type = NodeType.VIRTUAL
245
+
246
+ def get_info(self) -> Dict[str, Any]:
247
+ """Get virtual directory metadata.
248
+
249
+ Returns:
250
+ Dict with virtual directory information
251
+ """
252
+ info = super().get_info()
253
+ info["type"] = "virtual"
254
+ return info
255
+
256
+
257
+ class SymlinkNode(Node):
258
+ """A symbolic link pointing to another node.
259
+
260
+ Used for creating convenient shortcuts, like similar books
261
+ appearing as links in /books/42/similar/.
262
+
263
+ Attributes:
264
+ target_path: Path to the target node
265
+ metadata: Optional metadata dict to include in get_info()
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ name: str,
271
+ target_path: str,
272
+ parent: Optional[DirectoryNode] = None,
273
+ metadata: Optional[Dict[str, Any]] = None,
274
+ ):
275
+ """Initialize a symlink node.
276
+
277
+ Args:
278
+ name: Name of this symlink
279
+ target_path: Path to target node
280
+ parent: Parent directory
281
+ metadata: Optional metadata to include in get_info()
282
+ """
283
+ super().__init__(name, parent, NodeType.SYMLINK)
284
+ self.target_path = target_path
285
+ self.metadata = metadata or {}
286
+
287
+ def get_info(self) -> Dict[str, Any]:
288
+ """Get symlink metadata.
289
+
290
+ Returns:
291
+ Dict with symlink information plus any provided metadata
292
+ """
293
+ info = {
294
+ "type": "symlink",
295
+ "name": self.name,
296
+ "target": self.target_path,
297
+ "path": self.get_path(),
298
+ }
299
+ # Merge in any provided metadata
300
+ info.update(self.metadata)
301
+ return info
ebk/vfs/library_vfs.py ADDED
@@ -0,0 +1,124 @@
1
+ """Main LibraryVFS class - entry point for VFS access."""
2
+
3
+ from pathlib import Path as FilePath
4
+
5
+ from ebk.library_db import Library
6
+ from ebk.vfs.base import DirectoryNode, Node
7
+ from ebk.vfs.resolver import PathResolver
8
+ from ebk.vfs.nodes import RootNode
9
+
10
+
11
+ class LibraryVFS:
12
+ """Virtual File System for a library.
13
+
14
+ This is the main entry point for accessing the VFS. It creates
15
+ the root node and provides a resolver for path navigation.
16
+
17
+ Usage:
18
+ >>> lib = Library.open("/path/to/library")
19
+ >>> vfs = LibraryVFS(lib)
20
+ >>>
21
+ >>> # Navigate using resolver
22
+ >>> books_dir = vfs.resolver.resolve("/books", vfs.root)
23
+ >>> book_node = vfs.resolver.resolve("/books/42", vfs.root)
24
+ >>>
25
+ >>> # List children
26
+ >>> children = books_dir.list_children()
27
+ >>>
28
+ >>> # Read file content
29
+ >>> title_node = vfs.resolver.resolve("/books/42/title", vfs.root)
30
+ >>> if isinstance(title_node, FileNode):
31
+ >>> print(title_node.read_content())
32
+ """
33
+
34
+ def __init__(self, library: Library):
35
+ """Initialize VFS for a library.
36
+
37
+ Args:
38
+ library: Library instance
39
+ """
40
+ self.library = library
41
+ self.root = RootNode(library)
42
+ self.resolver = PathResolver(self.root)
43
+ self.current = self.root # Current working directory
44
+
45
+ def cd(self, path: str) -> bool:
46
+ """Change current directory.
47
+
48
+ Args:
49
+ path: Path to navigate to
50
+
51
+ Returns:
52
+ True if successful, False otherwise
53
+ """
54
+ new_dir = self.resolver.resolve_directory(path, self.current)
55
+ if new_dir is None:
56
+ return False
57
+
58
+ self.current = new_dir
59
+ return True
60
+
61
+ def pwd(self) -> str:
62
+ """Get current working directory path.
63
+
64
+ Returns:
65
+ Current path
66
+ """
67
+ return self.current.get_path()
68
+
69
+ def ls(self, path: str = ".") -> list:
70
+ """List children of a directory.
71
+
72
+ Args:
73
+ path: Path to list (default: current directory)
74
+
75
+ Returns:
76
+ List of nodes
77
+ """
78
+ node = self.resolver.resolve(path, self.current)
79
+ if node is None or not isinstance(node, DirectoryNode):
80
+ return []
81
+
82
+ return node.list_children()
83
+
84
+ def cat(self, path: str) -> str:
85
+ """Read content of a file node.
86
+
87
+ Args:
88
+ path: Path to file
89
+
90
+ Returns:
91
+ File content or error message
92
+ """
93
+ from ebk.vfs.base import FileNode
94
+
95
+ node = self.resolver.resolve(path, self.current)
96
+ if node is None:
97
+ return f"cat: {path}: No such file or directory"
98
+
99
+ if not isinstance(node, FileNode):
100
+ return f"cat: {path}: Is a directory"
101
+
102
+ return node.read_content()
103
+
104
+ def get_node(self, path: str) -> Node:
105
+ """Resolve a path to a node.
106
+
107
+ Args:
108
+ path: Path to resolve
109
+
110
+ Returns:
111
+ Resolved node or None
112
+ """
113
+ return self.resolver.resolve(path, self.current)
114
+
115
+ def complete(self, partial: str) -> list:
116
+ """Get tab completion candidates.
117
+
118
+ Args:
119
+ partial: Partial path
120
+
121
+ Returns:
122
+ List of completion candidates
123
+ """
124
+ return self.resolver.complete_path(partial, self.current)
@@ -0,0 +1,54 @@
1
+ """VFS node implementations."""
2
+
3
+ from ebk.vfs.nodes.root import RootNode
4
+ from ebk.vfs.nodes.books import BooksDirectoryNode, BookNode
5
+ from ebk.vfs.nodes.metadata import (
6
+ TitleFileNode,
7
+ AuthorsFileNode,
8
+ SubjectsFileNode,
9
+ DescriptionFileNode,
10
+ TextFileNode,
11
+ YearFileNode,
12
+ LanguageFileNode,
13
+ PublisherFileNode,
14
+ MetadataFileNode,
15
+ )
16
+ from ebk.vfs.nodes.files import FilesDirectoryNode, PhysicalFileNode
17
+ from ebk.vfs.nodes.similar import SimilarDirectoryNode, SimilarBookSymlink
18
+ from ebk.vfs.nodes.authors import AuthorsDirectoryNode, AuthorNode
19
+ from ebk.vfs.nodes.subjects import SubjectsDirectoryNode, SubjectNode
20
+ from ebk.vfs.nodes.tags import (
21
+ TagsDirectoryNode,
22
+ TagNode,
23
+ TagDescriptionFile,
24
+ TagColorFile,
25
+ TagStatsFile,
26
+ )
27
+
28
+ __all__ = [
29
+ "RootNode",
30
+ "BooksDirectoryNode",
31
+ "BookNode",
32
+ "TitleFileNode",
33
+ "AuthorsFileNode",
34
+ "SubjectsFileNode",
35
+ "DescriptionFileNode",
36
+ "TextFileNode",
37
+ "YearFileNode",
38
+ "LanguageFileNode",
39
+ "PublisherFileNode",
40
+ "MetadataFileNode",
41
+ "FilesDirectoryNode",
42
+ "PhysicalFileNode",
43
+ "SimilarDirectoryNode",
44
+ "SimilarBookSymlink",
45
+ "AuthorsDirectoryNode",
46
+ "AuthorNode",
47
+ "SubjectsDirectoryNode",
48
+ "SubjectNode",
49
+ "TagsDirectoryNode",
50
+ "TagNode",
51
+ "TagDescriptionFile",
52
+ "TagColorFile",
53
+ "TagStatsFile",
54
+ ]
@@ -0,0 +1,196 @@
1
+ """Author-related VFS nodes."""
2
+
3
+ from typing import List, Optional, Dict, Any
4
+
5
+ from ebk.vfs.base import VirtualNode, DirectoryNode, SymlinkNode, Node
6
+ from ebk.library_db import Library
7
+ from ebk.db.models import Author
8
+
9
+
10
+ class AuthorsDirectoryNode(VirtualNode):
11
+ """/authors/ - Virtual directory listing all authors.
12
+
13
+ Each child is an AuthorNode representing books by that author.
14
+ """
15
+
16
+ def __init__(self, library: Library, parent: Optional[DirectoryNode] = None):
17
+ """Initialize authors directory.
18
+
19
+ Args:
20
+ library: Library instance
21
+ parent: Parent node (usually root)
22
+ """
23
+ super().__init__(name="authors", parent=parent)
24
+ self.library = library
25
+
26
+ def list_children(self) -> List[Node]:
27
+ """List all authors.
28
+
29
+ Returns:
30
+ List of AuthorNode instances
31
+ """
32
+ # Query all authors from database
33
+ # For now, we'll get unique authors from books
34
+ authors_query = self.library.session.query(Author).all()
35
+
36
+ author_nodes = []
37
+ for author in authors_query:
38
+ # Create a slug from author name
39
+ slug = self._make_slug(author.name)
40
+ node = AuthorNode(author, slug, self.library, parent=self)
41
+ author_nodes.append(node)
42
+
43
+ return author_nodes
44
+
45
+ def get_child(self, name: str) -> Optional[Node]:
46
+ """Get an author by slug.
47
+
48
+ Args:
49
+ name: Author slug (e.g., "knuth-donald")
50
+
51
+ Returns:
52
+ AuthorNode or None
53
+ """
54
+ # Try to find author by matching slug
55
+ authors = self.library.session.query(Author).all()
56
+
57
+ for author in authors:
58
+ slug = self._make_slug(author.name)
59
+ if slug == name:
60
+ return AuthorNode(author, slug, self.library, parent=self)
61
+
62
+ return None
63
+
64
+ def _make_slug(self, name: str) -> str:
65
+ """Convert author name to filesystem-safe slug.
66
+
67
+ Args:
68
+ name: Author name
69
+
70
+ Returns:
71
+ Slugified name (e.g., "Donald Knuth" -> "knuth-donald")
72
+ """
73
+ # Simple slugification: lowercase, replace spaces with hyphens
74
+ # Reverse name order (Last, First -> first-last)
75
+ parts = name.lower().split()
76
+ if len(parts) >= 2:
77
+ # Assume "First Last" or "Last, First"
78
+ if "," in name:
79
+ # "Last, First" format
80
+ slug = "-".join(reversed([p.strip(",") for p in parts]))
81
+ else:
82
+ # "First Last" format - reverse to "last-first"
83
+ slug = "-".join(reversed(parts))
84
+ else:
85
+ slug = "-".join(parts)
86
+
87
+ # Remove special characters
88
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
89
+ return slug
90
+
91
+ def get_info(self) -> Dict[str, Any]:
92
+ """Get authors directory info.
93
+
94
+ Returns:
95
+ Dict with directory information
96
+ """
97
+ total = self.library.session.query(Author).count()
98
+ return {
99
+ "type": "virtual",
100
+ "name": "authors",
101
+ "total_authors": total,
102
+ "path": self.get_path(),
103
+ }
104
+
105
+
106
+ class AuthorNode(VirtualNode):
107
+ """/authors/knuth-donald/ - Books by a specific author.
108
+
109
+ Contains symlinks to books by this author.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ author: Author,
115
+ slug: str,
116
+ library: Library,
117
+ parent: Optional[DirectoryNode] = None,
118
+ ):
119
+ """Initialize author node.
120
+
121
+ Args:
122
+ author: Author database model
123
+ slug: Author slug for URL
124
+ library: Library instance
125
+ parent: Parent node (usually AuthorsDirectoryNode)
126
+ """
127
+ super().__init__(name=slug, parent=parent)
128
+ self.author = author
129
+ self.library = library
130
+
131
+ def list_children(self) -> List[Node]:
132
+ """List books by this author as symlinks.
133
+
134
+ Returns:
135
+ List of SymlinkNode instances
136
+ """
137
+ symlinks = []
138
+ for book in self.author.books:
139
+ target_path = f"/books/{book.id}"
140
+ name = str(book.id)
141
+
142
+ # Include book metadata for display
143
+ metadata = {
144
+ "title": book.title or "Untitled",
145
+ }
146
+ if book.authors:
147
+ metadata["author"] = ", ".join([a.name for a in book.authors])
148
+
149
+ symlink = SymlinkNode(name, target_path, parent=self, metadata=metadata)
150
+ symlinks.append(symlink)
151
+
152
+ return symlinks
153
+
154
+ def get_child(self, name: str) -> Optional[Node]:
155
+ """Get a book symlink by ID.
156
+
157
+ Args:
158
+ name: Book ID as string
159
+
160
+ Returns:
161
+ SymlinkNode or None
162
+ """
163
+ try:
164
+ book_id = int(name)
165
+ except ValueError:
166
+ return None
167
+
168
+ # Check if this book is by this author
169
+ for book in self.author.books:
170
+ if book.id == book_id:
171
+ target_path = f"/books/{book.id}"
172
+
173
+ # Include book metadata for display
174
+ metadata = {
175
+ "title": book.title or "Untitled",
176
+ }
177
+ if book.authors:
178
+ metadata["author"] = ", ".join([a.name for a in book.authors])
179
+
180
+ return SymlinkNode(name, target_path, parent=self, metadata=metadata)
181
+
182
+ return None
183
+
184
+ def get_info(self) -> Dict[str, Any]:
185
+ """Get author node info.
186
+
187
+ Returns:
188
+ Dict with author information
189
+ """
190
+ return {
191
+ "type": "virtual",
192
+ "name": self.name,
193
+ "author": self.author.name,
194
+ "book_count": len(self.author.books),
195
+ "path": self.get_path(),
196
+ }