Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions frontend/AllAtomPredictMixin.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import axios from 'axios'
import { structureRemarkLine } from './lib/structureRemark.js'

export default {
name : 'AllAtomPredictMixin',
Expand Down Expand Up @@ -42,20 +43,10 @@ export default {
}
},
prependRemark(pdbstr) {
let is_cif = false
if (pdbstr[0] == '#' || pdbstr.startsWith('data_')) {
is_cif = true
}

let prefix = is_cif ? '# ' : 'REMARK 90 '
let firstline = prefix + 'This model is rebuilt with cg2all(https://github.com/huhlim/cg2all)'
if (!is_cif && firstline.length > 79) {
firstline = firstline.slice(76) + '... '
}

firstline = firstline.padEnd(80, ' ') + '\n'
return firstline + pdbstr
return structureRemarkLine(
pdbstr, 'This model is rebuilt with cg2all(https://github.com/huhlim/cg2all)', 90
) + '\n' + pdbstr
},
}
}
</script>
</script>
111 changes: 1 addition & 110 deletions frontend/FoldDiscoSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,6 @@ import Databases from './Databases.vue';
import QueryTextarea from "./QueryTextarea.vue";
import MotifSelection from "./MotifSelection.vue";
import LigandMotifSelection from "./LigandMotifSelection.vue";
import SearchApiMixin from "./SearchApiMixin.vue";
import { searchBindingSites, fetchBindingSite } from "./lib/accession.js";

const db = BlobDatabase();
const storage = new StorageWrapper("folddisco");
Expand Down Expand Up @@ -323,12 +321,11 @@ function setDefaultMotif(structure) {
}

// FoldDisco's backend caps a motif at 32 residues; single source for the check and the report.
const MOTIF_RESIDUE_LIMIT = 32;

export default {
name: "FolddiscoSearch",
tool: "folddisco",
mixins: [ HistoryMixin, SearchApiMixin ],
mixins: [ HistoryMixin ],
components: {
Panel,
FileButton,
Expand Down Expand Up @@ -473,112 +470,6 @@ export default {
// },
},
methods: {
searchApiConfig() {
return {
tool: 'folddisco',
modeInfix: 'FOLDDISCO_', modeValuePrefix: '',
accessionExtras: ['QBioLip'],
sendsMode: false, // search() has `mode` commented out
needsQueryStructure: true, // isMotifValid needs the parsed structure
supportsTaxonomy: false, // taxfilter is commented out in search()
supportsIterative: false,
};
},
// ---- FoldDisco-specific API surface (see claude-plan/ai-friendly-search) ----
searchApiExtraValidation() {
const out = [];
if (!this.queryStructure) out.push('query structure has not parsed yet');
else if (!this.isMotifValid) out.push('motif is invalid for the loaded structure');
if (this.motifLen > 32) out.push(`motif has ${this.motifLen} residues; the limit is 32`);
return out;
},
// Metadata only, mirroring how `query` reports length-not-text: an auto-populated motif is
// the whole chain (801 residues for 4HHB = 914 tokens) and is by definition unsubmittable
// past 32, so shipping the string in an orientation call is pure cost. getMotif() has it.
searchApiExtraState() {
const { motif, ...meta } = this.getMotif();
return { motif: { ...meta, residues: 'call getMotif() for the residue list' } };
},
searchApiExtraNotes() {
return [
'The motif is auto-populated from the structure by setQuery(); setMotif() is an '
+ 'optional override and is order-independent.',
'searchBindingSites()/loadBindingSite() load a Q-BioLiP site and its motif '
+ 'together — the shortest path to a valid FoldDisco query.',
];
},
searchApiExtraMethods() {
return {
getMotif: this.getMotif,
setMotif: this.setMotif,
searchBindingSites: this.apiSearchBindingSites,
loadBindingSite: this.apiLoadBindingSite,
};
},
// `valid` is gone rather than redefined. It meant "the residues resolve against the loaded
// structure", which is not what the word implies: an auto-populated 801-residue motif was
// `valid: true` while `error` said "Motif too long" and validate() refused to submit. A stale
// reader of a redefined field gets no warning, so the name is retired.
getMotif() {
const length = this.motifLen;
const withinLimit = length > 0 && length <= MOTIF_RESIDUE_LIMIT;
return {
motif: this.motif ?? '',
length,
limit: MOTIF_RESIDUE_LIMIT,
residuesResolved: !!this.isMotifValid,
withinLimit,
// The field a caller actually wants, and validate()-consistent by construction.
submittable: !!this.isMotifValid && withinLimit,
error: this.motifError || null,
};
},
// Order-independent by design. The query watcher calls setDefaultMotif() whenever the
// query changes, so a motif set *before* setQuery() would be silently discarded.
// pendingMotif is the component's own mechanism for exactly this race (onMotifSelect
// uses it for the accession flow), so reuse it rather than documenting an ordering rule.
setMotif(motif) {
const value = String(motif ?? '');
this.pendingMotif = { query: this.query, motif: value };
this.motif = value;
return this.getMotif();
},
async apiSearchBindingSites(pdbId) {
if (!pdbId) return { ok: false, reason: 'pdbId is empty' };
try {
const results = await searchBindingSites(pdbId);
this._bindingSites = results;
return { ok: true, pdbId: String(pdbId).toUpperCase(),
sites: results.map((item, index) => ({
index,
ligand: item.Ligand?.ligname ?? null,
assembly: item.Receptor?.assembly ?? null,
relevant: item.Complex?.relvant === '1',
residues: (item.Complex?.bs ?? '').trim().split(/\s+/).filter(Boolean),
})) };
} catch {
return { ok: false, reason: `Q-BioLiP lookup failed for ${pdbId}` };
}
},
async apiLoadBindingSite(index) {
const sites = this._bindingSites ?? [];
const item = sites[Number(index)];
if (!item) {
return { ok: false, reason: 'no such binding site; call searchBindingSites() first',
available: sites.length };
}
let got;
try {
got = await fetchBindingSite(item);
} catch {
return { ok: false, reason: 'failed to load the binding-site structure' };
}
// Same order the accession button uses: set the query, then hand over the motif via
// pendingMotif so the async query watcher restores it instead of defaulting.
await this.setQuery(got.text);
this.setMotif(got.motif);
return { ok: true, name: got.name, motif: this.getMotif() };
},
async search() {
var request = {
q: this.query,
Expand Down
154 changes: 3 additions & 151 deletions frontend/FoldMasonSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
</template>

<script>
import { create } from 'axios';
import Panel from "./Panel.vue";
import FileButton from "./FileButton.vue";
import LoadAcessionButton from './LoadAcessionButton.vue';
Expand All @@ -142,10 +143,9 @@ import { HistoryMixin } from './lib/HistoryMixin.js';
import Databases from './Databases.vue';
import DragUploadBox from "./DragUploadBox.vue";
import { BlobDatabase } from "./lib/BlobDatabase.js";
import { registerPageApi } from './lib/resultsApi.js';
import { sourcesFor, fetchAccession } from './lib/accession.js';

const db = BlobDatabase();
const externalHttp = create();

export default {
name: "FoldMasonSearch",
Expand Down Expand Up @@ -174,31 +174,12 @@ export default {
};
},
async mounted() {
this._disposeApi = registerPageApi('search', 'foldmason', {
getState: this.getState,
validate: this.validate,
getQueries: this.getQueries,
addQuery: this.addQuery,
addQueries: this.addQueries,
removeQuery: this.apiRemoveQuery,
clearQueries: this.clearQueries,
getAccessionSources: this.getAccessionSources,
loadAccessions: this.loadAccessions,
submit: this.submit,
describePage: this.describePage,
_vm: this,
});
if (this.preloadAccessions.length > 0) {
this.queries = [];
}
this.retrieveAndClean()
return;
},
beforeDestroy() {
// FoldMasonSearch registers directly rather than through SearchApiMixin, so it needs its
// own disposer — without it window.searchApi survives navigation to the result page.
this._disposeApi?.();
},
computed: {
alignDisabled() {
return this.queries.length <= 1 || this.inSearch || this.queries.length >= 5000;
Expand Down Expand Up @@ -226,141 +207,12 @@ export default {
}
},
methods: {
// ---------------------------------------------------------------------------------
// API (window.searchApi). Deliberately NOT the SearchApiMixin: the input here is a
// list of files and validation is a count bound, so sharing would mean a mixin full
// of conditionals. See claude-plan/ai-friendly-search/context.md §3.
// ---------------------------------------------------------------------------------
getQueries() {
return (this.queries ?? []).map((q, index) => ({
index,
name: q.name,
length: q.text ? q.text.length : (q.file?.size ?? null),
source: q.file ? 'file' : 'text',
}));
},
// Accepts (name, text) or ({name, text}). addQueries takes objects, so passing one here
// failed with "name and text are both required" when both *were* supplied — a message that
// read as a lie.
addQuery(name, text) {
if (name && typeof name === 'object' && text === undefined) {
({ name, text } = name);
}
if (!name || !text) return { ok: false, reason: 'name and text are both required' };
this.queries.push({ name: String(name), text: String(text) });
return { ok: true, count: this.queries.length };
},
addQueries(list) {
const added = [], rejected = [];
for (const item of (Array.isArray(list) ? list : [list])) {
if (item?.name && item?.text) {
this.queries.push({ name: String(item.name), text: String(item.text) });
added.push(item.name);
} else {
rejected.push({ item, reason: 'needs { name, text }' });
}
}
return { ok: rejected.length === 0, added, rejected, count: this.queries.length };
},
// Wrapper, not an override: the page already has removeQuery() and the template binds
// it (@click:close). A same-named method here would silently win the object literal and
// change what the close button does.
async apiRemoveQuery(index) {
const i = Number(index);
if (!Number.isInteger(i) || i < 0 || i >= this.queries.length) {
return { ok: false, reason: `index ${index} out of range (0..${this.queries.length - 1})` };
}
await this.removeQuery(i);
return { ok: true, count: this.queries.length };
},
clearQueries() {
this.queries = [];
return { ok: true, count: 0 };
},
getAccessionSources() {
return sourcesFor([]).map(s => ({ value: s.value, text: s.text }));
},
// The `multiple` path: appends, mirroring @select="queries.push(...$event)".
async loadAccessions(list, source = 'PDB') {
const valid = this.getAccessionSources().map(s => s.value);
if (!valid.includes(source)) {
return { ok: false, reason: `unknown source: ${source}`, valid };
}
const wanted = (Array.isArray(list) ? list : String(list).split(/[,\s]+/))
.map(x => String(x).trim()).filter(Boolean);
if (wanted.length === 0) return { ok: false, reason: 'no accessions given' };
const settled = await Promise.allSettled(
wanted.map(a => fetchAccession(a, source)));
const added = [], failed = [];
settled.forEach((r, i) => {
if (r.status === 'fulfilled') {
this.queries.push({ name: r.value.name, text: r.value.text });
added.push({ requested: wanted[i], resolved: r.value.name });
} else {
failed.push(wanted[i]);
}
});
return { ok: failed.length === 0, added, failed, count: this.queries.length };
},
validate() {
const reasons = [];
const n = this.queries?.length ?? 0;
if (this.inSearch) reasons.push('a search is already running');
if (n <= 1) reasons.push(`FoldMason needs at least 2 structures; ${n} provided`);
if (n >= 5000) reasons.push(`too many structures: ${n} (limit is 4999)`);
return { ok: reasons.length === 0, reasons, count: n, bounds: { min: 2, max: 4999 } };
},
getState() {
return {
tool: 'foldmason',
queries: this.getQueries(),
count: this.queries?.length ?? 0,
inSearch: !!this.inSearch,
skippedEntries: this.skippedEntries ?? 0,
valid: this.validate(),
};
},
// Delegates to the page's own search() so the multipart body is never duplicated.
async submit() {
const v = this.validate();
if (!v.ok) return { ok: false, reason: 'validation failed', reasons: v.reasons };
this.errorMessage = { type: null, message: '' };
const before = this.$route?.fullPath ?? null;
try {
await this.search();
} catch (e) {
return { ok: false, status: 'ERROR', reason: `request failed: ${e?.message ?? e}` };
}
const msg = this.errorMessage?.message ?? '';
if (msg) {
const status = /rate limit/i.test(msg) ? 'RATELIMIT'
: /maintenance/i.test(msg) ? 'MAINTENANCE' : 'ERROR';
return { ok: false, status, reason: msg };
}
return { ok: true, ticket: this.$route?.params?.ticket ?? null,
route: this.$route?.name ?? null,
navigated: (this.$route?.fullPath ?? null) !== before };
},
describePage() {
return {
kind: 'search',
tool: 'foldmason',
count: this.queries?.length ?? 0,
bounds: { min: 2, max: 4999 },
accessionSources: this.getAccessionSources().map(s => s.value),
notes: [
'Input is a list of structures; validation is a count bound (2..4999).',
'submit() POSTs multipart, consumes rate limit and navigates away.',
'No mode, taxonomy or motif on this page.',
],
};
},
async handleLoadExample() {
let response = null;
try {
this.errorMessage = { type: null, message: "" };
const url = "https://search.foldseek.com/dl/foldmason_example.json";
response = await this.$axios.get(url);
response = await externalHttp.get(url);
if (!response) {
throw new Error(`Error fetching example: ${response.status}`);
}
Expand Down
Loading