mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 05:24:36 +03:00
feat: add cache clear and rescan APIs and message cache
- Implemented /api/cache/clear endpoint to delete all files in the cache directory. - Added tests for cache clear functionality, including cases for empty cache and method not allowed. - Introduced /api/rescan endpoint to trigger a manual rescan of the configured channels. - Added tests for rescan functionality, covering cases for not configured, method not allowed, and successful rescans. - Enhanced server initialization to bootstrap configuration from active profile if config.json is missing.
This commit is contained in:
@@ -25,5 +25,6 @@ session.json
|
||||
.env
|
||||
|
||||
todo
|
||||
todo.md
|
||||
|
||||
tmp
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.net.Uri
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
@@ -103,6 +104,19 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun configureWebView() {
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url ?: return false
|
||||
// External links (anything not our local server) open in the system browser
|
||||
if (url.host != "127.0.0.1") {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, url))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if (url != null && url.startsWith("http://127.0.0.1")) {
|
||||
setStatus("")
|
||||
|
||||
@@ -27,6 +27,10 @@ class ThefeedService : Service() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// If the process died, restart it
|
||||
if (process == null || !isProcessAlive()) {
|
||||
startClientProcessAsync()
|
||||
@@ -45,6 +49,8 @@ class ThefeedService : Service() {
|
||||
process = null
|
||||
savePort(-1)
|
||||
super.onDestroy()
|
||||
// Kill the entire app process so the activity doesn't remain open
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
@@ -160,12 +166,21 @@ class ThefeedService : Service() {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, ThefeedService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this, 1, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("thefeed")
|
||||
.setContentText(message)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Exit", stopPendingIntent)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
@@ -184,5 +199,6 @@ class ThefeedService : Service() {
|
||||
const val NOTIFICATION_ID = 1201
|
||||
const val PREFS_NAME = "thefeed_runtime"
|
||||
const val PREF_PORT = "port"
|
||||
const val ACTION_STOP = "com.thefeed.android.STOP"
|
||||
}
|
||||
}
|
||||
|
||||
+73
-15
@@ -17,10 +17,13 @@ import (
|
||||
// updates the active (healthy) resolver pool. It replaces the old file/CIDR
|
||||
// scanner — no file I/O; just a plain DNS probe on channel 0.
|
||||
type ResolverChecker struct {
|
||||
fetcher *Fetcher
|
||||
timeout time.Duration
|
||||
logFunc LogFunc
|
||||
started atomic.Bool // guards against double-start
|
||||
fetcher *Fetcher
|
||||
timeout time.Duration
|
||||
logFunc LogFunc
|
||||
started atomic.Bool // guards against double-start
|
||||
scanMu sync.Mutex // protects scanCancel
|
||||
scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock)
|
||||
scanCancel context.CancelFunc // cancels the currently running CheckNow
|
||||
}
|
||||
|
||||
// NewResolverChecker creates a health checker for the resolvers in fetcher.
|
||||
@@ -110,12 +113,46 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
|
||||
}()
|
||||
}
|
||||
|
||||
// CancelCurrentScan cancels any in-progress CheckNow call, causing it to
|
||||
// return early without updating the resolver list.
|
||||
func (rc *ResolverChecker) CancelCurrentScan() {
|
||||
rc.scanMu.Lock()
|
||||
if rc.scanCancel != nil {
|
||||
rc.scanCancel()
|
||||
rc.scanCancel = nil
|
||||
}
|
||||
rc.scanMu.Unlock()
|
||||
}
|
||||
|
||||
// CheckNow runs a single resolver health-check pass immediately.
|
||||
// ctx is used to abort in-flight probes early (e.g. when a profile is switched).
|
||||
func (rc *ResolverChecker) CheckNow(ctx context.Context) {
|
||||
// If a scan is already in progress the call is a no-op (returns false).
|
||||
// Returns true if the scan ran to completion.
|
||||
// Use CancelCurrentScan to abort a running scan from outside.
|
||||
func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
|
||||
// Non-blocking: if another scan is running, skip.
|
||||
if !rc.scanRunMu.TryLock() {
|
||||
return false
|
||||
}
|
||||
defer rc.scanRunMu.Unlock()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scanCtx, cancel := context.WithCancel(ctx)
|
||||
rc.scanMu.Lock()
|
||||
rc.scanCancel = cancel
|
||||
rc.scanMu.Unlock()
|
||||
defer func() {
|
||||
cancel()
|
||||
rc.scanMu.Lock()
|
||||
rc.scanCancel = nil
|
||||
rc.scanMu.Unlock()
|
||||
}()
|
||||
|
||||
resolvers := rc.fetcher.AllResolvers()
|
||||
if len(resolvers) == 0 {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
total := len(resolvers)
|
||||
@@ -129,7 +166,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) {
|
||||
|
||||
for _, r := range resolvers {
|
||||
// Stop launching new probes if context was cancelled.
|
||||
if ctx.Err() != nil {
|
||||
if scanCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
@@ -138,7 +175,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) {
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
ok := rc.checkOne(ctx, r)
|
||||
ok := rc.checkOne(scanCtx, r)
|
||||
mu.Lock()
|
||||
if ok {
|
||||
healthy = append(healthy, r)
|
||||
@@ -153,18 +190,19 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) {
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return // context cancelled — don't update resolver list
|
||||
if scanCtx.Err() != nil {
|
||||
return false // context cancelled — don't update resolver list
|
||||
}
|
||||
|
||||
rc.fetcher.SetActiveResolvers(healthy)
|
||||
if len(healthy) == 0 {
|
||||
rc.log("Resolver check done: 0/%d healthy", len(resolvers))
|
||||
rc.log("RESOLVER_SCAN done 0/%d", total)
|
||||
return
|
||||
return true
|
||||
}
|
||||
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
|
||||
rc.log("RESOLVER_SCAN done %d/%d", len(healthy), total)
|
||||
return true
|
||||
}
|
||||
|
||||
// checkOne probes a single resolver by sending a metadata channel query
|
||||
@@ -197,12 +235,32 @@ func (rc *ResolverChecker) checkOne(ctx context.Context, resolver string) bool {
|
||||
m.RecursionDesired = true
|
||||
m.SetEdns0(4096, false)
|
||||
|
||||
type exResult struct {
|
||||
resp *dns.Msg
|
||||
latency time.Duration
|
||||
err error
|
||||
}
|
||||
ch := make(chan exResult, 1)
|
||||
start := time.Now()
|
||||
resp, _, err := c.ExchangeContext(probeCtx, m, resolver)
|
||||
latency := time.Since(start)
|
||||
if err != nil || resp == nil {
|
||||
go func() {
|
||||
r, _, e := c.ExchangeContext(probeCtx, m, resolver)
|
||||
ch <- exResult{r, time.Since(start), e}
|
||||
}()
|
||||
|
||||
var resp *dns.Msg
|
||||
var latency time.Duration
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel() // ensure probeCtx resources freed
|
||||
rc.fetcher.RecordFailure(resolver)
|
||||
return false
|
||||
case res := <-ch:
|
||||
resp = res.resp
|
||||
latency = res.latency
|
||||
if res.err != nil || resp == nil {
|
||||
rc.fetcher.RecordFailure(resolver)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Require a decodable TXT record — same check as a real fetch.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newCheckerWithFetcher(t *testing.T) (*ResolverChecker, *Fetcher) {
|
||||
t.Helper()
|
||||
// Use localhost non-listening ports so no real DNS traffic leaves the machine.
|
||||
f := newTestFetcher(t, []string{"127.0.0.1:19753", "127.0.0.1:19754"})
|
||||
rc := NewResolverChecker(f, 200*time.Millisecond)
|
||||
return rc, f
|
||||
}
|
||||
|
||||
func TestResolverChecker_DefaultTimeout(t *testing.T) {
|
||||
f := newTestFetcher(t, []string{"127.0.0.1:19753"})
|
||||
rc := NewResolverChecker(f, 0)
|
||||
if rc.timeout != 15*time.Second {
|
||||
t.Errorf("default timeout = %v, want 15s", rc.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverChecker_CheckNow_SkipsWhenRunning(t *testing.T) {
|
||||
rc, _ := newCheckerWithFetcher(t)
|
||||
|
||||
// Manually lock to simulate a running scan.
|
||||
rc.scanRunMu.Lock()
|
||||
|
||||
// CheckNow should return false immediately (TryLock fails).
|
||||
result := rc.CheckNow(context.Background())
|
||||
if result {
|
||||
t.Error("CheckNow should return false when another scan is running")
|
||||
}
|
||||
|
||||
rc.scanRunMu.Unlock()
|
||||
}
|
||||
|
||||
func TestResolverChecker_CheckNow_CancelledContext(t *testing.T) {
|
||||
rc, _ := newCheckerWithFetcher(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled
|
||||
|
||||
result := rc.CheckNow(ctx)
|
||||
if result {
|
||||
t.Error("CheckNow should return false with cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverChecker_CheckNow_NoResolvers(t *testing.T) {
|
||||
f := newTestFetcher(t, nil) // no resolvers
|
||||
rc := NewResolverChecker(f, 200*time.Millisecond)
|
||||
|
||||
result := rc.CheckNow(context.Background())
|
||||
if !result {
|
||||
t.Error("CheckNow should return true when there are no resolvers")
|
||||
}
|
||||
}
|
||||
|
||||
// udpBlackhole opens a UDP listener that reads and discards all packets.
|
||||
// Returns the address and a cleanup function.
|
||||
func udpBlackhole(t *testing.T) string {
|
||||
t.Helper()
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen udp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
_, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// discard — never respond
|
||||
}
|
||||
}()
|
||||
return conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
func TestResolverChecker_CancelCurrentScan(t *testing.T) {
|
||||
// UDP blackhole: listener accepts packets but never responds,
|
||||
// so probes block until their context is cancelled.
|
||||
addr := udpBlackhole(t)
|
||||
f := newTestFetcher(t, []string{addr})
|
||||
rc := NewResolverChecker(f, 30*time.Second)
|
||||
|
||||
var scanDone sync.WaitGroup
|
||||
scanDone.Add(1)
|
||||
go func() {
|
||||
defer scanDone.Done()
|
||||
rc.CheckNow(context.Background())
|
||||
}()
|
||||
|
||||
// Give the goroutine time to start.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel should make the running scan return.
|
||||
rc.CancelCurrentScan()
|
||||
scanDone.Wait() // should not hang
|
||||
}
|
||||
|
||||
func TestResolverChecker_ConcurrentCheckNow_OnlyOneRuns(t *testing.T) {
|
||||
rc, _ := newCheckerWithFetcher(t)
|
||||
|
||||
// Fire 5 concurrent CheckNow calls.
|
||||
var wg sync.WaitGroup
|
||||
results := make([]bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
results[idx] = rc.CheckNow(context.Background())
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// At most 1 should have actually run (returned true or false depending on
|
||||
// the mock — but only 1 should have entered the scan body).
|
||||
trueCount := 0
|
||||
for _, r := range results {
|
||||
if r {
|
||||
trueCount++
|
||||
}
|
||||
}
|
||||
// Most should be false (skipped), at most 1 true.
|
||||
if trueCount > 1 {
|
||||
t.Errorf("expected at most 1 CheckNow to run, got %d", trueCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverChecker_StartAndNotify_OnlyOnce(t *testing.T) {
|
||||
rc, _ := newCheckerWithFetcher(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// StartAndNotify should only allow one start — the second call is a no-op.
|
||||
rc.StartAndNotify(ctx, nil)
|
||||
|
||||
// started flag should be true.
|
||||
if !rc.started.Load() {
|
||||
t.Error("started flag should be true after StartAndNotify")
|
||||
}
|
||||
|
||||
// Second call should not panic and should be a no-op.
|
||||
rc.StartAndNotify(ctx, nil)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestResolverChecker_SetLogFunc(t *testing.T) {
|
||||
rc, _ := newCheckerWithFetcher(t)
|
||||
|
||||
var logged []string
|
||||
var mu sync.Mutex
|
||||
rc.SetLogFunc(func(msg string) {
|
||||
mu.Lock()
|
||||
logged = append(logged, msg)
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
rc.log("test %d", 42)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(logged) != 1 || logged[0] != "test 42" {
|
||||
t.Errorf("logged = %v, want [test 42]", logged)
|
||||
}
|
||||
}
|
||||
+239
-36
@@ -29,7 +29,8 @@ input,textarea,select{font-family:inherit}
|
||||
.profile-btn:hover{background:var(--hover)}
|
||||
.profile-btn-avatar{width:24px;height:24px;border-radius:50%;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.profile-btn-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.profile-btn-arrow{font-size:10px;color:var(--text-dim);flex-shrink:0}
|
||||
.profile-btn-arrow{font-size:10px;color:var(--text-dim);flex-shrink:0;display:flex;align-items:center;gap:2px}
|
||||
.profile-btn-arrow .plus{font-size:16px;font-weight:700;color:var(--accent)}
|
||||
.sidebar-search{padding:7px 12px;border:none;border-radius:18px;background:var(--surface);color:var(--text);font-size:13px;outline:none;width:100%}
|
||||
.sidebar-search::placeholder{color:var(--text-dim)}
|
||||
.icon-btn{width:36px;height:36px;border:none;background:none;color:var(--text-dim);font-size:17px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
@@ -38,7 +39,7 @@ input,textarea,select{font-family:inherit}
|
||||
/* ===== CHANNEL LIST ===== */
|
||||
.channel-list{flex:1;overflow-y:auto}
|
||||
.channel-section-title{padding:8px 14px 4px;font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px}
|
||||
.ch-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;transition:background .1s;gap:10px}
|
||||
.ch-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;gap:10px;contain:content}
|
||||
.ch-item:hover{background:var(--hover)}
|
||||
.ch-item.active{background:var(--accent)}
|
||||
.ch-item.active .ch-name,.ch-item.active .ch-preview{color:#fff}
|
||||
@@ -75,6 +76,8 @@ input,textarea,select{font-family:inherit}
|
||||
.messages{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:4px;direction:ltr}
|
||||
.msg-date-sep{text-align:center;padding:8px 0;font-size:12px;color:var(--text-dim)}
|
||||
.msg-date-sep span{background:rgba(0,0,0,.3);padding:3px 10px;border-radius:10px}
|
||||
.msg-gap-sep{text-align:center;padding:6px 0;font-size:11px;color:var(--error)}
|
||||
.msg-gap-sep span{background:rgba(229,57,53,.12);padding:3px 10px;border-radius:10px;border:1px dashed rgba(229,57,53,.3)}
|
||||
.msg{max-width:min(82%,580px);padding:7px 10px 4px;border-radius:12px;line-height:1.7;word-break:break-word;white-space:pre-wrap;font-size:inherit;background:var(--msg-in);border:1px solid rgba(255,255,255,.07);align-self:flex-start;border-bottom-left-radius:4px}
|
||||
.msg-meta{display:flex;justify-content:flex-end;gap:6px;font-size:10px;color:var(--text-dim);margin-top:2px;direction:ltr}
|
||||
.media-tag{display:block;padding:2px 6px;border-radius:4px;background:rgba(51,144,236,.15);color:var(--accent);font-size:11px;margin-bottom:6px}
|
||||
@@ -96,7 +99,10 @@ input,textarea,select{font-family:inherit}
|
||||
.progress-bar{width:100%;height:3px;background:var(--border);border-radius:2px;overflow:hidden}
|
||||
.progress-fill{height:100%;background:var(--accent);transition:width .2s}
|
||||
@keyframes prog-pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:11px;cursor:pointer;padding:0 2px;opacity:0;transition:opacity .15s;line-height:1;flex-shrink:0}.msg:hover .msg-copy-btn{opacity:1}
|
||||
@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
@keyframes badge-pulse{0%,100%{box-shadow:0 0 0 0 rgba(51,144,236,.4)}50%{box-shadow:0 0 0 4px rgba(51,144,236,0)}}
|
||||
.refresh-has-new{animation:badge-pulse 2s ease-in-out infinite;color:var(--accent)!important}
|
||||
.msg-copy-btn{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 3px;line-height:1;flex-shrink:0;opacity:.45;transition:opacity .15s}.msg-copy-btn:hover{opacity:1}
|
||||
|
||||
/* ===== LOG ===== */
|
||||
.log-toggle{display:flex;align-items:center;justify-content:space-between;padding:3px 14px;background:var(--bg2);cursor:pointer;user-select:none;font-size:10px;color:var(--text-dim);letter-spacing:.5px;border-top:1px solid var(--border)}
|
||||
@@ -214,7 +220,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<button class="profile-btn" id="profileBtn" onclick="openProfiles()">
|
||||
<div class="profile-btn-avatar" id="profileBtnAvatar">?</div>
|
||||
<span class="profile-btn-name" id="profileBtnName" data-i18n="set_up">Set Up</span>
|
||||
<span class="profile-btn-arrow">▼</span>
|
||||
<span class="profile-btn-arrow"><span class="plus">+</span>▼</span>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings" data-i18n-title="settings">⚙</button>
|
||||
<button class="icon-btn" onclick="jumpToLog()" title="LOG">📜</button>
|
||||
@@ -240,7 +246,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<span class="next-fetch-label" id="nextFetchTimer"></span><span class="next-fetch-info" id="nextFetchInfoBtn" style="display:none" data-i18n-title="next_fetch_info" title="" onclick="showToast(t('next_fetch_info'))" tabindex="0">ⓘ</span>
|
||||
<button class="icon-btn" onclick="doRefresh()" title="Refresh">↻</button>
|
||||
<button class="icon-btn" id="refreshBtn" onclick="doRefreshUI()" title="Refresh" style="width:40px;height:40px;font-size:20px">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
@@ -289,6 +295,10 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<span data-i18n="version">Version</span>
|
||||
<span id="appVersionEl" style="font-family:monospace;color:var(--text)">-</span>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:12px;color:var(--text-dim)" data-i18n="clear_cache">Clear Cache</span>
|
||||
<button class="btn btn-flat" onclick="clearCache()" style="font-size:11px;padding:4px 12px;color:var(--danger,#e74c3c)" data-i18n="clear_cache">Clear Cache</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="closeSettings()" data-i18n="cancel">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="save">Save</button>
|
||||
@@ -300,9 +310,8 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<div class="modal-overlay" id="profilesModal">
|
||||
<div class="modal">
|
||||
<h2 data-i18n="profiles">Profiles</h2>
|
||||
<div id="profilesListEl"></div>
|
||||
<!-- Import URI -->
|
||||
<div class="import-section">
|
||||
<!-- Import URI at top -->
|
||||
<div class="import-section" style="margin-top:0;margin-bottom:14px;padding:10px 12px;background:var(--bg);border-radius:8px;border:1px solid var(--border)">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px" data-i18n="import_uri_label">Import URI</div>
|
||||
<div class="import-row">
|
||||
<input id="importUriInput" placeholder="thefeed://..." data-i18n-ph="import_uri_ph">
|
||||
@@ -311,9 +320,10 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
|
||||
<div id="importError" style="color:var(--error);font-size:12px;display:none;margin-top:6px"></div>
|
||||
<div id="importSuccess" style="color:var(--success);font-size:12px;display:none;margin-top:6px"></div>
|
||||
</div>
|
||||
<div id="profilesListEl"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-flat" onclick="closeProfiles()" data-i18n="close">Close</button>
|
||||
<button class="btn btn-primary" onclick="openProfileEditor(null)" data-i18n="add_profile">+ Add Profile</button>
|
||||
<button class="btn btn-outline" onclick="openProfileEditor(null)" data-i18n="add_manual">✎ Create Manually</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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+='<span class="active-badge">'+t('active')+'</span>';
|
||||
h+='</div><div class="profile-row-domain">'+esc(p.config.domain)+'</div></div>';
|
||||
h+='<div class="profile-row-btns">';
|
||||
if(isActive)h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();doRescanFromProfiles()" title="'+t('rescan')+'" style="color:var(--success)">🔄</button>';
|
||||
h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();toggleSharePanel(\''+p.id+'\')" title="'+t('share')+'">🔗</button>';
|
||||
h+='<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\''+p.id+'\')" title="'+t('edit')+'">✎</button>';
|
||||
h+='</div></div>';
|
||||
@@ -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')+' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> '+t('no_channels_hint2'));
|
||||
el.innerHTML='<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:13px">'+t('no_channels')+'<br><span id="no-ch-hint" style="font-size:11px;opacity:.7;line-height:1.8">'+_hint+'</span></div>';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;ci<channels.length;ci++){
|
||||
var ch=channels[ci],nm=ch.Name||ch.name||'Channel '+(ci+1);
|
||||
if(existingItems[ci].dataset.name!==nm){needsFullRebuild=true;break}
|
||||
}
|
||||
if(!needsFullRebuild){
|
||||
for(var ui=0;ui<channels.length;ui++){
|
||||
var num=ui+1;
|
||||
existingItems[ui].classList.toggle('active',num===selectedChannel);
|
||||
var lastID=channels[ui].LastMsgID||channels[ui].lastMsgID||0;
|
||||
var showBadge=previousMsgIDs[num]>0&&lastID>previousMsgIDs[num]&&num!==selectedChannel;
|
||||
var previewEl=existingItems[ui].querySelector('.ch-preview');
|
||||
if(previewEl)previewEl.innerHTML=showBadge?'<span class="ch-badge">NEW</span>':'';
|
||||
}
|
||||
_updateRefreshBadge();return;
|
||||
}
|
||||
}
|
||||
var pubs=[],privs=[];
|
||||
for(var i=0;i<channels.length;i++){var c=channels[i];(c.ChatType===1||c.chatType===1?privs:pubs).push({ch:c,idx:i})}
|
||||
function section(title,items){
|
||||
if(!items.length)return'';var h='';
|
||||
if(title)h+='<div class="channel-section-title">'+esc(title)+'</div>';
|
||||
for(var j=0;j<items.length;j++){
|
||||
var e=items[j],num=e.idx+1;
|
||||
var name=e.ch.Name||e.ch.name||'Channel '+num;
|
||||
var e=items[j],num2=e.idx+1;
|
||||
var name=e.ch.Name||e.ch.name||'Channel '+num2;
|
||||
var isPriv=e.ch.ChatType===1||e.ch.chatType===1;
|
||||
var active=num===selectedChannel?' active':'';
|
||||
var active=num2===selectedChannel?' active':'';
|
||||
var lastID=e.ch.LastMsgID||e.ch.lastMsgID||0;
|
||||
var badge=(previousMsgIDs[num]>0&&lastID>previousMsgIDs[num]&&num!==selectedChannel)?'<span class="ch-badge">NEW</span>':'';
|
||||
h+='<div class="ch-item'+active+'" data-name="'+esc(name)+'" onclick="selectChannel('+num+')">';
|
||||
var badge=(previousMsgIDs[num2]>0&&lastID>previousMsgIDs[num2]&&num2!==selectedChannel)?'<span class="ch-badge">NEW</span>':'';
|
||||
h+='<div class="ch-item'+active+'" data-name="'+esc(name)+'" onclick="selectChannel('+num2+')">';
|
||||
h+='<div class="ch-avatar">'+esc(name.charAt(0).toUpperCase())+'</div>';
|
||||
h+='<div class="ch-info"><div class="ch-name">'+esc(name)+(isPriv?'<span class="ch-type-tag">'+t('private')+'</span>':'')+'</div>';
|
||||
h+='<div class="ch-preview">'+badge+'</div></div></div>';
|
||||
@@ -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;k<channels.length;k++){
|
||||
var num3=k+1,lid=channels[k].LastMsgID||channels[k].lastMsgID||0;
|
||||
if(previousMsgIDs[num3]>0&&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='<div class="empty-state"><p>'+t('loading')+'</p></div>';
|
||||
// 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='<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">'+t('fetching_channel')+' '+(name||num)+'</div><div class="progress-bar"><div class="progress-fill" style="width:40%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div>';
|
||||
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;i<stored.length;i++){var id=stored[i].ID||stored[i].id;if(id){byId[id]=true;merged.push(stored[i])}}
|
||||
for(var j=0;j<fresh.length;j++){var id2=fresh[j].ID||fresh[j].id;if(id2&&!byId[id2]){merged.push(fresh[j])}}
|
||||
merged.sort(function(a,b){return(a.Timestamp||a.timestamp||0)-(b.Timestamp||b.timestamp||0)});
|
||||
if(merged.length>200)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='<div class="empty-state"><p>'+t('no_messages')+'</p><p style="font-size:12px;opacity:.6;margin-top:6px">'+t('no_messages_hint')+'</p></div>';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<msgs.length;i++){
|
||||
var msg=msgs[i];
|
||||
var ts=new Date((msg.Timestamp||msg.timestamp)*1000);
|
||||
var dateStr=ts.toLocaleDateString(dateLocale,{year:'numeric',month:'long',day:'numeric'});
|
||||
if(dateStr!==lastDate){html+='<div class="msg-date-sep"><span>'+dateStr+'</span></div>';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+='<div class="msg-gap-sep"><span>'+t('missed_messages').replace('{n}',gap)+'</span></div>';
|
||||
}
|
||||
}
|
||||
prevId=id;
|
||||
var ts=new Date((msg.Timestamp||msg.timestamp)*1000);
|
||||
var dateStr=ts.toLocaleDateString(dateLocale,dateOpts);
|
||||
if(dateStr!==lastDate){html+='<div class="msg-date-sep"><span dir="auto">'+dateStr+'</span></div>';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<mediaTypes.length;m++){
|
||||
@@ -871,9 +1014,10 @@ function renderMessages(msgs){
|
||||
textHtml=esc(text.substring(mediaTypes[m].length).replace(/^\n/,''));break
|
||||
}
|
||||
}
|
||||
html+='<div class="msg'+(isPersian(text)?' rtl-msg':'')+'" dir="auto">'+mediaHtml+textHtml+'<div class="msg-meta"><span>#'+id+'</span><span>'+timeStr+'</span></div></div>';
|
||||
html+='<div class="msg'+(isPersian(text)?' rtl-msg':'')+'" dir="auto">'+mediaHtml+textHtml+'<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg('+i+')" title="'+t('copy')+'">📋</button><span>#'+id+'</span><span>'+timeStr+'</span></div></div>';
|
||||
}
|
||||
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='<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
||||
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='<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
||||
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')+'... <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
||||
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+')'+ ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
||||
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')+' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> '+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;i<items.length;i++){
|
||||
var lu=parseInt(items[i].dataset.lastUpdate||'0');
|
||||
if(lu>0&&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='<div class="progress-item" id="prog-init"><button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">'+t('loading')+'</div><div class="progress-bar"><div class="progress-fill" style="width:30%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div></div>';
|
||||
p.innerHTML='<div class="progress-item" id="prog-init" data-last-update="'+Date.now()+'"><button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">'+t('loading')+'</div><div class="progress-bar"><div class="progress-fill" style="width:30%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div></div>';
|
||||
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 =====
|
||||
|
||||
+71
-2
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user