Let saved searches from different sources be read as one

A saved search is one source, one channel on it, that channel's filters
and the words. Two of them on different sources had no way to be read
together, and a channel group is not it: every channel in a group belongs
to the same server and shares one query, where the interesting pair is
"uncensored JAV over here" and "amateur 1080p over there".

So a saved search can now hold several of those instead of one. Opening it
feeds a single grid from all of them, taken alternately -- one from each in
turn, the way a group zips its channels -- rather than one source's page
and then the next. Each member keeps its own source, channel, filters and
words, because having its own words is most of why it was worth saving
separately.

Every entry is read as a list of members, a plain search being a merge of
one. That is what lets the daily check, mark-all-read and the bookmark go
on working without learning what a merge is: they ask each member and zip
the answers, so the badge counts what the grid would have shown.

The search box is turned off while a merge is open and says why -- there
is nothing for one box to say for several members. A tag or an uploader
tapped on a card steps out of the merge onto one real channel instead,
since otherwise it would reload the same videos and look broken.

A merge answers to no single server, so it is not stored as a channel
preference and is not looked up from a server's status: its members ride
on the session, and a reload rebuilds it from the saved search so a rename
is picked up rather than frozen in. One source failing drops that member
and leaves the rest readable. Merging a merge flattens it, so "the
searches in it" means the same however it was assembled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-11-01 07:59:12 +00:00
parent c60f5e01dc
commit 2fcc17877d
8 changed files with 857 additions and 31 deletions

View File

@@ -511,7 +511,25 @@ App.ui = App.ui || {};
// Where the search points, in one line: the channel, what the filters
// narrow it to, and the words themselves.
const hostOf = function(url) {
try { return new URL(url).host; } catch (err) { return String(url || ''); }
};
const metaLine = function(entry) {
// A merge has no channel or words of its own -- what it is, is the
// searches in it, so those are what the row says. Each one names
// where it looks and which source it looks on, because two members
// differing only by source is the whole reason to merge.
if (App.savedSearches.isMerged(entry)) {
const shown = entry.members.slice(0, 3).map((member) => {
const where = member.channelName || member.channelId;
const words = member.query ? ` “${member.query}”` : '';
return `${where}${words} · ${hostOf(member.server)}`;
});
const rest = entry.members.length - shown.length;
const tail = rest > 0 ? ` + ${rest} more` : '';
return `${entry.members.length} searches · ${shown.join(' + ')}${tail}`;
}
const parts = [entry.channelName || entry.channelId];
if (entry.optionLabels && entry.optionLabels.length) {
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
@@ -614,10 +632,18 @@ App.ui = App.ui || {};
return strip;
};
// Which of the three shapes a row is in. A row only has to be built
// again when it changes shape; otherwise the one on screen is updated
// where it stands (see render).
// Picking rows to merge. A merge is made here rather than from a row's
// own menu because it is about several rows at once -- and because
// "which ones" is the whole question.
let selecting = false;
let picked = new Set();
// Which shape a row is in. A row only has to be built again when it
// changes shape; otherwise the one on screen is updated where it
// stands (see render). Being picked is a shape too: the row swaps its
// count for a tick and its tools for nothing.
const modeOf = function(entry) {
if (selecting) return picked.has(entry.id) ? 'pick-on' : 'pick-off';
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
};
@@ -662,9 +688,30 @@ App.ui = App.ui || {};
text.appendChild(stamp);
main.appendChild(count);
main.appendChild(text);
main.addEventListener('click', () => choose(entry.id));
main.addEventListener('click', () => {
if (selecting) {
if (picked.has(entry.id)) picked.delete(entry.id);
else picked.add(entry.id);
render();
return;
}
choose(entry.id);
});
row.appendChild(main);
// While picking, a tap means "this one too" rather than "open
// this", so the row says so: a tick where the count was, and no
// rename or remove to catch the thumb on the way past.
if (selecting) {
row.classList.add('is-selecting');
row.classList.toggle('is-picked', picked.has(entry.id));
count.classList.remove('is-quiet');
count.classList.add('saved-pick');
count.textContent = picked.has(entry.id) ? '✓' : '';
count.removeAttribute('title');
return row;
}
if (pending && pending.id === entry.id && pending.mode === 'rename') {
const field = renameField(entry, row);
name.replaceWith(field);
@@ -737,6 +784,8 @@ App.ui = App.ui || {};
const add = el('saved-add-btn');
const check = el('saved-check-btn');
const read = el('saved-read-btn');
const select = el('saved-select-btn');
const mergeBtn = el('saved-merge-btn');
if (!list) return;
const entries = App.savedSearches.getAll();
@@ -781,13 +830,33 @@ App.ui = App.ui || {};
read.disabled = marking || !App.savedSearches.totalNew();
read.textContent = marking ? 'Marking…' : 'Mark all read';
}
if (select) {
// There is nothing to merge with fewer than two of them.
select.hidden = entries.length < 2;
select.textContent = selecting ? 'Cancel' : 'Merge…';
}
if (mergeBtn) {
mergeBtn.hidden = !selecting;
mergeBtn.disabled = picked.size < 2;
mergeBtn.textContent = picked.size < 2
? 'Pick two or more to merge'
: `Merge ${picked.size} selected`;
}
if (add) {
// While picking, the merge is the only action that means
// anything down here.
add.hidden = selecting;
const match = App.savedSearches.findMatching();
add.disabled = !!match || !App.savedSearches.current();
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
}
};
const stopSelecting = function() {
selecting = false;
picked = new Set();
};
const choose = function(id) {
close();
App.savedSearches.apply(id);
@@ -797,6 +866,7 @@ App.ui = App.ui || {};
const box = panel();
if (!box) return;
pending = null;
stopSelecting();
render();
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
@@ -820,6 +890,7 @@ App.ui = App.ui || {};
box.setAttribute('aria-hidden', 'true');
}
pending = null;
stopSelecting();
const trigger = el('saved-searches-btn');
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
@@ -905,6 +976,32 @@ App.ui = App.ui || {};
});
}
const select = el('saved-select-btn');
if (select) {
select.addEventListener('click', () => {
if (selecting) stopSelecting();
else { selecting = true; picked = new Set(); pending = null; }
render();
});
}
const mergeBtn = el('saved-merge-btn');
if (mergeBtn) {
mergeBtn.addEventListener('click', () => {
// In the order they are listed, not the order they were
// ticked: the list is the reader's own arrangement.
const order = App.savedSearches.getAll()
.map((entry) => entry.id)
.filter((id) => picked.has(id));
const made = App.savedSearches.merge(order);
stopSelecting();
// Straight into renaming it: a merge's own name is the
// only thing about it nobody has said yet.
if (made) pending = { id: made.id, mode: 'rename' };
render();
});
}
const check = el('saved-check-btn');
if (check) {
check.addEventListener('click', () => {
@@ -1285,8 +1382,36 @@ App.ui = App.ui || {};
})();
// Settings + menu rendering.
// A merge carries its members' own words, and one box cannot stand for
// several (see fetchMergedBatch, which does not read it). So rather than
// leave a live-looking box that changes nothing, it is turned off and says
// why. Switching to any single channel turns it back on.
let searchPlaceholder = null;
App.ui.syncSearchForSession = function(session) {
const searchInput = document.getElementById('search-input');
if (!searchInput) return;
// Kept from the markup the first time through, so putting it back does
// not depend on remembering what it said.
if (searchPlaceholder === null) searchPlaceholder = searchInput.placeholder;
const merged = !!(session && session.channel && session.channel.isMerged);
searchInput.disabled = merged;
searchInput.classList.toggle('is-inert', merged);
if (merged) {
searchInput.value = '';
searchInput.placeholder = 'Each search in this merge keeps its own words';
} else {
searchInput.placeholder = searchPlaceholder;
}
const clearBtn = document.getElementById('search-clear-btn');
if (clearBtn && merged) {
clearBtn.classList.remove('is-visible');
clearBtn.disabled = true;
}
};
App.ui.renderMenu = function() {
const session = App.storage.getSession();
App.ui.syncSearchForSession(session);
const serverEntries = App.storage.getServerEntries();
const sourceSelect = document.getElementById('source-select');
const filtersContainer = document.getElementById('filters-container');