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 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(/&amp;/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;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });