whatsapp-sweeper 1.0.0__tar.gz
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.
- whatsapp_sweeper-1.0.0/.gitignore +7 -0
- whatsapp_sweeper-1.0.0/LICENSE +21 -0
- whatsapp_sweeper-1.0.0/PKG-INFO +83 -0
- whatsapp_sweeper-1.0.0/README.md +59 -0
- whatsapp_sweeper-1.0.0/pyproject.toml +78 -0
- whatsapp_sweeper-1.0.0/src/whatsapp_sweeper/__init__.py +3 -0
- whatsapp_sweeper-1.0.0/src/whatsapp_sweeper/__main__.py +4 -0
- whatsapp_sweeper-1.0.0/src/whatsapp_sweeper/index.html +622 -0
- whatsapp_sweeper-1.0.0/src/whatsapp_sweeper/server.py +395 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Almog Baku
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: whatsapp-sweeper
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Find and delete WhatsApp Desktop's bloated media cache, fast.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlmogBaku/whatsapp-sweeper
|
|
6
|
+
Project-URL: Repository, https://github.com/AlmogBaku/whatsapp-sweeper
|
|
7
|
+
Project-URL: Issues, https://github.com/AlmogBaku/whatsapp-sweeper/issues
|
|
8
|
+
Author-email: Almog Baku <almog.baku@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cleanup,macos,media,storage,whatsapp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Environment :: MacOS X
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Desktop Environment :: File Managers
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: pillow>=9.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ๐งน WhatsApp Sweeper
|
|
26
|
+
|
|
27
|
+
A local, fast tool for finding and deleting the media WhatsApp Desktop has quietly piled up on your Mac.
|
|
28
|
+
|
|
29
|
+
## The problem
|
|
30
|
+
|
|
31
|
+
WhatsApp Desktop downloads every photo, video, and voice note you ever receive and keeps it forever in a local cache. It's not unusual for that folder to quietly grow to 20-30GB โ years of stickers, forwarded videos, and group-chat noise you'll never look at again.
|
|
32
|
+
|
|
33
|
+
WhatsApp's engineers clearly track exactly which file belongs to which chat and sender โ it's all sitting right there in their own local database. They just never shipped a way for you to see or clean it up. So here we are.
|
|
34
|
+
|
|
35
|
+
## What it does
|
|
36
|
+
|
|
37
|
+
WhatsApp Sweeper scans WhatsApp Desktop's media cache, cross-references it against WhatsApp's own local chat database to figure out who sent each file and from which chat, and gives you a simple visual grid โ sorted by size, biggest offenders first โ so you can select what to delete and reclaim the space in a couple of minutes.
|
|
38
|
+
|
|
39
|
+
- **Sorted by size** โ see what's actually eating your disk first
|
|
40
|
+
- **Real thumbnails** โ generated from the source files, not WhatsApp's blurry tiny cache
|
|
41
|
+
- **Sender & group filters** โ include/exclude by person or by group, combinable
|
|
42
|
+
- **Finder-style selection** โ click, shift-click for ranges, drag to multi-select
|
|
43
|
+
- **Runs entirely on your machine** โ no network calls, no accounts, nothing uploaded
|
|
44
|
+
|
|
45
|
+
> [!NOTE]
|
|
46
|
+
> This only reads WhatsApp's local cache and database to *display* information. It never sends anything anywhere โ everything happens on `localhost`.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- macOS (this reads WhatsApp Desktop's macOS-specific cache and database โ it won't work anywhere else)
|
|
51
|
+
- WhatsApp Desktop, installed and opened at least once
|
|
52
|
+
- Python 3.9+
|
|
53
|
+
- [ffmpeg](https://ffmpeg.org) (optional, for sharper video thumbnails โ falls back to WhatsApp's own cached preview frame if it's missing)
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
No install step needed โ just run it:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uvx whatsapp-sweeper
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or, with [pipx](https://pipx.pypa.io):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pipx run whatsapp-sweeper
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Either one launches a local server and opens your browser to it automatically. Leave the terminal running while you use it; `Ctrl+C` to stop.
|
|
70
|
+
|
|
71
|
+
## Using it
|
|
72
|
+
|
|
73
|
+
- Click a thumbnail to open the file in its default app; double-click also works.
|
|
74
|
+
- Click, `Shift`-click, `Cmd`-click, or drag across thumbnails to select multiple โ like Finder's icon view.
|
|
75
|
+
- Use the sidebar to filter by sender or by group/chat. Clicking a name cycles it through include-only โ exclude โ off, and both filters combine.
|
|
76
|
+
- **Select all** selects everything matching the current filter, not just what's scrolled into view.
|
|
77
|
+
|
|
78
|
+
> [!IMPORTANT]
|
|
79
|
+
> Deleting is permanent โ there is no trash or undo. Review your selection before confirming. WhatsApp will simply treat removed media as not-yet-downloaded if you ever need it again.
|
|
80
|
+
|
|
81
|
+
## How sender resolution works
|
|
82
|
+
|
|
83
|
+
Media files on disk are just UUIDs sitting in per-chat folders. To label each one with a sender and date, WhatsApp Sweeper reads `ChatStorage.sqlite` โ the same database WhatsApp Desktop itself uses โ joining the media entry to its message, then to the group member and chat, to resolve a display name. Older files that have aged out of WhatsApp's own message history simply show up as "Unknown" โ that's WhatsApp's retention limit, not a bug here.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ๐งน WhatsApp Sweeper
|
|
2
|
+
|
|
3
|
+
A local, fast tool for finding and deleting the media WhatsApp Desktop has quietly piled up on your Mac.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
WhatsApp Desktop downloads every photo, video, and voice note you ever receive and keeps it forever in a local cache. It's not unusual for that folder to quietly grow to 20-30GB โ years of stickers, forwarded videos, and group-chat noise you'll never look at again.
|
|
8
|
+
|
|
9
|
+
WhatsApp's engineers clearly track exactly which file belongs to which chat and sender โ it's all sitting right there in their own local database. They just never shipped a way for you to see or clean it up. So here we are.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
WhatsApp Sweeper scans WhatsApp Desktop's media cache, cross-references it against WhatsApp's own local chat database to figure out who sent each file and from which chat, and gives you a simple visual grid โ sorted by size, biggest offenders first โ so you can select what to delete and reclaim the space in a couple of minutes.
|
|
14
|
+
|
|
15
|
+
- **Sorted by size** โ see what's actually eating your disk first
|
|
16
|
+
- **Real thumbnails** โ generated from the source files, not WhatsApp's blurry tiny cache
|
|
17
|
+
- **Sender & group filters** โ include/exclude by person or by group, combinable
|
|
18
|
+
- **Finder-style selection** โ click, shift-click for ranges, drag to multi-select
|
|
19
|
+
- **Runs entirely on your machine** โ no network calls, no accounts, nothing uploaded
|
|
20
|
+
|
|
21
|
+
> [!NOTE]
|
|
22
|
+
> This only reads WhatsApp's local cache and database to *display* information. It never sends anything anywhere โ everything happens on `localhost`.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- macOS (this reads WhatsApp Desktop's macOS-specific cache and database โ it won't work anywhere else)
|
|
27
|
+
- WhatsApp Desktop, installed and opened at least once
|
|
28
|
+
- Python 3.9+
|
|
29
|
+
- [ffmpeg](https://ffmpeg.org) (optional, for sharper video thumbnails โ falls back to WhatsApp's own cached preview frame if it's missing)
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
No install step needed โ just run it:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx whatsapp-sweeper
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or, with [pipx](https://pipx.pypa.io):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pipx run whatsapp-sweeper
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Either one launches a local server and opens your browser to it automatically. Leave the terminal running while you use it; `Ctrl+C` to stop.
|
|
46
|
+
|
|
47
|
+
## Using it
|
|
48
|
+
|
|
49
|
+
- Click a thumbnail to open the file in its default app; double-click also works.
|
|
50
|
+
- Click, `Shift`-click, `Cmd`-click, or drag across thumbnails to select multiple โ like Finder's icon view.
|
|
51
|
+
- Use the sidebar to filter by sender or by group/chat. Clicking a name cycles it through include-only โ exclude โ off, and both filters combine.
|
|
52
|
+
- **Select all** selects everything matching the current filter, not just what's scrolled into view.
|
|
53
|
+
|
|
54
|
+
> [!IMPORTANT]
|
|
55
|
+
> Deleting is permanent โ there is no trash or undo. Review your selection before confirming. WhatsApp will simply treat removed media as not-yet-downloaded if you ever need it again.
|
|
56
|
+
|
|
57
|
+
## How sender resolution works
|
|
58
|
+
|
|
59
|
+
Media files on disk are just UUIDs sitting in per-chat folders. To label each one with a sender and date, WhatsApp Sweeper reads `ChatStorage.sqlite` โ the same database WhatsApp Desktop itself uses โ joining the media entry to its message, then to the group member and chat, to resolve a display name. Older files that have aged out of WhatsApp's own message history simply show up as "Unknown" โ that's WhatsApp's retention limit, not a bug here.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "whatsapp-sweeper"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Find and delete WhatsApp Desktop's bloated media cache, fast."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Almog Baku", email = "almog.baku@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["whatsapp", "macos", "storage", "cleanup", "media"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Environment :: MacOS X",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: MacOS :: MacOS X",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Desktop Environment :: File Managers",
|
|
21
|
+
"Topic :: Utilities",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"Pillow>=9.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/AlmogBaku/whatsapp-sweeper"
|
|
29
|
+
Repository = "https://github.com/AlmogBaku/whatsapp-sweeper"
|
|
30
|
+
Issues = "https://github.com/AlmogBaku/whatsapp-sweeper/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
whatsapp-sweeper = "whatsapp_sweeper.server:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/whatsapp_sweeper"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
43
|
+
"src/whatsapp_sweeper/index.html" = "whatsapp_sweeper/index.html"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = [
|
|
47
|
+
"src/whatsapp_sweeper",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.semantic_release]
|
|
53
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
54
|
+
branch = "main"
|
|
55
|
+
upload_to_vcs_release = true
|
|
56
|
+
|
|
57
|
+
[tool.semantic_release.commit_parser_options]
|
|
58
|
+
allowed_tags = ["feat", "fix", "perf", "refactor", "breaking", "chore", "docs", "style", "ci"]
|
|
59
|
+
minor_tags = ["feat"]
|
|
60
|
+
patch_tags = ["fix", "perf", "refactor"]
|
|
61
|
+
|
|
62
|
+
[tool.semantic_release.changelog]
|
|
63
|
+
exclude_commit_patterns = [
|
|
64
|
+
"^chore",
|
|
65
|
+
"^docs",
|
|
66
|
+
"^style",
|
|
67
|
+
"^ci",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.semantic_release.branches.main]
|
|
71
|
+
match = "main"
|
|
72
|
+
prerelease = false
|
|
73
|
+
|
|
74
|
+
[tool.semantic_release.remote]
|
|
75
|
+
type = "github"
|
|
76
|
+
|
|
77
|
+
[tool.semantic_release.publish]
|
|
78
|
+
build_command = "uv build"
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
<title>WhatsApp Sweeper</title>
|
|
2
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%A7%B9%3C/text%3E%3C/svg%3E">
|
|
3
|
+
<style>
|
|
4
|
+
:root { color-scheme: dark; }
|
|
5
|
+
* { box-sizing: border-box; }
|
|
6
|
+
html, body { height: 100%; margin: 0; }
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
9
|
+
background: #121212;
|
|
10
|
+
color: #e8e8e8;
|
|
11
|
+
display: flex;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* ---- sidebar ---- */
|
|
16
|
+
#sidebar {
|
|
17
|
+
width: 260px;
|
|
18
|
+
flex-shrink: 0;
|
|
19
|
+
height: 100%;
|
|
20
|
+
min-height: 0;
|
|
21
|
+
background: #1a1a1a;
|
|
22
|
+
border-right: 1px solid #2a2a2a;
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
}
|
|
27
|
+
#brand {
|
|
28
|
+
display: flex; align-items: center; gap: 8px;
|
|
29
|
+
padding: 14px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
|
|
30
|
+
}
|
|
31
|
+
#brand .logo {
|
|
32
|
+
font-size: 26px; line-height: 1; display: flex; align-items: center; justify-content: center;
|
|
33
|
+
}
|
|
34
|
+
#brand .name { font-size: 15px; font-weight: 700; color: #eee; }
|
|
35
|
+
#sidebar h2 { font-size: 14px; margin: 14px 14px 6px; color: #999; text-transform: uppercase; letter-spacing: .05em; }
|
|
36
|
+
#sidebar .hint { font-size: 11px; color: #777; margin: 0 14px 8px; line-height: 1.4; }
|
|
37
|
+
.filter-section { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
38
|
+
.filter-section + .filter-section { border-top: 1px solid #2a2a2a; }
|
|
39
|
+
.filter-search {
|
|
40
|
+
width: calc(100% - 28px);
|
|
41
|
+
margin: 4px 14px 8px; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
|
|
42
|
+
background: #111; color: #eee; font-size: 12px; outline: none;
|
|
43
|
+
}
|
|
44
|
+
.filter-search:focus { border-color: #4a8fe0; }
|
|
45
|
+
.filter-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 12px; }
|
|
46
|
+
.sender-row {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 8px;
|
|
50
|
+
padding: 6px 8px;
|
|
51
|
+
border-radius: 6px;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
}
|
|
55
|
+
.sender-row:hover { background: #232323; }
|
|
56
|
+
.sender-row .chip {
|
|
57
|
+
width: 20px; height: 20px; border-radius: 5px; flex-shrink: 0;
|
|
58
|
+
border: 1px solid #444; display: flex; align-items: center; justify-content: center;
|
|
59
|
+
font-size: 12px; font-weight: bold;
|
|
60
|
+
}
|
|
61
|
+
.sender-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
62
|
+
.sender-row .size { color: #888; font-size: 11px; }
|
|
63
|
+
.sender-row.include .chip { background: #1e5f2e; border-color: #2e8b40; color: #7ee08b; }
|
|
64
|
+
.sender-row.exclude .chip { background: #5f1e1e; border-color: #8b2e2e; color: #e07e7e; }
|
|
65
|
+
.sender-row.include { background: #14261a; }
|
|
66
|
+
.sender-row.exclude { background: #261414; }
|
|
67
|
+
|
|
68
|
+
/* ---- main ---- */
|
|
69
|
+
#main { flex: 1; height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
|
|
70
|
+
#toolbar {
|
|
71
|
+
display: flex; align-items: center; gap: 16px;
|
|
72
|
+
padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #2a2a2a;
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
}
|
|
75
|
+
#toolbar .stat { font-size: 13px; color: #aaa; }
|
|
76
|
+
#toolbar .spacer { flex: 1; }
|
|
77
|
+
#deleteBtn {
|
|
78
|
+
background: #7a1f1f; color: #fff; border: none; padding: 8px 16px;
|
|
79
|
+
border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
#deleteBtn:disabled { background: #3a3a3a; color: #777; cursor: default; }
|
|
82
|
+
#deleteBtn:not(:disabled):hover { background: #9a2a2a; }
|
|
83
|
+
#selectAllBtn {
|
|
84
|
+
background: #2a2a2a; color: #ddd; border: 1px solid #3a3a3a; padding: 8px 16px;
|
|
85
|
+
border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
86
|
+
}
|
|
87
|
+
#selectAllBtn:hover { background: #333; }
|
|
88
|
+
|
|
89
|
+
#grid {
|
|
90
|
+
flex: 1; min-height: 0; overflow-y: auto; padding: 16px;
|
|
91
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
92
|
+
gap: 12px; align-content: start;
|
|
93
|
+
}
|
|
94
|
+
.card {
|
|
95
|
+
position: relative; cursor: pointer; border: 2px solid transparent; border-radius: 8px;
|
|
96
|
+
aspect-ratio: 1 / 1;
|
|
97
|
+
}
|
|
98
|
+
/* overflow:hidden must live on this inner wrapper, not on .card itself:
|
|
99
|
+
overflow!=visible on a grid item zeroes its "automatic minimum size",
|
|
100
|
+
which makes the grid miscompute the row's auto track height. */
|
|
101
|
+
.card .frame {
|
|
102
|
+
position: absolute; inset: 0; overflow: hidden; border-radius: 8px; background: #1e1e1e;
|
|
103
|
+
}
|
|
104
|
+
.card.selected { border-color: #4a8fe0; }
|
|
105
|
+
.card img, .card .placeholder {
|
|
106
|
+
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
107
|
+
-webkit-user-drag: none; user-select: none;
|
|
108
|
+
}
|
|
109
|
+
.card .placeholder {
|
|
110
|
+
display: flex; align-items: center; justify-content: center;
|
|
111
|
+
font-size: 12px; color: #888; background: #262626; text-transform: uppercase;
|
|
112
|
+
}
|
|
113
|
+
.card input[type=checkbox] {
|
|
114
|
+
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; z-index: 2;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
.card .overlay {
|
|
118
|
+
position: absolute; left: 0; right: 0; bottom: 0;
|
|
119
|
+
background: linear-gradient(transparent, rgba(0,0,0,.85));
|
|
120
|
+
padding: 18px 6px 5px; font-size: 10.5px; line-height: 1.35; color: #eee;
|
|
121
|
+
pointer-events: none;
|
|
122
|
+
}
|
|
123
|
+
.card .overlay .sender { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
124
|
+
.card .overlay .chat { color: #8fa8c9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
125
|
+
.card .overlay .meta { color: #bbb; display: flex; justify-content: space-between; gap: 4px; }
|
|
126
|
+
.card .duration {
|
|
127
|
+
position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,.7);
|
|
128
|
+
padding: 2px 5px; border-radius: 4px; font-size: 10px;
|
|
129
|
+
}
|
|
130
|
+
#sentinel { height: 1px; grid-column: 1 / -1; }
|
|
131
|
+
#empty { color: #777; padding: 40px; text-align: center; grid-column: 1 / -1; }
|
|
132
|
+
#marquee {
|
|
133
|
+
position: fixed; border: 1px solid #4a8fe0; background: rgba(74,143,224,.15);
|
|
134
|
+
pointer-events: none; display: none; z-index: 50;
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
|
|
138
|
+
<div id="sidebar">
|
|
139
|
+
<div id="brand">
|
|
140
|
+
<span class="logo">๐งน</span>
|
|
141
|
+
<span class="name">WhatsApp Sweeper</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="filter-section">
|
|
144
|
+
<h2>Filter by sender</h2>
|
|
145
|
+
<div class="hint">Click a name to cycle: include only โ exclude โ off. Both can be active together.</div>
|
|
146
|
+
<input type="text" class="filter-search" id="senderSearch" placeholder="Search sendersโฆ">
|
|
147
|
+
<div class="filter-list" id="senderList"></div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="filter-section">
|
|
150
|
+
<h2>Filter by group / chat</h2>
|
|
151
|
+
<input type="text" class="filter-search" id="chatSearch" placeholder="Search groupsโฆ">
|
|
152
|
+
<div class="filter-list" id="chatList"></div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div id="main">
|
|
157
|
+
<div id="toolbar">
|
|
158
|
+
<span class="stat" id="totalStat">Loadingโฆ</span>
|
|
159
|
+
<span class="spacer"></span>
|
|
160
|
+
<span class="stat" id="selStat"></span>
|
|
161
|
+
<button id="selectAllBtn">Select all</button>
|
|
162
|
+
<button id="deleteBtn" disabled>Delete selected</button>
|
|
163
|
+
</div>
|
|
164
|
+
<div id="grid"></div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<script>
|
|
168
|
+
const state = {
|
|
169
|
+
include: new Set(),
|
|
170
|
+
exclude: new Set(),
|
|
171
|
+
includeChat: new Set(),
|
|
172
|
+
excludeChat: new Set(),
|
|
173
|
+
selected: new Set(), // ids
|
|
174
|
+
selectedSizeOverride: null, // set by "select all" (exact server total); cleared on any manual toggle
|
|
175
|
+
itemsById: new Map(), // id -> item, for size lookups
|
|
176
|
+
lastClickedId: null, // shift-click range anchor
|
|
177
|
+
filteredTotal: 0, // total_count for the current filter (from last /api/items)
|
|
178
|
+
nextOffset: 0,
|
|
179
|
+
loading: false,
|
|
180
|
+
done: false,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function humanSize(bytes) {
|
|
184
|
+
if (bytes == null) return '';
|
|
185
|
+
const units = ['B','KB','MB','GB','TB'];
|
|
186
|
+
let i = 0, v = bytes;
|
|
187
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
188
|
+
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function humanDuration(sec) {
|
|
192
|
+
if (sec == null) return null;
|
|
193
|
+
sec = Math.round(sec);
|
|
194
|
+
const m = Math.floor(sec / 60), s = sec % 60;
|
|
195
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function humanDate(epoch) {
|
|
199
|
+
if (!epoch) return '';
|
|
200
|
+
const d = new Date(epoch * 1000);
|
|
201
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Generic filter-section wiring: shared by "sender" and "chat" (group) filters.
|
|
205
|
+
const filterSections = {
|
|
206
|
+
sender: { endpoint: '/api/senders', listId: 'senderList', searchId: 'senderSearch', include: state.include, exclude: state.exclude },
|
|
207
|
+
chat: { endpoint: '/api/chats', listId: 'chatList', searchId: 'chatSearch', include: state.includeChat, exclude: state.excludeChat },
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
async function loadFilterSection(key) {
|
|
211
|
+
const cfg = filterSections[key];
|
|
212
|
+
const res = await fetch(cfg.endpoint);
|
|
213
|
+
const rows = await res.json();
|
|
214
|
+
const list = document.getElementById(cfg.listId);
|
|
215
|
+
list.innerHTML = '';
|
|
216
|
+
for (const s of rows) {
|
|
217
|
+
const row = document.createElement('div');
|
|
218
|
+
row.className = 'sender-row';
|
|
219
|
+
row.dataset.name = s.name.toLowerCase();
|
|
220
|
+
row.innerHTML = `<span class="chip"></span><span class="name">${escapeHtml(s.name)}</span><span class="size">${humanSize(s.total_size)}</span>`;
|
|
221
|
+
row.onclick = () => cycleFilter(key, s.name, row);
|
|
222
|
+
list.appendChild(row);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cycleFilter(key, name, row) {
|
|
227
|
+
const { include, exclude } = filterSections[key];
|
|
228
|
+
if (include.has(name)) {
|
|
229
|
+
include.delete(name);
|
|
230
|
+
exclude.add(name);
|
|
231
|
+
row.className = 'sender-row exclude';
|
|
232
|
+
row.querySelector('.chip').textContent = 'โ';
|
|
233
|
+
} else if (exclude.has(name)) {
|
|
234
|
+
exclude.delete(name);
|
|
235
|
+
row.className = 'sender-row';
|
|
236
|
+
row.querySelector('.chip').textContent = '';
|
|
237
|
+
} else {
|
|
238
|
+
include.add(name);
|
|
239
|
+
row.className = 'sender-row include';
|
|
240
|
+
row.querySelector('.chip').textContent = 'โ';
|
|
241
|
+
}
|
|
242
|
+
resetGrid();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function wireFilterSearch(key) {
|
|
246
|
+
const cfg = filterSections[key];
|
|
247
|
+
document.getElementById(cfg.searchId).addEventListener('input', (e) => {
|
|
248
|
+
const q = e.target.value.trim().toLowerCase();
|
|
249
|
+
for (const row of document.getElementById(cfg.listId).children) {
|
|
250
|
+
row.style.display = row.dataset.name.includes(q) ? '' : 'none';
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function escapeHtml(s) {
|
|
256
|
+
const d = document.createElement('div');
|
|
257
|
+
d.textContent = s ?? '';
|
|
258
|
+
return d.innerHTML;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resetGrid() {
|
|
262
|
+
document.getElementById('grid').innerHTML = '';
|
|
263
|
+
state.selected.clear();
|
|
264
|
+
state.selectedSizeOverride = null;
|
|
265
|
+
state.lastClickedId = null;
|
|
266
|
+
updateSelStat();
|
|
267
|
+
state.nextOffset = 0;
|
|
268
|
+
state.done = false;
|
|
269
|
+
loadMore();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function loadMore() {
|
|
273
|
+
if (state.loading || state.done) return;
|
|
274
|
+
state.loading = true;
|
|
275
|
+
const params = new URLSearchParams({
|
|
276
|
+
offset: state.nextOffset,
|
|
277
|
+
limit: 60,
|
|
278
|
+
include: [...state.include].join(','),
|
|
279
|
+
exclude: [...state.exclude].join(','),
|
|
280
|
+
include_chat: [...state.includeChat].join(','),
|
|
281
|
+
exclude_chat: [...state.excludeChat].join(','),
|
|
282
|
+
});
|
|
283
|
+
const res = await fetch('/api/items?' + params);
|
|
284
|
+
const data = await res.json();
|
|
285
|
+
state.filteredTotal = data.total_count;
|
|
286
|
+
document.getElementById('totalStat').textContent =
|
|
287
|
+
`${data.total_count.toLocaleString()} files ยท ${humanSize(data.total_size)}`;
|
|
288
|
+
updateSelectAllLabel();
|
|
289
|
+
|
|
290
|
+
const grid = document.getElementById('grid');
|
|
291
|
+
const sentinel = document.getElementById('sentinel');
|
|
292
|
+
if (sentinel) sentinel.remove();
|
|
293
|
+
|
|
294
|
+
if (data.items.length === 0 && state.nextOffset === 0) {
|
|
295
|
+
const empty = document.createElement('div');
|
|
296
|
+
empty.id = 'empty';
|
|
297
|
+
empty.textContent = 'No files match this filter.';
|
|
298
|
+
grid.appendChild(empty);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const item of data.items) {
|
|
302
|
+
grid.appendChild(renderCard(item));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (data.next_offset != null) {
|
|
306
|
+
state.nextOffset = data.next_offset;
|
|
307
|
+
const s = document.createElement('div');
|
|
308
|
+
s.id = 'sentinel';
|
|
309
|
+
grid.appendChild(s);
|
|
310
|
+
observer.observe(s);
|
|
311
|
+
} else {
|
|
312
|
+
state.done = true;
|
|
313
|
+
}
|
|
314
|
+
state.loading = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function renderCard(item) {
|
|
318
|
+
state.itemsById.set(item.id, item);
|
|
319
|
+
|
|
320
|
+
const card = document.createElement('div');
|
|
321
|
+
const preSelected = state.selected.has(item.id);
|
|
322
|
+
card.className = preSelected ? 'card selected' : 'card';
|
|
323
|
+
card.dataset.id = item.id;
|
|
324
|
+
|
|
325
|
+
const frame = document.createElement('div');
|
|
326
|
+
frame.className = 'frame';
|
|
327
|
+
card.appendChild(frame);
|
|
328
|
+
|
|
329
|
+
const cb = document.createElement('input');
|
|
330
|
+
cb.type = 'checkbox';
|
|
331
|
+
cb.checked = preSelected;
|
|
332
|
+
cb.onclick = (e) => {
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
setCardSelected(item.id, cb.checked);
|
|
335
|
+
state.lastClickedId = item.id;
|
|
336
|
+
updateSelStat();
|
|
337
|
+
};
|
|
338
|
+
card.appendChild(cb);
|
|
339
|
+
|
|
340
|
+
if (item.has_thumb) {
|
|
341
|
+
const img = document.createElement('img');
|
|
342
|
+
img.loading = 'lazy';
|
|
343
|
+
img.draggable = false;
|
|
344
|
+
img.src = `/thumb/${item.id}`;
|
|
345
|
+
img.onerror = () => { img.replaceWith(placeholder(item.ext)); };
|
|
346
|
+
frame.appendChild(img);
|
|
347
|
+
} else {
|
|
348
|
+
frame.appendChild(placeholder(item.ext));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const dur = humanDuration(item.duration);
|
|
352
|
+
if (dur) {
|
|
353
|
+
const d = document.createElement('div');
|
|
354
|
+
d.className = 'duration';
|
|
355
|
+
d.textContent = dur;
|
|
356
|
+
frame.appendChild(d);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const overlay = document.createElement('div');
|
|
360
|
+
overlay.className = 'overlay';
|
|
361
|
+
const showChat = item.chat && item.chat !== item.sender;
|
|
362
|
+
overlay.innerHTML = `
|
|
363
|
+
<div class="sender">${escapeHtml(item.sender)}</div>
|
|
364
|
+
${showChat ? `<div class="chat">${escapeHtml(item.chat)}</div>` : ''}
|
|
365
|
+
<div class="meta"><span>${humanSize(item.size)}</span><span>${humanDate(item.date)}</span></div>
|
|
366
|
+
`;
|
|
367
|
+
frame.appendChild(overlay);
|
|
368
|
+
|
|
369
|
+
// Finder-like selection: click = select only, shift = range, cmd/ctrl = toggle.
|
|
370
|
+
// Double-click opens the file.
|
|
371
|
+
card.addEventListener('click', (e) => {
|
|
372
|
+
if (e.shiftKey && state.lastClickedId != null) {
|
|
373
|
+
selectRange(state.lastClickedId, item.id);
|
|
374
|
+
} else if (e.metaKey || e.ctrlKey) {
|
|
375
|
+
setCardSelected(item.id, !state.selected.has(item.id));
|
|
376
|
+
state.lastClickedId = item.id;
|
|
377
|
+
} else {
|
|
378
|
+
clearSelection();
|
|
379
|
+
setCardSelected(item.id, true);
|
|
380
|
+
state.lastClickedId = item.id;
|
|
381
|
+
}
|
|
382
|
+
updateSelStat();
|
|
383
|
+
});
|
|
384
|
+
card.addEventListener('dblclick', () => {
|
|
385
|
+
fetch('/api/open', {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: { 'Content-Type': 'application/json' },
|
|
388
|
+
body: JSON.stringify({ id: item.id }),
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return card;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function placeholder(ext) {
|
|
396
|
+
const p = document.createElement('div');
|
|
397
|
+
p.className = 'placeholder';
|
|
398
|
+
p.textContent = ext || 'file';
|
|
399
|
+
return p;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function setCardSelected(id, selected) {
|
|
403
|
+
state.selectedSizeOverride = null; // manual change invalidates the "select all" exact total
|
|
404
|
+
const card = document.querySelector(`.card[data-id="${id}"]`);
|
|
405
|
+
if (selected) {
|
|
406
|
+
state.selected.add(id);
|
|
407
|
+
if (card) card.classList.add('selected');
|
|
408
|
+
} else {
|
|
409
|
+
state.selected.delete(id);
|
|
410
|
+
if (card) card.classList.remove('selected');
|
|
411
|
+
}
|
|
412
|
+
if (card) {
|
|
413
|
+
const cb = card.querySelector('input[type=checkbox]');
|
|
414
|
+
if (cb) cb.checked = selected;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function clearSelection() {
|
|
419
|
+
for (const id of [...state.selected]) setCardSelected(id, false);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function selectRange(fromId, toId) {
|
|
423
|
+
const ids = [...document.querySelectorAll('.card')].map(c => Number(c.dataset.id));
|
|
424
|
+
const i1 = ids.indexOf(fromId), i2 = ids.indexOf(toId);
|
|
425
|
+
if (i1 === -1 || i2 === -1) {
|
|
426
|
+
clearSelection();
|
|
427
|
+
setCardSelected(toId, true);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const [lo, hi] = i1 < i2 ? [i1, i2] : [i2, i1];
|
|
431
|
+
clearSelection();
|
|
432
|
+
for (let i = lo; i <= hi; i++) setCardSelected(ids[i], true);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function updateSelStat() {
|
|
436
|
+
const n = state.selected.size;
|
|
437
|
+
let total = state.selectedSizeOverride;
|
|
438
|
+
if (total == null) {
|
|
439
|
+
total = 0;
|
|
440
|
+
for (const id of state.selected) {
|
|
441
|
+
const it = state.itemsById.get(id);
|
|
442
|
+
if (it) total += it.size;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
document.getElementById('selStat').textContent = n ? `${n} selected ยท ${humanSize(total)}` : '';
|
|
446
|
+
document.getElementById('deleteBtn').disabled = n === 0;
|
|
447
|
+
updateSelectAllLabel();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function updateSelectAllLabel() {
|
|
451
|
+
const btn = document.getElementById('selectAllBtn');
|
|
452
|
+
const allSelected = state.filteredTotal > 0 && state.selected.size >= state.filteredTotal;
|
|
453
|
+
btn.textContent = allSelected ? 'Deselect all' : `Select all ${state.filteredTotal.toLocaleString()}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
document.getElementById('selectAllBtn').onclick = async () => {
|
|
457
|
+
const allSelected = state.filteredTotal > 0 && state.selected.size >= state.filteredTotal;
|
|
458
|
+
if (allSelected) {
|
|
459
|
+
clearSelection();
|
|
460
|
+
updateSelStat();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const params = new URLSearchParams({
|
|
464
|
+
include: [...state.include].join(','),
|
|
465
|
+
exclude: [...state.exclude].join(','),
|
|
466
|
+
include_chat: [...state.includeChat].join(','),
|
|
467
|
+
exclude_chat: [...state.excludeChat].join(','),
|
|
468
|
+
});
|
|
469
|
+
const res = await fetch('/api/select_all?' + params);
|
|
470
|
+
const data = await res.json();
|
|
471
|
+
state.selected = new Set(data.ids);
|
|
472
|
+
state.selectedSizeOverride = data.total_size;
|
|
473
|
+
// Reflect selection on whatever cards are already rendered.
|
|
474
|
+
for (const card of document.querySelectorAll('.card')) {
|
|
475
|
+
const id = Number(card.dataset.id);
|
|
476
|
+
const sel = state.selected.has(id);
|
|
477
|
+
card.classList.toggle('selected', sel);
|
|
478
|
+
const cb = card.querySelector('input[type=checkbox]');
|
|
479
|
+
if (cb) cb.checked = sel;
|
|
480
|
+
}
|
|
481
|
+
updateSelStat();
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// ---- Finder-style rubber-band drag selection ----
|
|
485
|
+
const marquee = document.createElement('div');
|
|
486
|
+
marquee.id = 'marquee';
|
|
487
|
+
document.body.appendChild(marquee);
|
|
488
|
+
|
|
489
|
+
let dragStart = null;
|
|
490
|
+
let dragMoved = false;
|
|
491
|
+
let dragAdditive = false;
|
|
492
|
+
let dragSnapshot = null;
|
|
493
|
+
let lastMouse = { x: 0, y: 0 };
|
|
494
|
+
let autoScrollRAF = null;
|
|
495
|
+
|
|
496
|
+
const AUTOSCROLL_EDGE = 50; // px from top/bottom edge that triggers scrolling
|
|
497
|
+
const AUTOSCROLL_SPEED = 16; // max px scrolled per animation frame
|
|
498
|
+
|
|
499
|
+
document.getElementById('grid').addEventListener('mousedown', (e) => {
|
|
500
|
+
// Only the checkbox opts out of drag-select; dragging that starts on top of
|
|
501
|
+
// a thumbnail should still paint-select, like Finder icon view.
|
|
502
|
+
if (e.button !== 0 || e.target.closest('input[type=checkbox]')) return;
|
|
503
|
+
dragStart = { x: e.clientX, y: e.clientY };
|
|
504
|
+
dragMoved = false;
|
|
505
|
+
dragAdditive = e.shiftKey || e.metaKey || e.ctrlKey;
|
|
506
|
+
dragSnapshot = new Set(state.selected);
|
|
507
|
+
if (!dragAdditive) clearSelection();
|
|
508
|
+
e.preventDefault();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
document.addEventListener('mousemove', (e) => {
|
|
512
|
+
lastMouse = { x: e.clientX, y: e.clientY };
|
|
513
|
+
if (!dragStart) return;
|
|
514
|
+
if (!dragMoved) {
|
|
515
|
+
// Don't show the marquee (or touch selection) for a plain click with no
|
|
516
|
+
// real movement โ let the card's own click handler own that case.
|
|
517
|
+
const moved = Math.abs(e.clientX - dragStart.x) > 3 || Math.abs(e.clientY - dragStart.y) > 3;
|
|
518
|
+
if (!moved) return;
|
|
519
|
+
dragMoved = true;
|
|
520
|
+
marquee.style.display = 'block';
|
|
521
|
+
if (!autoScrollRAF) autoScrollRAF = requestAnimationFrame(autoScrollTick);
|
|
522
|
+
}
|
|
523
|
+
updateMarquee(e.clientX, e.clientY);
|
|
524
|
+
applyMarqueeSelection();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
document.addEventListener('mouseup', () => {
|
|
528
|
+
if (!dragStart) return;
|
|
529
|
+
dragStart = null;
|
|
530
|
+
dragMoved = false;
|
|
531
|
+
marquee.style.display = 'none';
|
|
532
|
+
updateSelStat();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Autoscroll the grid while dragging near its top/bottom edge, like Finder.
|
|
536
|
+
// The marquee anchor moves with the content so it stays pinned to the same
|
|
537
|
+
// pixel as the grid scrolls underneath the still-held cursor.
|
|
538
|
+
function autoScrollTick() {
|
|
539
|
+
if (!dragStart) { autoScrollRAF = null; return; }
|
|
540
|
+
autoScrollRAF = requestAnimationFrame(autoScrollTick);
|
|
541
|
+
if (!dragMoved) return;
|
|
542
|
+
|
|
543
|
+
const grid = document.getElementById('grid');
|
|
544
|
+
const rect = grid.getBoundingClientRect();
|
|
545
|
+
const y = lastMouse.y;
|
|
546
|
+
let dy = 0;
|
|
547
|
+
if (y < rect.top + AUTOSCROLL_EDGE) {
|
|
548
|
+
dy = -AUTOSCROLL_SPEED * Math.min(1, (rect.top + AUTOSCROLL_EDGE - y) / AUTOSCROLL_EDGE);
|
|
549
|
+
} else if (y > rect.bottom - AUTOSCROLL_EDGE) {
|
|
550
|
+
dy = AUTOSCROLL_SPEED * Math.min(1, (y - (rect.bottom - AUTOSCROLL_EDGE)) / AUTOSCROLL_EDGE);
|
|
551
|
+
}
|
|
552
|
+
if (dy === 0) return;
|
|
553
|
+
|
|
554
|
+
const before = grid.scrollTop;
|
|
555
|
+
grid.scrollTop += dy;
|
|
556
|
+
const actualDy = grid.scrollTop - before;
|
|
557
|
+
if (actualDy === 0) return;
|
|
558
|
+
|
|
559
|
+
dragStart.y -= actualDy;
|
|
560
|
+
updateMarquee(lastMouse.x, lastMouse.y);
|
|
561
|
+
applyMarqueeSelection();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function updateMarquee(x, y) {
|
|
565
|
+
const left = Math.min(x, dragStart.x), top = Math.min(y, dragStart.y);
|
|
566
|
+
const width = Math.abs(x - dragStart.x), height = Math.abs(y - dragStart.y);
|
|
567
|
+
marquee.style.left = left + 'px';
|
|
568
|
+
marquee.style.top = top + 'px';
|
|
569
|
+
marquee.style.width = width + 'px';
|
|
570
|
+
marquee.style.height = height + 'px';
|
|
571
|
+
marquee._rect = { left, top, right: left + width, bottom: top + height };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function rectsIntersect(a, b) {
|
|
575
|
+
return !(b.left > a.right || b.right < a.left || b.top > a.bottom || b.bottom < a.top);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function applyMarqueeSelection() {
|
|
579
|
+
const mRect = marquee._rect;
|
|
580
|
+
for (const card of document.querySelectorAll('.card')) {
|
|
581
|
+
const id = Number(card.dataset.id);
|
|
582
|
+
const intersects = rectsIntersect(mRect, card.getBoundingClientRect());
|
|
583
|
+
setCardSelected(id, intersects || (dragAdditive && dragSnapshot.has(id)));
|
|
584
|
+
}
|
|
585
|
+
updateSelStat();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
document.getElementById('deleteBtn').onclick = async () => {
|
|
589
|
+
const n = state.selected.size;
|
|
590
|
+
let total = 0;
|
|
591
|
+
for (const id of state.selected) {
|
|
592
|
+
const it = state.itemsById.get(id);
|
|
593
|
+
if (it) total += it.size;
|
|
594
|
+
}
|
|
595
|
+
if (!confirm(`Permanently delete ${n} file${n === 1 ? '' : 's'} (${humanSize(total)})? This cannot be undone.`)) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const ids = [...state.selected];
|
|
599
|
+
const res = await fetch('/api/delete', {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: { 'Content-Type': 'application/json' },
|
|
602
|
+
body: JSON.stringify({ ids }),
|
|
603
|
+
});
|
|
604
|
+
await res.json();
|
|
605
|
+
// Full refresh: re-pull the (now smaller) filtered list and both filter
|
|
606
|
+
// panels, rather than just plucking deleted cards out of the DOM.
|
|
607
|
+
state.itemsById.clear();
|
|
608
|
+
resetGrid();
|
|
609
|
+
loadFilterSection('sender');
|
|
610
|
+
loadFilterSection('chat');
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const observer = new IntersectionObserver((entries) => {
|
|
614
|
+
for (const e of entries) if (e.isIntersecting) loadMore();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
loadFilterSection('sender');
|
|
618
|
+
loadFilterSection('chat');
|
|
619
|
+
wireFilterSearch('sender');
|
|
620
|
+
wireFilterSearch('chat');
|
|
621
|
+
loadMore();
|
|
622
|
+
</script>
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WhatsApp Sweeper: find and delete WhatsApp Desktop's bloated media cache.
|
|
3
|
+
|
|
4
|
+
Run: whatsapp-sweeper
|
|
5
|
+
Then open http://localhost:8765 (opened automatically).
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import http.server
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socketserver
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import webbrowser
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
MEDIA_DIR = Path(os.path.expanduser(
|
|
24
|
+
"~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/Message/Media"
|
|
25
|
+
))
|
|
26
|
+
DB_PATH = os.path.expanduser(
|
|
27
|
+
"~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite"
|
|
28
|
+
)
|
|
29
|
+
STATIC_DIR = Path(__file__).parent
|
|
30
|
+
PORT = 8765
|
|
31
|
+
SIDECAR_EXTS = {".thumb", ".favicon", ".mmsthumb"}
|
|
32
|
+
APPLE_EPOCH_OFFSET = 978307200 # 2001-01-01 -> unix epoch
|
|
33
|
+
|
|
34
|
+
IMAGE_EXTS = {"jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff"}
|
|
35
|
+
VIDEO_EXTS = {"mp4", "mov", "m4v", "3gp", "avi", "mkv"}
|
|
36
|
+
THUMB_SIZE = 320
|
|
37
|
+
THUMB_CACHE_DIR = Path(tempfile.gettempdir()) / "whatsapp-sweeper-thumbs"
|
|
38
|
+
THUMB_CACHE_DIR.mkdir(exist_ok=True)
|
|
39
|
+
|
|
40
|
+
RECORDS = {} # id -> record dict, mutated on delete
|
|
41
|
+
ORDER = [] # ids sorted by size desc, fixed at startup
|
|
42
|
+
|
|
43
|
+
DB_QUERY = """
|
|
44
|
+
SELECT
|
|
45
|
+
mi.ZMEDIALOCALPATH AS path,
|
|
46
|
+
mi.ZMOVIEDURATION AS duration,
|
|
47
|
+
m.ZMESSAGEDATE AS msg_date,
|
|
48
|
+
m.ZISFROMME AS is_from_me,
|
|
49
|
+
m.ZFROMJID AS from_jid,
|
|
50
|
+
gm.ZMEMBERJID AS member_jid,
|
|
51
|
+
pn_member.ZPUSHNAME AS member_pushname,
|
|
52
|
+
pn_direct.ZPUSHNAME AS direct_pushname,
|
|
53
|
+
cs.ZPARTNERNAME AS chat_name,
|
|
54
|
+
cs.ZSESSIONTYPE AS session_type
|
|
55
|
+
FROM ZWAMEDIAITEM mi
|
|
56
|
+
JOIN ZWAMESSAGE m ON m.Z_PK = mi.ZMESSAGE
|
|
57
|
+
LEFT JOIN ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
|
|
58
|
+
LEFT JOIN ZWAPROFILEPUSHNAME pn_member ON pn_member.ZJID = gm.ZMEMBERJID
|
|
59
|
+
LEFT JOIN ZWAPROFILEPUSHNAME pn_direct ON pn_direct.ZJID = m.ZFROMJID
|
|
60
|
+
JOIN ZWACHATSESSION cs ON cs.Z_PK = m.ZCHATSESSION
|
|
61
|
+
WHERE mi.ZMEDIALOCALPATH IS NOT NULL
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_db_index():
|
|
66
|
+
"""rel path (inside MEDIA_DIR) -> {sender, chat, duration, date}"""
|
|
67
|
+
index = {}
|
|
68
|
+
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
|
69
|
+
conn.row_factory = sqlite3.Row
|
|
70
|
+
for row in conn.execute(DB_QUERY):
|
|
71
|
+
rel = row["path"]
|
|
72
|
+
if rel.startswith("Media/"):
|
|
73
|
+
rel = rel[len("Media/"):]
|
|
74
|
+
|
|
75
|
+
if row["is_from_me"]:
|
|
76
|
+
sender = "Me"
|
|
77
|
+
elif row["member_pushname"]:
|
|
78
|
+
sender = row["member_pushname"]
|
|
79
|
+
elif row["direct_pushname"]:
|
|
80
|
+
sender = row["direct_pushname"]
|
|
81
|
+
elif row["session_type"] == 0 and row["chat_name"]:
|
|
82
|
+
sender = row["chat_name"]
|
|
83
|
+
else:
|
|
84
|
+
jid = row["member_jid"] or row["from_jid"] or ""
|
|
85
|
+
sender = jid.split("@")[0] if jid else "Unknown"
|
|
86
|
+
|
|
87
|
+
date = None
|
|
88
|
+
if row["msg_date"] is not None:
|
|
89
|
+
date = row["msg_date"] + APPLE_EPOCH_OFFSET
|
|
90
|
+
|
|
91
|
+
index[rel] = {
|
|
92
|
+
"sender": sender or "Unknown",
|
|
93
|
+
"chat": row["chat_name"] or sender or "Unknown",
|
|
94
|
+
"duration": row["duration"],
|
|
95
|
+
"date": date,
|
|
96
|
+
}
|
|
97
|
+
conn.close()
|
|
98
|
+
return index
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scan_media():
|
|
102
|
+
db_index = load_db_index()
|
|
103
|
+
next_id = 0
|
|
104
|
+
for root, _dirs, files in os.walk(MEDIA_DIR):
|
|
105
|
+
for name in files:
|
|
106
|
+
if name.startswith("."):
|
|
107
|
+
continue
|
|
108
|
+
ext = os.path.splitext(name)[1].lower()
|
|
109
|
+
if ext in SIDECAR_EXTS:
|
|
110
|
+
continue
|
|
111
|
+
full = Path(root) / name
|
|
112
|
+
rel = str(full.relative_to(MEDIA_DIR))
|
|
113
|
+
try:
|
|
114
|
+
st = full.stat()
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
meta = db_index.get(rel)
|
|
118
|
+
ext_lower = ext.lstrip(".").lower()
|
|
119
|
+
is_video = ext_lower in VIDEO_EXTS
|
|
120
|
+
has_sidecar_thumb = os.path.exists(os.path.splitext(full)[0] + ".thumb")
|
|
121
|
+
RECORDS[next_id] = {
|
|
122
|
+
"id": next_id,
|
|
123
|
+
"path": str(full),
|
|
124
|
+
"name": name,
|
|
125
|
+
"ext": ext_lower,
|
|
126
|
+
"size": st.st_size,
|
|
127
|
+
"date": meta["date"] if meta and meta["date"] else st.st_mtime,
|
|
128
|
+
"duration": meta["duration"] if meta and is_video else None,
|
|
129
|
+
"sender": meta["sender"] if meta else "Unknown",
|
|
130
|
+
"chat": meta["chat"] if meta else "Unknown",
|
|
131
|
+
"has_thumb": ext_lower in IMAGE_EXTS or is_video or has_sidecar_thumb,
|
|
132
|
+
}
|
|
133
|
+
next_id += 1
|
|
134
|
+
ORDER[:] = sorted(RECORDS.keys(), key=lambda i: RECORDS[i]["size"], reverse=True)
|
|
135
|
+
total_size = sum(r["size"] for r in RECORDS.values())
|
|
136
|
+
print(f"Indexed {len(RECORDS)} files, {total_size / (1024**3):.1f} GB")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def thumb_cache_path(path):
|
|
140
|
+
key = hashlib.sha1(path.encode()).hexdigest()
|
|
141
|
+
return THUMB_CACHE_DIR / f"{key}.jpg"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_or_make_thumb(rec):
|
|
145
|
+
"""Real thumbnail generated from the source file (cached to disk),
|
|
146
|
+
falling back to WhatsApp's own tiny cached .thumb sidecar on failure."""
|
|
147
|
+
cache_path = thumb_cache_path(rec["path"])
|
|
148
|
+
if cache_path.exists():
|
|
149
|
+
return cache_path
|
|
150
|
+
|
|
151
|
+
ext = rec["ext"]
|
|
152
|
+
try:
|
|
153
|
+
if ext in IMAGE_EXTS:
|
|
154
|
+
with Image.open(rec["path"]) as im:
|
|
155
|
+
im = im.convert("RGB")
|
|
156
|
+
im.thumbnail((THUMB_SIZE, THUMB_SIZE))
|
|
157
|
+
im.save(cache_path, "JPEG", quality=85)
|
|
158
|
+
return cache_path
|
|
159
|
+
elif ext in VIDEO_EXTS:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
[
|
|
162
|
+
"ffmpeg", "-y", "-ss", "0.5", "-i", rec["path"],
|
|
163
|
+
"-frames:v", "1", "-vf", f"scale={THUMB_SIZE}:-1",
|
|
164
|
+
str(cache_path),
|
|
165
|
+
],
|
|
166
|
+
capture_output=True, timeout=15,
|
|
167
|
+
)
|
|
168
|
+
if result.returncode == 0 and cache_path.exists():
|
|
169
|
+
return cache_path
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
print(f"thumb generation failed for {rec['path']}: {exc}")
|
|
172
|
+
|
|
173
|
+
legacy = os.path.splitext(rec["path"])[0] + ".thumb"
|
|
174
|
+
return Path(legacy) if os.path.exists(legacy) else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def compute_agg(key):
|
|
178
|
+
agg = {}
|
|
179
|
+
for r in RECORDS.values():
|
|
180
|
+
s = agg.setdefault(r[key], {"name": r[key], "count": 0, "total_size": 0})
|
|
181
|
+
s["count"] += 1
|
|
182
|
+
s["total_size"] += r["size"]
|
|
183
|
+
return sorted(agg.values(), key=lambda s: s["total_size"], reverse=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def compute_senders():
|
|
187
|
+
return compute_agg("sender")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def compute_chats():
|
|
191
|
+
return compute_agg("chat")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def filter_ids(include, exclude, include_chat, exclude_chat):
|
|
195
|
+
for i in ORDER:
|
|
196
|
+
r = RECORDS.get(i)
|
|
197
|
+
if r is None:
|
|
198
|
+
continue
|
|
199
|
+
if include and r["sender"] not in include:
|
|
200
|
+
continue
|
|
201
|
+
if r["sender"] in exclude:
|
|
202
|
+
continue
|
|
203
|
+
if include_chat and r["chat"] not in include_chat:
|
|
204
|
+
continue
|
|
205
|
+
if r["chat"] in exclude_chat:
|
|
206
|
+
continue
|
|
207
|
+
yield i, r
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def item_json(i, r):
|
|
211
|
+
return {
|
|
212
|
+
"id": i,
|
|
213
|
+
"name": r["name"],
|
|
214
|
+
"ext": r["ext"],
|
|
215
|
+
"size": r["size"],
|
|
216
|
+
"date": r["date"],
|
|
217
|
+
"duration": r["duration"],
|
|
218
|
+
"sender": r["sender"],
|
|
219
|
+
"chat": r["chat"],
|
|
220
|
+
"has_thumb": r["has_thumb"],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
225
|
+
def log_message(self, fmt, *args):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def _send_json(self, obj, status=200):
|
|
229
|
+
body = json.dumps(obj).encode()
|
|
230
|
+
self.send_response(status)
|
|
231
|
+
self.send_header("Content-Type", "application/json")
|
|
232
|
+
self.send_header("Content-Length", str(len(body)))
|
|
233
|
+
self.end_headers()
|
|
234
|
+
self.wfile.write(body)
|
|
235
|
+
|
|
236
|
+
def _serve_bytes(self, data, content_type):
|
|
237
|
+
self.send_response(200)
|
|
238
|
+
self.send_header("Content-Type", content_type)
|
|
239
|
+
self.send_header("Content-Length", str(len(data)))
|
|
240
|
+
self.end_headers()
|
|
241
|
+
self.wfile.write(data)
|
|
242
|
+
|
|
243
|
+
def do_GET(self):
|
|
244
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
245
|
+
if parsed.path == "/":
|
|
246
|
+
try:
|
|
247
|
+
data = (STATIC_DIR / "index.html").read_bytes()
|
|
248
|
+
except OSError:
|
|
249
|
+
self.send_error(404)
|
|
250
|
+
return
|
|
251
|
+
self._serve_bytes(data, "text/html; charset=utf-8")
|
|
252
|
+
elif parsed.path == "/api/items":
|
|
253
|
+
self._api_items(parsed)
|
|
254
|
+
elif parsed.path == "/api/senders":
|
|
255
|
+
self._send_json(compute_senders())
|
|
256
|
+
elif parsed.path == "/api/chats":
|
|
257
|
+
self._send_json(compute_chats())
|
|
258
|
+
elif parsed.path == "/api/select_all":
|
|
259
|
+
self._api_select_all(parsed)
|
|
260
|
+
elif parsed.path.startswith("/thumb/"):
|
|
261
|
+
self._serve_thumb(parsed.path[len("/thumb/"):])
|
|
262
|
+
else:
|
|
263
|
+
self.send_error(404)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _parse_filters(qs):
|
|
267
|
+
include = {s for s in qs.get("include", [""])[0].split(",") if s}
|
|
268
|
+
exclude = {s for s in qs.get("exclude", [""])[0].split(",") if s}
|
|
269
|
+
include_chat = {s for s in qs.get("include_chat", [""])[0].split(",") if s}
|
|
270
|
+
exclude_chat = {s for s in qs.get("exclude_chat", [""])[0].split(",") if s}
|
|
271
|
+
return include, exclude, include_chat, exclude_chat
|
|
272
|
+
|
|
273
|
+
def _api_items(self, parsed):
|
|
274
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
275
|
+
try:
|
|
276
|
+
offset = int(qs.get("offset", ["0"])[0])
|
|
277
|
+
limit = min(int(qs.get("limit", ["60"])[0]), 200)
|
|
278
|
+
except ValueError:
|
|
279
|
+
offset, limit = 0, 60
|
|
280
|
+
|
|
281
|
+
matched = list(filter_ids(*self._parse_filters(qs)))
|
|
282
|
+
total_count = len(matched)
|
|
283
|
+
total_size = sum(r["size"] for _, r in matched)
|
|
284
|
+
page = matched[offset: offset + limit]
|
|
285
|
+
next_offset = offset + limit if offset + limit < total_count else None
|
|
286
|
+
self._send_json({
|
|
287
|
+
"items": [item_json(i, r) for i, r in page],
|
|
288
|
+
"total_count": total_count,
|
|
289
|
+
"total_size": total_size,
|
|
290
|
+
"next_offset": next_offset,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
def _api_select_all(self, parsed):
|
|
294
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
295
|
+
matched = list(filter_ids(*self._parse_filters(qs)))
|
|
296
|
+
self._send_json({
|
|
297
|
+
"ids": [i for i, _r in matched],
|
|
298
|
+
"total_count": len(matched),
|
|
299
|
+
"total_size": sum(r["size"] for _, r in matched),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
def _serve_thumb(self, id_str):
|
|
303
|
+
try:
|
|
304
|
+
rec = RECORDS[int(id_str)]
|
|
305
|
+
except (KeyError, ValueError):
|
|
306
|
+
self.send_error(404)
|
|
307
|
+
return
|
|
308
|
+
thumb_path = get_or_make_thumb(rec)
|
|
309
|
+
if thumb_path is None:
|
|
310
|
+
self.send_error(404)
|
|
311
|
+
return
|
|
312
|
+
try:
|
|
313
|
+
data = thumb_path.read_bytes()
|
|
314
|
+
except OSError:
|
|
315
|
+
self.send_error(404)
|
|
316
|
+
return
|
|
317
|
+
self._serve_bytes(data, "image/jpeg")
|
|
318
|
+
|
|
319
|
+
def do_POST(self):
|
|
320
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
321
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
322
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
323
|
+
try:
|
|
324
|
+
payload = json.loads(raw or b"{}")
|
|
325
|
+
except json.JSONDecodeError:
|
|
326
|
+
payload = {}
|
|
327
|
+
|
|
328
|
+
if parsed.path == "/api/open":
|
|
329
|
+
self._api_open(payload)
|
|
330
|
+
elif parsed.path == "/api/delete":
|
|
331
|
+
self._api_delete(payload)
|
|
332
|
+
else:
|
|
333
|
+
self.send_error(404)
|
|
334
|
+
|
|
335
|
+
def _api_open(self, payload):
|
|
336
|
+
rec = RECORDS.get(payload.get("id"))
|
|
337
|
+
if rec is None:
|
|
338
|
+
self._send_json({"ok": False, "error": "not found"}, 404)
|
|
339
|
+
return
|
|
340
|
+
subprocess.run(["open", rec["path"]])
|
|
341
|
+
self._send_json({"ok": True})
|
|
342
|
+
|
|
343
|
+
def _api_delete(self, payload):
|
|
344
|
+
ids = payload.get("ids", [])
|
|
345
|
+
results = {}
|
|
346
|
+
for id_ in ids:
|
|
347
|
+
rec = RECORDS.get(id_)
|
|
348
|
+
if rec is None:
|
|
349
|
+
results[str(id_)] = False
|
|
350
|
+
continue
|
|
351
|
+
try:
|
|
352
|
+
os.remove(rec["path"])
|
|
353
|
+
except OSError as exc:
|
|
354
|
+
results[str(id_)] = False
|
|
355
|
+
print(f"delete failed for {rec['path']}: {exc}")
|
|
356
|
+
continue
|
|
357
|
+
base = os.path.splitext(rec["path"])[0]
|
|
358
|
+
for ext in SIDECAR_EXTS:
|
|
359
|
+
try:
|
|
360
|
+
os.remove(base + ext)
|
|
361
|
+
except OSError:
|
|
362
|
+
pass
|
|
363
|
+
del RECORDS[id_]
|
|
364
|
+
results[str(id_)] = True
|
|
365
|
+
self._send_json({"results": results})
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main():
|
|
369
|
+
if sys.platform != "darwin":
|
|
370
|
+
sys.exit(
|
|
371
|
+
"WhatsApp Sweeper only supports macOS (it reads WhatsApp Desktop's "
|
|
372
|
+
"local media cache and chat database, which only exist there)."
|
|
373
|
+
)
|
|
374
|
+
if not MEDIA_DIR.exists():
|
|
375
|
+
sys.exit(
|
|
376
|
+
f"Couldn't find WhatsApp Desktop's media folder at:\n {MEDIA_DIR}\n"
|
|
377
|
+
"Is WhatsApp Desktop installed, and have you opened it at least once?"
|
|
378
|
+
)
|
|
379
|
+
if not os.path.exists(DB_PATH):
|
|
380
|
+
sys.exit(f"Couldn't find WhatsApp's chat database at:\n {DB_PATH}")
|
|
381
|
+
|
|
382
|
+
scan_media()
|
|
383
|
+
with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Handler) as httpd:
|
|
384
|
+
httpd.allow_reuse_address = True
|
|
385
|
+
url = f"http://localhost:{PORT}"
|
|
386
|
+
print(f"Serving on {url}")
|
|
387
|
+
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
|
388
|
+
try:
|
|
389
|
+
httpd.serve_forever()
|
|
390
|
+
except KeyboardInterrupt:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
main()
|