@viralspin/mcp 0.4.0 → 0.5.2

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.
@@ -0,0 +1,148 @@
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.labelsAfter = labelsAfter;
7
+ exports.regroup = regroup;
8
+ exports.backfillEmbeddings = backfillEmbeddings;
9
+ exports.applyOps = applyOps;
10
+ /**
11
+ * Changes after the sort, without reading the pictures again: add or drop a label ("tennis"), combine folders, rename one, hide one. The scan kept
12
+ * every picture's embedding next to the plan (<plan>.emb, float32 rows), so a new label is a dot product per picture: seconds for 29k pictures.
13
+ * Every change is kept on the plan (`ops`) and replayed in order, so a later label does not undo an earlier merge.
14
+ */
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const plan_1 = require("./plan");
17
+ const path_1 = __importDefault(require("path"));
18
+ const images_1 = require("./images");
19
+ const topics_1 = require("./topics");
20
+ const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
21
+ /** The creator's labels after the ops: the ones the sort started with, plus added ones, minus removed ones. */
22
+ function labelsAfter(start, ops) {
23
+ let out = [...start];
24
+ for (const o of ops) {
25
+ if (o.op === "add_label" && !out.some((l) => same(l, o.label)))
26
+ out.push(o.label.trim());
27
+ if (o.op === "remove_label")
28
+ out = out.filter((l) => !same(l, o.label));
29
+ }
30
+ return out;
31
+ }
32
+ /** Merges, renames and hides, in order, on the items' bucket names (labels are not touched). Pure. */
33
+ function regroup(plan, ops) {
34
+ for (const o of ops) {
35
+ const out = o.op === "exclude" ? new Set(o.items) : null;
36
+ plan.items.forEach((it, i) => {
37
+ if (!it.topic)
38
+ return;
39
+ // "not tennis": these pictures leave that folder for their next label (or Other), and stay out on every later change
40
+ if (o.op === "exclude" && out.has(i) && same(o.bucket, it.topic)) {
41
+ it.topic = it.tags?.find((t) => !same(t, o.bucket)) ?? "Other";
42
+ return;
43
+ }
44
+ if (o.op === "merge" && o.from.some((f) => same(f, it.topic)))
45
+ it.topic = o.into.trim();
46
+ if (o.op === "rename" && same(o.from, it.topic))
47
+ it.topic = o.to.trim();
48
+ if (o.op === "hide" && same(o.bucket, it.topic))
49
+ it.topic = "Hidden";
50
+ });
51
+ }
52
+ }
53
+ /** Apply new ops to a saved plan (and its embeddings, for label changes). Returns a line saying what changed. */
54
+ /**
55
+ * A sort made before fingerprints were kept: make them once, from the small previews it already saved on this computer (480px, so this never
56
+ * goes back to the original drive), or the original file when a preview is missing. About 0.15 s a picture; every change afterwards is instant.
57
+ */
58
+ async function backfillEmbeddings(plan, embPath, onProgress) {
59
+ const clf = await (0, topics_1.labeller)((0, topics_1.labelSet)(plan.labels ?? []));
60
+ if (!clf)
61
+ throw new Error("The picture model is not available on this computer.");
62
+ const todo = plan.items.map((_, i) => i).filter((i) => plan.items[i].dupOf === undefined);
63
+ let buf = null;
64
+ let d = 512, n = 0;
65
+ const t0 = Date.now();
66
+ for (const i of todo) {
67
+ const it = plan.items[i];
68
+ const src = it.thumb && fs_1.default.existsSync(it.thumb) ? { file: it.thumb, kind: "image" } : { file: path_1.default.join(plan.root, it.rel), kind: it.kind };
69
+ try {
70
+ const rgb = await (0, images_1.rgb224)(src.file, src.kind);
71
+ const l = rgb ? await clf(rgb, 224, 224) : null;
72
+ if (l?.emb) {
73
+ if (!buf) {
74
+ d = l.emb.length;
75
+ buf = new Float32Array(plan.items.length * d);
76
+ }
77
+ buf.set(l.emb, i * d);
78
+ }
79
+ }
80
+ catch { /* leave this row empty: it stays where it was */ }
81
+ if (++n % 20 === 0 || n === todo.length)
82
+ onProgress?.(n, todo.length, Math.round(((Date.now() - t0) / n) * (todo.length - n)));
83
+ }
84
+ fs_1.default.writeFileSync(embPath, Buffer.from((buf ?? new Float32Array(0)).buffer));
85
+ }
86
+ async function applyOps(planPath, embPath, add, onProgress) {
87
+ const plan = JSON.parse(fs_1.default.readFileSync(planPath, "utf8"));
88
+ if (plan.by !== "topic")
89
+ throw new Error("Changing folders needs a sort By topic.");
90
+ const ops = [...(plan.ops ?? []), ...add];
91
+ const before = new Map();
92
+ for (const it of plan.items)
93
+ if (it.topic && it.dupOf === undefined)
94
+ before.set(it.topic, (before.get(it.topic) ?? 0) + 1);
95
+ if (add.some((o) => o.op === "add_label" || o.op === "remove_label")) {
96
+ if (!fs_1.default.existsSync(embPath))
97
+ await backfillEmbeddings(plan, embPath, onProgress);
98
+ const set = (0, topics_1.labelSet)(labelsAfter(plan.labels ?? [], ops));
99
+ const T = await (0, topics_1.textEmbeddings)(set);
100
+ if (!T)
101
+ throw new Error("The picture model is not available on this computer.");
102
+ const d = T[0].length;
103
+ const raw = fs_1.default.readFileSync(embPath);
104
+ const E = new Float32Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 4));
105
+ const found = plan.items.map((it, i) => {
106
+ if (it.dupOf !== undefined || (i + 1) * d > E.length)
107
+ return undefined;
108
+ const row = E.subarray(i * d, (i + 1) * d);
109
+ return row.some((x) => x !== 0) ? (0, topics_1.labelsFromEmbedding)(row, T, set) : { tags: [], best: null };
110
+ });
111
+ const bucket = (0, topics_1.bucketFor)(found, set);
112
+ plan.items.forEach((it, i) => { if (found[i]) {
113
+ it.topic = bucket[i] ?? "Other";
114
+ it.tags = found[i].tags;
115
+ } });
116
+ regroup(plan, ops); // every merge / rename / hide so far, on top of the fresh labels
117
+ }
118
+ else
119
+ regroup(plan, add); // only the new ones: the earlier ones are already in the items
120
+ plan.ops = ops;
121
+ // labels already fold small subjects into their group (bucketFor), so nothing is folded again here: a label the creator asked for keeps its card even with one picture
122
+ plan.buckets = (0, plan_1.planBuckets)(plan.items, { by: "topic", minBucket: 1 }).filter((b) => b.name !== "Hidden");
123
+ plan.moves = (0, plan_1.planMoves)(plan.root, plan.items, plan.buckets);
124
+ plan.totals = (0, plan_1.summarize)(plan.items, plan.buckets);
125
+ fs_1.default.writeFileSync(planPath, JSON.stringify(plan));
126
+ const after = new Map();
127
+ for (const it of plan.items)
128
+ if (it.topic && it.dupOf === undefined)
129
+ after.set(it.topic, (after.get(it.topic) ?? 0) + 1);
130
+ const lines = [];
131
+ for (const o of add) {
132
+ if (o.op === "add_label") {
133
+ const n = [...after.entries()].find(([k]) => same(k, o.label))?.[1] ?? 0;
134
+ lines.push(n ? `${o.label}: ${n.toLocaleString()} pictures` : `${o.label}: no pictures matched`);
135
+ }
136
+ if (o.op === "remove_label")
137
+ lines.push(`Dropped the ${o.label} label`);
138
+ if (o.op === "merge")
139
+ lines.push(`Combined ${o.from.join(", ")} into ${o.into} (${(after.get(o.into.trim()) ?? 0).toLocaleString()})`);
140
+ if (o.op === "rename")
141
+ lines.push(`Renamed ${o.from} to ${o.to}`);
142
+ if (o.op === "exclude")
143
+ lines.push(`Took ${o.items.length} out of ${o.bucket}`);
144
+ if (o.op === "hide")
145
+ lines.push(`Hid ${o.bucket} (${(before.get(o.bucket) ?? 0).toLocaleString()} pictures stay on your computer)`);
146
+ }
147
+ return { plan, note: lines.join(" · ") || "No change" };
148
+ }
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PREVIEW_W = void 0;
40
+ exports.ffmpegPath = ffmpegPath;
41
+ exports.hasFfmpeg = hasFfmpeg;
42
+ exports.canReadPictures = canReadPictures;
43
+ exports.dhash = dhash;
44
+ exports.makeThumb = makeThumb;
45
+ exports.makePreview = makePreview;
46
+ exports.rgb224 = rgb224;
47
+ exports.fileMeta = fileMeta;
48
+ exports.takenMonth = takenMonth;
49
+ /**
50
+ * Pixels for the organizer: a 64-bit picture hash and a small thumbnail. `sharp` (libvips, prebuilt, shrink-on-load, far faster than a process per picture)
51
+ * when it is installed; otherwise ffmpeg (VIRALSPIN_FFMPEG, the connector's own kit, or PATH). Video thumbnails always need ffmpeg and are skipped without it.
52
+ */
53
+ const child_process_1 = require("child_process");
54
+ const fs_1 = __importDefault(require("fs"));
55
+ const os_1 = __importDefault(require("os"));
56
+ const path_1 = __importDefault(require("path"));
57
+ let sharpMod;
58
+ async function sharp() {
59
+ if (sharpMod === undefined) {
60
+ try {
61
+ const m = await Promise.resolve().then(() => __importStar(require("sharp")));
62
+ sharpMod = m.default ?? m;
63
+ sharpMod.cache(false);
64
+ }
65
+ catch {
66
+ sharpMod = null;
67
+ }
68
+ }
69
+ return sharpMod;
70
+ }
71
+ /** Where ffmpeg is: VIRALSPIN_FFMPEG, the connector's own kit, or PATH. */
72
+ function ffmpegPath() {
73
+ if (process.env.VIRALSPIN_FFMPEG)
74
+ return process.env.VIRALSPIN_FFMPEG;
75
+ const exe = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
76
+ for (const d of [path_1.default.join(os_1.default.homedir(), ".viralspin", "kit", "bin"), path_1.default.join(os_1.default.homedir(), ".viralspin", "ffmpeg")]) {
77
+ const p = path_1.default.join(d, exe);
78
+ if (fs_1.default.existsSync(p))
79
+ return p;
80
+ }
81
+ return "ffmpeg";
82
+ }
83
+ let ffOk;
84
+ function hasFfmpeg() {
85
+ if (ffOk !== undefined)
86
+ return Promise.resolve(ffOk);
87
+ return new Promise((res) => (0, child_process_1.execFile)(ffmpegPath(), ["-version"], (e) => { ffOk = !e; res(ffOk); }));
88
+ }
89
+ /** Can this computer make picture hashes at all (sharp or ffmpeg)? Used to say so plainly instead of failing quietly. */
90
+ async function canReadPictures() { return !!(await sharp()) || (await hasFfmpeg()); }
91
+ const dhashOf = (b) => {
92
+ if (b.length < 72)
93
+ return null;
94
+ let bits = "";
95
+ for (let y = 0; y < 8; y++)
96
+ for (let x = 0; x < 8; x++)
97
+ bits += b[y * 9 + x] > b[y * 9 + x + 1] ? "1" : "0";
98
+ let h = "";
99
+ for (let i = 0; i < 64; i += 4)
100
+ h += parseInt(bits.slice(i, i + 4), 2).toString(16);
101
+ return h;
102
+ };
103
+ /** 64-bit difference hash: a 9x8 greyscale shrink, each pixel compared with its right neighbour. Null when the picture can not be read. */
104
+ async function dhash(file) {
105
+ const s = await sharp();
106
+ if (s) {
107
+ try {
108
+ return dhashOf(await s(file, { failOn: "none" }).rotate().resize(9, 8, { fit: "fill" }).greyscale().raw().toBuffer());
109
+ }
110
+ catch { /* fall through to ffmpeg */ }
111
+ }
112
+ return new Promise((res) => {
113
+ const ff = (0, child_process_1.spawn)(ffmpegPath(), ["-v", "error", "-i", file, "-vframes", "1", "-vf", "scale=9:8:flags=area,format=gray", "-f", "rawvideo", "-"]);
114
+ const bufs = [];
115
+ ff.stdout.on("data", (d) => bufs.push(d));
116
+ ff.on("error", () => res(null));
117
+ ff.on("close", () => res(dhashOf(Buffer.concat(bufs))));
118
+ });
119
+ }
120
+ /** A 480px-wide jpeg thumbnail of a picture, or the first second of a clip. False when it could not be made. */
121
+ async function makeThumb(file, kind, dst) {
122
+ const s = kind === "image" ? await sharp() : null;
123
+ if (s) {
124
+ try {
125
+ await s(file, { failOn: "none" }).rotate().resize({ width: 480, withoutEnlargement: true }).jpeg({ quality: 70 }).toFile(dst);
126
+ return fs_1.default.existsSync(dst);
127
+ }
128
+ catch { /* fall through */ }
129
+ }
130
+ return new Promise((res) => {
131
+ const ff = (0, child_process_1.spawn)(ffmpegPath(), ["-v", "error", "-y", ...(kind === "video" ? ["-ss", "1"] : []), "-i", file, "-vframes", "1", "-vf", "scale=480:-2", "-q:v", "5", dst]);
132
+ ff.on("error", () => res(false));
133
+ ff.on("close", (c) => res(c === 0 && fs_1.default.existsSync(dst)));
134
+ });
135
+ }
136
+ /** A large (up to PREVIEW_W px wide) jpeg of a picture, for full-screen viewing and zooming in: the 480 px thumbnail is soft on a big screen. False when it could not be made. */
137
+ exports.PREVIEW_W = 2400;
138
+ async function makePreview(file, dst, kind = "image") {
139
+ // a clip's still: a frame about a second in (the first is often black or mid-fade); a clip shorter than that falls back to its first frame
140
+ if (kind === "video")
141
+ return (await ffFrame(file, dst, ["-ss", "1"])) || ffFrame(file, dst, []);
142
+ const s = await sharp();
143
+ if (s) {
144
+ try {
145
+ await s(file, { failOn: "none" }).rotate().resize({ width: exports.PREVIEW_W, withoutEnlargement: true }).jpeg({ quality: 86 }).toFile(dst);
146
+ return fs_1.default.existsSync(dst);
147
+ }
148
+ catch { /* fall through */ }
149
+ }
150
+ return ffFrame(file, dst, []);
151
+ }
152
+ function ffFrame(file, dst, seek) {
153
+ return new Promise((res) => {
154
+ const ff = (0, child_process_1.spawn)(ffmpegPath(), ["-v", "error", "-y", ...seek, "-i", file, "-vframes", "1", "-vf", `scale='min(${exports.PREVIEW_W},iw)':-2`, "-q:v", "2", dst]);
155
+ ff.on("error", () => res(false));
156
+ ff.on("close", (c) => res(c === 0 && fs_1.default.existsSync(dst) && fs_1.default.statSync(dst).size > 0));
157
+ });
158
+ }
159
+ /** A 224x224 RGB picture (cover-cropped) for the topic model: sharp for a picture, a frame at about 1 s for a clip. Null when it can not be read. */
160
+ async function rgb224(file, kind) {
161
+ const s = kind === "image" ? await sharp() : null;
162
+ if (s) {
163
+ try {
164
+ return await s(file, { failOn: "none" }).rotate().resize(224, 224, { fit: "cover" }).removeAlpha().toColourspace("srgb").raw().toBuffer();
165
+ }
166
+ catch { /* fall through */ }
167
+ }
168
+ return new Promise((res) => {
169
+ const ff = (0, child_process_1.spawn)(ffmpegPath(), ["-v", "error", ...(kind === "video" ? ["-ss", "1"] : []), "-i", file, "-vframes", "1", "-vf", "scale=224:224:force_original_aspect_ratio=increase,crop=224:224", "-pix_fmt", "rgb24", "-f", "rawvideo", "-"]);
170
+ const bufs = [];
171
+ ff.stdout.on("data", (d) => bufs.push(d));
172
+ ff.on("error", () => res(null));
173
+ ff.on("close", () => { const b = Buffer.concat(bufs); res(b.length >= 224 * 224 * 3 ? b.subarray(0, 224 * 224 * 3) : null); });
174
+ });
175
+ }
176
+ /**
177
+ * What the viewer shows about one file: its pixel size (as displayed, so a rotated phone photo reads portrait), when it was taken (EXIF
178
+ * DateTimeOriginal, to the second) and, for a clip, how long it runs. Every field is best effort: sharp for pictures, ffmpeg's banner for clips
179
+ * and for anything sharp can't open (HEIC without libheif); null when neither knows.
180
+ */
181
+ async function fileMeta(file, kind) {
182
+ const out = { width: null, height: null, taken: null, duration: null };
183
+ const s = kind === "image" ? await sharp() : null;
184
+ if (s) {
185
+ try {
186
+ const m = await s(file, { failOn: "none" }).metadata();
187
+ const turned = (m.orientation ?? 1) >= 5;
188
+ out.width = (turned ? m.height : m.width) ?? null;
189
+ out.height = (turned ? m.width : m.height) ?? null;
190
+ const d = m.exif ? /(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(m.exif.toString("latin1")) : null;
191
+ if (d && +d[1] > 1990)
192
+ out.taken = `${d[1]}-${d[2]}-${d[3]}T${d[4]}:${d[5]}:${d[6]}`;
193
+ }
194
+ catch { /* ffmpeg below */ }
195
+ }
196
+ if (out.width && (kind === "image"))
197
+ return out;
198
+ await new Promise((res) => {
199
+ (0, child_process_1.execFile)(ffmpegPath(), ["-hide_banner", "-i", file], { timeout: 15000 }, (_e, _o, err) => {
200
+ const t = String(err || "");
201
+ const v = /Stream #[^\n]*Video:[^\n]*?(\d{2,5})x(\d{2,5})/.exec(t), rot = /rotate\s*:\s*-?(90|270)|rotation of -?(90|270)/.exec(t);
202
+ if (v && !out.width) {
203
+ const w = +v[1], h = +v[2];
204
+ out.width = rot ? h : w;
205
+ out.height = rot ? w : h;
206
+ }
207
+ const du = /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/.exec(t);
208
+ if (du)
209
+ out.duration = +du[1] * 3600 + +du[2] * 60 + +du[3];
210
+ const c = /creation_time\s*:\s*(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(t);
211
+ if (c && !out.taken)
212
+ out.taken = `${c[1]}T${c[2]}`;
213
+ res();
214
+ });
215
+ });
216
+ return out;
217
+ }
218
+ /** "YYYY-MM" a picture was taken, from its EXIF DateTimeOriginal (sharp reads only the header), or null. */
219
+ async function takenMonth(file) {
220
+ const s = await sharp();
221
+ if (!s)
222
+ return null;
223
+ try {
224
+ const meta = await s(file, { failOn: "none" }).metadata();
225
+ const m = meta.exif ? /(\d{4}):(\d{2}):\d{2} \d{2}:\d{2}:\d{2}/.exec(meta.exif.toString("latin1")) : null;
226
+ return m && +m[1] > 1990 && +m[2] >= 1 && +m[2] <= 12 ? `${m[1]}-${m[2]}` : null;
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
@@ -0,0 +1,204 @@
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.safeName = exports.kindOf = exports.ORGANIZED_DIR = exports.VID = exports.IMG = void 0;
7
+ exports.hamming = hamming;
8
+ exports.markDuplicates = markDuplicates;
9
+ exports.planBuckets = planBuckets;
10
+ exports.planMoves = planMoves;
11
+ exports.summarize = summarize;
12
+ exports.applyMoves = applyMoves;
13
+ exports.undoMoves = undoMoves;
14
+ // Pure planning + safe-move helpers for the folder organizer (no network, no vision, no ffmpeg), so they are testable.
15
+ // The scan (scan.ts) fills `items`; this turns them into buckets and a PROPOSED list of moves. Nothing here touches the
16
+ // disk except applyMoves / undoMoves, and those only ever rename: never delete, never overwrite.
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ exports.IMG = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".bmp", ".tif", ".tiff"]);
20
+ exports.VID = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]);
21
+ exports.ORGANIZED_DIR = "_Organized";
22
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
23
+ const kindOf = (file) => { const e = path_1.default.extname(file).toLowerCase(); return exports.VID.has(e) ? "video" : exports.IMG.has(e) ? "image" : null; };
24
+ exports.kindOf = kindOf;
25
+ /** A folder-safe bucket name. */
26
+ const safeName = (s) => (String(s || "").replace(/[<>:"/\\|?*\x00-\x1f]/g, " ").replace(/\s+/g, " ").trim().replace(/[. ]+$/, "").slice(0, 60)) || "Misc";
27
+ exports.safeName = safeName;
28
+ const titleCase = (s) => s.replace(/\b[a-z]/g, (c) => c.toUpperCase());
29
+ /** Hamming distance of two 64-bit hashes given as 16-char hex strings. */
30
+ function hamming(a, b) {
31
+ let d = 0;
32
+ for (let i = 0; i < 16; i++) {
33
+ let x = parseInt(a[i], 16) ^ parseInt(b[i], 16);
34
+ while (x) {
35
+ d += x & 1;
36
+ x >>= 1;
37
+ }
38
+ }
39
+ return d;
40
+ }
41
+ /** Mark every copy after the first as a duplicate of it: identical content hash, or (images) a near-identical picture hash. The oldest file is the original. */
42
+ function markDuplicates(items, { near = 4 } = {}) {
43
+ const order = items.map((_, i) => i).sort((a, b) => items[a].mtime - items[b].mtime || items[a].rel.localeCompare(items[b].rel));
44
+ const byHash = new Map();
45
+ const seenD = [];
46
+ for (const i of order) {
47
+ const it = items[i];
48
+ if (it.hash) {
49
+ const hit = byHash.get(it.hash);
50
+ if (hit !== undefined) {
51
+ it.dupOf = hit;
52
+ it.dupKind = "exact";
53
+ continue;
54
+ }
55
+ byHash.set(it.hash, i);
56
+ }
57
+ if (it.dhash && it.kind === "image") {
58
+ const hit = seenD.find((j) => hamming(items[j].dhash, it.dhash) <= near);
59
+ if (hit !== undefined) {
60
+ it.dupOf = hit;
61
+ it.dupKind = "near";
62
+ continue;
63
+ }
64
+ seenD.push(i);
65
+ }
66
+ }
67
+ return items.filter((x) => x.dupOf !== undefined).length;
68
+ }
69
+ /** The label a picture goes under, per way of sorting. */
70
+ function bucketOf(it, by) {
71
+ if (by === "type")
72
+ return { kind: "type", name: it.kind === "video" ? "Videos" : "Pictures" };
73
+ if (by === "date") {
74
+ const d = new Date(it.mtime);
75
+ return { kind: "date", name: `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")} ${MONTHS[d.getUTCMonth()]}` };
76
+ }
77
+ if (by === "topic") {
78
+ if (it.topic)
79
+ return { kind: "topic", name: (0, exports.safeName)(it.topic) }; // the on-device topic model's label, kept as written
80
+ const s = (it.subject || it.tags?.[0] || "").trim().toLowerCase();
81
+ return s ? { kind: "topic", name: titleCase((0, exports.safeName)(s)) } : { kind: "topic", name: "Unsorted" };
82
+ }
83
+ // folder: the first folder under the root (a file at the root goes under "Loose")
84
+ const parts = it.rel.split(/[\\/]/);
85
+ return { kind: "folder", name: parts.length > 1 ? (0, exports.safeName)(parts[0]) : "Loose" };
86
+ }
87
+ /**
88
+ * Buckets for the scan. Extra copies always go to one "Duplicates" bucket (moved, never deleted); the rest by `by`: folder | date | topic | type.
89
+ * Tiny topic buckets (< minBucket) fold into "Other" so 300 one-picture folders do not appear.
90
+ */
91
+ function planBuckets(items, { by = "folder", minBucket = 3 } = {}) {
92
+ const map = new Map();
93
+ const add = (kind, name, i, media) => { const k = `${kind}:${media ?? "any"}:${name}`; if (!map.has(k))
94
+ map.set(k, { id: k, name, kind, media, items: [] }); map.get(k).items.push(i); };
95
+ items.forEach((it, i) => {
96
+ if (it.dupOf !== undefined)
97
+ return add("duplicates", "Duplicates", i);
98
+ const b = bucketOf(it, by);
99
+ add(b.kind, b.name, i, it.live ? "live" : it.kind); // a Live Photo (still + its clip) sits in its own section: motion for 7-second reels
100
+ });
101
+ let buckets = [...map.values()];
102
+ if (by === "topic") {
103
+ const small = buckets.filter((b) => b.kind === "topic" && b.name !== "Unsorted" && b.items.length < minBucket);
104
+ if (small.length) {
105
+ // per side: tiny picture buckets join the pictures' Other, tiny clip buckets the clips'
106
+ for (const media of ["image", "video", "live"]) {
107
+ const mine = small.filter((b) => b.media === media);
108
+ if (!mine.length)
109
+ continue;
110
+ const into = buckets.find((b) => b.kind === "topic" && b.media === media && b.name === "Other" && !mine.includes(b));
111
+ if (into)
112
+ into.items.push(...mine.flatMap((b) => b.items));
113
+ else
114
+ buckets.push({ id: `topic:${media}:Other`, name: "Other", kind: "topic", media, items: mine.flatMap((b) => b.items) });
115
+ }
116
+ buckets = buckets.filter((b) => !small.includes(b));
117
+ }
118
+ }
119
+ return buckets.sort((a, b) => Number(a.kind === "duplicates") - Number(b.kind === "duplicates") || b.items.length - a.items.length || a.name.localeCompare(b.name));
120
+ }
121
+ /** Where each file would go. `root` is the scanned folder; everything lands in <root>/_Organized/<bucket>/ and keeps its file name. */
122
+ function planMoves(root, items, buckets) {
123
+ const moves = [];
124
+ const taken = new Set();
125
+ for (const b of buckets) {
126
+ for (const i of b.items) {
127
+ const it = items[i];
128
+ const from = path_1.default.join(root, it.rel);
129
+ const dir = path_1.default.join(root, exports.ORGANIZED_DIR, (0, exports.safeName)(b.name));
130
+ const ext = path_1.default.extname(it.rel), stem = path_1.default.basename(it.rel, ext);
131
+ let to = path_1.default.join(dir, stem + ext), n = 1;
132
+ while (taken.has(to.toLowerCase()))
133
+ to = path_1.default.join(dir, `${stem} (${++n})${ext}`);
134
+ taken.add(to.toLowerCase());
135
+ moves.push({ from, to });
136
+ }
137
+ }
138
+ return moves;
139
+ }
140
+ function summarize(items, buckets) {
141
+ return {
142
+ files: items.length,
143
+ images: items.filter((x) => x.kind === "image").length,
144
+ videos: items.filter((x) => x.kind === "video").length,
145
+ bytes: items.reduce((s, x) => s + (x.size || 0), 0),
146
+ dupes: items.filter((x) => x.dupOf !== undefined).length,
147
+ buckets: buckets.length,
148
+ };
149
+ }
150
+ /**
151
+ * Do the moves. Only renames; a destination that already exists gets a " (n)" suffix, never overwritten; a source that is gone is skipped.
152
+ * The undo manifest is rewritten as it goes, so a crash halfway is still reversible.
153
+ */
154
+ function applyMoves(root, moves, id) {
155
+ const dir = path_1.default.join(root, exports.ORGANIZED_DIR);
156
+ fs_1.default.mkdirSync(dir, { recursive: true });
157
+ const manifest = path_1.default.join(dir, `.viralspin-undo-${id}.json`);
158
+ const done = [];
159
+ let skipped = 0;
160
+ for (const m of moves) {
161
+ try {
162
+ if (!fs_1.default.existsSync(m.from)) {
163
+ skipped++;
164
+ continue;
165
+ }
166
+ fs_1.default.mkdirSync(path_1.default.dirname(m.to), { recursive: true });
167
+ let to = m.to, n = 1;
168
+ const ext = path_1.default.extname(to), stem = to.slice(0, to.length - ext.length);
169
+ while (fs_1.default.existsSync(to))
170
+ to = `${stem} (${++n})${ext}`;
171
+ fs_1.default.renameSync(m.from, to);
172
+ done.push({ from: m.from, to });
173
+ if (done.length % 25 === 0)
174
+ fs_1.default.writeFileSync(manifest, JSON.stringify(done));
175
+ }
176
+ catch {
177
+ skipped++;
178
+ }
179
+ }
180
+ fs_1.default.writeFileSync(manifest, JSON.stringify(done));
181
+ return { moved: done.length, skipped, manifest };
182
+ }
183
+ /** Put every moved file back (newest first). A file that is no longer where it was put, or whose old spot is taken, is left alone and counted. */
184
+ function undoMoves(manifest) {
185
+ const done = JSON.parse(fs_1.default.readFileSync(manifest, "utf8"));
186
+ let restored = 0, left = 0;
187
+ for (const m of [...done].reverse()) {
188
+ try {
189
+ if (!fs_1.default.existsSync(m.to) || fs_1.default.existsSync(m.from)) {
190
+ left++;
191
+ continue;
192
+ }
193
+ fs_1.default.mkdirSync(path_1.default.dirname(m.from), { recursive: true });
194
+ fs_1.default.renameSync(m.to, m.from);
195
+ restored++;
196
+ }
197
+ catch {
198
+ left++;
199
+ }
200
+ }
201
+ if (!left)
202
+ fs_1.default.renameSync(manifest, manifest.replace(/\.json$/, ".undone.json"));
203
+ return { restored, left };
204
+ }