Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide an export option for search results #1398

Merged
merged 27 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d1426bb
scaffolding
aaronhelton Mar 22, 2024
c1656c0
add format selector
aaronhelton Mar 22, 2024
633a139
Basic interaction
aaronhelton Mar 22, 2024
4d8a223
use links object and enable download
aaronhelton Apr 5, 2024
ddd4aea
Merge master (#1400)
jbukhari Apr 17, 2024
a7aa4dd
Set output fields and chenge format select to radio
aaronhelton Apr 18, 2024
c903849
merge from main
aaronhelton May 31, 2024
5a08dd2
Jbukhari/patch/aaronhelton/issue779 (#1439)
jbukhari Jun 5, 2024
9733c6f
merge main
aaronhelton Jun 19, 2024
fb519f2
enable CSV in export
aaronhelton Jun 19, 2024
dd09719
set output fields
aaronhelton Jun 20, 2024
c8037d2
fix duplicate format handling
aaronhelton Jun 20, 2024
53cb98c
update messaging for output fields
aaronhelton Jun 20, 2024
7b7d929
change parameter name from "of" to "fields"
aaronhelton Jun 21, 2024
0fd96ff
bump dlx to 1.4.5 to include 001 in CSV
aaronhelton Jul 3, 2024
1a0c47d
Merge branch 'main' into aaronhelton/issue779
aaronhelton Jul 3, 2024
a22e242
bump urllib3 to 1.26.19
aaronhelton Jul 3, 2024
c479ecd
Merge branch 'main' into aaronhelton/issue779
jbukhari Jul 10, 2024
357641e
syntax (#1477)
jbukhari Jul 10, 2024
065f87b
resolve merge conflict (#1478)
jbukhari Jul 16, 2024
2587e24
syntax (#1479)
jbukhari Jul 16, 2024
8e87b0b
try an export endpoint
aaronhelton Jul 26, 2024
c6b45cd
add export "list type" in search args and try to use it in export.js
aaronhelton Jul 26, 2024
8f2283a
Use API pagination to build export file (#1501)
jbukhari Aug 2, 2024
ed4d842
Merge branch 'main' into aaronhelton/issue779
aaronhelton Aug 2, 2024
1ed4909
preserve output field selection on format change and discard subfield…
aaronhelton Aug 5, 2024
f26e57b
default fields/selectedFields to empty string instead of null (#1504)
jbukhari Aug 8, 2024
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
17 changes: 16 additions & 1 deletion dlx_rest/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ class RecordsList(Resource):
'subtype',
type=str,
choices=['default', 'speech', 'vote', 'all', '']
)
args.add_argument(
'fields',
type=str,
help='Comma separated list of fields you want returned (e.g., for export)'
)
# This is so we can benchmark the two search formats
args.add_argument(
Expand Down Expand Up @@ -287,10 +292,19 @@ def get(self, collection):
project = dict.fromkeys(tags, True)
elif fmt == 'brief_speech':
tags = ['269', '596', '700', '710', '711', '791', '991', '992']

# make sure logical fields are available for sorting
tags += (list(DlxConfig.bib_logical_fields.keys()) + list(DlxConfig.auth_logical_fields.keys()))
project = dict.fromkeys(tags, True)
elif fmt in ['mrk', 'xml', 'csv']:
project = None
output_fields = args.get("fields")
if output_fields is not None:
tags = [f.strip().split('__')[0] for f in output_fields.split(',')]
# make sure logical fields are available for sorting
tags += (list(DlxConfig.bib_logical_fields.keys()) + list(DlxConfig.auth_logical_fields.keys()))
project = dict.fromkeys(tags, True)

elif fmt:
project = None
else:
Expand Down Expand Up @@ -434,6 +448,7 @@ def post(self, collection):
else:
abort(500, 'POST request failed for unknown reasons')


# Records list count
@ns.route('/marc/<string:collection>/records/count')
@ns.param('collection', '"bibs" or "auths"')
Expand Down
169 changes: 169 additions & 0 deletions dlx_rest/static/js/modals/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Jmarc } from "../jmarc.mjs"

export let exportmodal = {
props: {
links: {
type: Object
}
},
template: `<div v-if="showModal">
<transition name="modal">
<div class="modal-mask">
<div class="modal-wrapper">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content" style="width: 40%">
<div class="modal-header">
<h5 class="modal-title">Export Results</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true" @click="reloadPage()">&times;</span> <!-- this prevents the API page traversal from continuing after the user closes the modal -->
</button>
</div>
<div id="preview-text" class="modal-body">
<div class="container" id="format-select">
Select format:
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="inlineRadioOptions" id="inlineRadio2" value="option2" @click="setFormat('mrk')" checked>
<label class="form-check-label" for="inlineRadio2">MRK</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="inlineRadioOptions" id="inlineRadio3" value="option3" @click="setFormat('xml')">
<label class="form-check-label" for="inlineRadio3">XML</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="inlineRadioOptions" id="inlineRadio1" value="option1" @click="setFormat('csv')">
<label class="form-check-label" for="inlineRadio1">CSV</label>
</div>
<br/>
<span>Output Fields: </span>
<input type="text" placeholder="comma separated list of fields (tags only)" @keyup="setOutputFields($event)">
</div>
</div>
<div class="modal-footer">
<div id="results-spinner" class="col d-flex justify-content-center">
<div class="spinner-border" role="status" v-show="showSpinner">
<span class="sr-only">Loading...</span>
</div>
<div style="display: inline-block; padding: 5" v-show="currentPage">
<span>&nbsp;{{ currentPage }}</span>
</div>
</div>
<button v-if="! showSpinner" type="button" class="btn btn-primary" @click="submitExport">Submit</button>
<button type="button" class="btn btn-danger" @click="reloadPage()">Cancel</button> <!-- this prevents the API page traversal from continuing after the user closes the modal -->
</div>
</div>
</div>
</div>
</div>
</transition>
</div>`,
data: function() {
return {
showModal: false,
showSpinner: false,
selectedFormat: 'mrk',
selectedFields: null,
selectedExportUrl: null,
currentPage: null
}
},
methods: {
show: async function() {
this.showModal = true
this.setFormat('mrk')
},
setFormat(format) {
this.selectedFormat = format
this.selectedExportUrl = this.links.format[format.toUpperCase()]
let url = new URL(this.selectedExportUrl)
let search = new URLSearchParams(url.search)
search.set("fields", this.selectedFields)
search.set("start", 1)
search.set("limit", 100)
//search.set("listtype", "export")
url.search = search
this.selectedExportUrl = url
},
setOutputFields(e) {
this.selectedFields = e.target.value
let url = new URL(this.selectedExportUrl)
let search = new URLSearchParams(url.search)
search.set("fields", e.target.value)
search.set("limit", 100)
search.set("listtype", "export")
url.search = search
this.selectedExportUrl = url
},
async submitExport() {
this.currentPage = null;
this.showSpinner = true;
const url = new URL(this.selectedExportUrl);
const params = new URLSearchParams(url.search);
const format = params.get("format");
const countUrl = this.selectedExportUrl.toString().replace('/records', '/records/count');
const total = await fetch(countUrl).then(response => response.json()).then(json => json['data']);
let currentUrl = this.selectedExportUrl;
let page = 0;
let mimetype = null;
let buffer = '';
let xml = format === 'xml' ?
// XMLDocument object will be used to combine xml from each page
(new DOMParser()).parseFromString("<collection></collection>", "text/xml") :
null;

while (true) {
// cycle through pages synchronously until no more records are found
const response = await fetch(currentUrl);
const blob = await response.blob();
const text = await blob.text();
mimetype = response.headers.get("Content-Type");

if (mimetype.match('^text/xml')) {
const pageXml = (new DOMParser()).parseFromString(text, "text/xml")
const recordNodes = pageXml.getElementsByTagName("record")

if (recordNodes.length > 0) {
for (const recordXml of [...recordNodes]) { // have to use the "..." operator on the node list to treat it as an array
xml.getElementsByTagName("collection")[0].appendChild(recordXml);
}
} else {
// end of results
break
}
} else if (text) {
// mrk, csv are plain text
buffer += text
} else {
// end of results
break
}

let newUrl = new URL(currentUrl);
let params = new URLSearchParams(newUrl.search);
params.set("start", Number(params.get("start")) + Number(params.get("limit")));
newUrl.search = params;
currentUrl = newUrl;
this.currentPage = `page: ${++page} / ${Math.ceil(total / 100)}`;
}

const blob = new File([format === 'xml' ? (new XMLSerializer()).serializeToString(xml) : buffer], {"type": mimetype});
this.download(blob, `export.${this.selectedFormat}`);
this.showSpinner = false;
this.currentPage = "Done!"
},
download(blob, filename) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.style.display = "none"
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
},
reloadPage() {
// this doesn't work when embedded in the template for some reason
location.reload()
}
}
}
14 changes: 13 additions & 1 deletion dlx_rest/static/js/search/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { countcomponent } from "./count.js";
import basket from "../api/basket.js";
import user from "../api/user.js";
import { Jmarc } from "../jmarc.mjs";
import { exportmodal } from "../modals/export.js";

export let searchcomponent = {
// onclick="addRemoveBasket("add","{{record['id']}}","{{coll}}","{{prefix}}")"
Expand Down Expand Up @@ -162,6 +163,8 @@ export let searchcomponent = {
<a class="mx-1 result-link" href="#" @click="selectAll">All</a>
<a class="mx-1 result-link" href="#" @click="selectNone">None</a>
<a class="mx-1 result-link" href="#" @click="sendToBasket">Send Selected to Basket (limit: 100)</a>
<a v-if="collectionTitle=='speeches'" class="ml-auto result-link" :href="uibase + '/records/speeches/review'">Speech Review</a>
<a class="ml-auto result-link"><i class="fas fa-share-square" title="Export Results" @click="showExportModal"></i></a>
</div>
<div id="results-list" v-for="result in this.results" :key="result._id">
<div class="row mt-1 bg-light border-bottom">
Expand Down Expand Up @@ -228,6 +231,7 @@ export let searchcomponent = {
<li v-else class="page-item disabled"><a class="page-link result-link" href="">Next</a></li>
</ul>
</nav>
<exportmodal ref="exportmodal" :links="this.links"></exportmodal>
</div>`,
data: function () {
let myParams = this.search_url.split("?")[1];
Expand Down Expand Up @@ -831,15 +835,23 @@ export let searchcomponent = {
toggleButton.className = "fas fa-file preview-toggle";
toggleButton.title = "preview record";
},

showExportModal() {
//console.log(this.links.format)
this.$refs.exportmodal.show()
},

toggleEngine(e) {
// toggle the search type
console.log("Toggling search engine")
this.params.engine = e.target.checked ? "atlas" : "community"
this.rebuildUrl("engine", this.engine)

}
},
components: {
'sortcomponent': sortcomponent,
'countcomponent': countcomponent
'countcomponent': countcomponent,
'exportmodal': exportmodal
}
}
Loading