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 - + ${newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew)} @@ -688,18 +769,8 @@ container.querySelectorAll('.dest-select').forEach(select => { select.value = select.dataset.initial; - const row = select.closest('.sort-row'); - const newInput = row.querySelector('.dest-new-input'); - select.addEventListener('change', () => { - const showNew = select.value === NEW_FOLDER_VALUE; - newInput.style.display = showNew ? 'block' : 'none'; - if (showNew) newInput.focus(); - }); - }); - container.querySelectorAll('.dest-new-input').forEach(input => { - input.addEventListener('input', () => { - input.classList.toggle('empty', input.value.trim() === ''); - }); + const field = select.closest('.sort-field'); + wireNewFolderFields(select, field); }); } @@ -757,14 +828,14 @@ const idx = parseInt(row.dataset.idx, 10); const item = lastItems[idx]; const select = row.querySelector('.dest-select'); - const newInput = row.querySelector('.dest-new-input'); + const fieldsWrap = select.closest('.sort-field'); const renameInput = row.querySelector('.rename-input'); const usingNew = select.value === NEW_FOLDER_VALUE; - const destination = (usingNew ? newInput.value : select.value).trim(); + const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim(); const name = renameInput.value.trim(); if (!destination) { skippedEmpty.push(item.name); - if (usingNew) newInput.classList.add('empty'); + if (usingNew) fieldsWrap.querySelector('.new-folder-name').classList.add('empty'); return; } moves.push({ source: item.path, destination, name }); @@ -1176,7 +1247,7 @@ - + ${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
@@ -1187,16 +1258,10 @@ row.after(panel); const select = panel.querySelector('.move-dest-select'); - const newInput = panel.querySelector('.move-dest-new-input'); - select.addEventListener('change', () => { - const showNew = select.value === NEW_FOLDER_VALUE; - newInput.style.display = showNew ? 'block' : 'none'; - if (showNew) newInput.focus(); - }); + wireNewFolderFields(select, panel.querySelector('.sort-field')); if (!optionsHtml) { select.value = NEW_FOLDER_VALUE; - newInput.style.display = 'block'; - newInput.focus(); + panel.querySelector('.new-folder-name').focus(); } const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button'); @@ -1206,14 +1271,14 @@ function submitMove(panel, btnEl, path) { const select = panel.querySelector('.move-dest-select'); - const newInput = panel.querySelector('.move-dest-new-input'); + const fieldsWrap = panel.querySelector('.sort-field'); const status = panel.querySelector('.curate-move-status'); const usingNew = select.value === NEW_FOLDER_VALUE; - const destination = (usingNew ? newInput.value : select.value).trim(); + const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim(); if (!destination) { status.style.color = 'var(--error)'; status.textContent = 'Choose or type a destination first.'; - if (usingNew) newInput.focus(); + if (usingNew) panel.querySelector('.new-folder-name').focus(); return; } status.style.color = 'var(--text-muted)';