Preserve exact original download filenames
Browse files
backend/record_metadata.py
CHANGED
|
@@ -172,6 +172,9 @@ def reader_resolved_record(record: dict, asset: dict) -> dict:
|
|
| 172 |
return {
|
| 173 |
"url": source, "download": source, "extension": extension,
|
| 174 |
"original_extension": extension, "title": record.get("File") or record.get("Name") or "在线阅读",
|
|
|
|
|
|
|
|
|
|
| 175 |
"path": "/".join([repo, *folders]), "folder": "/".join(folders), "repo": repo,
|
| 176 |
**reader_asset_fields(asset, extension),
|
| 177 |
}
|
|
|
|
| 172 |
return {
|
| 173 |
"url": source, "download": source, "extension": extension,
|
| 174 |
"original_extension": extension, "title": record.get("File") or record.get("Name") or "在线阅读",
|
| 175 |
+
# Keep the exact indexed filename separate from the display title.
|
| 176 |
+
# The stem may itself end in an extension, or include trailing spaces.
|
| 177 |
+
"filename": build_record_relative_path(record).rsplit("/", 1)[-1],
|
| 178 |
"path": "/".join([repo, *folders]), "folder": "/".join(folders), "repo": repo,
|
| 179 |
**reader_asset_fields(asset, extension),
|
| 180 |
}
|
static/app.js
CHANGED
|
@@ -2546,7 +2546,7 @@ function getReaderLink(rec, returnUrl = location.href, cacheMetadata = true) {
|
|
| 2546 |
readerRecord.OcrUrl = `/txt/${encodeRecordPath(VoiceOfMLReader.txtRelativePath(relPath))}`;
|
| 2547 |
}
|
| 2548 |
const readerUrl = VoiceOfMLReader.readerUrl(readerRecord, "/static/reader.html");
|
| 2549 |
-
if (cacheMetadata) try { const readerId = new URL(readerUrl, location.origin).searchParams.get("id"); if (readerId) { const sourceData = { url: readerRecord.ReaderLink || readerRecord.Link, download: readerRecord.Link, title: readerRecord.File, extension: readerRecord.ReaderExtension || readerRecord.Extension, original_extension: readerRecord.Extension, repo: String(readerRecord.Repo || "").split("/").pop(), folder: readerRecord.Folder, chapter_manifest: readerRecord.ReaderChapterManifest || "", pdf_document: readerRecord.ReaderPdfDocument || "" }; cacheReaderMetadata(readerId, sourceData, true); } } catch (_) {}
|
| 2550 |
return readerUrl;
|
| 2551 |
}
|
| 2552 |
|
|
|
|
| 2546 |
readerRecord.OcrUrl = `/txt/${encodeRecordPath(VoiceOfMLReader.txtRelativePath(relPath))}`;
|
| 2547 |
}
|
| 2548 |
const readerUrl = VoiceOfMLReader.readerUrl(readerRecord, "/static/reader.html");
|
| 2549 |
+
if (cacheMetadata) try { const readerId = new URL(readerUrl, location.origin).searchParams.get("id"); if (readerId) { const sourceData = { url: readerRecord.ReaderLink || readerRecord.Link, download: readerRecord.Link, title: readerRecord.File, filename: String(readerRecord.File || "") + (readerRecord.Extension ? "." + readerRecord.Extension : ""), extension: readerRecord.ReaderExtension || readerRecord.Extension, original_extension: readerRecord.Extension, repo: String(readerRecord.Repo || "").split("/").pop(), folder: readerRecord.Folder, chapter_manifest: readerRecord.ReaderChapterManifest || "", pdf_document: readerRecord.ReaderPdfDocument || "" }; cacheReaderMetadata(readerId, sourceData, true); } } catch (_) {}
|
| 2550 |
return readerUrl;
|
| 2551 |
}
|
| 2552 |
|
static/reader/core/reader-source.mjs
CHANGED
|
@@ -18,8 +18,16 @@ export function createReaderSource(readerContext) {
|
|
| 18 |
return path;
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
function readerDownloadUrl(filename, link) {
|
| 22 |
-
return readerContext.readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`);
|
| 23 |
}
|
| 24 |
|
| 25 |
async function startReaderDownload(event) {
|
|
|
|
| 18 |
return path;
|
| 19 |
}
|
| 20 |
|
| 21 |
+
function readerOriginalFilename(fallback) {
|
| 22 |
+
// The human-readable title can omit a repeated suffix or normalize spaces;
|
| 23 |
+
// the authoritative metadata keeps the exact source filename separately.
|
| 24 |
+
const name = readerContext.resolvedReaderData?.filename;
|
| 25 |
+
return typeof name === "string" && name.trim() && !/[\/\\\x00-\x1f\x7f]/.test(name)
|
| 26 |
+
? name : fallback;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
function readerDownloadUrl(filename, link) {
|
| 30 |
+
return readerContext.readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(readerOriginalFilename(filename || "file"))}&link=${encodeURIComponent(link || "")}`);
|
| 31 |
}
|
| 32 |
|
| 33 |
async function startReaderDownload(event) {
|
tests/test_fastapi_endpoints.py
CHANGED
|
@@ -409,9 +409,17 @@ class FastApiEndpointTests(unittest.TestCase):
|
|
| 409 |
response=self.client.get('/api/reader-resolve',params={'id':'2w2m8oowhjtl9'})
|
| 410 |
self.assertEqual(response.status_code,200)
|
| 411 |
self.assertTrue(response.json()['download'].endswith('/www.maopost.com_postl_0566_001M.jpg.jpg'))
|
|
|
|
| 412 |
self.assertEqual(response.json()['extension'],'webp')
|
| 413 |
self.assertIn(asset['p'],response.json()['url'])
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
def test_legacy_double_extension_alias_collision_fails_closed(self):
|
| 416 |
app.records=[{"Repo":"VoiceOfML/Test", "File":name, "Extension":"jpg", "Folder":[]}
|
| 417 |
for name in ('one.jpg','two.jpg')]
|
|
|
|
| 409 |
response=self.client.get('/api/reader-resolve',params={'id':'2w2m8oowhjtl9'})
|
| 410 |
self.assertEqual(response.status_code,200)
|
| 411 |
self.assertTrue(response.json()['download'].endswith('/www.maopost.com_postl_0566_001M.jpg.jpg'))
|
| 412 |
+
self.assertEqual(response.json()['filename'],'www.maopost.com_postl_0566_001M.jpg.jpg')
|
| 413 |
self.assertEqual(response.json()['extension'],'webp')
|
| 414 |
self.assertIn(asset['p'],response.json()['url'])
|
| 415 |
|
| 416 |
+
def test_resolved_filename_preserves_case_spaces_and_repeated_extension(self):
|
| 417 |
+
for stem, extension in [('poster.jpg','jpg'),('Lecture','PDF'),('Book ','pdf'),('README','')]:
|
| 418 |
+
with self.subTest(stem=stem,extension=extension):
|
| 419 |
+
record={'Repo':'VoiceOfML/Test','File':stem,'Extension':extension,'Folder':['docs']}
|
| 420 |
+
data=app.reader_resolved_record(record,{})
|
| 421 |
+
self.assertEqual(data['filename'],stem+('.'+extension if extension else ''))
|
| 422 |
+
|
| 423 |
def test_legacy_double_extension_alias_collision_fails_closed(self):
|
| 424 |
app.records=[{"Repo":"VoiceOfML/Test", "File":name, "Extension":"jpg", "Folder":[]}
|
| 425 |
for name in ('one.jpg','two.jpg')]
|
tests/test_reader_navigation_host.py
CHANGED
|
@@ -110,6 +110,14 @@ process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8'
|
|
| 110 |
self.assertTrue(row['link'].endswith('/目录/'+row['filename']))
|
| 111 |
self.assertTrue(result[0]['reader'])
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
def test_explicit_close_ignores_late_panel_state_before_history_pop(self):
|
| 114 |
self.page.evaluate('navigateToReader("/static/reader.html?ext=txt")')
|
| 115 |
self.page.frame_locator('.reader-overlay').locator('body').wait_for()
|
|
|
|
| 110 |
self.assertTrue(row['link'].endswith('/目录/'+row['filename']))
|
| 111 |
self.assertTrue(result[0]['reader'])
|
| 112 |
|
| 113 |
+
def test_session_handoff_keeps_exact_original_filename(self):
|
| 114 |
+
result=self.page.evaluate('''()=>{
|
| 115 |
+
const record={Repo:'VoiceOfML/Test',Folder:['docs'],File:'poster.jpg',Extension:'JPG'};
|
| 116 |
+
const url=getReaderLink(record),id=new URL(url,location.href).searchParams.get('id');
|
| 117 |
+
return JSON.parse(sessionStorage.getItem('reader-source:'+id));
|
| 118 |
+
}''')
|
| 119 |
+
self.assertEqual(result['filename'],'poster.jpg.JPG')
|
| 120 |
+
|
| 121 |
def test_explicit_close_ignores_late_panel_state_before_history_pop(self):
|
| 122 |
self.page.evaluate('navigateToReader("/static/reader.html?ext=txt")')
|
| 123 |
self.page.frame_locator('.reader-overlay').locator('body').wait_for()
|
tests/test_reader_pdf_loading.py
CHANGED
|
@@ -60,6 +60,48 @@ class PdfLoadingTests(unittest.TestCase):
|
|
| 60 |
self.assertEqual(len(self.modules), 1)
|
| 61 |
self.assertNotIn('reader-module-retry', self.modules[0])
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def test_first_range_overlaps_held_engine_and_is_consumed_once(self):
|
| 64 |
held = []
|
| 65 |
self.page.route(MODULE, lambda route: held.append(route))
|
|
|
|
| 60 |
self.assertEqual(len(self.modules), 1)
|
| 61 |
self.assertNotIn('reader-module-retry', self.modules[0])
|
| 62 |
|
| 63 |
+
def test_resolved_original_download_name_keeps_exact_repeated_extension(self):
|
| 64 |
+
original='https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/poster.jpg.jpg'
|
| 65 |
+
image=b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
|
| 66 |
+
self.page.route('**/api/reader-resolve?id=filename-test',lambda route:route.fulfill(json={
|
| 67 |
+
'url':original,'download':original,'extension':'jpg','original_extension':'jpg',
|
| 68 |
+
'title':'poster.jpg','filename':'poster.jpg.jpg'}))
|
| 69 |
+
self.page.route('**/api/reader-content**',lambda route:route.fulfill(content_type='image/gif',body=image))
|
| 70 |
+
self.page.goto(self.origin+'/static/reader.html?id=filename-test&ext=jpg',wait_until='domcontentloaded')
|
| 71 |
+
self.page.locator('.reader-image').wait_for()
|
| 72 |
+
query=urllib.parse.parse_qs(urllib.parse.urlsplit(self.page.locator('#download').get_attribute('href')).query)
|
| 73 |
+
self.assertEqual(query['file'],['poster.jpg.jpg'])
|
| 74 |
+
self.assertEqual(query['link'],[original])
|
| 75 |
+
|
| 76 |
+
def test_malformed_original_filename_metadata_is_not_used_for_download(self):
|
| 77 |
+
source='https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/safe.txt'
|
| 78 |
+
self.page.route('**/api/reader-resolve?id=filename-invalid',lambda route:route.fulfill(json={
|
| 79 |
+
'url':source,'download':source,'extension':'txt','original_extension':'txt',
|
| 80 |
+
'title':'safe','filename':'../unsafe.txt'}))
|
| 81 |
+
self.page.route('**/api/reader-content**',lambda route:route.fulfill(content_type='text/plain',body='safe'))
|
| 82 |
+
self.page.goto(self.origin+'/static/reader.html?id=filename-invalid&ext=txt',wait_until='domcontentloaded')
|
| 83 |
+
self.page.locator('.reader-text').wait_for()
|
| 84 |
+
query=urllib.parse.parse_qs(urllib.parse.urlsplit(self.page.locator('#download').get_attribute('href')).query)
|
| 85 |
+
self.assertEqual(query['file'],['safe.txt'])
|
| 86 |
+
|
| 87 |
+
def test_original_download_name_survives_title_change_and_button_handoff(self):
|
| 88 |
+
source='https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/Sample%20.PDF'
|
| 89 |
+
checks=[]
|
| 90 |
+
self.page.route('**/api/reader-resolve?id=filename-click',lambda route:route.fulfill(json={
|
| 91 |
+
'url':source,'download':source,'extension':'txt','original_extension':'pdf',
|
| 92 |
+
'title':'Sample ','filename':'Sample .PDF'}))
|
| 93 |
+
self.page.route('**/api/reader-content**',lambda route:route.fulfill(content_type='text/plain',body='safe'))
|
| 94 |
+
self.page.route('**/api/download/check?**',lambda route:route.fulfill(json={'ok':True}))
|
| 95 |
+
self.page.route('**/api/download?**',lambda route:(checks.append(route.request.url),route.fulfill(body='file')))
|
| 96 |
+
self.page.goto(self.origin+'/static/reader.html?id=filename-click&ext=txt',wait_until='domcontentloaded')
|
| 97 |
+
self.page.locator('.reader-text').wait_for()
|
| 98 |
+
self.page.locator('#download').click()
|
| 99 |
+
self.page.wait_for_function("()=>document.querySelector('#download-feedback')?.textContent.includes('已发起下载')")
|
| 100 |
+
self.assertEqual(len(checks),1)
|
| 101 |
+
query=urllib.parse.parse_qs(urllib.parse.urlsplit(checks[0]).query)
|
| 102 |
+
self.assertEqual(query['file'],['Sample .PDF'])
|
| 103 |
+
self.assertEqual(query['link'],[source])
|
| 104 |
+
|
| 105 |
def test_first_range_overlaps_held_engine_and_is_consumed_once(self):
|
| 106 |
held = []
|
| 107 |
self.page.route(MODULE, lambda route: held.append(route))
|