diff --git a/README.md b/README.md
index bd1b9c8..5e43942 100644
--- a/README.md
+++ b/README.md
@@ -101,10 +101,18 @@ silently stay blank instead of erroring.
existing folder, a suggested new subfolder under a broader category match,
or "needs review" (unchecked by default) when nothing overlaps at all.
Every row has a folder-picker dropdown (listing every real category in the
- library, plus "+ Create new folder…" to type a brand-new path) so a wrong
- or low-confidence guess is one click to correct, and a name field to
- rename the course's folder in the same move. Nothing on disk moves until
- you review and hit Apply. Matching ignores common noise (e-learning
+ library, plus "+ Create new folder…") so a wrong or low-confidence guess
+ is one click to correct, and a name field to rename the course's folder
+ in the same move. Creating a new folder means picking its parent from a
+ dropdown and typing just the new folder's own name, with a live "Will
+ create: X/Y" preview so the resulting path is confirmed before applying -
+ the same picker Manage Library's Move action uses. The picker excludes
+ actual course/item folders, not just organizational ones - including a
+ leaf folder holding a single non-video/audio file (an ebook, an
+ audiobook in a format this app doesn't play, etc.) that Library
+ browsing's own course detection wouldn't catch either, since there's no
+ video/audio file to key off. Nothing on disk moves until you review and
+ hit Apply. Matching ignores common noise (e-learning
platform names, release/distribution-group tags, dates) via a stopword
list, and beyond that treats a match against a folder's own deliberate
name as always stronger evidence than a word only borrowed from a
diff --git a/offlineu_core.py b/offlineu_core.py
index 083be81..b0e226c 100644
--- a/offlineu_core.py
+++ b/offlineu_core.py
@@ -1263,6 +1263,29 @@ def _tokenize_ordered(name: str) -> List[str]:
return ordered
+def _is_unrecognized_leaf_item(directory: Path) -> bool:
+ """
+ A folder with files directly inside but no subfolders at all -
+ virtually always a single downloaded item (an ebook, an audiobook in a
+ format this app doesn't play, or some other misc file drop) rather
+ than an organizational category, even when _looks_like_course's
+ video/audio check doesn't recognize it as a course (e.g. an EPUB-only
+ folder has no media files at all, so _has_direct_media never fires).
+ Scoped to _build_category_index only - not a general "is this a
+ course" replacement, since this app still can't do anything useful
+ with the file itself, so it'd be wrong to surface it as a course
+ elsewhere (Library browsing, stats, search, ...).
+ """
+ try:
+ entries = list(directory.iterdir())
+ except (PermissionError, OSError):
+ return False
+ has_subdir = any(e.is_dir() and not e.name.startswith('.') for e in entries)
+ if has_subdir:
+ return False
+ return any(e.is_file() and not e.name.startswith('.') for e in entries)
+
+
def _build_category_index() -> List[Dict[str, Any]]:
"""
Every category/subcategory folder currently in the library (e.g. IT,
@@ -1282,10 +1305,13 @@ def _build_category_index() -> List[Dict[str, Any]]:
categories' course titles) can't outweigh a specific match
elsewhere just by sharing more of it.
Stops recursing into a folder once it reads as a course itself
- (_looks_like_course, the same rule the Library browser uses), so
- individual courses never show up as if they were categories to file
- things under. The Unsorted folder itself is excluded - it's the
- source, never a valid destination.
+ (_looks_like_course, the same rule the Library browser uses) or as an
+ unrecognized leaf item (_is_unrecognized_leaf_item - an ebook/
+ audiobook/misc file that isn't video or audio, so _looks_like_course
+ doesn't catch it either), so individual courses and standalone files
+ never show up as if they were categories to file things under. The
+ Unsorted folder itself is excluded - it's the source, never a valid
+ destination.
"""
library_root = Path(get_library_root())
try:
@@ -1308,7 +1334,7 @@ def _build_category_index() -> List[Dict[str, Any]]:
continue
except OSError:
pass
- if _looks_like_course(entry):
+ if _looks_like_course(entry) or _is_unrecognized_leaf_item(entry):
continue
new_parts = path_parts + [entry.name]
path_tokens: Set[str] = set()
diff --git a/templates/unsorted.html b/templates/unsorted.html
index 0ef9f00..e037cf8 100644
--- a/templates/unsorted.html
+++ b/templates/unsorted.html
@@ -415,12 +415,29 @@
gap: 8px;
}
.dest-picker select { flex: 1; }
- .dest-new-input {
+ .new-folder-fields {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
margin-top: 6px;
}
- .dest-new-input.empty {
+ .new-folder-fields .new-folder-parent,
+ .new-folder-fields .new-folder-name {
+ flex: 1;
+ min-width: 140px;
+ }
+ .new-folder-name.empty {
border-color: var(--error);
}
+ .new-folder-preview {
+ font-size: 0.8em;
+ color: var(--text-muted);
+ margin-top: 5px;
+ min-height: 1.1em;
+ }
+ .new-folder-preview.ready {
+ color: var(--accent);
+ }
.action-bar {
position: sticky;
bottom: 0;
@@ -641,6 +658,65 @@
.catch(() => {});
}
+ function newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew) {
+ return `
+
+
+
+
+ `;
+ }
+
+ // Wires the shared "+ Create new folder" behavior for one
+ // dest-select + its new-folder-fields sibling: toggling visibility,
+ // restoring the parent select's prefilled value (can't be an HTML
+ // `selected` attribute since the