kitowall 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/LICENSE.md +46 -0
- package/NOTICE.md +12 -0
- package/README.md +94 -0
- package/TRADEMARKS.md +13 -0
- package/dist/adapters/genericJson.js +216 -0
- package/dist/adapters/localFolder.js +30 -0
- package/dist/adapters/reddit.js +256 -0
- package/dist/adapters/remoteBase.js +2 -0
- package/dist/adapters/selector.js +82 -0
- package/dist/adapters/staticUrl.js +126 -0
- package/dist/adapters/unsplash.js +219 -0
- package/dist/adapters/wallhaven.js +259 -0
- package/dist/cli.js +1101 -0
- package/dist/core/cache.js +283 -0
- package/dist/core/candidates.js +2 -0
- package/dist/core/config.js +367 -0
- package/dist/core/configValidator.js +144 -0
- package/dist/core/controller.js +379 -0
- package/dist/core/doctor.js +224 -0
- package/dist/core/favorites.js +35 -0
- package/dist/core/history.js +34 -0
- package/dist/core/hydrate.js +124 -0
- package/dist/core/init.js +141 -0
- package/dist/core/logs.js +135 -0
- package/dist/core/outputs.js +40 -0
- package/dist/core/remotePack.js +2 -0
- package/dist/core/scheduler.js +24 -0
- package/dist/core/state.js +181 -0
- package/dist/core/systemd.js +65 -0
- package/dist/core/watch.js +78 -0
- package/dist/managers/swww.js +65 -0
- package/dist/utils/exec.js +47 -0
- package/dist/utils/fs.js +70 -0
- package/dist/utils/hash.js +11 -0
- package/dist/utils/jsonPath.js +71 -0
- package/dist/utils/net.js +70 -0
- package/package.json +35 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Kitotsu Attribution License v1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kitotsu
|
|
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 use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
8
|
+
the Software, and to permit persons to whom the Software is furnished to do
|
|
9
|
+
so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
1. Required attribution.
|
|
12
|
+
Any redistribution, public use, or commercial/non-commercial derivative work
|
|
13
|
+
must include clear credit to the original author:
|
|
14
|
+
"Original code by Kitotsu".
|
|
15
|
+
|
|
16
|
+
2. Preservation of notices.
|
|
17
|
+
The copyright notice, this license text, and attribution notice must be
|
|
18
|
+
included in all copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
3. No trademark grant.
|
|
21
|
+
No rights are granted to use the Kitowall or Kitotsu trademarks, names,
|
|
22
|
+
logos, or brand assets except as expressly allowed in writing.
|
|
23
|
+
See `TRADEMARKS.md` and `ui/src/assets/logo-LICENSE.md`.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
|
|
33
|
+
## Attribution Text (Required)
|
|
34
|
+
Original code by Kitotsu
|
|
35
|
+
|
|
36
|
+
## Resumen en Español
|
|
37
|
+
Se permite usar, modificar, distribuir y comercializar el software, pero es
|
|
38
|
+
obligatorio dar crédito visible al autor original con el texto:
|
|
39
|
+
"Original code by Kitotsu".
|
|
40
|
+
|
|
41
|
+
Debes conservar este archivo de licencia y el aviso de copyright en copias o
|
|
42
|
+
porciones sustanciales del software.
|
|
43
|
+
|
|
44
|
+
No se conceden derechos sobre marca, nombre o logo. El software se entrega
|
|
45
|
+
"TAL CUAL", sin garantías, y el autor no se hace responsable por usos de
|
|
46
|
+
terceros.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Attribution Notice
|
|
2
|
+
|
|
3
|
+
If you use, redistribute, or build derivative works from this project
|
|
4
|
+
(commercially or non-commercially), you must include the following credit in a
|
|
5
|
+
visible place:
|
|
6
|
+
|
|
7
|
+
`Original code by Kitotsu`
|
|
8
|
+
|
|
9
|
+
Recommended locations:
|
|
10
|
+
- README or about section
|
|
11
|
+
- application credits screen
|
|
12
|
+
- product/legal documentation
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<img src="https://github.com/KitotsuMolina/Kitowall/blob/master/assets/kitowall.png?raw=true" alt="Kitowall" width="420" />
|
|
2
|
+
|
|
3
|
+
# Kitowall
|
|
4
|
+
|
|
5
|
+
`Kitowall` is a wallpaper manager for Hyprland/Wayland using `swww`.
|
|
6
|
+
|
|
7
|
+
Current version: `1.0.0`.
|
|
8
|
+
|
|
9
|
+
## What You Can Do
|
|
10
|
+
- Rotate wallpapers with transitions.
|
|
11
|
+
- Use different wallpapers per monitor.
|
|
12
|
+
- Organize by thematic packs (`sao`, `edgerunners`, etc.).
|
|
13
|
+
- Download from sources: `local`, `wallhaven`, `unsplash`, `reddit`, `generic_json`, `static_url`.
|
|
14
|
+
- Manage everything from CLI and desktop UI.
|
|
15
|
+
|
|
16
|
+
## Default Paths
|
|
17
|
+
- Config: `~/.config/kitowall/config.json`
|
|
18
|
+
- Runtime state: `~/.local/state/kitowall/state.json`
|
|
19
|
+
- History: `~/.local/state/kitowall/history.json`
|
|
20
|
+
- Logs: `~/.local/state/kitowall/logs.jsonl`
|
|
21
|
+
- Wallpapers: `~/Pictures/Wallpapers/<pack>`
|
|
22
|
+
|
|
23
|
+
## Quick Start (CLI)
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
27
|
+
|
|
28
|
+
node dist/cli.js outputs
|
|
29
|
+
node dist/cli.js status
|
|
30
|
+
node dist/cli.js next
|
|
31
|
+
node dist/cli.js check --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start (UI)
|
|
35
|
+
```bash
|
|
36
|
+
cd ui
|
|
37
|
+
npm install
|
|
38
|
+
npm run tauri:dev
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If your system needs it on Wayland:
|
|
42
|
+
```bash
|
|
43
|
+
WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri:dev
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Package / Release
|
|
47
|
+
- Release checklist: `RELEASE_CHECKLIST.md`
|
|
48
|
+
- Release notes: `RELEASE_NOTES_1.0.0.md`
|
|
49
|
+
- Dependencies: `DEPENDENCIES.md`
|
|
50
|
+
- Flatpak packaging: `flatpak/`
|
|
51
|
+
|
|
52
|
+
Main commands:
|
|
53
|
+
```bash
|
|
54
|
+
# Validate CLI before release
|
|
55
|
+
npm run release:check
|
|
56
|
+
|
|
57
|
+
# Build distributable CLI tarball
|
|
58
|
+
npm run package:cli
|
|
59
|
+
|
|
60
|
+
# Build desktop app package (Tauri)
|
|
61
|
+
npm run package:ui
|
|
62
|
+
|
|
63
|
+
# Full pipeline
|
|
64
|
+
npm run package:all
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Flatpak (Linux)
|
|
68
|
+
```bash
|
|
69
|
+
# 1) Build desktop binary
|
|
70
|
+
cd ui
|
|
71
|
+
npm run tauri:build
|
|
72
|
+
cd ..
|
|
73
|
+
|
|
74
|
+
# 2) Prepare flatpak sources (binary + icon)
|
|
75
|
+
./flatpak/prepare.sh
|
|
76
|
+
|
|
77
|
+
# 3) Build and install flatpak
|
|
78
|
+
flatpak-builder flatpak/build-dir flatpak/io.kitotsu.KitoWall.yml --user --install --force-clean
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## User Docs
|
|
82
|
+
- Current status: `STATUS.md`
|
|
83
|
+
- Config examples: `CONFIG_EXAMPLES.md`
|
|
84
|
+
- UI details: `ui/README.md`
|
|
85
|
+
|
|
86
|
+
## Known Issues
|
|
87
|
+
- Flatpak watch unit failing with `/app/bin/node` in user systemd:
|
|
88
|
+
- `issues/flatpak-watch-service-failed.md`
|
|
89
|
+
|
|
90
|
+
## Legal
|
|
91
|
+
- License: `LICENSE.md`
|
|
92
|
+
- Attribution notice: `NOTICE.md`
|
|
93
|
+
- Trademarks: `TRADEMARKS.md`
|
|
94
|
+
- Logo license: `ui/src/assets/logo-LICENSE.md`
|
package/TRADEMARKS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Trademark Notice
|
|
2
|
+
|
|
3
|
+
`Kitowall`, the `Kitowall` name, and the `Kitotsu` logos/wordmarks are trademarks of Kitotsu.
|
|
4
|
+
|
|
5
|
+
No trademark rights are granted by the software license.
|
|
6
|
+
|
|
7
|
+
You may use this project under `LICENSE.md` terms, but you may not use the
|
|
8
|
+
`Kitowall` name, `Kitotsu` brand assets, or associated logos to imply endorsement,
|
|
9
|
+
official status, affiliation, or to rebrand derivative/commercial distributions
|
|
10
|
+
without prior written permission from Kitotsu.
|
|
11
|
+
|
|
12
|
+
If you fork or redistribute this software, remove or replace protected branding
|
|
13
|
+
unless you have explicit permission.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GenericJsonAdapter = void 0;
|
|
7
|
+
// Generic JSON remote adapter.
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const fs_2 = require("../utils/fs");
|
|
11
|
+
const hash_1 = require("../utils/hash");
|
|
12
|
+
const jsonPath_1 = require("../utils/jsonPath");
|
|
13
|
+
const logs_1 = require("../core/logs");
|
|
14
|
+
const net_1 = require("../utils/net");
|
|
15
|
+
class GenericJsonAdapter {
|
|
16
|
+
constructor(packName, config, cache) {
|
|
17
|
+
this.name = `generic_json:${packName}`;
|
|
18
|
+
this.packName = packName;
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.cache = cache;
|
|
21
|
+
const cacheDir = cache.getDir();
|
|
22
|
+
const indexDir = path_1.default.join(cacheDir, 'indexes');
|
|
23
|
+
(0, fs_2.ensureDir)(indexDir);
|
|
24
|
+
this.indexPath = path_1.default.join(indexDir, `${packName}.json`);
|
|
25
|
+
}
|
|
26
|
+
async refreshIndex(opts) {
|
|
27
|
+
const endpoint = this.config.endpoint;
|
|
28
|
+
const imagePath = this.config.imagePath;
|
|
29
|
+
if (!endpoint || !imagePath) {
|
|
30
|
+
this.lastError = 'Missing endpoint or imagePath';
|
|
31
|
+
return { count: 0 };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
(0, logs_1.appendSystemLog)({
|
|
35
|
+
level: 'info',
|
|
36
|
+
source: 'generic_json',
|
|
37
|
+
pack: this.packName,
|
|
38
|
+
action: 'refresh-request',
|
|
39
|
+
url: endpoint
|
|
40
|
+
});
|
|
41
|
+
const res = await (0, net_1.fetchWithRetry)(endpoint);
|
|
42
|
+
(0, logs_1.appendSystemLog)({
|
|
43
|
+
level: res.ok ? 'info' : 'warn',
|
|
44
|
+
source: 'generic_json',
|
|
45
|
+
pack: this.packName,
|
|
46
|
+
action: 'refresh-response',
|
|
47
|
+
url: endpoint,
|
|
48
|
+
status: res.status
|
|
49
|
+
});
|
|
50
|
+
const json = await res.json();
|
|
51
|
+
const [target, resolvedPath] = (0, jsonPath_1.getTarget)(json, imagePath);
|
|
52
|
+
const candidates = [];
|
|
53
|
+
const max = this.config.candidateLimit ?? 50;
|
|
54
|
+
// If imagePath uses @random and we want multiple candidates,
|
|
55
|
+
// rerun JSONPath to collect distinct items.
|
|
56
|
+
if (imagePath.includes('@random') && max > 1) {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (let i = 0; i < max; i++) {
|
|
59
|
+
const [t, r] = (0, jsonPath_1.getTarget)(json, imagePath);
|
|
60
|
+
if (typeof t !== 'string' && typeof t !== 'number')
|
|
61
|
+
continue;
|
|
62
|
+
const imageDownloadUrl = (this.config.imagePrefix ?? '') + String(t);
|
|
63
|
+
if (seen.has(imageDownloadUrl))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(imageDownloadUrl);
|
|
66
|
+
candidates.push(this.buildCandidate(imageDownloadUrl, r, json));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (Array.isArray(target)) {
|
|
70
|
+
for (const item of target) {
|
|
71
|
+
if (typeof item !== 'string' && typeof item !== 'number')
|
|
72
|
+
continue;
|
|
73
|
+
const imageDownloadUrl = (this.config.imagePrefix ?? '') + String(item);
|
|
74
|
+
candidates.push(this.buildCandidate(imageDownloadUrl, resolvedPath, json));
|
|
75
|
+
if (candidates.length >= max)
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (typeof target === 'string' || typeof target === 'number') {
|
|
80
|
+
const imageDownloadUrl = (this.config.imagePrefix ?? '') + String(target);
|
|
81
|
+
candidates.push(this.buildCandidate(imageDownloadUrl, resolvedPath, json));
|
|
82
|
+
}
|
|
83
|
+
const index = { updatedAt: Date.now(), candidates };
|
|
84
|
+
(0, fs_2.ensureDir)(path_1.default.dirname(this.indexPath));
|
|
85
|
+
fs_1.default.writeFileSync(this.indexPath, JSON.stringify(index, null, 2));
|
|
86
|
+
(0, logs_1.appendSystemLog)({
|
|
87
|
+
level: 'info',
|
|
88
|
+
source: 'generic_json',
|
|
89
|
+
pack: this.packName,
|
|
90
|
+
action: 'refresh-complete',
|
|
91
|
+
message: `candidates=${candidates.length}`
|
|
92
|
+
});
|
|
93
|
+
return { count: candidates.length };
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
97
|
+
(0, logs_1.appendSystemLog)({
|
|
98
|
+
level: 'error',
|
|
99
|
+
source: 'generic_json',
|
|
100
|
+
pack: this.packName,
|
|
101
|
+
action: 'refresh-error',
|
|
102
|
+
message: this.lastError
|
|
103
|
+
});
|
|
104
|
+
return { count: 0 };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async listCandidates() {
|
|
108
|
+
if (!fs_1.default.existsSync(this.indexPath))
|
|
109
|
+
return [];
|
|
110
|
+
const raw = fs_1.default.readFileSync(this.indexPath, 'utf8');
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
return parsed.candidates ?? [];
|
|
113
|
+
}
|
|
114
|
+
async hydrate(candidate) {
|
|
115
|
+
const localPath = this.localPathFor(candidate);
|
|
116
|
+
if (!fs_1.default.existsSync(localPath)) {
|
|
117
|
+
(0, fs_2.ensureDir)(path_1.default.dirname(localPath));
|
|
118
|
+
(0, logs_1.appendSystemLog)({
|
|
119
|
+
level: 'info',
|
|
120
|
+
source: 'generic_json',
|
|
121
|
+
pack: this.packName,
|
|
122
|
+
action: 'hydrate-request',
|
|
123
|
+
url: candidate.url
|
|
124
|
+
});
|
|
125
|
+
const res = await (0, net_1.fetchWithRetry)(candidate.url);
|
|
126
|
+
(0, logs_1.appendSystemLog)({
|
|
127
|
+
level: res.ok ? 'info' : 'warn',
|
|
128
|
+
source: 'generic_json',
|
|
129
|
+
pack: this.packName,
|
|
130
|
+
action: 'hydrate-response',
|
|
131
|
+
url: candidate.url,
|
|
132
|
+
status: res.status
|
|
133
|
+
});
|
|
134
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
135
|
+
fs_1.default.writeFileSync(localPath, buffer);
|
|
136
|
+
this.cache.addEntry({
|
|
137
|
+
key: candidate.id,
|
|
138
|
+
localPath,
|
|
139
|
+
sizeBytes: buffer.length,
|
|
140
|
+
addedAt: Date.now(),
|
|
141
|
+
ttlSec: candidate.ttlSec
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { localPath };
|
|
145
|
+
}
|
|
146
|
+
async status() {
|
|
147
|
+
let lastRefresh;
|
|
148
|
+
let cacheItems;
|
|
149
|
+
let cacheBytes;
|
|
150
|
+
if (fs_1.default.existsSync(this.indexPath)) {
|
|
151
|
+
const raw = fs_1.default.readFileSync(this.indexPath, 'utf8');
|
|
152
|
+
const parsed = JSON.parse(raw);
|
|
153
|
+
lastRefresh = parsed.updatedAt;
|
|
154
|
+
}
|
|
155
|
+
const index = this.cache.loadIndex();
|
|
156
|
+
const entries = index.entries.filter(e => e.localPath.includes(this.packName));
|
|
157
|
+
cacheItems = entries.length;
|
|
158
|
+
cacheBytes = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
159
|
+
return {
|
|
160
|
+
ok: !this.lastError,
|
|
161
|
+
lastRefresh,
|
|
162
|
+
cacheItems,
|
|
163
|
+
cacheBytes,
|
|
164
|
+
lastError: this.lastError
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
localPathFor(candidate) {
|
|
168
|
+
const cacheDir = this.cache.getDir();
|
|
169
|
+
const ext = path_1.default.extname(candidate.url.split('?')[0]) || '.jpg';
|
|
170
|
+
const base = (0, hash_1.sha256Hex)(candidate.id);
|
|
171
|
+
return path_1.default.join(this.cache.getDownloadDir(), this.packName, `${base}${ext}`);
|
|
172
|
+
}
|
|
173
|
+
buildCandidate(imageUrl, resolvedPath, json) {
|
|
174
|
+
const postPath = this.config.postPath ?? '';
|
|
175
|
+
const authorNamePath = this.config.authorNamePath ?? '';
|
|
176
|
+
const authorUrlPath = this.config.authorUrlPath ?? '';
|
|
177
|
+
let postUrl = '';
|
|
178
|
+
if (postPath) {
|
|
179
|
+
const postObj = (0, jsonPath_1.getTarget)(json, (0, jsonPath_1.replaceRandomInPath)(postPath, resolvedPath))[0];
|
|
180
|
+
if (typeof postObj === 'string' || typeof postObj === 'number')
|
|
181
|
+
postUrl = (this.config.postPrefix ?? '') + String(postObj);
|
|
182
|
+
}
|
|
183
|
+
let authorName;
|
|
184
|
+
if (authorNamePath) {
|
|
185
|
+
const authorObj = (0, jsonPath_1.getTarget)(json, (0, jsonPath_1.replaceRandomInPath)(authorNamePath, resolvedPath))[0];
|
|
186
|
+
if (typeof authorObj === 'string' && authorObj !== '')
|
|
187
|
+
authorName = authorObj;
|
|
188
|
+
}
|
|
189
|
+
let authorUrl = '';
|
|
190
|
+
if (authorUrlPath) {
|
|
191
|
+
const authorUrlObj = (0, jsonPath_1.getTarget)(json, (0, jsonPath_1.replaceRandomInPath)(authorUrlPath, resolvedPath))[0];
|
|
192
|
+
if (typeof authorUrlObj === 'string' || typeof authorUrlObj === 'number')
|
|
193
|
+
authorUrl = (this.config.authorUrlPrefix ?? '') + String(authorUrlObj);
|
|
194
|
+
}
|
|
195
|
+
const id = (0, hash_1.sha256Hex)(`${this.packName}:${imageUrl}`);
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
source: 'generic_json',
|
|
199
|
+
url: imageUrl,
|
|
200
|
+
pageUrl: postUrl || undefined,
|
|
201
|
+
author: authorName,
|
|
202
|
+
authorUrl: authorUrl || undefined,
|
|
203
|
+
previewUrl: undefined,
|
|
204
|
+
ttlSec: this.config.ttlSec,
|
|
205
|
+
tags: undefined,
|
|
206
|
+
rating: undefined,
|
|
207
|
+
score: undefined,
|
|
208
|
+
remoteId: undefined,
|
|
209
|
+
mime: undefined,
|
|
210
|
+
fileExtHint: undefined,
|
|
211
|
+
width: undefined,
|
|
212
|
+
height: undefined
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
exports.GenericJsonAdapter = GenericJsonAdapter;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalFolderAdapter = void 0;
|
|
4
|
+
// Local folder adapter for image selection.
|
|
5
|
+
const fs_1 = require("../utils/fs");
|
|
6
|
+
class LocalFolderAdapter {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.paths = options.paths;
|
|
9
|
+
}
|
|
10
|
+
/** Devuelve el pool completo (barajado) para que el selector haga su trabajo */
|
|
11
|
+
getAllImages() {
|
|
12
|
+
const all = (0, fs_1.listImagesRecursive)(this.paths);
|
|
13
|
+
if (all.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
return (0, fs_1.shuffle)(all);
|
|
16
|
+
}
|
|
17
|
+
/** Compatibilidad: devuelve count imágenes (puede repetir si el pool es pequeño) */
|
|
18
|
+
getImages(count) {
|
|
19
|
+
const shuffled = this.getAllImages();
|
|
20
|
+
if (shuffled.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
if (shuffled.length >= count)
|
|
23
|
+
return shuffled.slice(0, count);
|
|
24
|
+
const results = [...shuffled];
|
|
25
|
+
while (results.length < count)
|
|
26
|
+
results.push(shuffled[results.length % shuffled.length]);
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.LocalFolderAdapter = LocalFolderAdapter;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RedditAdapter = void 0;
|
|
7
|
+
// Reddit remote adapter.
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const fs_2 = require("../utils/fs");
|
|
11
|
+
const hash_1 = require("../utils/hash");
|
|
12
|
+
const logs_1 = require("../core/logs");
|
|
13
|
+
const net_1 = require("../utils/net");
|
|
14
|
+
class RedditAdapter {
|
|
15
|
+
constructor(packName, config, cache) {
|
|
16
|
+
this.name = `reddit:${packName}`;
|
|
17
|
+
this.packName = packName;
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.cache = cache;
|
|
20
|
+
const indexDir = path_1.default.join(cache.getDir(), 'indexes');
|
|
21
|
+
(0, fs_2.ensureDir)(indexDir);
|
|
22
|
+
this.indexPath = path_1.default.join(indexDir, `${packName}.json`);
|
|
23
|
+
}
|
|
24
|
+
async refreshIndex() {
|
|
25
|
+
try {
|
|
26
|
+
const subs = this.normalizeSubreddits(this.config.subreddits);
|
|
27
|
+
if (subs.length === 0) {
|
|
28
|
+
this.lastError = 'Missing subreddits';
|
|
29
|
+
return { count: 0 };
|
|
30
|
+
}
|
|
31
|
+
const queries = this.buildQueries();
|
|
32
|
+
const candidates = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const q of queries) {
|
|
35
|
+
const base = `/r/${subs.join('+')}/search.json?q=${encodeURIComponent(q)}&restrict_sr=1`;
|
|
36
|
+
const urls = [
|
|
37
|
+
`https://www.reddit.com${base}`,
|
|
38
|
+
`https://old.reddit.com${base}`
|
|
39
|
+
];
|
|
40
|
+
const json = await this.fetchWithFallback(urls);
|
|
41
|
+
for (const child of json.data.children) {
|
|
42
|
+
const data = child.data;
|
|
43
|
+
if (data.post_hint !== 'image')
|
|
44
|
+
continue;
|
|
45
|
+
if (this.config.allowSfw && data.over_18)
|
|
46
|
+
continue;
|
|
47
|
+
const image = data.preview?.images?.[0]?.source;
|
|
48
|
+
if (!image)
|
|
49
|
+
continue;
|
|
50
|
+
if (!this.passesResolution(image.width, image.height))
|
|
51
|
+
continue;
|
|
52
|
+
if (!this.passesRatio(image.width, image.height))
|
|
53
|
+
continue;
|
|
54
|
+
const imageUrl = this.ampDecode(image.url);
|
|
55
|
+
if (seen.has(imageUrl))
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(imageUrl);
|
|
58
|
+
const id = (0, hash_1.sha256Hex)(`${this.packName}:${imageUrl}`);
|
|
59
|
+
candidates.push({
|
|
60
|
+
id,
|
|
61
|
+
source: 'reddit',
|
|
62
|
+
url: imageUrl,
|
|
63
|
+
previewUrl: imageUrl,
|
|
64
|
+
pageUrl: data.permalink ? `https://www.reddit.com${data.permalink}` : undefined,
|
|
65
|
+
author: undefined,
|
|
66
|
+
authorUrl: undefined,
|
|
67
|
+
tags: undefined,
|
|
68
|
+
rating: data.over_18 ? 'nsfw' : 'safe',
|
|
69
|
+
score: data.ups,
|
|
70
|
+
width: image.width,
|
|
71
|
+
height: image.height,
|
|
72
|
+
ttlSec: this.config.ttlSec
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const index = { updatedAt: Date.now(), candidates };
|
|
77
|
+
fs_1.default.writeFileSync(this.indexPath, JSON.stringify(index, null, 2));
|
|
78
|
+
(0, logs_1.appendSystemLog)({
|
|
79
|
+
level: 'info',
|
|
80
|
+
source: 'reddit',
|
|
81
|
+
pack: this.packName,
|
|
82
|
+
action: 'refresh-complete',
|
|
83
|
+
message: `candidates=${candidates.length}`
|
|
84
|
+
});
|
|
85
|
+
return { count: candidates.length };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
89
|
+
(0, logs_1.appendSystemLog)({
|
|
90
|
+
level: 'error',
|
|
91
|
+
source: 'reddit',
|
|
92
|
+
pack: this.packName,
|
|
93
|
+
action: 'refresh-error',
|
|
94
|
+
message: this.lastError
|
|
95
|
+
});
|
|
96
|
+
return { count: 0 };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async listCandidates() {
|
|
100
|
+
if (!fs_1.default.existsSync(this.indexPath))
|
|
101
|
+
return [];
|
|
102
|
+
const raw = fs_1.default.readFileSync(this.indexPath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
return parsed.candidates ?? [];
|
|
105
|
+
}
|
|
106
|
+
async hydrate(candidate) {
|
|
107
|
+
const localPath = this.localPathFor(candidate);
|
|
108
|
+
if (!fs_1.default.existsSync(localPath)) {
|
|
109
|
+
(0, fs_2.ensureDir)(path_1.default.dirname(localPath));
|
|
110
|
+
(0, logs_1.appendSystemLog)({
|
|
111
|
+
level: 'info',
|
|
112
|
+
source: 'reddit',
|
|
113
|
+
pack: this.packName,
|
|
114
|
+
action: 'hydrate-request',
|
|
115
|
+
url: candidate.url
|
|
116
|
+
});
|
|
117
|
+
const res = await (0, net_1.fetchWithRetry)(candidate.url);
|
|
118
|
+
(0, logs_1.appendSystemLog)({
|
|
119
|
+
level: res.ok ? 'info' : 'warn',
|
|
120
|
+
source: 'reddit',
|
|
121
|
+
pack: this.packName,
|
|
122
|
+
action: 'hydrate-response',
|
|
123
|
+
url: candidate.url,
|
|
124
|
+
status: res.status
|
|
125
|
+
});
|
|
126
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
127
|
+
fs_1.default.writeFileSync(localPath, buffer);
|
|
128
|
+
this.cache.addEntry({
|
|
129
|
+
key: candidate.id,
|
|
130
|
+
localPath,
|
|
131
|
+
sizeBytes: buffer.length,
|
|
132
|
+
addedAt: Date.now(),
|
|
133
|
+
ttlSec: candidate.ttlSec
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { localPath };
|
|
137
|
+
}
|
|
138
|
+
async status() {
|
|
139
|
+
let lastRefresh;
|
|
140
|
+
let cacheItems;
|
|
141
|
+
let cacheBytes;
|
|
142
|
+
if (fs_1.default.existsSync(this.indexPath)) {
|
|
143
|
+
const raw = fs_1.default.readFileSync(this.indexPath, 'utf8');
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
lastRefresh = parsed.updatedAt;
|
|
146
|
+
}
|
|
147
|
+
const index = this.cache.loadIndex();
|
|
148
|
+
const entries = index.entries.filter(e => e.localPath.includes(this.packName));
|
|
149
|
+
cacheItems = entries.length;
|
|
150
|
+
cacheBytes = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
151
|
+
return {
|
|
152
|
+
ok: !this.lastError,
|
|
153
|
+
lastRefresh,
|
|
154
|
+
cacheItems,
|
|
155
|
+
cacheBytes,
|
|
156
|
+
lastError: this.lastError
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
localPathFor(candidate) {
|
|
160
|
+
const ext = path_1.default.extname(candidate.url.split('?')[0]) || '.jpg';
|
|
161
|
+
const base = (0, hash_1.sha256Hex)(candidate.id);
|
|
162
|
+
return path_1.default.join(this.cache.getDownloadDir(), this.packName, `${base}${ext}`);
|
|
163
|
+
}
|
|
164
|
+
normalizeSubreddits(input) {
|
|
165
|
+
if (!input)
|
|
166
|
+
return [];
|
|
167
|
+
if (Array.isArray(input))
|
|
168
|
+
return input.map(s => s.trim()).filter(Boolean);
|
|
169
|
+
return String(input)
|
|
170
|
+
.split(',')
|
|
171
|
+
.map(s => s.trim())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
buildQueries() {
|
|
175
|
+
const base = this.packName;
|
|
176
|
+
const subs = (this.config.subthemes ?? []).map(s => s.trim()).filter(Boolean);
|
|
177
|
+
const queries = [];
|
|
178
|
+
if (base)
|
|
179
|
+
queries.push(base);
|
|
180
|
+
for (const sub of subs) {
|
|
181
|
+
if (base)
|
|
182
|
+
queries.push(`${base} ${sub}`);
|
|
183
|
+
else
|
|
184
|
+
queries.push(sub);
|
|
185
|
+
}
|
|
186
|
+
if (queries.length === 0)
|
|
187
|
+
queries.push('');
|
|
188
|
+
return queries;
|
|
189
|
+
}
|
|
190
|
+
passesResolution(width, height) {
|
|
191
|
+
const minW = this.config.minWidth ?? 0;
|
|
192
|
+
const minH = this.config.minHeight ?? 0;
|
|
193
|
+
if (width < minW)
|
|
194
|
+
return false;
|
|
195
|
+
if (height < minH)
|
|
196
|
+
return false;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
passesRatio(width, height) {
|
|
200
|
+
const ratioW = this.config.ratioW ?? 0;
|
|
201
|
+
const ratioH = this.config.ratioH ?? 0;
|
|
202
|
+
if (ratioW <= 0 || ratioH <= 0)
|
|
203
|
+
return true;
|
|
204
|
+
return width / ratioW * ratioH >= height;
|
|
205
|
+
}
|
|
206
|
+
ampDecode(input) {
|
|
207
|
+
return input.replace(/&/g, '&');
|
|
208
|
+
}
|
|
209
|
+
async fetchWithFallback(urls) {
|
|
210
|
+
let lastStatus;
|
|
211
|
+
let lastErr;
|
|
212
|
+
for (const url of urls) {
|
|
213
|
+
try {
|
|
214
|
+
(0, logs_1.appendSystemLog)({
|
|
215
|
+
level: 'info',
|
|
216
|
+
source: 'reddit',
|
|
217
|
+
pack: this.packName,
|
|
218
|
+
action: 'refresh-request',
|
|
219
|
+
url
|
|
220
|
+
});
|
|
221
|
+
const res = await (0, net_1.fetchWithRetry)(url, {
|
|
222
|
+
headers: {
|
|
223
|
+
'User-Agent': 'Kitowall/0.1 (wallpaper CLI)',
|
|
224
|
+
'Accept': 'application/json',
|
|
225
|
+
'Accept-Language': 'en-US,en;q=0.8'
|
|
226
|
+
}
|
|
227
|
+
}, {
|
|
228
|
+
retries: 1,
|
|
229
|
+
retryOnStatus: [429, 500, 502, 503, 504]
|
|
230
|
+
});
|
|
231
|
+
(0, logs_1.appendSystemLog)({
|
|
232
|
+
level: res.ok ? 'info' : 'warn',
|
|
233
|
+
source: 'reddit',
|
|
234
|
+
pack: this.packName,
|
|
235
|
+
action: 'refresh-response',
|
|
236
|
+
url,
|
|
237
|
+
status: res.status
|
|
238
|
+
});
|
|
239
|
+
return (await res.json());
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
243
|
+
lastErr = msg;
|
|
244
|
+
// Solo fallback de dominio para 403/429 o errores de red.
|
|
245
|
+
if (msg.includes('HTTP 403') || msg.includes('HTTP 429') || msg.includes('fetch failed') || msg.includes('NetworkError')) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (lastStatus)
|
|
252
|
+
throw new Error(`HTTP ${lastStatus}`);
|
|
253
|
+
throw new Error(lastErr ?? 'Reddit fetch failed');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
exports.RedditAdapter = RedditAdapter;
|