Skip to content

Commit d6431b4

Browse files
committed
feat: pin recent documents, clear file caches and preserve zoom position
1 parent 60804c9 commit d6431b4

11 files changed

Lines changed: 213 additions & 25 deletions

‎electron/main.mjs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ const recentContextMenuLabels={
3737
ja:{open:'開く',hide:'非表示',copy:'ファイルの場所をコピー',reveal:'Finder で表示',status:'翻訳状況を表示'},
3838
ko:{open:'열기',hide:'가리기',copy:'파일 위치 복사',reveal:'Finder에서 보기',status:'번역 상태 보기'},
3939
};
40+
const recentExtraLabels={
41+
en:{pin:'Pin',unpin:'Unpin',clearCache:'Clear Translation Cache'},
42+
'zh-CN':{pin:'置顶',unpin:'取消置顶',clearCache:'清除翻译缓存'},
43+
'zh-TW':{pin:'置頂',unpin:'取消置頂',clearCache:'清除翻譯快取'},
44+
ja:{pin:'ピン留め',unpin:'ピン留め解除',clearCache:'翻訳キャッシュを削除'},
45+
ko:{pin:'고정',unpin:'고정 해제',clearCache:'번역 캐시 지우기'},
46+
fr:{pin:'Épingler',unpin:'Désépingler',clearCache:'Effacer le cache de traduction'},
47+
es:{pin:'Fijar',unpin:'Desfijar',clearCache:'Borrar caché de traducción'}
48+
};
4049
const recentContextMenuLabelsFor=locale=>recentContextMenuLabels[locale]||recentContextMenuLabels.en;
4150
const haptics=createHaptics({packaged:app.isPackaged});app.on('will-quit',()=>haptics.close());
4251
const pendingFiles=[];
@@ -368,17 +377,20 @@ else {
368377
if(action==='contextMenu'){
369378
const id=value,path=typeof id==='string'?recents.path(id):undefined;
370379
if(typeof id!=='string'||!id||typeof path!=='string'||!path)throw Error('Document no longer in history.');
371-
const labels=recentContextMenuLabelsFor(preferences?.load?.().uiLanguage);
380+
const locale=preferences?.load?.().uiLanguage,labels={...recentContextMenuLabelsFor(locale),...(recentExtraLabels[locale]||recentExtraLabels.en)},entry=recents.list().find(item=>item.id===id);
372381
return new Promise((resolve,reject)=>{
373382
let selected=null,pending=Promise.resolve();
374383
const template=[
375384
{label:labels.open,click:()=>{selected='open';}},
385+
{label:entry?.pinned?labels.unpin:labels.pin,click:()=>{pending=recents.setPinned(id,!entry?.pinned).then(()=>{selected='pin';});}},
376386
{label:labels.hide,click:()=>{pending=recents.remove(id).then(()=>{selected='hide';});}},
377387
{type:'separator'},
378388
{label:labels.copy,click:()=>{pending=Promise.resolve().then(()=>clipboard.writeText(path));}},
379389
{label:labels.reveal,click:()=>{pending=Promise.resolve().then(()=>shell.showItemInFolder(path));}},
380390
{type:'separator'},
381391
{label:labels.status,click:()=>{selected='status';}},
392+
{type:'separator'},
393+
{label:labels.clearCache,click:()=>{pending=(async()=>{const document=await readSystemPDF(path);const response=await fetch(windows.get(window).backend.origin+'/api/translation-cache/clear',{method:'POST',headers:{'Content-Type':'application/pdf','X-Preview-Token':token},body:document.bytes});if(!response.ok)throw Error('Could not clear translation cache.');await recents.clearTranslationStatus(id);selected='clearCache';})();}},
382394
];
383395
try{Menu.buildFromTemplate(template).popup({window,callback:()=>{pending.then(()=>resolve(selected),reject);}});}catch(error){reject(error);}
384396
});
@@ -389,7 +401,7 @@ else {
389401
if(action==='setThumbnail')return recents.setThumbnail(value?.id,value?.thumbnail);
390402
if(action==='setView')return recents.setView(value?.id,value?.view);
391403
if(action==='setTranslationStatus')return recents.setTranslationStatus(value?.id,value?.status);
392-
const state=windows.get(window),path=value?.ticket?state.tickets.get(value.ticket):value?.path;if(!path){await documentSession.close(window.id);if(!state.unkeyedAnnotationSource?.preserveWithoutPath){state.unkeyedAnnotationSource=null;state.annotationSources.clear();}return {entries:recents.list(),recentId:null};}if(smoke&&!['A quieter way to read.pdf','Portrait and landscape.pdf'].includes(path.split(/[\\/]/).pop()))throw Error('Test document rejected.');await validateSystemPDF(path);if(value?.ticket)state.tickets.delete(value.ticket);if(!smoke)app.addRecentDocument(path);const entries=await recents.remember(path,value?.thumbnail),first=entries[0],source={path,reliable:true,preserveWithoutPath:false};state.annotationSources.set(first.id,source);state.unkeyedAnnotationSource=source;const restored=state.restoreView;state.restoreView=null;await documentSession.open(window.id,{path,view:restored||first?.view});return {entries,recentId:first?.id??null,view:restored||first?.view};
404+
const state=windows.get(window),path=value?.ticket?state.tickets.get(value.ticket):value?.path;if(!path){await documentSession.close(window.id);if(!state.unkeyedAnnotationSource?.preserveWithoutPath){state.unkeyedAnnotationSource=null;state.annotationSources.clear();}return {entries:recents.list(),recentId:null};}if(smoke&&!['A quieter way to read.pdf','Portrait and landscape.pdf'].includes(path.split(/[\\/]/).pop()))throw Error('Test document rejected.');await validateSystemPDF(path);if(value?.ticket)state.tickets.delete(value.ticket);if(!smoke)app.addRecentDocument(path);const entries=await recents.remember(path,value?.thumbnail),first=entries.find(item=>recents.path(item.id)===path),source={path,reliable:true,preserveWithoutPath:false};state.annotationSources.set(first.id,source);state.unkeyedAnnotationSource=source;const restored=state.restoreView;state.restoreView=null;await documentSession.open(window.id,{path,view:restored||first?.view});return {entries,recentId:first?.id??null,view:restored||first?.view};
393405
});
394406
ipcMain.handle('documents:open',async(event,value)=>{const target=trustedWindow(event);if(typeof value?.path==='string'){await validateSystemPDF(value.path);await openDocumentWindow(value.path,target);}else{if(!value||typeof value.name!=='string'||!(value.bytes instanceof Uint8Array)||value.bytes.byteLength>50*1024*1024)throw Error('Invalid PDF.');await openDocumentWindow({name:value.name,bytes:value.bytes},target);}return true;});
395407
ipcMain.handle('window:new',async event=>{trustedWindow(event);await createWindow();});

‎electron/recents.mjs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {readFile,writeFile,mkdir,rename} from 'node:fs/promises';
22
import {dirname,basename} from 'node:path';
33
import {randomUUID} from 'node:crypto';
44
const maxThumbnailLength=200000;
5+
const maxUnpinnedRecents=10;
56
const pngDataURL=/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/;
67
const readingViewFields=['page','offsetX','offsetY','zoom','fit','direction','columns','sidebar','showTranslations'];
78
const translationStatusFields=['totalPages','completedPages','partialPages','failedPages','engine','language','updatedAt'];
@@ -34,15 +35,20 @@ export function validateTranslationStatus(status){
3435
}
3536
function storedTranslationStatus(status){try{return validateTranslationStatus(status);}catch{return undefined;}}
3637
function cloneTranslationStatus(status){return status===undefined?undefined:{...status};}
38+
function normalizeEntries(entries){
39+
const pinned=[],unpinned=[];
40+
for(const entry of entries)(entry.pinned?pinned:unpinned).push(entry);
41+
return [...pinned,...unpinned.slice(0,maxUnpinnedRecents)];
42+
}
3743
function listedEntry(entry){
38-
const result={id:entry.id,name:basename(entry.path),thumbnail:entry.thumbnail};
44+
const result={id:entry.id,name:basename(entry.path),thumbnail:entry.thumbnail,pinned:entry.pinned===true};
3945
if(entry.view!==undefined)result.view=cloneReadingView(entry.view);
4046
if(entry.translationStatus!==undefined)result.translationStatus=cloneTranslationStatus(entry.translationStatus);
4147
return result;
4248
}
4349
export async function createRecents(path){
44-
let entries=[];try{entries=JSON.parse(await readFile(path,'utf8')).filter(e=>typeof e.id==='string'&&typeof e.path==='string').slice(0,10).map(e=>{const entry={id:e.id,path:e.path},thumbnail=storedThumbnail(e.thumbnail),view=storedReadingView(e.view),translationStatus=storedTranslationStatus(e.translationStatus);if(thumbnail!==undefined)entry.thumbnail=thumbnail;if(view!==undefined)entry.view=view;if(translationStatus!==undefined)entry.translationStatus=translationStatus;return entry;});}catch{}
50+
let entries=[];try{const stored=JSON.parse(await readFile(path,'utf8'));entries=normalizeEntries((Array.isArray(stored)?stored:[]).filter(e=>e&&typeof e.id==='string'&&typeof e.path==='string').map(e=>{const entry={id:e.id,path:e.path,pinned:e.pinned===true},thumbnail=storedThumbnail(e.thumbnail),view=storedReadingView(e.view),translationStatus=storedTranslationStatus(e.translationStatus);if(thumbnail!==undefined)entry.thumbnail=thumbnail;if(view!==undefined)entry.view=view;if(translationStatus!==undefined)entry.translationStatus=translationStatus;return entry;}));}catch{}
4551
let writes=Promise.resolve();
4652
function persist(){const data=JSON.stringify(entries);writes=writes.catch(()=>{}).then(async()=>{await mkdir(dirname(path),{recursive:true});await writeFile(path+'.tmp',data);await rename(path+'.tmp',path);});return writes;}
47-
return {list:()=>entries.map(listedEntry),path:id=>entries.find(e=>e.id===id)?.path,async remember(file,thumbnail){const current=entries.find(e=>e.path===file),entry={id:current?.id||randomUUID(),path:file};if(thumbnail===undefined){if(current?.thumbnail!==undefined)entry.thumbnail=current.thumbnail;}else entry.thumbnail=validateThumbnail(thumbnail);if(current?.view!==undefined)entry.view=cloneReadingView(current.view);if(current?.translationStatus!==undefined)entry.translationStatus=cloneTranslationStatus(current.translationStatus);entries=[entry,...entries.filter(e=>e.path!==file)].slice(0,10);await persist();return this.list();},async setThumbnail(id,thumbnail){const entry=entries.find(e=>e.id===id);if(!entry)throw Error('Document no longer in history.');entry.thumbnail=validateThumbnail(thumbnail);await persist();return this.list();},async setView(id,view){const validated=validateReadingView(view),entry=entries.find(e=>e.id===id);if(!entry)return this.list();entry.view=validated;await persist();return this.list();},async setTranslationStatus(id,status){const validated=validateTranslationStatus(status),entry=entries.find(e=>e.id===id);if(!entry)return this.list();entry.translationStatus=validated;await persist();return this.list();},async remove(id){const index=entries.findIndex(e=>e.id===id);if(index<0)throw Error('Document no longer in history.');entries.splice(index,1);await persist();return this.list();},async clear(){entries=[];await persist();return [];},flush:()=>writes};
53+
return {list:()=>entries.map(listedEntry),path:id=>entries.find(e=>e.id===id)?.path,async remember(file,thumbnail){const current=entries.find(e=>e.path===file),entry={id:current?.id||randomUUID(),path:file,pinned:current?.pinned===true};if(thumbnail===undefined){if(current?.thumbnail!==undefined)entry.thumbnail=current.thumbnail;}else entry.thumbnail=validateThumbnail(thumbnail);if(current?.view!==undefined)entry.view=cloneReadingView(current.view);if(current?.translationStatus!==undefined)entry.translationStatus=cloneTranslationStatus(current.translationStatus);entries=normalizeEntries([entry,...entries.filter(e=>e.path!==file)]);await persist();return this.list();},async setThumbnail(id,thumbnail){const entry=entries.find(e=>e.id===id);if(!entry)throw Error('Document no longer in history.');entry.thumbnail=validateThumbnail(thumbnail);await persist();return this.list();},async setView(id,view){const validated=validateReadingView(view),entry=entries.find(e=>e.id===id);if(!entry)return this.list();entry.view=validated;await persist();return this.list();},async setTranslationStatus(id,status){const validated=validateTranslationStatus(status),entry=entries.find(e=>e.id===id);if(!entry)return this.list();entry.translationStatus=validated;await persist();return this.list();},async clearTranslationStatus(id){const entry=entries.find(e=>e.id===id);if(!entry)return this.list();delete entry.translationStatus;await persist();return this.list();},async setPinned(id,pinned){if(typeof pinned!=='boolean')throw Error('Invalid recent document pinned state.');const entry=entries.find(e=>e.id===id);if(!entry)return this.list();entry.pinned=pinned;entries=normalizeEntries(entries);await persist();return this.list();},async remove(id){const index=entries.findIndex(e=>e.id===id);if(index<0)throw Error('Document no longer in history.');entries.splice(index,1);await persist();return this.list();},async clear(){entries=entries.filter(entry=>entry.pinned);await persist();return this.list();},flush:()=>writes};
4854
}

‎server/document-cache.mjs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {readFile,mkdir,writeFile,rename,rm} from 'node:fs/promises';
2+
import {join} from 'node:path';
3+
import {randomUUID} from 'node:crypto';
4+
export function createDocumentCache(directory){
5+
const hashOf=hash=>hash.copy().digest('hex');
6+
async function scope(hash){
7+
const id=hashOf(hash);
8+
try{return id+':'+(await readFile(join(directory,'documents',id+'.generation'),'utf8')).trim();}catch(error){if(error.code!=='ENOENT')throw error;return '';}
9+
}
10+
async function clear(hash){
11+
const id=hashOf(hash),root=join(directory,'documents');await mkdir(root,{recursive:true});
12+
const file=join(root,id+'.generation'),temp=file+'.'+randomUUID()+'.tmp';
13+
await writeFile(temp,randomUUID());await rename(temp,file);
14+
await rm(join(root,id),{recursive:true,force:true});
15+
}
16+
return {scope,clear};
17+
}

‎server/document-cache.test.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import {mkdtemp,rm,mkdir,writeFile,stat} from 'node:fs/promises';
4+
import {tmpdir} from 'node:os';
5+
import {join} from 'node:path';
6+
import {createHash} from 'node:crypto';
7+
import {createDocumentCache} from './document-cache.mjs';
8+
test('clearing a document persists invalidation and leaves other documents unchanged',async()=>{
9+
const root=await mkdtemp(join(tmpdir(),'reader-document-cache-'));
10+
try{
11+
const a=createHash('sha256').update('a'),b=createHash('sha256').update('b'),cache=createDocumentCache(root);
12+
assert.equal(await cache.scope(a),'');assert.equal(await cache.scope(b),'');
13+
const old=join(root,'documents',a.copy().digest('hex'));await mkdir(old,{recursive:true});await writeFile(join(old,'old.json'),'{}');
14+
await cache.clear(a);const scope=await cache.scope(a);assert.ok(scope);
15+
await assert.rejects(stat(old),{code:'ENOENT'});
16+
assert.equal(await cache.scope(b),'');assert.equal(await createDocumentCache(root).scope(a),scope);
17+
await cache.clear(a);assert.notEqual(await cache.scope(a),scope);
18+
}finally{await rm(root,{recursive:true,force:true});}
19+
});

0 commit comments

Comments
 (0)