initial version of subfolder support

This commit is contained in:
Dmitriy Kazimirov 2025-04-06 07:12:05 +00:00
parent e0b2e5f482
commit e031c7738b
2 changed files with 313 additions and 38 deletions
src/api

View file

@ -237,40 +237,64 @@ def search():
@app.route('/files', methods=['GET'])
def list_files():
books_dir = "/books"
files = []
file_tree = []
total_files = 0
total_size = 0
try:
# Check if indexing is in progress
indexing_in_progress = get_progress() is not None
for filename in os.listdir(books_dir):
file_path = os.path.join(books_dir, filename)
if os.path.isfile(file_path):
file_size = os.path.getsize(file_path)
# Extract book title from filename if possible
title = filename
if ' - ' in filename: # Common pattern in filenames
title_parts = filename.split(' - ')
if len(title_parts) > 1:
title = ' - '.join(title_parts[:-1]) # Take all but last part
# Walk through directory structure
for root, dirs, files in os.walk(books_dir):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.')]
# Get relative path from books_dir
rel_path = os.path.relpath(root, books_dir)
if rel_path == '.':
rel_path = ''
files.append({
'name': filename,
'title': title,
'path': filename,
'size': file_size,
'size_mb': round(file_size / (1024 * 1024), 2)
# Add directories first
for dir_name in sorted(dirs):
dir_path = os.path.join(rel_path, dir_name)
file_tree.append({
'type': 'directory',
'name': dir_name,
'path': dir_path,
'children': []
})
# Add files
for file_name in sorted(files):
file_path = os.path.join(books_dir, rel_path, file_name)
if os.path.isfile(file_path):
file_size = os.path.getsize(file_path)
# Extract book title from filename if possible
title = file_name
if ' - ' in file_name: # Common pattern in filenames
title_parts = file_name.split(' - ')
if len(title_parts) > 1:
title = ' - '.join(title_parts[:-1]) # Take all but last part
full_path = os.path.join(rel_path, file_name)
file_tree.append({
'type': 'file',
'name': file_name,
'title': title,
'path': full_path,
'size': file_size,
'size_mb': round(file_size / (1024 * 1024), 2)
})
total_files += 1
total_size += file_size
# Calculate totals
total_files = len(files)
total_size = sum(f['size'] for f in files)
total_size_mb = round(total_size / (1024 * 1024), 2)
# If it's an API request, return JSON
if request.headers.get('Accept') == 'application/json':
return jsonify({
'files': files,
'file_tree': file_tree,
'total_files': total_files,
'total_size': total_size,
'total_size_mb': total_size_mb,
@ -279,7 +303,7 @@ def list_files():
# Otherwise, render the HTML template
return render_template('files.html',
files=files,
file_tree=file_tree,
total_files=total_files,
total_size=total_size,
total_size_mb=total_size_mb,
@ -607,6 +631,66 @@ def reset_index():
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/folder_contents/<path:folder_path>', methods=['GET'])
def get_folder_contents(folder_path):
"""Get contents of a specific folder"""
books_dir = "/books"
# Decode URL-encoded path and normalize
decoded_path = unquote(folder_path)
# Remove any leading slashes
decoded_path = decoded_path.lstrip('/')
# Join paths safely
full_path = os.path.normpath(os.path.join(books_dir, decoded_path))
# Validate the path is within the books directory
if not os.path.abspath(full_path).startswith(os.path.abspath(books_dir)):
return jsonify({"error": "Access denied: Folder path outside of books directory"}), 403
# Check if the path exists and is a directory
if not os.path.exists(full_path) or not os.path.isdir(full_path):
return jsonify({"error": "Folder not found"}), 404
try:
contents = []
# List directories first
for item in sorted(os.listdir(full_path)):
item_path = os.path.join(full_path, item)
rel_path = os.path.join(decoded_path, item)
if os.path.isdir(item_path):
contents.append({
'type': 'directory',
'name': item,
'path': rel_path
})
elif os.path.isfile(item_path):
file_size = os.path.getsize(item_path)
# Extract book title from filename if possible
title = item
if ' - ' in item: # Common pattern in filenames
title_parts = item.split(' - ')
if len(title_parts) > 1:
title = ' - '.join(title_parts[:-1]) # Take all but last part
contents.append({
'type': 'file',
'name': item,
'title': title,
'path': rel_path,
'size': file_size,
'size_mb': round(file_size / (1024 * 1024), 2)
})
return jsonify({
'folder_path': decoded_path,
'contents': contents
})
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
logging.info("Starting the API - inside main block")

View file

@ -72,6 +72,43 @@
text-decoration: underline;
color: #2196f3;
}
/* Folder styles */
.folder-item {
cursor: pointer;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.folder-name {
display: flex;
align-items: center;
font-weight: bold;
color: #2196f3;
}
.folder-icon {
margin-right: 8px;
width: 20px;
text-align: center;
}
.folder-contents {
margin-left: 20px;
display: none; /* Hidden by default */
}
.folder-contents.expanded {
display: block;
}
.breadcrumb {
margin-bottom: 15px;
padding: 8px 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
.breadcrumb a {
color: #2196f3;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
@ -108,30 +145,184 @@
<h2>Available Files</h2>
{% if files %}
{% if file_tree %}
<ul class="file-list">
{% for file in files %}
<li class="file-item">
<span class="file-name">
<a href="/file/{{ file.path }}">
{% if file.path.endswith('.epub') %}
<br><a href="/file_html/{{ file.path }}" class="plain-view-link">(View as HTML)</a>
{% endif %}
{% if file.title != file.name %}
<span class="book-title">{{ file.title }}</span>
<span class="file-name-muted">{{ file.name }}</span>
{% for item in file_tree %}
{% if item.type == 'directory' %}
<li class="folder-item" onclick="toggleFolder(this, event)">
<div class="folder-name">
<span class="folder-icon">📁</span>
{{ item.name }}
</div>
<div class="folder-contents" id="folder-{{ item.path|replace('/', '-') }}">
<!-- Contents will be populated dynamically -->
</div>
</li>
{% else %}
<li class="file-item">
<span class="file-name">
<a href="/file/{{ item.path }}">
{% if item.path.endswith('.epub') %}
<span class="book-title">{{ item.title }}</span>
<span class="file-name-muted">{{ item.name }}</span>
<br><a href="/file_html/{{ item.path }}" class="plain-view-link">(View as HTML)</a>
{% elif item.title != item.name %}
<span class="book-title">{{ item.title }}</span>
<span class="file-name-muted">{{ item.name }}</span>
{% else %}
{{ file.name }}
{{ item.name }}
{% endif %}
</a>
</span>
<span class="file-size">{{ file.size_mb }} MB</span>
</li>
</a>
</span>
<span class="file-size">{{ item.size_mb }} MB</span>
</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p>No files available. Please add files to the books directory.</p>
{% endif %}
<script>
function toggleFolder(folderElement, event) {
// Prevent event from bubbling up to parent folders
event.stopPropagation();
// Toggle the expanded class on the folder contents
const contentsElement = folderElement.querySelector('.folder-contents');
contentsElement.classList.toggle('expanded');
// Change folder icon based on state
const folderIcon = folderElement.querySelector('.folder-icon');
if (contentsElement.classList.contains('expanded')) {
folderIcon.textContent = '📂'; // Open folder
// If this is the first time opening, fetch contents
if (contentsElement.getAttribute('data-loaded') !== 'true') {
const folderPath = contentsElement.id.replace('folder-', '').replace(/-/g, '/');
fetchFolderContents(folderPath, contentsElement);
contentsElement.setAttribute('data-loaded', 'true');
}
} else {
folderIcon.textContent = '📁'; // Closed folder
}
}
function fetchFolderContents(folderPath, containerElement) {
// Show loading indicator
containerElement.innerHTML = '<p>Loading folder contents...</p>';
// Make AJAX request to get folder contents
fetch('/folder_contents/' + encodeURIComponent(folderPath))
.then(response => {
if (!response.ok) {
throw new Error('Failed to load folder contents');
}
return response.json();
})
.then(data => {
// Clear loading message
containerElement.innerHTML = '';
if (data.contents && data.contents.length > 0) {
// Create a list for the contents
const contentsList = document.createElement('ul');
contentsList.className = 'file-list';
// Add each item to the list
data.contents.forEach(item => {
const listItem = document.createElement('li');
if (item.type === 'directory') {
// Create folder item
listItem.className = 'folder-item';
listItem.onclick = function(e) { toggleFolder(this, e); };
const folderNameDiv = document.createElement('div');
folderNameDiv.className = 'folder-name';
const folderIcon = document.createElement('span');
folderIcon.className = 'folder-icon';
folderIcon.textContent = '📁';
folderNameDiv.appendChild(folderIcon);
folderNameDiv.appendChild(document.createTextNode(item.name));
const folderContents = document.createElement('div');
folderContents.className = 'folder-contents';
folderContents.id = 'folder-' + item.path.replace(/\//g, '-');
listItem.appendChild(folderNameDiv);
listItem.appendChild(folderContents);
} else {
// Create file item
listItem.className = 'file-item';
const fileNameSpan = document.createElement('span');
fileNameSpan.className = 'file-name';
const fileLink = document.createElement('a');
fileLink.href = '/file/' + item.path;
if (item.path.endsWith('.epub')) {
const bookTitle = document.createElement('span');
bookTitle.className = 'book-title';
bookTitle.textContent = item.title;
const fileName = document.createElement('span');
fileName.className = 'file-name-muted';
fileName.textContent = item.name;
fileLink.appendChild(bookTitle);
fileLink.appendChild(fileName);
const htmlViewLink = document.createElement('a');
htmlViewLink.href = '/file_html/' + item.path;
htmlViewLink.className = 'plain-view-link';
htmlViewLink.textContent = '(View as HTML)';
fileNameSpan.appendChild(fileLink);
fileNameSpan.appendChild(document.createElement('br'));
fileNameSpan.appendChild(htmlViewLink);
} else if (item.title !== item.name) {
const bookTitle = document.createElement('span');
bookTitle.className = 'book-title';
bookTitle.textContent = item.title;
const fileName = document.createElement('span');
fileName.className = 'file-name-muted';
fileName.textContent = item.name;
fileLink.appendChild(bookTitle);
fileLink.appendChild(fileName);
fileNameSpan.appendChild(fileLink);
} else {
fileLink.textContent = item.name;
fileNameSpan.appendChild(fileLink);
}
const fileSizeSpan = document.createElement('span');
fileSizeSpan.className = 'file-size';
fileSizeSpan.textContent = item.size_mb + ' MB';
listItem.appendChild(fileNameSpan);
listItem.appendChild(fileSizeSpan);
}
contentsList.appendChild(listItem);
});
containerElement.appendChild(contentsList);
} else {
containerElement.innerHTML = '<p>Empty folder</p>';
}
})
.catch(error => {
containerElement.innerHTML = '<p style="color: red;">Error: ' + error.message + '</p>';
console.error('Error fetching folder contents:', error);
});
}
</script>
</div>
<footer>