2026-10-23 19:59:02 +08:00
'use strict' ;
Object . assign ( MESSAGES . zh ,{ 'source-browser' : '从来源选入照片' });
Object . assign ( MESSAGES . en ,{ 'source-browser' : 'Import from source' });
icons [ 'source-browser' ] = '▧' ;
const sourceBrowser = { source : null , filters : {}, offset : 0 , previous : []};
function openSourceBrowser ( sourceId ){ sourceBrowser . source = sourceId ; sourceBrowser . offset = 0 ; sourceBrowser . previous = []; return navigate ( 'source-browser' );}
views [ 'source-browser' ] = async function ( epoch ){
const sources = await api ( '/api/v1/source-libraries' ); if ( epoch !== state . epoch ) return ;
if ( ! sources . length ) return navigate ( 'gallery' );
if ( ! sources . some ( s => s . id === sourceBrowser . source )){ sourceBrowser . source = sources [ 0 ]. id ; sourceBrowser . filters = {}; sourceBrowser . offset = 0 ; sourceBrowser . previous = [];}
const source = sources . find ( s => s . id === sourceBrowser . source ), nas = source . kind === 'synology-photos' , f = sourceBrowser . filters ;
const input = ( name , label , type = 'text' , extra = '' )=> `<div class="field"><label for="browse- ${ name } "> ${ label } </label><input id="browse- ${ name } " name=" ${ name } " type=" ${ type } " value=" ${ esc ( f [ name ] || '' ) } " ${ extra } ></div>` ;
$ ( '#main' ). innerHTML = `<section class="card"><div class="library-source-toolbar"><div class="field"><label for="browse-source"> ${ tr ( '照片来源' , 'Photo source' ) } </label><select id="browse-source"> ${ librarySelectOptions ( sources , source . id , '' ) } </select></div><button id="browse-back"> ${ tr ( '返回来源图库' , 'Back to source library' ) } </button></div><p class="hint"> ${ tr ( '按批读取照片信息,预览仅在进入可视区域时加载;选入后保存在本地,不会推送设备。' , 'Reads metadata in bounded batches and loads previews on demand. Import stores locally without sending to devices.' ) } </p><form id="source-browser-filters"><fieldset ${ nas ? '' : 'disabled' } ><div class="source-browser-filters"> ${ input ( 'filename' , tr ( '文件名关键字' , 'Filename keyword' ), 'search' , 'maxlength="200"' ) }${ input ( 'folder' , tr ( '文件夹关键字' , 'Folder keyword' ), 'search' , 'maxlength="500"' ) }${ input ( 'min_mb' , tr ( '最小文件大小(MB) ' , 'Min file size (MB)' ), 'number' , 'min="0" step="0.1" max="1000000"' ) }${ input ( 'max_mb' , tr ( '最大文件大小(MB) ' , 'Max file size (MB)' ), 'number' , 'min="0" step="0.1" max="1000000"' ) }${ input ( 'min_width' , tr ( '最小宽度(px) ' , 'Min width (px)' ), 'number' , 'min="0" max="100000"' ) }${ input ( 'max_width' , tr ( '最大宽度(px) ' , 'Max width (px)' ), 'number' , 'min="0" max="100000"' ) }${ input ( 'min_height' , tr ( '最小高度(px) ' , 'Min height (px)' ), 'number' , 'min="0" max="100000"' ) }${ input ( 'max_height' , tr ( '最大高度(px) ' , 'Max height (px)' ), 'number' , 'min="0" max="100000"' ) } <div class="field"><label for="browse-date_field"> ${ tr ( '日期类型' , 'Date type' ) } </label><select name="date_field" id="browse-date_field"><option value="indexed_time" ${ f . date_field !== 'time' ? 'selected' : '' } > ${ tr ( 'NAS 创建/入库日期' , 'NAS indexed date' ) } </option><option value="time" ${ f . date_field === 'time' ? 'selected' : '' } > ${ tr ( '照片拍摄日期' , 'Photo date' ) } </option></select></div> ${ input ( 'date_from' , tr ( '开始日期' , 'From date' ), 'date' ) }${ input ( 'date_to' , tr ( '结束日期(含当天)' , 'Through date (inclusive)' ), 'date' ) } <div class="field"><label for="browse-person_id"> ${ tr ( '人物识别' , 'Recognized person' ) } </label><select id="browse-person_id" name="person_id"><option value=""> ${ tr ( '不限人物' , 'Any person' ) } </option> ${ f . person_id ? `<option value=" ${ esc ( f . person_id ) } " selected> ${ esc ( f . person_name || f . person_id ) } </option>` : '' } </select><small id="browse-people-status" class="hint"></small></div></div><div class="actions"><button type="submit" class="primary"> ${ tr ( '搜索 / 筛选' , 'Search / Filter' ) } </button><button type="reset"> ${ tr ( '重置' , 'Reset' ) } </button></div></fieldset></form><p class="hint"> ${ nas ? tr ( '筛选在当前来源设置范围内进行;日期使用系统时区,入库日期不等同于原文件的创建时间。筛选较少见照片时,可继续下一页检索,页面不会等待全库扫描。' , 'Filters stay within this source. Dates use the system timezone; indexed dates are not original file creation dates. Continue through pages for rare matches without waiting for a full scan.' ) : tr ( '此来源使用现有目录接口;高级筛选和快速分页目前支持 Synology Photos。' , 'This provider uses its existing catalog. Advanced filters and fast paging currently support Synology Photos.' ) } </p></section><p id="browse-status" role="status"> ${ tr ( '正在读取本批照片…' , 'Reading this batch…' ) } </p><div id="browse-gallery" class="gallery source-browser-gallery" aria-busy="true"></div><div class="actions library-pagination"><button id="browse-prev" ${ sourceBrowser . previous . length ? '' : 'disabled' } > ${ tr ( '上一页' , 'Previous' ) } </button><button id="browse-next" disabled> ${ tr ( '下一页' , 'Next' ) } </button></div>` ;
bind ( 'browse-back' ,()=>{ libraryUI . source = source . id ; libraryUI . offset = 0 ; return navigate ( 'gallery' );});
$ ( '#browse-source' ). onchange = ()=>{ sourceBrowser . filters = {}; return openSourceBrowser ( $ ( '#browse-source' ). value );};
$ ( '#source-browser-filters' ). onsubmit = busy ( async e =>{ e . preventDefault (); const values = Object . fromEntries ( new FormData ( e . currentTarget )); const people = $ ( '#browse-person_id' ); sourceBrowser . filters = {... values , person_name : people . selectedOptions [ 0 ] ? . textContent || '' }; sourceBrowser . offset = 0 ; sourceBrowser . previous = []; await navigate ();});
$ ( '#source-browser-filters' ). onreset = e =>{ e . preventDefault (); sourceBrowser . filters = {}; sourceBrowser . offset = 0 ; sourceBrowser . previous = []; navigate ();};
const params = new URLSearchParams ({ offset : sourceBrowser . offset , limit : 36 }); for ( const [ key , value ] of Object . entries ( f )){ if ( value !== '' && key !== 'person_name' &&! key . endsWith ( '_mb' )) params . set ( key , value );} for ( const [ key , target ] of [[ 'min_mb' , 'min_bytes' ],[ 'max_mb' , 'max_bytes' ]]) if ( f [ key ] !== undefined && f [ key ] !== '' ) params . set ( target , Math . round ( Number ( f [ key ]) * 1024 * 1024 ));
if ( nas ) loadBrowserPeople ( source , epoch );
let page ; try { page = await api ( '/api/v1/source-libraries/' + encodeURIComponent ( source . id ) + '/browse?' + params );} catch ( error ){ if ( epoch !== state . epoch ) return ; $ ( '#browse-status' ). textContent = error . message ; $ ( '#browse-gallery' ). setAttribute ( 'aria-busy' , 'false' ); return ;}
if ( epoch !== state . epoch ) return ;
$ ( '#browse-status' ). textContent = tr ( `本批找到 ${ page . items . length } 张照片 ${ page . paged_provider ? `,检查 ${ page . scanned } 条记录` : '' } 。 ${ ! page . items . length && page . next_offset !== null ? '可继续下一页检索。' : '' } ` , ` ${ page . items . length } matches in this batch ${ page . paged_provider ? `, ${ page . scanned } records checked` : '' } . ${ ! page . items . length && page . next_offset !== null ? ' Continue to the next batch.' : '' } ` );
$ ( '#browse-gallery' ). innerHTML = page . items . map (( item , i )=> `<article class="card image-card"><button class="image-preview" data-browse-preview=" ${ i } " ${ item . preview_url ? '' : 'disabled' } > ${ item . preview_url ? `<img class="thumb" alt=" ${ esc ( item . name ) } " data-src=" ${ esc ( item . preview_url ) } ">` : `<span class="empty"> ${ tr ( '选入后预览' , 'Preview after import' ) } </span>` } </button><h3 title=" ${ esc ( item . name ) } "> ${ esc ( item . name ) } </h3><small class="browse-folder"> ${ esc ( item . folder || item . source_path || '' ) } </small><small> ${ item . width && item . height ? ` ${ item . width } × ${ item . height } px · ` : '' }${ item . filesize ? ` ${ ( item . filesize / 1024 / 1024 ). toFixed ( 2 ) } MB` : '' } </small><small> ${ item . indexed_time ? tr ( '入库:' , 'Indexed: ' ) + esc ( fmt ( new Date ( item . indexed_time * 1000 ). toISOString ())) : '' } </small><small> ${ item . time ? tr ( '拍摄:' , 'Photo: ' ) + esc ( fmt ( new Date ( item . time * 1000 ). toISOString ())) : '' } </small><small> ${ esc (( item . people || []). map ( p => p . name ). filter ( Boolean ). join ( '、' )) } </small><div class="actions"><button data-browse-preview=" ${ i } " ${ item . preview_url ? '' : 'disabled' } > ${ tr ( '预览' , 'Preview' ) } </button><button class="primary" data-browse-import=" ${ i } "> ${ tr ( '选入本地' , 'Import locally' ) } </button></div></article>` ). join ( '' ); $ ( '#browse-gallery' ). setAttribute ( 'aria-busy' , 'false' );
$ ( '#browse-next' ). disabled = page . next_offset === null ;
2026-10-24 00:38:00 +08:00
$ ( '#browse-next' ). textContent = tr ( '继续读取' , 'Read next batch' );
if ( ! page . items . length ) $ ( '#browse-gallery' ). innerHTML = `<section class="card empty"><p> ${ page . next_offset !== null ? tr ( '本批未找到匹配照片,不代表整个来源没有结果。请继续读取下一批。' , 'No matches in this batch. This does not mean the entire source has no results. Read the next batch.' ) : tr ( '已读至来源末尾,当前筛选范围内没有更多结果。' , 'End of source reached. No further results for these filters.' ) } </p></section>` ;
2026-10-23 19:59:02 +08:00
bind ( 'browse-prev' ,()=>{ sourceBrowser . offset = sourceBrowser . previous . pop () || 0 ; return navigate ();});
bind ( 'browse-next' ,()=>{ if ( page . next_offset === null ) return ; sourceBrowser . previous . push ( sourceBrowser . offset ); sourceBrowser . offset = page . next_offset ; return navigate ();});
document . querySelectorAll ( '[data-browse-preview]' ). forEach ( b => b . onclick = busy (()=>{ const item = page . items [ b . dataset . browsePreview ]; return openImageViewer ( item . preview_url , item . name );}));
document . querySelectorAll ( '[data-browse-import]' ). forEach ( b => b . onclick = busy ( async ()=>{ const item = page . items [ b . dataset . browseImport ]; await api ( '/api/v1/source-libraries/' + encodeURIComponent ( source . id ) + '/import' ,{ method : 'POST' , body : { item_id : item . id }}); if ( epoch !== state . epoch ) return ; b . textContent = tr ( '已选入' , 'Imported' ); b . disabled = true ; libraryUI . offset = 0 ; toast ( tr ( '已保存到来源图库' , 'Saved in source library' ));}));
hydrateImages ();
};
async function loadBrowserPeople ( source , epoch ){
try { const people = await api ( '/api/v1/integrations/synology-photos/people?space=' + encodeURIComponent ( source . people_space || 'shared' )); if ( epoch !== state . epoch ) return ; $ ( '#browse-person_id' ). innerHTML = '<option value="">' + tr ( '不限人物' , 'Any person' ) + '</option>' + people . map ( p => `<option value=" ${ p . id } " ${ String ( p . id ) === String ( sourceBrowser . filters . person_id ) ? 'selected' : '' } > ${ esc ( p . named ? p . name : tr ( '未命名人物 ' , 'Unnamed person ' ) + p . id ) } ( ${ p . count } )</option>` ). join ( '' );} catch ( error ){ if ( epoch === state . epoch ) $ ( '#browse-people-status' ). textContent = tr ( '人物列表暂不可用,可继续浏览照片' , 'People unavailable; photo browsing remains available' );}
}