@@ -384,8 +394,13 @@ var I18N = {
channel_placeholder:'نام کاربری کانال',
version:'نسخه',
edit:'ویرایش',share:'اشتراک\u200cگذاری',delete:'حذف',save:'ذخیره',cancel:'لغو',
- copied:'پیوند کپی شد!',copy:'کپی',active:'فعال',
+ copied:'کپی شد!',copy:'کپی',active:'فعال',
private:'خصوصی',no_config:'ابتدا پروفایل را ذخیره کنید',
+ refreshing:'در حال بروزرسانی...',fetching_channel:'در حال دریافت کانال...',
+ msg_copied:'پیام کپی شد!',rescan_started:'بررسی مجدد شروع شد',
+ add_manual:'✎ ساخت دستی',rescan:'بررسی مجدد',
+ new_messages:'پیام جدید',missed_messages:'{n} پیام از دست رفته یا حذف شده',
+ clear_cache:'پاک کردن کش',cache_cleared:'کش پاک شد!',
},
en: {
search:'Search...',settings:'Settings',profiles:'Profiles',
@@ -415,6 +430,11 @@ var I18N = {
edit:'Edit',share:'Share',delete:'Delete',save:'Save',cancel:'Cancel',
copied:'URI copied!',copy:'Copy',active:'Active',
private:'Private',no_config:'Save a profile first',
+ refreshing:'Refreshing...',fetching_channel:'Fetching channel...',
+ msg_copied:'Message copied!',rescan_started:'Rescan started',
+ add_manual:'✎ Create Manually',rescan:'Rescan',
+ new_messages:'New messages',missed_messages:'{n} messages missed or deleted',
+ clear_cache:'Clear Cache',cache_cleared:'Cache cleared!',
}
};
var lang = localStorage.getItem('thefeed_lang') || 'fa';
@@ -454,11 +474,11 @@ async function init(){
connectSSE();
try{
var r=await fetch('/api/status');var st=await r.json();
+ await loadProfiles();
if(!st.configured){openProfiles();return}
telegramLoggedIn=!!st.telegramLoggedIn;
serverNextFetch=st.nextFetch||0;
updateNextFetchDisplay();
- await loadProfiles();
await loadChannels();
if(channels&&channels.length>0)await selectChannel(1);
else{showInitProgress();await doRefresh();openChat()}
@@ -490,10 +510,16 @@ async function saveSettings(){
try{await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fontSize:fs,debug:dbg})})}catch(e){}
closeSettings();
}
+async function clearCache(){
+ try{var r=await fetch('/api/cache/clear',{method:'POST'});var j=await r.json();if(j.ok){alert(t('cache_cleared'))}}catch(e){}
+}
// ===== SSE =====
function connectSSE(){
if(eventSource)eventSource.close();
+ // Clear stale progress items from a previous connection
+ document.getElementById('progressPanel').innerHTML='';
+ resolverScanHint='';
eventSource=new EventSource('/api/events');
eventSource.addEventListener('log',function(e){addLogLine(JSON.parse(e.data))});
eventSource.addEventListener('update',async function(e){
@@ -570,6 +596,7 @@ function renderProfilesModal(){
if(isActive)h+=''+t('active')+'';
h+='
'+esc(p.config.domain)+'
';
h+='
';
+ if(isActive)h+='';
h+='';
h+='';
h+='
';
@@ -738,6 +765,7 @@ async function saveProfile(){
var r=await fetch('/api/profiles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:action,profile:profile})});
if(!r.ok){errEl.textContent=await r.text();errEl.style.display='block';return}
await loadProfiles();
+ var savedEditId=editingProfileId;
closeProfileEditor();
if(wasFirst){
closeProfiles();
@@ -746,8 +774,11 @@ async function saveProfile(){
showInitProgress();
}else{
renderProfilesModal();
- // If we updated the active profile, reload data
- if(editingProfileId===activeProfileId||!editingProfileId){
+ // Only rescan if we updated the currently active profile
+ if(savedEditId&&savedEditId===activeProfileId){
+ showToast(t('rescan_started'));
+ document.getElementById('progressPanel').innerHTML='';
+ showInitProgress();
await loadChannels();
if(selectedChannel>0)await loadMessages(selectedChannel);
}
@@ -755,6 +786,16 @@ async function saveProfile(){
}catch(e){errEl.textContent=e.message;errEl.style.display='block'}
}
+// ===== RESCAN =====
+async function doRescanFromProfiles(){
+ closeProfiles();
+ showToast(t('rescan_started'));
+ document.getElementById('progressPanel').innerHTML='';
+ showInitProgress();
+ try{await fetch('/api/rescan',{method:'POST'})}catch(e){}
+ setTimeout(function(){loadChannels().then(function(){if(selectedChannel>0)loadMessages(selectedChannel)})},3000);
+}
+
async function deleteEditingProfile(){
if(!editingProfileId)return;
if(!confirm(t('delete')+'?'))return;
@@ -790,25 +831,52 @@ async function loadChannels(){
}catch(e){}
}
+var _renderChannelsTimer=null;
function renderChannels(){
+ // Debounce: avoid rapid sequential DOM rebuilds that cause hover flicker
+ if(_renderChannelsTimer)clearTimeout(_renderChannelsTimer);
+ _renderChannelsTimer=setTimeout(_renderChannelsNow,50);
+}
+function _renderChannelsNow(){
+ _renderChannelsTimer=null;
var el=document.getElementById('channelList');
if(!channels||!channels.length){
var _hint=resolverScanHint||(t('no_channels_hint')+' '+t('no_channels_hint2'));
el.innerHTML='
'+t('no_channels')+' '+_hint+'
';return
}
+ // Fast-path: if channel count matches existing items, just update classes/badges
+ var existingItems=el.querySelectorAll('.ch-item');
+ if(existingItems.length===channels.length){
+ var needsFullRebuild=false;
+ for(var ci=0;ci0&&lastID>previousMsgIDs[num]&&num!==selectedChannel;
+ var previewEl=existingItems[ui].querySelector('.ch-preview');
+ if(previewEl)previewEl.innerHTML=showBadge?'NEW':'';
+ }
+ _updateRefreshBadge();return;
+ }
+ }
var pubs=[],privs=[];
for(var i=0;i'+esc(title)+'';
for(var j=0;j0&&lastID>previousMsgIDs[num]&&num!==selectedChannel)?'NEW':'';
- h+='
';
+ var badge=(previousMsgIDs[num2]>0&&lastID>previousMsgIDs[num2]&&num2!==selectedChannel)?'NEW':'';
+ h+='
';
h+='
'+esc(name.charAt(0).toUpperCase())+'
';
h+='
'+esc(name)+(isPriv?''+t('private')+'':'')+'
';
h+='
'+badge+'
';
@@ -816,6 +884,15 @@ function renderChannels(){
return h;
}
el.innerHTML=section('',pubs)+section(t('private'),privs);
+ _updateRefreshBadge();
+}
+function _updateRefreshBadge(){
+ var hasNew=false;
+ for(var k=0;k0&&lid>previousMsgIDs[num3]&&num3!==selectedChannel){hasNew=true;break}
+ }
+ document.getElementById('refreshBtn').classList.toggle('refresh-has-new',hasNew);
}
async function selectChannel(num){
@@ -825,10 +902,25 @@ async function selectChannel(num){
document.getElementById('chatName').textContent=name;
renderChannels();updateSendPanel();
document.getElementById('messages').innerHTML='
'+t('loading')+'
';
+ // Show immediate feedback progress bar
+ showChannelFetchProgress(num,name);
await loadMessages(num);
await doRefresh(true);
}
+function showChannelFetchProgress(num,name){
+ var panel=document.getElementById('progressPanel');
+ var id='prog-fetch-ch-'+num;
+ var existing=document.getElementById(id);
+ if(existing)return;
+ var item=document.createElement('div');item.id=id;item.className='progress-item';
+ item.dataset.lastUpdate=Date.now();
+ item.innerHTML='
'+t('fetching_channel')+' '+(name||num)+'
';
+ panel.insertBefore(item,panel.firstChild);
+ // Auto-remove after messages load or after timeout
+ setTimeout(function(){var el=document.getElementById(id);if(el)el.remove()},15000);
+}
+
function updateSendPanel(){
var panel=document.getElementById('sendPanel');
var ch=channels[selectedChannel-1];
@@ -837,13 +929,46 @@ function updateSendPanel(){
else panel.classList.remove('visible');
}
+// ===== PERSISTENT MESSAGE STORE =====
+function msgStoreKey(chNum){return 'thefeed_msgs_'+activeProfileId+'_'+chNum}
+function loadStoredMessages(chNum){
+ try{var raw=localStorage.getItem(msgStoreKey(chNum));return raw?JSON.parse(raw):[]}catch(e){return[]}
+}
+function saveStoredMessages(chNum,msgs){
+ try{
+ // Keep max 200 messages, sorted by timestamp
+ msgs.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)});
+ if(msgs.length>200)msgs=msgs.slice(msgs.length-200);
+ localStorage.setItem(msgStoreKey(chNum),JSON.stringify(msgs));
+ }catch(e){}
+}
+function mergeMessages(stored,fresh){
+ var byId={};var merged=[];
+ for(var i=0;i200)merged=merged.slice(merged.length-200);
+ return merged;
+}
+
// ===== MESSAGES =====
async function loadMessages(chNum){
- if(chNum===selectedChannel){var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum]);}
+ if(chNum===selectedChannel){
+ // Show persisted messages immediately
+ var stored=loadStoredMessages(chNum);
+ if(stored.length)renderMessages(stored);
+ else{var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum])}
+ }
try{
var r=await fetch('/api/messages/'+chNum);if(chNum!==selectedChannel)return;
var msgs=await r.json();if(chNum!==selectedChannel)return;
- renderMessages(msgs);
+ // Merge with stored messages
+ var stored2=loadStoredMessages(chNum);
+ var merged=mergeMessages(stored2,msgs||[]);
+ saveStoredMessages(chNum,merged);
+ renderMessages(merged);
+ // Remove fetch progress bar for this channel
+ var fetchBar=document.getElementById('prog-fetch-ch-'+chNum);if(fetchBar)fetchBar.remove();
var _cache=loadCache()||{channels:channels,messages:{}};if(!_cache.messages)_cache.messages={};_cache.messages[''+chNum]=msgs;_cache.ts=Date.now();saveCache(_cache);
if(channels[chNum-1]){previousMsgIDs[chNum]=channels[chNum-1].LastMsgID||channels[chNum-1].lastMsgID||0;renderChannels()}
}catch(e){}
@@ -852,17 +977,35 @@ async function loadMessages(chNum){
function renderMessages(msgs){
var el=document.getElementById('messages');
if(!msgs||!msgs.length){el.innerHTML='
'+t('no_messages')+'
'+t('no_messages_hint')+'
';return}
+ // Check if user is near the bottom before re-render (within 150px)
+ var wasAtBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150;
+ var isFirstRender=el.querySelector('.empty-state')!==null||el.querySelector('.msg')===null;
msgs.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)});
- var html='',lastDate='';
+ var html='',lastDate='',prevId=0;
+ currentMsgTexts=[];
var dateLocale=lang==='fa'?'fa-IR':'en-US';
+ var dateOpts=lang==='fa'?{year:'numeric',month:'long',day:'numeric',calendar:'persian'}:{year:'numeric',month:'long',day:'numeric'};
for(var i=0;i'+dateStr+'
';lastDate=dateStr}
- var timeStr=ts.toLocaleTimeString(dateLocale,{hour:'2-digit',minute:'2-digit'});
var id=msg.ID||msg.id;
+ // Gap detection: only show if IDs are close enough to be meaningful
+ // Telegram IDs can have natural gaps; only flag when we have stored history
+ // and the gap is between messages we've seen before vs new ones
+ if(prevId>0&&id>prevId+1){
+ var gap=id-prevId-1;
+ // Only show gap separator for small-ish gaps (likely real missed messages)
+ // and only when we have more than just a few messages (initial fetch won't trigger)
+ if(gap<=500&&msgs.length>=10){
+ html+='
'+t('missed_messages').replace('{n}',gap)+'
';
+ }
+ }
+ prevId=id;
+ var ts=new Date((msg.Timestamp||msg.timestamp)*1000);
+ var dateStr=ts.toLocaleDateString(dateLocale,dateOpts);
+ if(dateStr!==lastDate){html+='
'+dateStr+'
';lastDate=dateStr}
+ var timeStr=ts.toLocaleTimeString(dateLocale,{hour:'2-digit',minute:'2-digit'});
var text=msg.Text||msg.text||'';
+ currentMsgTexts.push(text);
var mediaHtml='',textHtml=esc(text);
var mediaTypes=['[IMAGE]','[VIDEO]','[FILE]','[AUDIO]','[STICKER]','[GIF]','[POLL]','[CONTACT]','[LOCATION]'];
for(var m=0;m'+mediaHtml+textHtml+'
#'+id+''+timeStr+'
';
+ html+='
'+mediaHtml+textHtml+'
#'+id+''+timeStr+'
';
}
- el.innerHTML=html;el.scrollTop=el.scrollHeight;
+ el.innerHTML=html;
+ if(isFirstRender||wasAtBottom)el.scrollTop=el.scrollHeight;
}
// ===== LOG =====
@@ -910,6 +1054,7 @@ function updateServerFetchDisplay(line){
item.dataset.total=total;
item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+total+'s';
item.querySelector('.progress-fill').style.width='0%';
+ item.dataset.lastUpdate=Date.now();
return;
}
if(!item)return;
@@ -920,6 +1065,7 @@ function updateServerFetchDisplay(line){
var pct=Math.round(((total2-remaining)/total2)*100);
item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+remaining+'s';
item.querySelector('.progress-fill').style.width=pct+'%';
+ item.dataset.lastUpdate=Date.now();
return;
}
// SERVER_FETCH_WAIT done
@@ -930,6 +1076,19 @@ function updateServerFetchDisplay(line){
}
}
+function ensureResolverScanItem(){
+ var item=document.getElementById('prog-resolvers');
+ if(!item){
+ var panel=document.getElementById('progressPanel');
+ // Remove the generic init loading bar when resolver scan takes over
+ var initItem=document.getElementById('prog-init');if(initItem)initItem.remove();
+ item=document.createElement('div');item.id='prog-resolvers';item.className='progress-item';
+ item.innerHTML='
';
+ panel.insertBefore(item,panel.firstChild);
+ }
+ return item;
+}
+
function updateResolverScanDisplay(line){
var panel=document.getElementById('progressPanel');
var item=document.getElementById('prog-resolvers');
@@ -938,19 +1097,18 @@ function updateResolverScanDisplay(line){
if(startMatch){
var total=parseInt(startMatch[1]);
resolverScanDone=0;resolverScanHealthy=0;resolverScanTotal=total;
- if(!item){
- item=document.createElement('div');item.id='prog-resolvers';item.className='progress-item';
- item.innerHTML='
';
- panel.insertBefore(item,panel.firstChild);
- }
+ item=ensureResolverScanItem();
item.dataset.total=total;
item.querySelector('.progress-label').textContent=t('scanning_resolvers')+' 0/'+total;
item.querySelector('.progress-fill').style.width='0%';
+ item.dataset.lastUpdate=Date.now();
resolverScanHint=t('scanning_resolvers')+'... ';
var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=resolverScanHint;
return;
}
- if(!item)return;
+ // If the item was removed (e.g. SSE reconnect cleared the panel) but scan
+ // is still in progress, re-create it so progress updates keep showing.
+ if(!item)item=ensureResolverScanItem();
// RESOLVER_SCAN progress D/T healthy=H
var progMatch=line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)(?: healthy=(\d+))?/);
if(progMatch){
@@ -962,6 +1120,7 @@ function updateResolverScanDisplay(line){
var label=t('scanning_resolvers')+' '+done+'/'+tot+' \u2713'+resolverScanHealthy;
item.querySelector('.progress-label').textContent=label;
item.querySelector('.progress-fill').style.width=pct+'%';
+ item.dataset.lastUpdate=Date.now();
resolverScanHint=t('scanning_resolvers')+' ('+done+'/'+tot+', \u2713'+resolverScanHealthy+')'+ ' ';
var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=resolverScanHint;
return;
@@ -974,6 +1133,8 @@ function updateResolverScanDisplay(line){
item.querySelector('.progress-label').textContent=t('scanning_resolvers')+': '+healthy+'/'+total2+' active';
item.querySelector('.progress-fill').style.width='100%';
resolverScanHint='';
+ // Remove init loading bar if it's still around
+ var initItem=document.getElementById('prog-init');if(initItem)initItem.remove();
var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=t('no_channels_hint')+' '+t('no_channels_hint2');
setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},2000);
// Scan is done — load channels in case the SSE 'update' event was dropped.
@@ -1000,9 +1161,21 @@ function updateProgressDisplay(line){
}
item.querySelector('.progress-label').textContent=label;
item.querySelector('.progress-fill').style.width=percent+'%';
+ // Mark last-update time so stale items can be cleaned
+ item.dataset.lastUpdate=Date.now();
if(percent>=100)setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},800);
}
+// Periodically remove stale progress items that stopped updating (missed 'done' event)
+setInterval(function(){
+ var now=Date.now();
+ var items=document.getElementById('progressPanel').querySelectorAll('.progress-item');
+ for(var i=0;i0&&now-lu>30000)items[i].remove();
+ }
+},10000);
+
function toggleLog(){
logVisible=!logVisible;
var p=document.getElementById('logPanel');var ic=document.getElementById('logToggleIcon');
@@ -1025,7 +1198,7 @@ function jumpToLog(){
function showInitProgress(){
document.getElementById('progressPanel').innerHTML='';
var p=document.getElementById('progressPanel');
- p.innerHTML='
'+t('loading')+'
';
+ p.innerHTML='
'+t('loading')+'
';
if(window.innerWidth<=768){openChat();openLog();}
}
function startAutoRefresh(){if(autoRefreshTimer)return;autoRefreshTimer=setInterval(function(){if(selectedChannel>0)doRefresh(true)},600000)}
@@ -1035,11 +1208,21 @@ function updateNextFetchDisplay(){
var info=document.getElementById('nextFetchInfoBtn');
if(!serverNextFetch){el.textContent='';if(info)info.style.display='none';return}
if(info){info.style.display='';info.title=t('next_fetch_info')}
+ var autoRefreshed=false;
function tick(){var now=Math.floor(Date.now()/1000),d=serverNextFetch-now;
- if(d<=0){el.textContent='';return}
+ if(d<=0){el.textContent='';
+ if(!autoRefreshed){autoRefreshed=true;setTimeout(function(){doAutoRefreshAfterCountdown()},3000)}
+ return}
var m=Math.floor(d/60),s=d%60;el.textContent=m+':'+(s<10?'0':'')+s}
tick();nextFetchInterval=setInterval(tick,1000);
}
+async function doRefreshUI(){
+ var btn=document.getElementById('refreshBtn');
+ btn.style.animation='spin .8s linear';
+ showToast(t('refreshing'));
+ await doRefresh(false);
+ setTimeout(function(){btn.style.animation=''},800);
+}
async function doRefresh(quiet){
try{
var url='/api/refresh';
@@ -1050,6 +1233,26 @@ async function doRefresh(quiet){
}catch(e){}
}
+async function doAutoRefreshAfterCountdown(){
+ // Auto-triggered when server fetch countdown reaches 0
+ // Clear metadata cache so we get fresh data
+ try{localStorage.removeItem(cacheKey())}catch(e){}
+ try{
+ // Reload channels (this also fetches /api/status and updates the timer)
+ await loadChannels();
+ // If we have a selected channel, reload its messages too
+ if(selectedChannel>0)await loadMessages(selectedChannel);
+ // If the timer still didn't show (server may not have refreshed yet), retry after a delay
+ if(!serverNextFetch||serverNextFetch<=Math.floor(Date.now()/1000)){
+ setTimeout(async function(){
+ try{var sr=await fetch('/api/status');var st=await sr.json();
+ if(st.nextFetch&&st.nextFetch>Math.floor(Date.now()/1000)){serverNextFetch=st.nextFetch;updateNextFetchDisplay()}
+ }catch(e){}
+ },15000);
+ }
+ }catch(e){}
+}
+
// ===== SEND =====
async function sendMessage(){
var input=document.getElementById('sendInput');var text=input.value.trim();
@@ -1064,7 +1267,7 @@ async function sendMessage(){
// ===== COPY MSG =====
function copyMsg(idx){
var text=currentMsgTexts[idx];if(text===undefined)return;
- navigator.clipboard.writeText(text).then(function(){showToast(t('copied'))}).catch(function(){});
+ navigator.clipboard.writeText(text).then(function(){showToast(t('msg_copied'))}).catch(function(){});
}
// ===== TOAST =====
diff --git a/internal/web/web.go b/internal/web/web.go
index a93da11..8893046 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -126,6 +126,20 @@ func New(dataDir string, port int, password string) (*Server, error) {
if err := s.initFetcher(); err != nil {
log.Printf("Warning: could not initialize fetcher: %v", err)
}
+ } else {
+ // config.json missing — try to bootstrap from the active profile
+ if pl, plErr := s.loadProfiles(); plErr == nil && pl.Active != "" {
+ for _, p := range pl.Profiles {
+ if p.ID == pl.Active {
+ _ = s.saveConfig(&p.Config)
+ s.config = &p.Config
+ if err := s.initFetcher(); err != nil {
+ log.Printf("Warning: could not initialize fetcher from profile: %v", err)
+ }
+ break
+ }
+ }
+ }
}
return s, nil
@@ -143,12 +157,14 @@ func (s *Server) Run() error {
mux.HandleFunc("/api/channels", s.handleChannels)
mux.HandleFunc("/api/messages/", s.handleMessages)
mux.HandleFunc("/api/refresh", s.handleRefresh)
+ mux.HandleFunc("/api/rescan", s.handleRescan)
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/api/admin", s.handleAdmin)
mux.HandleFunc("/api/events", s.handleSSE)
mux.HandleFunc("/api/profiles", s.handleProfiles)
mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch)
mux.HandleFunc("/api/settings", s.handleSettings)
+ mux.HandleFunc("/api/cache/clear", s.handleClearCache)
mux.HandleFunc("/", s.handleIndex)
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
@@ -314,6 +330,27 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true})
}
+func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", 405)
+ return
+ }
+ s.mu.RLock()
+ checker := s.checker
+ baseCtx := s.fetcherCtx
+ s.mu.RUnlock()
+ if checker == nil || baseCtx == nil {
+ http.Error(w, "not configured", 400)
+ return
+ }
+ go func() {
+ if checker.CheckNow(baseCtx) {
+ s.refreshMetadataOnly()
+ }
+ }()
+ writeJSON(w, map[string]any{"ok": true})
+}
+
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
@@ -512,6 +549,7 @@ func (s *Server) initFetcher() error {
defer s.mu.Unlock()
// Cancel goroutines from the previous fetcher configuration.
+ // This also cancels any in-progress manual rescan (via the context chain).
if s.fetcherCancel != nil {
s.fetcherCancel()
}
@@ -1002,6 +1040,8 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
pl = &ProfileList{}
}
+ needsReinit := false
+
switch req.Action {
case "create":
req.Profile.ID = generateID()
@@ -1011,12 +1051,16 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
pl.Profiles = append(pl.Profiles, req.Profile)
if len(pl.Profiles) == 1 {
pl.Active = req.Profile.ID
+ needsReinit = true
}
case "update":
for i, p := range pl.Profiles {
if p.ID == req.Profile.ID {
pl.Profiles[i] = req.Profile
+ if p.ID == pl.Active {
+ needsReinit = true
+ }
break
}
}
@@ -1029,6 +1073,7 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
pl.Active = ""
if len(pl.Profiles) > 0 {
pl.Active = pl.Profiles[0].ID
+ needsReinit = true
}
}
break
@@ -1060,8 +1105,8 @@ func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
return
}
- // If active profile changed, update config.json and re-init the fetcher.
- if pl.Active != "" {
+ // Only re-init the fetcher when the active profile's config was modified.
+ if needsReinit && pl.Active != "" {
for _, p := range pl.Profiles {
if p.ID == pl.Active {
_ = s.saveConfig(&p.Config)
@@ -1189,3 +1234,27 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", 405)
}
}
+
+// handleClearCache deletes all files in the cache directory.
+func (s *Server) handleClearCache(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", 405)
+ return
+ }
+ cacheDir := filepath.Join(s.dataDir, "cache")
+ entries, err := os.ReadDir(cacheDir)
+ if err != nil {
+ writeJSON(w, map[string]any{"ok": true, "deleted": 0})
+ return
+ }
+ deleted := 0
+ for _, e := range entries {
+ if !e.IsDir() {
+ if os.Remove(filepath.Join(cacheDir, e.Name())) == nil {
+ deleted++
+ }
+ }
+ }
+ s.addLog(fmt.Sprintf("Cache cleared: %d files deleted", deleted))
+ writeJSON(w, map[string]any{"ok": true, "deleted": deleted})
+}
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index 3606c48..2e0c4c5 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -1319,3 +1319,164 @@ func TestE2E_WebAPI_SendTooLong(t *testing.T) {
t.Errorf("send too long: expected 400, got %d", resp.StatusCode)
}
}
+
+// --- Cache Clear API Tests ---
+
+func TestE2E_WebAPI_ClearCache_Empty(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp := postJSON(t, base+"/api/cache/clear", "")
+ m := decodeJSON(t, resp)
+ if resp.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ if m["ok"] != true {
+ t.Errorf("expected ok=true, got %v", m["ok"])
+ }
+ if m["deleted"] != float64(0) {
+ t.Errorf("deleted = %v, want 0", m["deleted"])
+ }
+}
+
+func TestE2E_WebAPI_ClearCache_WithFiles(t *testing.T) {
+ domain := "cache.example.com"
+ passphrase := "cache-test-key"
+ channels := []string{"cached"}
+ msgs := map[int][]protocol.Message{
+ 1: {{ID: 1, Timestamp: 1700000000, Text: "Cached msg"}},
+ }
+
+ resolver, cancelDNS := startDNSServer(t, domain, passphrase, channels, msgs)
+ defer cancelDNS()
+
+ dataDir := t.TempDir()
+ port := findFreePort(t, "tcp")
+ srv, err := web.New(dataDir, port, "")
+ if err != nil {
+ t.Fatalf("create web server: %v", err)
+ }
+ go srv.Run()
+ time.Sleep(200 * time.Millisecond)
+ base := fmt.Sprintf("http://127.0.0.1:%d", port)
+
+ // Configure and trigger channel fetch to populate cache.
+ cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`,
+ domain, passphrase, resolver)
+ http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON))
+ time.Sleep(2 * time.Second) // wait for resolver scan + metadata
+
+ http.Post(base+"/api/refresh?channel=1", "application/json", nil)
+ time.Sleep(1500 * time.Millisecond)
+
+ // Verify cache files exist.
+ cacheDir := dataDir + "/cache"
+ entries, _ := os.ReadDir(cacheDir)
+ if len(entries) == 0 {
+ t.Fatal("expected cache files to exist after fetch")
+ }
+
+ // Clear cache.
+ resp := postJSON(t, base+"/api/cache/clear", "")
+ m := decodeJSON(t, resp)
+ if resp.StatusCode != 200 {
+ t.Fatalf("clear cache: expected 200, got %d", resp.StatusCode)
+ }
+ if m["ok"] != true {
+ t.Errorf("expected ok=true, got %v", m["ok"])
+ }
+ deleted, _ := m["deleted"].(float64)
+ if deleted == 0 {
+ t.Error("expected deleted > 0 after clearing populated cache")
+ }
+
+ // Verify cache dir is empty.
+ entries2, _ := os.ReadDir(cacheDir)
+ if len(entries2) != 0 {
+ t.Errorf("expected 0 files after clear, got %d", len(entries2))
+ }
+}
+
+func TestE2E_WebAPI_ClearCache_MethodNotAllowed(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp, err := http.Get(base + "/api/cache/clear")
+ if err != nil {
+ t.Fatalf("GET /api/cache/clear: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 405 {
+ t.Errorf("expected 405, got %d", resp.StatusCode)
+ }
+}
+
+// --- Rescan API Tests ---
+
+func TestE2E_WebAPI_Rescan_NotConfigured(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp := postJSON(t, base+"/api/rescan", "")
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("rescan without config: expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_Rescan_MethodNotAllowed(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp, err := http.Get(base + "/api/rescan")
+ if err != nil {
+ t.Fatalf("GET /api/rescan: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 405 {
+ t.Errorf("expected 405, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_Rescan_Configured(t *testing.T) {
+ domain := "rescan.example.com"
+ passphrase := "rescan-test-key"
+ channels := []string{"rescanned"}
+ msgs := map[int][]protocol.Message{
+ 1: {{ID: 1, Timestamp: 1700000000, Text: "test"}},
+ }
+
+ resolver, cancelDNS := startDNSServer(t, domain, passphrase, channels, msgs)
+ defer cancelDNS()
+
+ dataDir := t.TempDir()
+ port := findFreePort(t, "tcp")
+ srv, err := web.New(dataDir, port, "")
+ if err != nil {
+ t.Fatalf("create web server: %v", err)
+ }
+ go srv.Run()
+ time.Sleep(200 * time.Millisecond)
+ base := fmt.Sprintf("http://127.0.0.1:%d", port)
+
+ cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`,
+ domain, passphrase, resolver)
+ http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON))
+ time.Sleep(2 * time.Second)
+
+ // Call rescan — should succeed.
+ resp := postJSON(t, base+"/api/rescan", "")
+ m := decodeJSON(t, resp)
+ if resp.StatusCode != 200 {
+ t.Fatalf("rescan: expected 200, got %d", resp.StatusCode)
+ }
+ if m["ok"] != true {
+ t.Errorf("expected ok=true, got %v", m["ok"])
+ }
+
+ // Rapid double-call should also succeed (second is a no-op via TryLock).
+ resp2 := postJSON(t, base+"/api/rescan", "")
+ m2 := decodeJSON(t, resp2)
+ if resp2.StatusCode != 200 {
+ t.Fatalf("rescan double: expected 200, got %d", resp2.StatusCode)
+ }
+ if m2["ok"] != true {
+ t.Errorf("double rescan: expected ok=true, got %v", m2["ok"])
+ }
+}