Exclude course/item leaves from destination picker; ease new-folder UX
The destination picker (Sort Unsorted, Manage Library Move) was listing individual course/item folders as pickable destinations whenever they held content course detection doesn't recognize as video/audio - ebooks, audiobooks in unsupported formats, etc. Add a leaf-item check scoped to the picker's own category index so these no longer show up, without touching the shared course-detection heuristic used elsewhere (Library browsing, search, stats). Also replace "Create new folder"'s single free-text path field with a parent-folder picker plus a plain new-folder-name field and a live "Will create: X/Y" preview, so the resulting path is confirmed before applying instead of hand-typed blind. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,10 +101,18 @@ silently stay blank instead of erroring.
|
|||||||
existing folder, a suggested new subfolder under a broader category match,
|
existing folder, a suggested new subfolder under a broader category match,
|
||||||
or "needs review" (unchecked by default) when nothing overlaps at all.
|
or "needs review" (unchecked by default) when nothing overlaps at all.
|
||||||
Every row has a folder-picker dropdown (listing every real category in the
|
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
|
library, plus "+ Create new folder…") so a wrong or low-confidence guess
|
||||||
or low-confidence guess is one click to correct, and a name field to
|
is one click to correct, and a name field to rename the course's folder
|
||||||
rename the course's folder in the same move. Nothing on disk moves until
|
in the same move. Creating a new folder means picking its parent from a
|
||||||
you review and hit Apply. Matching ignores common noise (e-learning
|
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
|
platform names, release/distribution-group tags, dates) via a stopword
|
||||||
list, and beyond that treats a match against a folder's own deliberate
|
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
|
name as always stronger evidence than a word only borrowed from a
|
||||||
|
|||||||
+31
-5
@@ -1263,6 +1263,29 @@ def _tokenize_ordered(name: str) -> List[str]:
|
|||||||
return ordered
|
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]]:
|
def _build_category_index() -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Every category/subcategory folder currently in the library (e.g. IT,
|
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
|
categories' course titles) can't outweigh a specific match
|
||||||
elsewhere just by sharing more of it.
|
elsewhere just by sharing more of it.
|
||||||
Stops recursing into a folder once it reads as a course itself
|
Stops recursing into a folder once it reads as a course itself
|
||||||
(_looks_like_course, the same rule the Library browser uses), so
|
(_looks_like_course, the same rule the Library browser uses) or as an
|
||||||
individual courses never show up as if they were categories to file
|
unrecognized leaf item (_is_unrecognized_leaf_item - an ebook/
|
||||||
things under. The Unsorted folder itself is excluded - it's the
|
audiobook/misc file that isn't video or audio, so _looks_like_course
|
||||||
source, never a valid destination.
|
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())
|
library_root = Path(get_library_root())
|
||||||
try:
|
try:
|
||||||
@@ -1308,7 +1334,7 @@ def _build_category_index() -> List[Dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if _looks_like_course(entry):
|
if _looks_like_course(entry) or _is_unrecognized_leaf_item(entry):
|
||||||
continue
|
continue
|
||||||
new_parts = path_parts + [entry.name]
|
new_parts = path_parts + [entry.name]
|
||||||
path_tokens: Set[str] = set()
|
path_tokens: Set[str] = set()
|
||||||
|
|||||||
+98
-33
@@ -415,12 +415,29 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.dest-picker select { flex: 1; }
|
.dest-picker select { flex: 1; }
|
||||||
.dest-new-input {
|
.new-folder-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 6px;
|
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);
|
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 {
|
.action-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -641,6 +658,65 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew) {
|
||||||
|
return `
|
||||||
|
<div class="new-folder-fields" style="display: ${isNew ? 'flex' : 'none'};">
|
||||||
|
<select class="text-input new-folder-parent" data-initial="${escapeAttr(prefilledParent)}">
|
||||||
|
<option value="">(Library root — top level)</option>
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
<input type="text" class="text-input new-folder-name ${isNew && !prefilledName ? 'empty' : ''}"
|
||||||
|
value="${escapeAttr(prefilledName)}" placeholder="New folder name">
|
||||||
|
</div>
|
||||||
|
<div class="new-folder-preview"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <option> list is shared/built once
|
||||||
|
// per render), and keeping the "Will create: ..." preview live.
|
||||||
|
function wireNewFolderFields(destSelect, fieldsWrap) {
|
||||||
|
const fieldsEl = fieldsWrap.querySelector('.new-folder-fields');
|
||||||
|
const preview = fieldsWrap.querySelector('.new-folder-preview');
|
||||||
|
const parentSelect = fieldsEl.querySelector('.new-folder-parent');
|
||||||
|
const nameInput = fieldsEl.querySelector('.new-folder-name');
|
||||||
|
if (parentSelect.dataset.initial) parentSelect.value = parentSelect.dataset.initial;
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const parent = parentSelect.value;
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
preview.textContent = '';
|
||||||
|
preview.classList.remove('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preview.textContent = `Will create: ${parent ? parent + '/' + name : name}`;
|
||||||
|
preview.classList.add('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
destSelect.addEventListener('change', () => {
|
||||||
|
const showNew = destSelect.value === NEW_FOLDER_VALUE;
|
||||||
|
fieldsEl.style.display = showNew ? 'flex' : 'none';
|
||||||
|
preview.style.display = showNew ? 'block' : 'none';
|
||||||
|
if (showNew) nameInput.focus();
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
parentSelect.addEventListener('change', updatePreview);
|
||||||
|
nameInput.addEventListener('input', () => {
|
||||||
|
nameInput.classList.toggle('empty', nameInput.value.trim() === '');
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
preview.style.display = destSelect.value === NEW_FOLDER_VALUE ? 'block' : 'none';
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newFolderDestination(fieldsWrap) {
|
||||||
|
const parent = fieldsWrap.querySelector('.new-folder-parent').value;
|
||||||
|
const name = fieldsWrap.querySelector('.new-folder-name').value.trim();
|
||||||
|
return name ? (parent ? `${parent}/${name}` : name) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderRows(items) {
|
function renderRows(items) {
|
||||||
const container = document.getElementById('sort-rows');
|
const container = document.getElementById('sort-rows');
|
||||||
const optionsHtml = categoryOptionsHtml();
|
const optionsHtml = categoryOptionsHtml();
|
||||||
@@ -652,8 +728,15 @@
|
|||||||
// lastCategories, so it's the only case the <select> can point
|
// lastCategories, so it's the only case the <select> can point
|
||||||
// straight at; 'new_folder' and 'manual' (and any hand-edited
|
// straight at; 'new_folder' and 'manual' (and any hand-edited
|
||||||
// destination that no longer matches a real folder) fall back
|
// destination that no longer matches a real folder) fall back
|
||||||
// to the "create new" option with the free-text path exposed.
|
// to the "create new" option, split into a parent-folder pick
|
||||||
|
// and a plain new-folder name.
|
||||||
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
|
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
|
||||||
|
let prefilledParent = '', prefilledName = '';
|
||||||
|
if (isNew && destValue) {
|
||||||
|
const slash = destValue.lastIndexOf('/');
|
||||||
|
if (slash === -1) { prefilledName = destValue; }
|
||||||
|
else { prefilledParent = destValue.slice(0, slash); prefilledName = destValue.slice(slash + 1); }
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<div class="sort-row" data-idx="${idx}">
|
<div class="sort-row" data-idx="${idx}">
|
||||||
<input type="checkbox" class="sort-row-check" ${checked}>
|
<input type="checkbox" class="sort-row-check" ${checked}>
|
||||||
@@ -677,9 +760,7 @@
|
|||||||
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="dest-new-input ${isNew && !destValue ? 'empty' : ''}"
|
${newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew)}
|
||||||
style="display: ${isNew ? 'block' : 'none'};"
|
|
||||||
value="${escapeAttr(isNew ? destValue : '')}" placeholder="e.g. IT/NewCategory">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,18 +769,8 @@
|
|||||||
|
|
||||||
container.querySelectorAll('.dest-select').forEach(select => {
|
container.querySelectorAll('.dest-select').forEach(select => {
|
||||||
select.value = select.dataset.initial;
|
select.value = select.dataset.initial;
|
||||||
const row = select.closest('.sort-row');
|
const field = select.closest('.sort-field');
|
||||||
const newInput = row.querySelector('.dest-new-input');
|
wireNewFolderFields(select, field);
|
||||||
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() === '');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,14 +828,14 @@
|
|||||||
const idx = parseInt(row.dataset.idx, 10);
|
const idx = parseInt(row.dataset.idx, 10);
|
||||||
const item = lastItems[idx];
|
const item = lastItems[idx];
|
||||||
const select = row.querySelector('.dest-select');
|
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 renameInput = row.querySelector('.rename-input');
|
||||||
const usingNew = select.value === NEW_FOLDER_VALUE;
|
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();
|
const name = renameInput.value.trim();
|
||||||
if (!destination) {
|
if (!destination) {
|
||||||
skippedEmpty.push(item.name);
|
skippedEmpty.push(item.name);
|
||||||
if (usingNew) newInput.classList.add('empty');
|
if (usingNew) fieldsWrap.querySelector('.new-folder-name').classList.add('empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
moves.push({ source: item.path, destination, name });
|
moves.push({ source: item.path, destination, name });
|
||||||
@@ -1176,7 +1247,7 @@
|
|||||||
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="move-dest-new-input text-input" style="display: none; margin-top: 6px;" placeholder="e.g. IT/NewCategory">
|
${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
|
||||||
</div>
|
</div>
|
||||||
<div class="curate-move-actions">
|
<div class="curate-move-actions">
|
||||||
<button class="btn btn-sm">Move</button>
|
<button class="btn btn-sm">Move</button>
|
||||||
@@ -1187,16 +1258,10 @@
|
|||||||
row.after(panel);
|
row.after(panel);
|
||||||
|
|
||||||
const select = panel.querySelector('.move-dest-select');
|
const select = panel.querySelector('.move-dest-select');
|
||||||
const newInput = panel.querySelector('.move-dest-new-input');
|
wireNewFolderFields(select, panel.querySelector('.sort-field'));
|
||||||
select.addEventListener('change', () => {
|
|
||||||
const showNew = select.value === NEW_FOLDER_VALUE;
|
|
||||||
newInput.style.display = showNew ? 'block' : 'none';
|
|
||||||
if (showNew) newInput.focus();
|
|
||||||
});
|
|
||||||
if (!optionsHtml) {
|
if (!optionsHtml) {
|
||||||
select.value = NEW_FOLDER_VALUE;
|
select.value = NEW_FOLDER_VALUE;
|
||||||
newInput.style.display = 'block';
|
panel.querySelector('.new-folder-name').focus();
|
||||||
newInput.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
|
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
|
||||||
@@ -1206,14 +1271,14 @@
|
|||||||
|
|
||||||
function submitMove(panel, btnEl, path) {
|
function submitMove(panel, btnEl, path) {
|
||||||
const select = panel.querySelector('.move-dest-select');
|
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 status = panel.querySelector('.curate-move-status');
|
||||||
const usingNew = select.value === NEW_FOLDER_VALUE;
|
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) {
|
if (!destination) {
|
||||||
status.style.color = 'var(--error)';
|
status.style.color = 'var(--error)';
|
||||||
status.textContent = 'Choose or type a destination first.';
|
status.textContent = 'Choose or type a destination first.';
|
||||||
if (usingNew) newInput.focus();
|
if (usingNew) panel.querySelector('.new-folder-name').focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status.style.color = 'var(--text-muted)';
|
status.style.color = 'var(--text-muted)';
|
||||||
|
|||||||
Reference in New Issue
Block a user