parallelclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /conversations — list with optional FTS5 search.
|
|
3
|
+
* GET /conversations/search — htmx partial (just the list).
|
|
4
|
+
*
|
|
5
|
+
* Query params:
|
|
6
|
+
* q — search query (FTS5 MATCH)
|
|
7
|
+
* source — filter by source ("telegram", "claude-code", etc.)
|
|
8
|
+
* limit — page size (default 50, max 200)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { renderPage, html, raw, esc, fmtDate, fmtNum } from '../templates.js';
|
|
12
|
+
|
|
13
|
+
const MAX_LIMIT = 200;
|
|
14
|
+
const DEFAULT_LIMIT = 50;
|
|
15
|
+
|
|
16
|
+
const SOURCES = ['telegram', 'claude-code', 'claude-cowork', 'cursor', 'obsidian', 'document'];
|
|
17
|
+
|
|
18
|
+
function clampLimit(raw) {
|
|
19
|
+
const n = parseInt(raw, 10);
|
|
20
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
|
|
21
|
+
return Math.min(n, MAX_LIMIT);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the conv-list HTML for either full page or htmx partial.
|
|
26
|
+
* Branches on whether `q` is present:
|
|
27
|
+
* - With q: FTS5 over messages_fts, group by conversation, rank by recency-boosted BM25
|
|
28
|
+
* - Without q: list conversations directly by last_ts DESC
|
|
29
|
+
*/
|
|
30
|
+
function fetchConversations(db, { q, source, limit }) {
|
|
31
|
+
if (q && q.trim()) {
|
|
32
|
+
// Sanitise query for FTS5: strip control chars but keep words/quotes/spaces.
|
|
33
|
+
const safe = q.trim().replace(/[^\p{L}\p{N}\s"'._-]/gu, ' ');
|
|
34
|
+
const sourceFilter = source ? 'AND c.source = @source' : '';
|
|
35
|
+
// snippet() can't survive an outer GROUP BY — we just count hits per chat.
|
|
36
|
+
// The snippet itself is rendered inside /c/:id?q=... via the highlight() helper.
|
|
37
|
+
const stmt = db.prepare(`
|
|
38
|
+
SELECT
|
|
39
|
+
c.conversation_id,
|
|
40
|
+
c.source,
|
|
41
|
+
c.title,
|
|
42
|
+
c.last_ts,
|
|
43
|
+
c.message_count,
|
|
44
|
+
COUNT(m.id) AS hit_count,
|
|
45
|
+
NULL AS snippet
|
|
46
|
+
FROM messages_fts
|
|
47
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
48
|
+
JOIN conversations c ON c.conversation_id = m.conversation_id
|
|
49
|
+
WHERE messages_fts MATCH @q
|
|
50
|
+
AND c.archived_at IS NULL
|
|
51
|
+
${sourceFilter}
|
|
52
|
+
GROUP BY c.conversation_id
|
|
53
|
+
ORDER BY c.last_ts DESC
|
|
54
|
+
LIMIT @limit
|
|
55
|
+
`);
|
|
56
|
+
return stmt.all({ q: safe, source, limit });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sourceFilter = source ? 'AND source = @source' : '';
|
|
60
|
+
return db
|
|
61
|
+
.prepare(
|
|
62
|
+
`
|
|
63
|
+
SELECT conversation_id, source, title, last_ts, message_count,
|
|
64
|
+
0 AS hit_count, NULL AS snippet
|
|
65
|
+
FROM conversations
|
|
66
|
+
WHERE archived_at IS NULL
|
|
67
|
+
AND message_count > 0
|
|
68
|
+
${sourceFilter}
|
|
69
|
+
ORDER BY last_ts DESC
|
|
70
|
+
LIMIT @limit
|
|
71
|
+
`
|
|
72
|
+
)
|
|
73
|
+
.all({ source, limit });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderList(rows, { q }) {
|
|
77
|
+
if (rows.length === 0) {
|
|
78
|
+
return html`
|
|
79
|
+
<div class="empty">
|
|
80
|
+
<h3>${q ? 'No matches' : 'No conversations'}</h3>
|
|
81
|
+
<p>${q ? 'Try a different query or remove filters.' : 'Wait for memex-sync to capture, or import Telegram exports.'}</p>
|
|
82
|
+
</div>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return html`
|
|
87
|
+
<div class="conv-list">
|
|
88
|
+
${rows.map(
|
|
89
|
+
(c) => html`
|
|
90
|
+
<a class="conv-item" href="/c/${encodeURIComponent(c.conversation_id)}${q ? '?q=' + encodeURIComponent(q) : ''}">
|
|
91
|
+
<div class="conv-item-top">
|
|
92
|
+
<span class="conv-title">${c.title || '(untitled)'}</span>
|
|
93
|
+
<span class="conv-count">
|
|
94
|
+
${c.hit_count > 0 ? `${fmtNum(c.hit_count)} hits · ` : ''}${fmtNum(c.message_count)} msgs
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="conv-meta">
|
|
98
|
+
<span class="conv-source-tag">${c.source}</span>
|
|
99
|
+
${fmtDate(c.last_ts)}
|
|
100
|
+
${c.snippet ? raw(` · <span class="search-meta">${c.snippet}</span>`) : null}
|
|
101
|
+
</div>
|
|
102
|
+
</a>
|
|
103
|
+
`
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function renderConversations(db, query, status) {
|
|
110
|
+
const q = query.q || '';
|
|
111
|
+
const source = query.source && SOURCES.includes(query.source) ? query.source : '';
|
|
112
|
+
const limit = clampLimit(query.limit);
|
|
113
|
+
|
|
114
|
+
let rows;
|
|
115
|
+
let error = null;
|
|
116
|
+
try {
|
|
117
|
+
rows = fetchConversations(db, { q, source, limit });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
rows = [];
|
|
120
|
+
error = e.message;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sourceChips = html`
|
|
124
|
+
<div class="search-bar" style="gap:6px;">
|
|
125
|
+
<a class="btn ${source ? '' : 'btn-primary'}" href="/conversations${q ? '?q=' + encodeURIComponent(q) : ''}">all</a>
|
|
126
|
+
${SOURCES.map(
|
|
127
|
+
(s) => html`
|
|
128
|
+
<a class="btn ${source === s ? 'btn-primary' : ''}" href="/conversations?source=${s}${q ? '&q=' + encodeURIComponent(q) : ''}">${s}</a>
|
|
129
|
+
`
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const searchBar = html`
|
|
135
|
+
<form class="search-bar" hx-get="/conversations/search" hx-target="#conv-list-target" hx-trigger="input changed delay:200ms, search" hx-include="[name=source]">
|
|
136
|
+
<input
|
|
137
|
+
class="search-input"
|
|
138
|
+
type="search"
|
|
139
|
+
name="q"
|
|
140
|
+
placeholder="🔍 Search conversations (FTS5)…"
|
|
141
|
+
value="${esc(q)}"
|
|
142
|
+
autocomplete="off"
|
|
143
|
+
/>
|
|
144
|
+
<input type="hidden" name="source" value="${esc(source)}" />
|
|
145
|
+
<span class="search-meta">${fmtNum(rows.length)} result${rows.length === 1 ? '' : 's'}${source ? ' · ' + source : ''}</span>
|
|
146
|
+
</form>
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const body = html`
|
|
150
|
+
${searchBar}
|
|
151
|
+
${sourceChips}
|
|
152
|
+
${error
|
|
153
|
+
? html`<div class="callout" style="border-left-color:var(--red-soft);"><strong>Search error:</strong> ${esc(error)}</div>`
|
|
154
|
+
: null}
|
|
155
|
+
<div id="conv-list-target">${renderList(rows, { q })}</div>
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
return renderPage({
|
|
159
|
+
title: 'Conversations',
|
|
160
|
+
active: 'conversations',
|
|
161
|
+
body,
|
|
162
|
+
status,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function renderConversationsPartial(db, query) {
|
|
167
|
+
const q = query.q || '';
|
|
168
|
+
const source = query.source && SOURCES.includes(query.source) ? query.source : '';
|
|
169
|
+
const limit = clampLimit(query.limit);
|
|
170
|
+
|
|
171
|
+
let rows;
|
|
172
|
+
try {
|
|
173
|
+
rows = fetchConversations(db, { q, source, limit });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return `<div class="callout" style="border-left-color:var(--red-soft);"><strong>Search error:</strong> ${esc(e.message)}</div>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const out = renderList(rows, { q });
|
|
179
|
+
return out && typeof out === 'object' && out.value ? out.value : String(out);
|
|
180
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET / — dashboard overview.
|
|
3
|
+
*
|
|
4
|
+
* Read-only snapshot of the memex corpus: stats, sources breakdown,
|
|
5
|
+
* pending Telegram exports, and recent conversations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { renderPage, html, raw, esc, fmtDate, fmtNum } from '../templates.js';
|
|
9
|
+
|
|
10
|
+
const SOURCE_ICONS = {
|
|
11
|
+
telegram: '📱',
|
|
12
|
+
'claude-code': '💬',
|
|
13
|
+
'claude-cowork': '🤝',
|
|
14
|
+
cursor: '✏️',
|
|
15
|
+
obsidian: '📓',
|
|
16
|
+
document: '📄',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function renderDashboard(db, status) {
|
|
20
|
+
// ----- Core stats -----
|
|
21
|
+
const totalMessages = db
|
|
22
|
+
.prepare("SELECT COUNT(*) AS n FROM messages WHERE role NOT IN ('boundary', 'summary')")
|
|
23
|
+
.get().n;
|
|
24
|
+
const totalConversations = db
|
|
25
|
+
.prepare('SELECT COUNT(*) AS n FROM conversations WHERE archived_at IS NULL')
|
|
26
|
+
.get().n;
|
|
27
|
+
const totalImports = db.prepare('SELECT COUNT(*) AS n FROM imports').get().n;
|
|
28
|
+
const dateRange = db
|
|
29
|
+
.prepare(
|
|
30
|
+
"SELECT MIN(ts) AS first, MAX(ts) AS last FROM messages WHERE ts IS NOT NULL AND ts > 0 AND role NOT IN ('boundary', 'summary')"
|
|
31
|
+
)
|
|
32
|
+
.get();
|
|
33
|
+
|
|
34
|
+
// ----- Sources breakdown -----
|
|
35
|
+
const sources = db
|
|
36
|
+
.prepare(
|
|
37
|
+
`
|
|
38
|
+
SELECT
|
|
39
|
+
source,
|
|
40
|
+
COUNT(*) AS msg_count,
|
|
41
|
+
COUNT(DISTINCT conversation_id) AS conv_count,
|
|
42
|
+
MIN(ts) AS first_ts,
|
|
43
|
+
MAX(ts) AS last_ts
|
|
44
|
+
FROM messages
|
|
45
|
+
WHERE role NOT IN ('boundary', 'summary')
|
|
46
|
+
GROUP BY source
|
|
47
|
+
ORDER BY msg_count DESC
|
|
48
|
+
`
|
|
49
|
+
)
|
|
50
|
+
.all();
|
|
51
|
+
|
|
52
|
+
// ----- Pending Telegram exports -----
|
|
53
|
+
let pendingList = [];
|
|
54
|
+
try {
|
|
55
|
+
const { listPending } = await import('../../telegram-pending.js');
|
|
56
|
+
pendingList = listPending();
|
|
57
|
+
} catch (_) {
|
|
58
|
+
/* pending module may be unavailable in stripped builds */
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ----- Recent conversations -----
|
|
62
|
+
const recent = db
|
|
63
|
+
.prepare(
|
|
64
|
+
`
|
|
65
|
+
SELECT conversation_id, source, title, last_ts, message_count
|
|
66
|
+
FROM conversations
|
|
67
|
+
WHERE archived_at IS NULL
|
|
68
|
+
AND message_count > 0
|
|
69
|
+
ORDER BY last_ts DESC
|
|
70
|
+
LIMIT 10
|
|
71
|
+
`
|
|
72
|
+
)
|
|
73
|
+
.all();
|
|
74
|
+
|
|
75
|
+
// ----- Build HTML -----
|
|
76
|
+
const statGrid = html`
|
|
77
|
+
<div class="stat-grid">
|
|
78
|
+
<div class="stat">
|
|
79
|
+
<div class="stat-value">${fmtNum(totalMessages)}</div>
|
|
80
|
+
<div class="stat-label">messages</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="stat">
|
|
83
|
+
<div class="stat-value">${fmtNum(totalConversations)}</div>
|
|
84
|
+
<div class="stat-label">conversations</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="stat">
|
|
87
|
+
<div class="stat-value">${fmtNum(sources.length)}</div>
|
|
88
|
+
<div class="stat-label">sources</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="stat">
|
|
91
|
+
<div class="stat-value">${fmtNum(totalImports)}</div>
|
|
92
|
+
<div class="stat-label">imports</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const sourcesCard = html`
|
|
98
|
+
<section class="card">
|
|
99
|
+
<div class="card-label">sources</div>
|
|
100
|
+
<ul class="sources-list">
|
|
101
|
+
${sources.map(
|
|
102
|
+
(s) => html`
|
|
103
|
+
<li>
|
|
104
|
+
<span class="src-name">${raw(SOURCE_ICONS[s.source] || '•')} ${s.source}</span>
|
|
105
|
+
<span class="src-meta">${fmtNum(s.msg_count)} msgs · ${fmtNum(s.conv_count)} chats</span>
|
|
106
|
+
<span class="src-spacer"></span>
|
|
107
|
+
<span class="src-meta">${fmtDate(s.first_ts)} → ${fmtDate(s.last_ts)}</span>
|
|
108
|
+
</li>
|
|
109
|
+
`
|
|
110
|
+
)}
|
|
111
|
+
</ul>
|
|
112
|
+
</section>
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const pendingCallout = pendingList.length
|
|
116
|
+
? html`
|
|
117
|
+
<div class="callout">
|
|
118
|
+
<strong>📬 ${pendingList.length} Telegram export${pendingList.length === 1 ? '' : 's'} awaiting review</strong>
|
|
119
|
+
—
|
|
120
|
+
${pendingList
|
|
121
|
+
.slice(0, 3)
|
|
122
|
+
.map((p) => esc(p.chat_title || p.basename || '?'))
|
|
123
|
+
.join(', ')}${pendingList.length > 3 ? `, + ${pendingList.length - 3} more` : ''}
|
|
124
|
+
· <a href="/pending">Review →</a>
|
|
125
|
+
</div>
|
|
126
|
+
`
|
|
127
|
+
: null;
|
|
128
|
+
|
|
129
|
+
const recentCard = html`
|
|
130
|
+
<section class="card">
|
|
131
|
+
<div class="card-label">recent conversations</div>
|
|
132
|
+
<div class="conv-list">
|
|
133
|
+
${recent.map(
|
|
134
|
+
(c) => html`
|
|
135
|
+
<a class="conv-item" href="/c/${encodeURIComponent(c.conversation_id)}">
|
|
136
|
+
<div class="conv-item-top">
|
|
137
|
+
<span class="conv-title">${c.title || '(untitled)'}</span>
|
|
138
|
+
<span class="conv-count">${fmtNum(c.message_count)} msgs</span>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="conv-meta">
|
|
141
|
+
<span class="conv-source-tag">${c.source}</span>
|
|
142
|
+
${fmtDate(c.last_ts)}
|
|
143
|
+
</div>
|
|
144
|
+
</a>
|
|
145
|
+
`
|
|
146
|
+
)}
|
|
147
|
+
${recent.length === 0
|
|
148
|
+
? html`<div class="empty"><h3>No conversations yet</h3><p>Wait for memex-sync to capture, or import Telegram exports.</p></div>`
|
|
149
|
+
: null}
|
|
150
|
+
</div>
|
|
151
|
+
${recent.length > 0
|
|
152
|
+
? html`<p style="margin-top:14px;text-align:right;"><a href="/conversations">All conversations →</a></p>`
|
|
153
|
+
: null}
|
|
154
|
+
</section>
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const corpusSpan = dateRange.first
|
|
158
|
+
? html`<p class="conv-meta" style="margin-top:6px;">Corpus span: ${fmtDate(dateRange.first)} → ${fmtDate(dateRange.last)}</p>`
|
|
159
|
+
: null;
|
|
160
|
+
|
|
161
|
+
const body = html`
|
|
162
|
+
${statGrid}
|
|
163
|
+
${corpusSpan}
|
|
164
|
+
${pendingCallout}
|
|
165
|
+
${sourcesCard}
|
|
166
|
+
${recentCard}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
return renderPage({
|
|
170
|
+
title: 'Dashboard',
|
|
171
|
+
active: 'dashboard',
|
|
172
|
+
body,
|
|
173
|
+
status,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /pending — Telegram exports awaiting decision.
|
|
3
|
+
* POST /pending/import — import selected entries (form data, name="index").
|
|
4
|
+
* POST /pending/skip — skip selected entries (same form).
|
|
5
|
+
*
|
|
6
|
+
* Reuses lib/telegram-pending.js (listPending/removePending) and
|
|
7
|
+
* lib/telegram-decisions.js (allowChat/skipChat/loadDecisions/saveDecisions).
|
|
8
|
+
* Imports require a writable DB handle, opened locally and closed on completion.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import Database from 'better-sqlite3';
|
|
15
|
+
|
|
16
|
+
import { renderPage, html, raw, esc, fmtDate, fmtNum, fmtBytes } from '../templates.js';
|
|
17
|
+
|
|
18
|
+
const HOME = homedir();
|
|
19
|
+
const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
|
|
20
|
+
const DB_PATH = join(MEMEX_DIR, 'data', 'memex.db');
|
|
21
|
+
|
|
22
|
+
function openWritableDb() {
|
|
23
|
+
return new Database(DB_PATH, { fileMustExist: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderPendingList(pendingList) {
|
|
27
|
+
if (pendingList.length === 0) {
|
|
28
|
+
return html`
|
|
29
|
+
<div class="empty">
|
|
30
|
+
<h3>Inbox empty</h3>
|
|
31
|
+
<p>Export a chat from Telegram Desktop (Settings → Advanced → Export) — memex-sync will stage it here for review.</p>
|
|
32
|
+
</div>
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return html`
|
|
37
|
+
<form id="pending-form" method="post" hx-target="#pending-target" hx-swap="innerHTML">
|
|
38
|
+
<div class="pending-list">
|
|
39
|
+
${pendingList.map(
|
|
40
|
+
(p) => html`
|
|
41
|
+
<div class="pending-item">
|
|
42
|
+
<div class="pending-item-top">
|
|
43
|
+
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;flex:1;">
|
|
44
|
+
<input type="checkbox" name="index" value="${p.index}" />
|
|
45
|
+
<span class="pending-title">📱 ${p.chat_title || p.basename || '(unknown)'}</span>
|
|
46
|
+
</label>
|
|
47
|
+
<span class="pending-count">${fmtNum(p.message_count || 0)} msgs</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="pending-meta">
|
|
50
|
+
${p.date_first ? esc(p.date_first.slice(0, 10)) : '?'} → ${p.date_last ? esc(p.date_last.slice(0, 10)) : '?'}
|
|
51
|
+
· <code>${esc(p.basename)}</code>
|
|
52
|
+
${p.size_bytes ? ' · ' + fmtBytes(p.size_bytes) : ''}
|
|
53
|
+
${p.senders_sample && p.senders_sample.length
|
|
54
|
+
? raw(' · senders: ' + esc(p.senders_sample.slice(0, 3).join(', ')) + (p.senders_sample.length > 3 ? ', …' : ''))
|
|
55
|
+
: null}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
`
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div class="pending-actions" style="margin-top:16px;">
|
|
62
|
+
<button type="submit" class="btn btn-primary" hx-post="/pending/import">Import selected</button>
|
|
63
|
+
<button type="submit" class="btn btn-danger" hx-post="/pending/skip">Skip selected</button>
|
|
64
|
+
<span class="search-meta" style="margin-left:auto;align-self:center;">
|
|
65
|
+
${fmtNum(pendingList.length)} pending
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
</form>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderDecisionsSection() {
|
|
73
|
+
let state;
|
|
74
|
+
try {
|
|
75
|
+
// Lazy import — avoids loading at server boot when decisions don't exist yet
|
|
76
|
+
// (re-require'd via dynamic import below since decisions is ESM).
|
|
77
|
+
return null;
|
|
78
|
+
} catch (_) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadDecisionsState() {
|
|
84
|
+
try {
|
|
85
|
+
const { loadDecisions } = await import('../../telegram-decisions.js');
|
|
86
|
+
return loadDecisions();
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function renderPending(status) {
|
|
93
|
+
const { listPending } = await import('../../telegram-pending.js');
|
|
94
|
+
const pendingList = listPending();
|
|
95
|
+
const decisions = await loadDecisionsState();
|
|
96
|
+
|
|
97
|
+
const allowed = decisions?.allowed ? Object.keys(decisions.allowed) : [];
|
|
98
|
+
const skipped = decisions?.skipped ? Object.keys(decisions.skipped) : [];
|
|
99
|
+
const blocked = decisions?.blocked ? Object.keys(decisions.blocked) : [];
|
|
100
|
+
|
|
101
|
+
const decisionsCard =
|
|
102
|
+
allowed.length || skipped.length || blocked.length
|
|
103
|
+
? html`
|
|
104
|
+
<section class="card" style="margin-top:24px;">
|
|
105
|
+
<div class="card-label">your decisions (history)</div>
|
|
106
|
+
<ul class="sources-list">
|
|
107
|
+
<li>
|
|
108
|
+
<span class="src-name">✅ Allowed</span>
|
|
109
|
+
<span class="src-meta">${fmtNum(allowed.length)}${allowed.length ? ': ' + esc(allowed.slice(0, 6).join(', ')) + (allowed.length > 6 ? ', …' : '') : ''}</span>
|
|
110
|
+
</li>
|
|
111
|
+
<li>
|
|
112
|
+
<span class="src-name">⏭️ Skipped</span>
|
|
113
|
+
<span class="src-meta">${fmtNum(skipped.length)}${skipped.length ? ': ' + esc(skipped.slice(0, 6).join(', ')) + (skipped.length > 6 ? ', …' : '') : ''}</span>
|
|
114
|
+
</li>
|
|
115
|
+
<li>
|
|
116
|
+
<span class="src-name">🚫 Blocked patterns</span>
|
|
117
|
+
<span class="src-meta">${fmtNum(blocked.length)}${blocked.length ? ': ' + esc(blocked.slice(0, 6).join(', ')) + (blocked.length > 6 ? ', …' : '') : ''}</span>
|
|
118
|
+
</li>
|
|
119
|
+
</ul>
|
|
120
|
+
</section>
|
|
121
|
+
`
|
|
122
|
+
: null;
|
|
123
|
+
|
|
124
|
+
const body = html`
|
|
125
|
+
<div class="callout">
|
|
126
|
+
<strong>Privacy-first.</strong> Telegram exports stay on disk until you import them.
|
|
127
|
+
Importing is a one-way decision — once in memex.db they can be searched and removed via
|
|
128
|
+
<code>memex telegram remove "<title>"</code>.
|
|
129
|
+
</div>
|
|
130
|
+
<div id="pending-target">${renderPendingList(pendingList)}</div>
|
|
131
|
+
${decisionsCard}
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
return renderPage({
|
|
135
|
+
title: 'Pending',
|
|
136
|
+
active: 'pending',
|
|
137
|
+
body,
|
|
138
|
+
status,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ----- POST handlers -----
|
|
143
|
+
|
|
144
|
+
function parseIndices(body) {
|
|
145
|
+
const raw = body.index;
|
|
146
|
+
if (raw == null) return [];
|
|
147
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
148
|
+
return arr
|
|
149
|
+
.map((v) => parseInt(v, 10))
|
|
150
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveTargets(pendingList, indices) {
|
|
154
|
+
const byIdx = new Map(pendingList.map((p) => [p.index, p]));
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const i of indices) {
|
|
157
|
+
const p = byIdx.get(i);
|
|
158
|
+
if (p) out.push(p);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function handleImport(body) {
|
|
164
|
+
const { listPending, removePending } = await import('../../telegram-pending.js');
|
|
165
|
+
const decisionsMod = await import('../../telegram-decisions.js');
|
|
166
|
+
const pendingList = listPending();
|
|
167
|
+
const indices = parseIndices(body);
|
|
168
|
+
|
|
169
|
+
if (indices.length === 0) {
|
|
170
|
+
return wrapFragment(html`
|
|
171
|
+
<div class="callout" style="border-left-color:var(--amber);">
|
|
172
|
+
<strong>Select at least one entry</strong> — tick the checkboxes you want to import.
|
|
173
|
+
</div>
|
|
174
|
+
${renderPendingList(pendingList)}
|
|
175
|
+
`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const targets = resolveTargets(pendingList, indices);
|
|
179
|
+
if (targets.length === 0) {
|
|
180
|
+
return wrapFragment(html`
|
|
181
|
+
<div class="callout" style="border-left-color:var(--red-soft);">
|
|
182
|
+
<strong>Stale selection</strong> — the entries you selected are no longer in pending.
|
|
183
|
+
</div>
|
|
184
|
+
${renderPendingList(pendingList)}
|
|
185
|
+
`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { importTelegramRaw } = await import('../../import-telegram.js');
|
|
189
|
+
const { parseTelegramHtmlExport } = await import('../../parse-telegram-html.js');
|
|
190
|
+
const state = decisionsMod.loadDecisions();
|
|
191
|
+
|
|
192
|
+
const results = [];
|
|
193
|
+
const db = openWritableDb();
|
|
194
|
+
try {
|
|
195
|
+
for (const t of targets) {
|
|
196
|
+
try {
|
|
197
|
+
let raw;
|
|
198
|
+
if (t.kind === 'html-dir') {
|
|
199
|
+
raw = parseTelegramHtmlExport(t.path);
|
|
200
|
+
} else if (t.kind === 'json-file') {
|
|
201
|
+
raw = JSON.parse(readFileSync(t.path, 'utf-8'));
|
|
202
|
+
} else if (t.kind === 'json-in-dir' && t.inner_json_path) {
|
|
203
|
+
raw = JSON.parse(readFileSync(t.inner_json_path, 'utf-8'));
|
|
204
|
+
} else {
|
|
205
|
+
results.push({ title: t.chat_title, error: `unknown kind: ${t.kind}` });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!raw) {
|
|
209
|
+
results.push({ title: t.chat_title, error: 'parse-failed' });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const r = importTelegramRaw(db, raw);
|
|
213
|
+
const title = raw.chats?.list?.[0]?.name || t.chat_title || 'Telegram chat';
|
|
214
|
+
decisionsMod.allowChat(state, title);
|
|
215
|
+
removePending(t.path);
|
|
216
|
+
results.push({ title, imported: r.totalImported });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
results.push({ title: t.chat_title, error: e.message });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
decisionsMod.saveDecisions(state);
|
|
222
|
+
} finally {
|
|
223
|
+
db.close();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const updated = listPending();
|
|
227
|
+
const okCount = results.filter((r) => !r.error).length;
|
|
228
|
+
const errCount = results.filter((r) => r.error).length;
|
|
229
|
+
const totalMsgs = results.reduce((s, r) => s + (r.imported || 0), 0);
|
|
230
|
+
|
|
231
|
+
return wrapFragment(html`
|
|
232
|
+
<div class="callout">
|
|
233
|
+
<strong>✓ Imported ${fmtNum(okCount)} chat${okCount === 1 ? '' : 's'}</strong>
|
|
234
|
+
— ${fmtNum(totalMsgs)} message${totalMsgs === 1 ? '' : 's'} now in memex.db.
|
|
235
|
+
${errCount > 0 ? raw(`<br/><span style="color:var(--red-soft);">⚠ ${errCount} failed: ${results.filter(r => r.error).map(r => esc(r.title || '?') + ' (' + esc(r.error) + ')').join(', ')}</span>`) : null}
|
|
236
|
+
</div>
|
|
237
|
+
${renderPendingList(updated)}
|
|
238
|
+
`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function handleSkip(body) {
|
|
242
|
+
const { listPending, removePending } = await import('../../telegram-pending.js');
|
|
243
|
+
const decisionsMod = await import('../../telegram-decisions.js');
|
|
244
|
+
const pendingList = listPending();
|
|
245
|
+
const indices = parseIndices(body);
|
|
246
|
+
|
|
247
|
+
if (indices.length === 0) {
|
|
248
|
+
return wrapFragment(html`
|
|
249
|
+
<div class="callout" style="border-left-color:var(--amber);">
|
|
250
|
+
<strong>Select at least one entry</strong> — tick the checkboxes you want to skip.
|
|
251
|
+
</div>
|
|
252
|
+
${renderPendingList(pendingList)}
|
|
253
|
+
`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const targets = resolveTargets(pendingList, indices);
|
|
257
|
+
const state = decisionsMod.loadDecisions();
|
|
258
|
+
for (const t of targets) {
|
|
259
|
+
if (t.chat_title) decisionsMod.skipChat(state, t.chat_title);
|
|
260
|
+
removePending(t.path);
|
|
261
|
+
}
|
|
262
|
+
decisionsMod.saveDecisions(state);
|
|
263
|
+
|
|
264
|
+
const updated = listPending();
|
|
265
|
+
return wrapFragment(html`
|
|
266
|
+
<div class="callout">
|
|
267
|
+
<strong>⏭️ Skipped ${fmtNum(targets.length)} entr${targets.length === 1 ? 'y' : 'ies'}.</strong>
|
|
268
|
+
Future re-exports of these chats will be auto-skipped.
|
|
269
|
+
</div>
|
|
270
|
+
${renderPendingList(updated)}
|
|
271
|
+
`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// htmx hx-target="#pending-target" → we return the inner HTML for that div.
|
|
275
|
+
function wrapFragment(node) {
|
|
276
|
+
return node && typeof node === 'object' && node.value ? node.value : String(node);
|
|
277
|
+
}
|