wacrawl-dashboard 0.1.1
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/README.md +133 -0
- package/apps/api/dist/db.js +29 -0
- package/apps/api/dist/index.js +115 -0
- package/apps/api/dist/lib/displayName.js +28 -0
- package/apps/api/dist/lib/mediaFsPath.js +80 -0
- package/apps/api/dist/lib/query.js +71 -0
- package/apps/api/dist/lib/searchIndex.js +57 -0
- package/apps/api/dist/lib/timezone.js +38 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +118 -0
- package/apps/api/dist/pathsResolve.js +142 -0
- package/apps/api/dist/routes/data.js +417 -0
- package/apps/api/dist/routes/settings.js +38 -0
- package/apps/api/dist/routes/stats.js +388 -0
- package/apps/api/dist/runtimePaths.js +10 -0
- package/apps/api/dist/scripts/optimizeDb.js +14 -0
- package/apps/api/dist/types.js +1 -0
- package/apps/web/dist/assets/index-1fGu93mF.css +1 -0
- package/apps/web/dist/assets/index-Dfsoi6vG.js +43 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/wacrawl-dashboard.js +41 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# WaCrawl Dashboard
|
|
2
|
+
|
|
3
|
+
Local WhatsApp analytics dashboard for the SQLite archive generated by `wacrawl`. A small monorepo runs a **read-only Express API** (`apps/api`) and a **React + Vite** UI (`apps/web`) that talks to it over CORS on localhost only.
|
|
4
|
+
|
|
5
|
+
## One-line install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx wacrawl-dashboard@latest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Opens the dashboard on `http://127.0.0.1:3001`. Requires Node 20+ and a WaCrawl archive at `~/.wacrawl/wacrawl.db`.
|
|
12
|
+
Point it at a different archive:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
WACRAWL_DB=/path/to/wacrawl.db npx wacrawl-dashboard@latest
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
### Dashboard
|
|
23
|
+
|
|
24
|
+
- **Overview cards** — total messages, chats, contacts, media files, and archive date span; each card can jump to Search, Chats, People, or Media as a shortcut.
|
|
25
|
+
- **Time window** — period filter in the top bar (**Day / Week / Month / Year / All**) drives chart stats (default **Year**). The **activity heatmap** uses the current calendar year.
|
|
26
|
+
- **Charts and lists** — message volume over time, top contacts (bar chart; click a contact to open **Chats** filtered to that JID), hour-of-day and day-of-week activity, monthly sent vs received vs ratio, media type breakdown (count and bytes), top media senders, response-time averages, busiest groups, messaging streaks (current and longest).
|
|
27
|
+
- **Word cloud** — top terms with an **All words** vs **Useful words** (stopword-filtered) toggle.
|
|
28
|
+
|
|
29
|
+
### People
|
|
30
|
+
|
|
31
|
+
- Contact-style list ranked by activity, with message, media, sent, and received counts and last message time. Privacy-oriented display for LID-style identifiers (JID hidden when appropriate).
|
|
32
|
+
|
|
33
|
+
### Chats
|
|
34
|
+
|
|
35
|
+
- **Two-pane browser** — conversation list (with kind: direct vs group, preview line, counts, last activity) and the latest messages for the selected chat.
|
|
36
|
+
- **Read-only inspection** — message bubbles, timestamps, optional **copy message** (including media-aware clipboard text), and inline **images / videos / audio** when paths resolve under your configured media root.
|
|
37
|
+
- **Deep links** — `?contact=<jid>` (for example from the dashboard top-contacts chart) selects the matching chat when present.
|
|
38
|
+
|
|
39
|
+
### Media
|
|
40
|
+
|
|
41
|
+
- **Paged grid** of indexed media with **infinite scroll** (intersection-based loading).
|
|
42
|
+
- Thumbnails for **images** and **videos**, affordances for **audio**, and a full-screen **lightbox** for preview and playback where the type supports it.
|
|
43
|
+
|
|
44
|
+
### Search
|
|
45
|
+
|
|
46
|
+
- **FTS-backed** full-text search over message content (plus chat and sender names) against the local archive; debounced queries after two or more characters.
|
|
47
|
+
- Result rows show context, optional **image/video thumbnails** and **inline audio**, link detection in snippets, **copy**, and media type badges. Optimized DB (below) is recommended for best performance.
|
|
48
|
+
|
|
49
|
+
### Settings
|
|
50
|
+
|
|
51
|
+
- Edit **archive paths** (WhatsApp shared container, primary WaCrawl DB, chat DB, contacts DB, media root) with **save** and **reset to `.env` defaults**.
|
|
52
|
+
- **Health** banner shows whether the primary database opens successfully and the resolved path.
|
|
53
|
+
- Effective paths combine **environment variables** with optional overrides persisted to `~/.wacrawl/dashboard-paths.json` so `<img>` / `<video>` URLs work without custom browser headers.
|
|
54
|
+
|
|
55
|
+
### App experience
|
|
56
|
+
|
|
57
|
+
- **Welcome / help** modal on first visit (stored in `localStorage`); reopen from **Help** in the sidebar or top bar.
|
|
58
|
+
- **Dark and light** themes (toggle in the top bar).
|
|
59
|
+
- **Keyboard shortcuts** use the **Meta** key (**⌘** on macOS, **Win** on many Windows keyboards): **Meta+K** opens Search; **Meta+1** Dashboard, **Meta+2** People, **Meta+3** Chats, **Meta+4** Media, **Meta+5** Search, **Meta+6** Settings.
|
|
60
|
+
|
|
61
|
+
### API and safety
|
|
62
|
+
|
|
63
|
+
- Listens on **`127.0.0.1`** with host and CORS restricted to local dev/preview origins.
|
|
64
|
+
- Opens the WaCrawl SQLite database in **readonly** mode where applicable; `/api/health` reports DB readability and resolved paths.
|
|
65
|
+
|
|
66
|
+
## Requirements
|
|
67
|
+
|
|
68
|
+
- Node.js 20+
|
|
69
|
+
- A WaCrawl archive at `~/.wacrawl/wacrawl.db` (or another path you configure)
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install
|
|
75
|
+
npm run dev
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The API runs on `http://127.0.0.1:3001` and the Vite app runs on `http://localhost:5173`.
|
|
79
|
+
|
|
80
|
+
Other root scripts:
|
|
81
|
+
|
|
82
|
+
- **`npm run build`** — build API then web for production.
|
|
83
|
+
- **`npm run preview`** — serve the built web app (Vite preview, localhost).
|
|
84
|
+
- **`npm run test`** — API unit tests (Vitest).
|
|
85
|
+
- **`npm run typecheck`** — TypeScript checks for both workspaces.
|
|
86
|
+
|
|
87
|
+
## Usage (npx one-liner)
|
|
88
|
+
|
|
89
|
+
The simplest way to run the dashboard is a single command:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx wacrawl-dashboard@latest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This installs and launches the latest published version directly from npm, no cloning or setup required. Point it at a different WaCrawl archive with the `WACRAWL_DB` environment variable:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
WACRAWL_DB=/path/to/wacrawl.db npx wacrawl-dashboard@latest
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The dashboard opens on `http://127.0.0.1:3001` and requires Node 20+.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Copy `apps/api/.env.example` to `apps/api/.env` (and optionally `apps/web/.env.example` to `apps/web/.env.local`) and edit paths for your WhatsApp container and WaCrawl database.
|
|
106
|
+
|
|
107
|
+
Supported variables:
|
|
108
|
+
|
|
109
|
+
- **`WACRAWL_WHATSAPP_CONTAINER`** — folder such as `Library/Group Containers/group.net.whatsapp.WhatsApp.shared`; used to derive default chat, contacts, and media paths when those variables are omitted.
|
|
110
|
+
- **`WACRAWL_CHAT_DB`** / **`WACRAWL_CONTACTS_DB`** — explicit paths to `ChatStorage.sqlite` and `ContactsV2.sqlite`.
|
|
111
|
+
- **`WACRAWL_MEDIA_ROOT`** — folder containing downloaded media (often `…/Message/Media`); relative paths in the archive resolve against this directory.
|
|
112
|
+
- **`WACRAWL_DB`** — primary SQLite file the dashboard queries (WaCrawl export schema from `wacrawl sync`). Defaults to `~/.wacrawl/wacrawl.db`.
|
|
113
|
+
- **`WACRAWL_PATHS_FILE`** (optional) — JSON file for UI-saved path overrides; defaults to `~/.wacrawl/dashboard-paths.json`.
|
|
114
|
+
|
|
115
|
+
On the web side, **`VITE_API_URL`** (default `http://127.0.0.1:3001`) sets the API base URL if your dev setup differs.
|
|
116
|
+
|
|
117
|
+
You can also open **Settings** in the sidebar (or **Meta+6**): overrides are saved to `~/.wacrawl/dashboard-paths.json` so the API picks them up without relying on browser-only headers (needed for `<img>` / `<video>` URLs).
|
|
118
|
+
|
|
119
|
+
To run the API once with env overrides:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
WACRAWL_DB=/path/to/wacrawl.db npm run dev -w @wacrawl/api
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Search optimization
|
|
126
|
+
|
|
127
|
+
Prepare the SQLite archive with supporting indexes and an FTS5 search table:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm run optimize-db -w @wacrawl/api
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Use `WACRAWL_DB=/path/to/wacrawl.db` with the same command to optimize a non-default archive. Re-run this after `wacrawl sync` so the FTS table includes newly synced messages.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { getResolvedPaths } from './runtimePaths.js';
|
|
3
|
+
let db = null;
|
|
4
|
+
let cachedPrimaryDb = '';
|
|
5
|
+
export function ensurePrimaryDatabase() {
|
|
6
|
+
const paths = getResolvedPaths();
|
|
7
|
+
const key = paths.primaryDb;
|
|
8
|
+
if (db && cachedPrimaryDb === key) {
|
|
9
|
+
return db;
|
|
10
|
+
}
|
|
11
|
+
db?.close();
|
|
12
|
+
db = new Database(paths.primaryDb, {
|
|
13
|
+
fileMustExist: true,
|
|
14
|
+
readonly: true,
|
|
15
|
+
});
|
|
16
|
+
cachedPrimaryDb = key;
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
export function getDb() {
|
|
20
|
+
return ensurePrimaryDatabase();
|
|
21
|
+
}
|
|
22
|
+
export function closeDb() {
|
|
23
|
+
db?.close();
|
|
24
|
+
db = null;
|
|
25
|
+
cachedPrimaryDb = '';
|
|
26
|
+
}
|
|
27
|
+
export function getDbPath() {
|
|
28
|
+
return getResolvedPaths().primaryDb;
|
|
29
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import cors from 'cors';
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { ensurePrimaryDatabase } from './db.js';
|
|
5
|
+
import { isMediaRootAccessible } from './lib/mediaFsPath.js';
|
|
6
|
+
import { dataRouter } from './routes/data.js';
|
|
7
|
+
import { settingsRouter } from './routes/settings.js';
|
|
8
|
+
import { statsRouter } from './routes/stats.js';
|
|
9
|
+
import { getResolvedPaths, pathsMiddleware } from './runtimePaths.js';
|
|
10
|
+
const PORT = Number(process.env.PORT ?? 3001);
|
|
11
|
+
const HOST = '127.0.0.1';
|
|
12
|
+
const ALLOWED_ORIGINS = new Set([
|
|
13
|
+
'http://localhost:5173',
|
|
14
|
+
'http://127.0.0.1:5173',
|
|
15
|
+
'http://localhost:4173',
|
|
16
|
+
'http://127.0.0.1:4173',
|
|
17
|
+
// Also allow same-origin requests when API serves the web frontend directly
|
|
18
|
+
`http://localhost:${PORT}`,
|
|
19
|
+
`http://127.0.0.1:${PORT}`,
|
|
20
|
+
]);
|
|
21
|
+
const ALLOWED_HOSTS = new Set([
|
|
22
|
+
`localhost:${PORT}`,
|
|
23
|
+
`127.0.0.1:${PORT}`,
|
|
24
|
+
]);
|
|
25
|
+
export function createApp() {
|
|
26
|
+
const app = express();
|
|
27
|
+
app.disable('x-powered-by');
|
|
28
|
+
app.use(express.json({ limit: '64kb' }));
|
|
29
|
+
app.use((req, res, next) => {
|
|
30
|
+
const host = req.headers.host;
|
|
31
|
+
if (host && !ALLOWED_HOSTS.has(host)) {
|
|
32
|
+
res.status(403).json({
|
|
33
|
+
error: {
|
|
34
|
+
code: 'FORBIDDEN_HOST',
|
|
35
|
+
message: 'Requests must target localhost.',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
app.use(cors({
|
|
43
|
+
origin(origin, callback) {
|
|
44
|
+
if (!origin || ALLOWED_ORIGINS.has(origin)) {
|
|
45
|
+
callback(null, true);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
callback(new Error('Origin is not allowed by CORS.'));
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
app.use(pathsMiddleware);
|
|
52
|
+
app.get('/api/health', (_req, res) => {
|
|
53
|
+
const paths = getResolvedPaths();
|
|
54
|
+
let primaryDbReadable = false;
|
|
55
|
+
try {
|
|
56
|
+
ensurePrimaryDatabase();
|
|
57
|
+
primaryDbReadable = true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
primaryDbReadable = false;
|
|
61
|
+
}
|
|
62
|
+
const mediaStatus = isMediaRootAccessible();
|
|
63
|
+
res.json({
|
|
64
|
+
ok: primaryDbReadable,
|
|
65
|
+
dbPath: paths.primaryDb,
|
|
66
|
+
paths,
|
|
67
|
+
mediaAccessible: mediaStatus.accessible,
|
|
68
|
+
mediaError: mediaStatus.error ?? null,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
app.use('/api/settings', settingsRouter);
|
|
72
|
+
app.use('/api/stats', statsRouter);
|
|
73
|
+
app.use('/api', dataRouter);
|
|
74
|
+
// JSON 404 only for /api routes; non-API routes fall through to static serving below
|
|
75
|
+
app.use('/api', (_req, res) => {
|
|
76
|
+
res.status(404).json({
|
|
77
|
+
error: {
|
|
78
|
+
code: 'NOT_FOUND',
|
|
79
|
+
message: 'Route not found.',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// Production static file serving — set WACRAWL_STATIC_DIR to the web dist folder
|
|
84
|
+
const staticDir = process.env.WACRAWL_STATIC_DIR;
|
|
85
|
+
if (staticDir) {
|
|
86
|
+
app.use(express.static(staticDir));
|
|
87
|
+
// SPA fallback: unmatched non-API GET requests return index.html
|
|
88
|
+
app.use((req, res, next) => {
|
|
89
|
+
if (req.method !== 'GET' || req.path.startsWith('/api')) {
|
|
90
|
+
next();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
res.sendFile('index.html', { root: staticDir });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const errorHandler = (error, _req, res, _next) => {
|
|
97
|
+
const code = error?.code === 'SQLITE_CANTOPEN' ? 'DATABASE_UNAVAILABLE' : 'INTERNAL_ERROR';
|
|
98
|
+
const message = code === 'DATABASE_UNAVAILABLE'
|
|
99
|
+
? 'Cannot open the WaCrawl database. Check Settings → Archive paths, WACRAWL_DB in .env, or run wacrawl sync.'
|
|
100
|
+
: 'Unexpected server error.';
|
|
101
|
+
res.status(code === 'DATABASE_UNAVAILABLE' ? 503 : 500).json({
|
|
102
|
+
error: {
|
|
103
|
+
code,
|
|
104
|
+
message,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
app.use(errorHandler);
|
|
109
|
+
return app;
|
|
110
|
+
}
|
|
111
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
112
|
+
createApp().listen(PORT, HOST, () => {
|
|
113
|
+
console.info(`WaCrawl API listening on http://${HOST}:${PORT}`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function cleanDisplayNameSql(expression) {
|
|
2
|
+
return `
|
|
3
|
+
CASE
|
|
4
|
+
WHEN ${expression} IS NOT NULL
|
|
5
|
+
AND TRIM(${expression}) <> ''
|
|
6
|
+
AND TRIM(${expression}) NOT LIKE '%=%'
|
|
7
|
+
AND TRIM(${expression}) NOT LIKE '%GIAB%'
|
|
8
|
+
AND TRIM(${expression}) NOT LIKE '%@lid'
|
|
9
|
+
THEN TRIM(${expression})
|
|
10
|
+
ELSE NULL
|
|
11
|
+
END
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
export function contactDisplayNameSql(alias) {
|
|
15
|
+
return `
|
|
16
|
+
COALESCE(
|
|
17
|
+
${cleanDisplayNameSql(`${alias}.full_name`)},
|
|
18
|
+
${cleanDisplayNameSql(`${alias}.business_name`)},
|
|
19
|
+
${cleanDisplayNameSql(`COALESCE(${alias}.first_name, '') || ' ' || COALESCE(${alias}.last_name, '')`)},
|
|
20
|
+
${cleanDisplayNameSql(`${alias}.first_name`)},
|
|
21
|
+
${cleanDisplayNameSql(`${alias}.username`)},
|
|
22
|
+
${cleanDisplayNameSql(`${alias}.phone`)}
|
|
23
|
+
)
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
export function contactMatchSql(alias, jidExpression) {
|
|
27
|
+
return `(${alias}.jid = ${jidExpression} OR ${alias}.lid = ${jidExpression})`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { getResolvedPaths } from '../runtimePaths.js';
|
|
5
|
+
/** Effective media directory (env + dashboard overrides + optional request headers). */
|
|
6
|
+
export function getArchiveMediaRoot() {
|
|
7
|
+
return getResolvedPaths().mediaRoot;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* WhatsApp Desktop on macOS stores media under
|
|
11
|
+
* ~/Library/Group Containers/…/Message/Media/<jid>/…
|
|
12
|
+
* but some DB rows store the path with just …/Media/<jid>/… (missing the
|
|
13
|
+
* "Message/" segment). When the stored absolute path doesn't exist, we try
|
|
14
|
+
* inserting "Message/" before the last "Media/" segment as a fallback.
|
|
15
|
+
*/
|
|
16
|
+
function tryWhatsAppMessageMediaFallback(abspath) {
|
|
17
|
+
const idx = abspath.lastIndexOf('/Media/');
|
|
18
|
+
if (idx === -1)
|
|
19
|
+
return null;
|
|
20
|
+
const prefix = abspath.slice(0, idx);
|
|
21
|
+
if (prefix.endsWith('/Message'))
|
|
22
|
+
return null;
|
|
23
|
+
const candidate = `${prefix}/Message${abspath.slice(idx)}`;
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(candidate, fs.constants.R_OK);
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Turn a stored media_path from the SQLite archive into an absolute filesystem path
|
|
34
|
+
* suitable for sendFile / fs access. Supports file:// URLs, platform-native separators,
|
|
35
|
+
* and paths relative to WACRAWL_MEDIA_ROOT or the WaCrawl DB directory.
|
|
36
|
+
*
|
|
37
|
+
* When the resolved absolute path doesn't exist, the function checks common WhatsApp
|
|
38
|
+
* directory layout variations (e.g. …/Message/Media vs …/Media) before giving up.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveArchiveMediaPath(storedPath) {
|
|
41
|
+
let p = storedPath.trim();
|
|
42
|
+
if (!p) {
|
|
43
|
+
throw new Error('Empty media path.');
|
|
44
|
+
}
|
|
45
|
+
if (p.startsWith('file:')) {
|
|
46
|
+
p = fileURLToPath(p);
|
|
47
|
+
}
|
|
48
|
+
p = path.normalize(p);
|
|
49
|
+
const resolved = path.isAbsolute(p) ? p : path.resolve(getArchiveMediaRoot(), p);
|
|
50
|
+
try {
|
|
51
|
+
fs.accessSync(resolved, fs.constants.R_OK);
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Primary path not readable — try WhatsApp layout fallback.
|
|
56
|
+
}
|
|
57
|
+
const fallback = tryWhatsAppMessageMediaFallback(resolved);
|
|
58
|
+
if (fallback)
|
|
59
|
+
return fallback;
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
/** Check whether the configured media root is readable. */
|
|
63
|
+
export function isMediaRootAccessible() {
|
|
64
|
+
const root = getArchiveMediaRoot();
|
|
65
|
+
try {
|
|
66
|
+
fs.accessSync(root, fs.constants.R_OK);
|
|
67
|
+
return { accessible: true };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const code = err.code;
|
|
71
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
72
|
+
return {
|
|
73
|
+
accessible: false,
|
|
74
|
+
error: 'Permission denied. On macOS, grant Full Disk Access to the app running the API server ' +
|
|
75
|
+
'(Terminal, iTerm, etc.) via System Settings \u2192 Privacy & Security \u2192 Full Disk Access.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { accessible: false, error: `Media root is not readable (${code ?? 'unknown error'}).` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getUnixTime } from 'date-fns';
|
|
2
|
+
const PERIOD_SECONDS = {
|
|
3
|
+
day: 86_400,
|
|
4
|
+
week: 604_800,
|
|
5
|
+
month: 2_592_000,
|
|
6
|
+
year: 31_536_000,
|
|
7
|
+
};
|
|
8
|
+
export function parsePeriod(value) {
|
|
9
|
+
if (value === 'day' || value === 'week' || value === 'month' || value === 'year' || value === 'all') {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
return 'all';
|
|
13
|
+
}
|
|
14
|
+
export function parseGranularity(value, fallback) {
|
|
15
|
+
if (value === 'day' || value === 'week' || value === 'month') {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
export function parseLimit(value, fallback = 10, max = 100) {
|
|
21
|
+
const parsed = Number(value);
|
|
22
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
return Math.min(Math.floor(parsed), max);
|
|
26
|
+
}
|
|
27
|
+
export function parseOffset(value) {
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
return Math.floor(parsed);
|
|
33
|
+
}
|
|
34
|
+
export function parseYear(value) {
|
|
35
|
+
const parsed = Number(value);
|
|
36
|
+
const currentYear = new Date().getFullYear();
|
|
37
|
+
if (!Number.isInteger(parsed) || parsed < 1970 || parsed > currentYear + 1) {
|
|
38
|
+
return currentYear;
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
export function sinceTimestamp(period, now = Date.now()) {
|
|
43
|
+
if (period === 'all') {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
return Math.floor(now / 1000) - PERIOD_SECONDS[period];
|
|
47
|
+
}
|
|
48
|
+
export function unixSecondsToIso(seconds) {
|
|
49
|
+
if (seconds == null) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return new Date(seconds * 1000).toISOString();
|
|
53
|
+
}
|
|
54
|
+
export function bucketDateSql(granularity) {
|
|
55
|
+
if (granularity === 'month') {
|
|
56
|
+
return "strftime('%Y-%m-01', ts, 'unixepoch')";
|
|
57
|
+
}
|
|
58
|
+
if (granularity === 'week') {
|
|
59
|
+
return "date(ts, 'unixepoch', 'weekday 1', '-7 days')";
|
|
60
|
+
}
|
|
61
|
+
return "date(ts, 'unixepoch')";
|
|
62
|
+
}
|
|
63
|
+
export function yearBounds(year) {
|
|
64
|
+
const start = getUnixTime(new Date(Date.UTC(year, 0, 1)));
|
|
65
|
+
const end = getUnixTime(new Date(Date.UTC(year + 1, 0, 1)));
|
|
66
|
+
return { start, end };
|
|
67
|
+
}
|
|
68
|
+
export function normalizeBucketDate(date, granularity) {
|
|
69
|
+
void granularity;
|
|
70
|
+
return date;
|
|
71
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cleanDisplayNameSql, contactDisplayNameSql, contactMatchSql } from './displayName.js';
|
|
2
|
+
export const MESSAGE_SEARCH_FTS_TABLE = 'messages_fts';
|
|
3
|
+
export function hasMessageSearchIndex(db) {
|
|
4
|
+
const row = db.prepare(`
|
|
5
|
+
SELECT COUNT(*) AS count
|
|
6
|
+
FROM sqlite_master
|
|
7
|
+
WHERE type = 'table' AND name = @tableName
|
|
8
|
+
`).get({ tableName: MESSAGE_SEARCH_FTS_TABLE });
|
|
9
|
+
return row.count > 0;
|
|
10
|
+
}
|
|
11
|
+
export function createSearchIndexes(db) {
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid ON messages(chat_jid);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts DESC);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_jid, ts DESC);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_jid ON contacts(jid);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_lid ON contacts(lid);
|
|
18
|
+
|
|
19
|
+
DROP TABLE IF EXISTS ${MESSAGE_SEARCH_FTS_TABLE};
|
|
20
|
+
CREATE VIRTUAL TABLE ${MESSAGE_SEARCH_FTS_TABLE} USING fts5(
|
|
21
|
+
text,
|
|
22
|
+
chat_name,
|
|
23
|
+
sender_name,
|
|
24
|
+
chat_contact_name,
|
|
25
|
+
sender_contact_name
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
INSERT INTO ${MESSAGE_SEARCH_FTS_TABLE} (
|
|
29
|
+
rowid,
|
|
30
|
+
text,
|
|
31
|
+
chat_name,
|
|
32
|
+
sender_name,
|
|
33
|
+
chat_contact_name,
|
|
34
|
+
sender_contact_name
|
|
35
|
+
)
|
|
36
|
+
SELECT
|
|
37
|
+
messages.rowid,
|
|
38
|
+
COALESCE(messages.text, ''),
|
|
39
|
+
COALESCE(
|
|
40
|
+
${cleanDisplayNameSql('chats.name')},
|
|
41
|
+
${cleanDisplayNameSql('messages.chat_name')},
|
|
42
|
+
${cleanDisplayNameSql('messages.chat_jid')},
|
|
43
|
+
''
|
|
44
|
+
),
|
|
45
|
+
COALESCE(
|
|
46
|
+
${cleanDisplayNameSql('messages.sender_name')},
|
|
47
|
+
${cleanDisplayNameSql('messages.sender_jid')},
|
|
48
|
+
''
|
|
49
|
+
),
|
|
50
|
+
COALESCE(${contactDisplayNameSql('chat_contacts')}, ''),
|
|
51
|
+
COALESCE(${contactDisplayNameSql('sender_contacts')}, '')
|
|
52
|
+
FROM messages
|
|
53
|
+
LEFT JOIN chats ON chats.jid = messages.chat_jid
|
|
54
|
+
LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
|
|
55
|
+
LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')};
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First string value from Express queryparams (handles string | string[]).
|
|
3
|
+
*/
|
|
4
|
+
export function firstQueryString(value) {
|
|
5
|
+
if (typeof value === 'string' && value.trim())
|
|
6
|
+
return value.trim();
|
|
7
|
+
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim())
|
|
8
|
+
return value[0].trim();
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* IANA timezone for stats bucketing when the client does not send one.
|
|
13
|
+
* UTC matches the legacy SQL strftime('%H', ts, 'unixepoch') behavior.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveStatsTimeZone(raw) {
|
|
16
|
+
const candidate = firstQueryString(raw);
|
|
17
|
+
if (!candidate)
|
|
18
|
+
return 'UTC';
|
|
19
|
+
try {
|
|
20
|
+
Intl.DateTimeFormat('en-US', { timeZone: candidate }).format(new Date());
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'UTC';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Calendar hour 0–23 for Unix epoch seconds in the given IANA time zone. */
|
|
28
|
+
export function hourInTimezone(epochSeconds, timeZone) {
|
|
29
|
+
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
30
|
+
timeZone,
|
|
31
|
+
hour: 'numeric',
|
|
32
|
+
hourCycle: 'h23',
|
|
33
|
+
}).formatToParts(new Date(epochSeconds * 1000));
|
|
34
|
+
const value = Number(parts.find((p) => p.type === 'hour')?.value);
|
|
35
|
+
if (!Number.isFinite(value))
|
|
36
|
+
return 0;
|
|
37
|
+
return Math.min(23, Math.max(0, value));
|
|
38
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { eng } from 'stopword';
|
|
2
|
+
/** Messaging / link / attachment noise beyond standard English stopwords. */
|
|
3
|
+
const CHAT_TECH_URL_EXTRA = [
|
|
4
|
+
'apk',
|
|
5
|
+
'asp',
|
|
6
|
+
'au',
|
|
7
|
+
'auth',
|
|
8
|
+
'be',
|
|
9
|
+
'bin',
|
|
10
|
+
'bmp',
|
|
11
|
+
'br',
|
|
12
|
+
'ca',
|
|
13
|
+
'caption',
|
|
14
|
+
'captioned',
|
|
15
|
+
'captioning',
|
|
16
|
+
'cn',
|
|
17
|
+
'co',
|
|
18
|
+
'com',
|
|
19
|
+
'de',
|
|
20
|
+
'doc',
|
|
21
|
+
'docx',
|
|
22
|
+
'dmg',
|
|
23
|
+
'epub',
|
|
24
|
+
'exe',
|
|
25
|
+
'facebook',
|
|
26
|
+
'fr',
|
|
27
|
+
'ftp',
|
|
28
|
+
'gif',
|
|
29
|
+
'gmail',
|
|
30
|
+
'google',
|
|
31
|
+
'htm',
|
|
32
|
+
'html',
|
|
33
|
+
'https',
|
|
34
|
+
'http',
|
|
35
|
+
'img',
|
|
36
|
+
'instagram',
|
|
37
|
+
'io',
|
|
38
|
+
'it',
|
|
39
|
+
'jp',
|
|
40
|
+
'jpeg',
|
|
41
|
+
'jpg',
|
|
42
|
+
'mobi',
|
|
43
|
+
'mov',
|
|
44
|
+
'mp3',
|
|
45
|
+
'mp4',
|
|
46
|
+
'msg',
|
|
47
|
+
'net',
|
|
48
|
+
'nl',
|
|
49
|
+
'omit',
|
|
50
|
+
'omitted',
|
|
51
|
+
'org',
|
|
52
|
+
'pdf',
|
|
53
|
+
'php',
|
|
54
|
+
'png',
|
|
55
|
+
'ppt',
|
|
56
|
+
'pptx',
|
|
57
|
+
'rar',
|
|
58
|
+
'telegram',
|
|
59
|
+
'tif',
|
|
60
|
+
'tiff',
|
|
61
|
+
'ttf',
|
|
62
|
+
'twitter',
|
|
63
|
+
'txt',
|
|
64
|
+
'uk',
|
|
65
|
+
'us',
|
|
66
|
+
'vosotras',
|
|
67
|
+
'vosotros',
|
|
68
|
+
'wav',
|
|
69
|
+
'webp',
|
|
70
|
+
'webm',
|
|
71
|
+
'webpage',
|
|
72
|
+
'website',
|
|
73
|
+
'whatsapp',
|
|
74
|
+
'www',
|
|
75
|
+
'xls',
|
|
76
|
+
'xlsx',
|
|
77
|
+
'youtu',
|
|
78
|
+
'youtube',
|
|
79
|
+
'zip',
|
|
80
|
+
];
|
|
81
|
+
/** Casual chat fillers not in `eng`. */
|
|
82
|
+
const CHAT_FILLER_EXTRA = ['btw', 'hello', 'hey', 'hi', 'hiya', 'hmm', 'idk', 'imo', 'lmao', 'lol', 'nope', 'okay', 'omg', 'pls', 'please', 'smh', 'thanx', 'thanks', 'thx', 'yep', 'yeah', 'yo'];
|
|
83
|
+
/** Porter “English” list gaps and other very common tokens (>=3 chars for our tokenizer). */
|
|
84
|
+
const EXTRA_EN_STOP = [
|
|
85
|
+
'anything',
|
|
86
|
+
'arent',
|
|
87
|
+
'cant',
|
|
88
|
+
'couldnt',
|
|
89
|
+
'didnt',
|
|
90
|
+
'does',
|
|
91
|
+
'doesnt',
|
|
92
|
+
'done',
|
|
93
|
+
'dont',
|
|
94
|
+
'every',
|
|
95
|
+
'everything',
|
|
96
|
+
'hadnt',
|
|
97
|
+
'havent',
|
|
98
|
+
'isnt',
|
|
99
|
+
'lets',
|
|
100
|
+
'nothing',
|
|
101
|
+
'not',
|
|
102
|
+
'nor',
|
|
103
|
+
'no',
|
|
104
|
+
'shall',
|
|
105
|
+
'shouldnt',
|
|
106
|
+
'something',
|
|
107
|
+
'wasnt',
|
|
108
|
+
'werent',
|
|
109
|
+
'will',
|
|
110
|
+
'wont',
|
|
111
|
+
'wouldnt',
|
|
112
|
+
'yes',
|
|
113
|
+
'yet',
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* Tokens dropped in "useful" word-cloud mode (lowercase; message tokens are `[a-z0-9]{3,}`).
|
|
117
|
+
*/
|
|
118
|
+
export const USEFUL_WORD_STOP_SET = new Set([...eng, ...CHAT_TECH_URL_EXTRA, ...CHAT_FILLER_EXTRA, ...EXTRA_EN_STOP]);
|