@viralspin/mcp 0.5.2 → 0.5.4
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/dist/organize-watch.js +34 -0
- package/dist/organizer/edit.js +40 -0
- package/dist/organizer/phoneTopics.js +70 -0
- package/dist/phoneReceive.js +199 -0
- package/dist/tools/organize.js +21 -11
- package/package.json +1 -1
package/dist/organize-watch.js
CHANGED
|
@@ -22,6 +22,7 @@ const child_process_1 = require("child_process");
|
|
|
22
22
|
const fs_1 = __importDefault(require("fs"));
|
|
23
23
|
const os_1 = __importDefault(require("os"));
|
|
24
24
|
const http_1 = __importDefault(require("http"));
|
|
25
|
+
const phoneReceive_1 = require("./phoneReceive");
|
|
25
26
|
const path_1 = __importDefault(require("path"));
|
|
26
27
|
const organize_js_1 = require("./tools/organize.js");
|
|
27
28
|
const plan_js_1 = require("./organizer/plan.js");
|
|
@@ -233,6 +234,39 @@ function startThumbServer(log) {
|
|
|
233
234
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
234
235
|
return void fs_1.default.createReadStream(f).pipe(res);
|
|
235
236
|
}
|
|
237
|
+
// receiving from an iPhone over the home network (phoneReceive.ts): the page starts, watches and stops it; the phone talks to the LAN server
|
|
238
|
+
if (route === "/receive/status") {
|
|
239
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
240
|
+
res.statusCode = 403;
|
|
241
|
+
return res.end();
|
|
242
|
+
}
|
|
243
|
+
res.setHeader("Content-Type", "application/json");
|
|
244
|
+
return res.end(JSON.stringify({ ...(0, phoneReceive_1.receiveStatus)(), defaultDest: (0, phoneReceive_1.defaultDest)() }));
|
|
245
|
+
}
|
|
246
|
+
if (route === "/receive/start" || route === "/receive/stop") {
|
|
247
|
+
if (req.method !== "POST" || !ORIGIN_OK.test(origin)) {
|
|
248
|
+
res.statusCode = 403;
|
|
249
|
+
return res.end();
|
|
250
|
+
}
|
|
251
|
+
if (route === "/receive/stop") {
|
|
252
|
+
(0, phoneReceive_1.stopReceive)(log);
|
|
253
|
+
res.setHeader("Content-Type", "application/json");
|
|
254
|
+
return res.end(JSON.stringify((0, phoneReceive_1.receiveStatus)()));
|
|
255
|
+
}
|
|
256
|
+
let raw = "";
|
|
257
|
+
req.on("data", (c) => { raw += c; if (raw.length > 10000)
|
|
258
|
+
req.destroy(); });
|
|
259
|
+
req.on("end", () => {
|
|
260
|
+
let dest;
|
|
261
|
+
try {
|
|
262
|
+
dest = JSON.parse(raw || "{}").dest;
|
|
263
|
+
}
|
|
264
|
+
catch { /* the default folder */ }
|
|
265
|
+
(0, phoneReceive_1.startReceive)(dest, log).then((st) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(st)); })
|
|
266
|
+
.catch((e) => { res.statusCode = 500; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: e.message })); });
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
236
270
|
if (route === "/ping") {
|
|
237
271
|
res.setHeader("Content-Type", "application/json");
|
|
238
272
|
return res.end(JSON.stringify({ ok: true, items: 1 }));
|
package/dist/organizer/edit.js
CHANGED
|
@@ -17,6 +17,8 @@ const plan_1 = require("./plan");
|
|
|
17
17
|
const path_1 = __importDefault(require("path"));
|
|
18
18
|
const images_1 = require("./images");
|
|
19
19
|
const topics_1 = require("./topics");
|
|
20
|
+
const images_2 = require("./images");
|
|
21
|
+
const phoneTopics_1 = require("./phoneTopics");
|
|
20
22
|
const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
21
23
|
/** The creator's labels after the ops: the ones the sort started with, plus added ones, minus removed ones. */
|
|
22
24
|
function labelsAfter(start, ops) {
|
|
@@ -33,6 +35,15 @@ function labelsAfter(start, ops) {
|
|
|
33
35
|
function regroup(plan, ops) {
|
|
34
36
|
for (const o of ops) {
|
|
35
37
|
const out = o.op === "exclude" ? new Set(o.items) : null;
|
|
38
|
+
// the phone's topics, as the pictures they matched: those pictures take that topic (a later merge / rename / hide still applies to it)
|
|
39
|
+
if (o.op === "assign") {
|
|
40
|
+
for (const [i, t] of o.items) {
|
|
41
|
+
const it = plan.items[i];
|
|
42
|
+
if (it && it.dupOf === undefined)
|
|
43
|
+
it.topic = t;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
36
47
|
plan.items.forEach((it, i) => {
|
|
37
48
|
if (!it.topic)
|
|
38
49
|
return;
|
|
@@ -83,10 +94,35 @@ async function backfillEmbeddings(plan, embPath, onProgress) {
|
|
|
83
94
|
}
|
|
84
95
|
fs_1.default.writeFileSync(embPath, Buffer.from((buf ?? new Float32Array(0)).buffer));
|
|
85
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Every picture's fingerprint for matching the phone's: the one the scan kept, else (a HEIC sharp can't read, mostly) one made from the small
|
|
99
|
+
* preview the sort already saved on this computer. Videos and duplicates get none. Previews made here are kept on the item for next time.
|
|
100
|
+
*/
|
|
101
|
+
async function pictureHashes(plan) {
|
|
102
|
+
const out = plan.items.map((it) => (it.kind === "image" && it.dupOf === undefined ? it.dhash : undefined));
|
|
103
|
+
const missing = plan.items.map((it, i) => i).filter((i) => plan.items[i].kind === "image" && plan.items[i].dupOf === undefined && !out[i] && plan.items[i].thumb && fs_1.default.existsSync(plan.items[i].thumb));
|
|
104
|
+
for (const i of missing) {
|
|
105
|
+
const h = await (0, images_2.dhash)(plan.items[i].thumb).catch(() => null);
|
|
106
|
+
if (h) {
|
|
107
|
+
out[i] = h;
|
|
108
|
+
plan.items[i].dhash = h;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
86
113
|
async function applyOps(planPath, embPath, add, onProgress) {
|
|
87
114
|
const plan = JSON.parse(fs_1.default.readFileSync(planPath, "utf8"));
|
|
88
115
|
if (plan.by !== "topic")
|
|
89
116
|
throw new Error("Changing folders needs a sort By topic.");
|
|
117
|
+
// the phone's topics arrive as fingerprints: match them against this sort's pictures once, and keep only what matched
|
|
118
|
+
const filed = new Map();
|
|
119
|
+
add = await Promise.all(add.map(async (o) => {
|
|
120
|
+
if (o.op !== "phone_topics")
|
|
121
|
+
return o;
|
|
122
|
+
const m = (0, phoneTopics_1.matchPhoneTopics)(await pictureHashes(plan), o.rows ?? []);
|
|
123
|
+
filed.set(o.source ?? "iPhone", m);
|
|
124
|
+
return { op: "assign", items: [...m.entries()], source: o.source ?? "iPhone" };
|
|
125
|
+
}));
|
|
90
126
|
const ops = [...(plan.ops ?? []), ...add];
|
|
91
127
|
const before = new Map();
|
|
92
128
|
for (const it of plan.items)
|
|
@@ -141,6 +177,10 @@ async function applyOps(planPath, embPath, add, onProgress) {
|
|
|
141
177
|
lines.push(`Renamed ${o.from} to ${o.to}`);
|
|
142
178
|
if (o.op === "exclude")
|
|
143
179
|
lines.push(`Took ${o.items.length} out of ${o.bucket}`);
|
|
180
|
+
if (o.op === "assign") {
|
|
181
|
+
const m = filed.get(o.source ?? "iPhone");
|
|
182
|
+
lines.push(o.items.length ? `Filed ${o.items.length.toLocaleString()} pictures under your ${o.source ?? "iPhone"}'s topics${m ? `: ${(0, phoneTopics_1.sayTopics)(m)}` : ""}` : `None of these pictures are in your ${o.source ?? "iPhone"}'s sort`);
|
|
183
|
+
}
|
|
144
184
|
if (o.op === "hide")
|
|
145
185
|
lines.push(`Hid ${o.bucket} (${(before.get(o.bucket) ?? 0).toLocaleString()} pictures stay on your computer)`);
|
|
146
186
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NEAR = void 0;
|
|
4
|
+
exports.matchPhoneTopics = matchPhoneTopics;
|
|
5
|
+
exports.sayTopics = sayTopics;
|
|
6
|
+
/**
|
|
7
|
+
* FILE THIS COMPUTER'S COPIES UNDER THE PHONE'S TOPICS. The iPhone app sorts the phone's library into topics and (opt-in) sends a small
|
|
8
|
+
* preview + topic per picture to My Story; the server fingerprints each preview (organized_assets.fp, the same 64-bit difference hash this
|
|
9
|
+
* organizer keeps per picture as `dhash`). Most people's phone photos are ALSO on their computer (an iCloud folder, an old backup, a camera
|
|
10
|
+
* roll dump), so a sort of that folder can take the phone's topics for the pictures it shares with the phone: no upload, nothing copied.
|
|
11
|
+
*
|
|
12
|
+
* A computer picture takes a phone topic when its hash is within NEAR bits of a phone picture's, the closest one winning. Videos are left
|
|
13
|
+
* alone (the phone sorts photos), and so are flat pictures (nearly every bit the same: a blank, dark or one-colour frame looks like any other).
|
|
14
|
+
* Candidates are found through the hash's four 16-bit quarters (two hashes within 3 bits always share one; 4 usually do), so 15k pictures
|
|
15
|
+
* against 5k phone rows is a few thousand comparisons, not 75 million.
|
|
16
|
+
*/
|
|
17
|
+
const plan_1 = require("./plan");
|
|
18
|
+
exports.NEAR = 4;
|
|
19
|
+
const usable = (h) => !!h && /^[0-9a-f]{16}$/i.test(h);
|
|
20
|
+
const flat = (h) => { const n = (0, plan_1.hamming)(h.toLowerCase(), "0000000000000000"); return n < 6 || n > 58; };
|
|
21
|
+
/**
|
|
22
|
+
* For each computer picture (by index) with a hash, the phone topic it takes, or nothing. `hashes[i]` is picture i's hash (undefined for a
|
|
23
|
+
* video, a duplicate, or a picture that could not be read). Pure.
|
|
24
|
+
*/
|
|
25
|
+
function matchPhoneTopics(hashes, rows) {
|
|
26
|
+
const bands = new Map();
|
|
27
|
+
const phone = rows.filter((r) => usable(r.fp) && !flat(r.fp) && r.topic && r.topic.trim()).map((r) => ({ fp: r.fp.toLowerCase(), topic: r.topic.trim() }));
|
|
28
|
+
phone.forEach((r, k) => {
|
|
29
|
+
for (let b = 0; b < 4; b++) {
|
|
30
|
+
const key = `${b}${r.fp.slice(b * 4, b * 4 + 4)}`;
|
|
31
|
+
const l = bands.get(key);
|
|
32
|
+
if (l)
|
|
33
|
+
l.push(k);
|
|
34
|
+
else
|
|
35
|
+
bands.set(key, [k]);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const out = new Map();
|
|
39
|
+
hashes.forEach((h, i) => {
|
|
40
|
+
if (!usable(h) || flat(h))
|
|
41
|
+
return;
|
|
42
|
+
const hh = h.toLowerCase();
|
|
43
|
+
let best = exports.NEAR + 1, topic = null;
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (let b = 0; b < 4; b++) {
|
|
46
|
+
for (const k of bands.get(`${b}${hh.slice(b * 4, b * 4 + 4)}`) ?? []) {
|
|
47
|
+
if (seen.has(k))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(k);
|
|
50
|
+
const d = (0, plan_1.hamming)(hh, phone[k].fp);
|
|
51
|
+
if (d < best) {
|
|
52
|
+
best = d;
|
|
53
|
+
topic = phone[k].topic;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (topic)
|
|
58
|
+
out.set(i, topic);
|
|
59
|
+
});
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** "Beach 220 · Family 140 · Tennis 38 · 4 more" for the note the app shows after the change. */
|
|
63
|
+
function sayTopics(m) {
|
|
64
|
+
const n = new Map();
|
|
65
|
+
for (const t of m.values())
|
|
66
|
+
n.set(t, (n.get(t) ?? 0) + 1);
|
|
67
|
+
const top = [...n.entries()].sort((a, b) => b[1] - a[1]);
|
|
68
|
+
const shown = top.slice(0, 4).map(([t, c]) => `${t} ${c.toLocaleString()}`).join(" · ");
|
|
69
|
+
return top.length > 4 ? `${shown} · ${top.length - 4} more` : shown;
|
|
70
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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.RECEIVE_PORT = void 0;
|
|
7
|
+
exports.lanAddresses = lanAddresses;
|
|
8
|
+
exports.isPrivate = isPrivate;
|
|
9
|
+
exports.safePart = safePart;
|
|
10
|
+
exports.targetPath = targetPath;
|
|
11
|
+
exports.defaultDest = defaultDest;
|
|
12
|
+
exports.receiveStatus = receiveStatus;
|
|
13
|
+
exports.stopReceive = stopReceive;
|
|
14
|
+
exports.startReceive = startReceive;
|
|
15
|
+
/**
|
|
16
|
+
* RECEIVE FROM AN IPHONE, over the home network. The iPhone app sends the ORIGINAL photos and videos of the topics the creator ticked, straight
|
|
17
|
+
* to this computer, into <folder>/<topic>/<file name>: nothing goes through ViralSpin's servers.
|
|
18
|
+
*
|
|
19
|
+
* The web page starts a session over loopback (POST /receive/start, ViralSpin origins only, like every other loopback route). Only then does
|
|
20
|
+
* this open a second, small server on the LAN (RECEIVE_PORT), which answers only:
|
|
21
|
+
* - requests carrying the session's one-time token (shown to the phone in a QR code), and
|
|
22
|
+
* - from private-network addresses (10/8, 172.16/12, 192.168/16, link-local, loopback),
|
|
23
|
+
* and it closes itself after IDLE_MS without a request, or when the page stops it. A name or topic is reduced to a plain file name, so nothing
|
|
24
|
+
* is ever written outside the chosen folder.
|
|
25
|
+
*/
|
|
26
|
+
const http_1 = __importDefault(require("http"));
|
|
27
|
+
const fs_1 = __importDefault(require("fs"));
|
|
28
|
+
const os_1 = __importDefault(require("os"));
|
|
29
|
+
const path_1 = __importDefault(require("path"));
|
|
30
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
31
|
+
exports.RECEIVE_PORT = 47840;
|
|
32
|
+
const IDLE_MS = 30 * 60000;
|
|
33
|
+
let session = null;
|
|
34
|
+
/** This computer's addresses a phone on the same Wi-Fi can reach: private IPv4s, wired/wireless first, no virtual adapters' link-local. */
|
|
35
|
+
function lanAddresses() {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, addrs] of Object.entries(os_1.default.networkInterfaces())) {
|
|
38
|
+
if (/vEthernet|VirtualBox|VMware|WSL|Hyper-V|docker|utun|tailscale|ZeroTier/i.test(name))
|
|
39
|
+
continue;
|
|
40
|
+
for (const a of addrs ?? [])
|
|
41
|
+
if (a.family === "IPv4" && !a.internal && isPrivate(a.address))
|
|
42
|
+
out.push(a.address);
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(out)];
|
|
45
|
+
}
|
|
46
|
+
/** Is this a private / local address (the only ones the LAN server answers)? */
|
|
47
|
+
function isPrivate(ip) {
|
|
48
|
+
const v = ip.replace(/^::ffff:/, "");
|
|
49
|
+
if (v === "127.0.0.1" || v === "::1")
|
|
50
|
+
return true;
|
|
51
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v);
|
|
52
|
+
if (m) {
|
|
53
|
+
const a = +m[1], b = +m[2];
|
|
54
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
55
|
+
}
|
|
56
|
+
return /^fe80:/i.test(v) || /^f[cd][0-9a-f]{2}:/i.test(v);
|
|
57
|
+
}
|
|
58
|
+
/** A topic or file name as one safe path part: no separators, no "..", no characters Windows refuses, never empty. */
|
|
59
|
+
function safePart(s, fallback) {
|
|
60
|
+
const t = String(s ?? "").normalize("NFC").replace(/[\\/:*?"<>|\x00-\x1f]/g, " ").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 120); // no leading dots, no trailing dot or space (Windows)
|
|
61
|
+
return t && t !== "." && t !== ".." ? t : fallback;
|
|
62
|
+
}
|
|
63
|
+
/** Where a file of a topic lands: <dest>/<topic>/<name>, inside dest whatever the request said. */
|
|
64
|
+
function targetPath(dest, topic, name) {
|
|
65
|
+
const p = path_1.default.join(dest, safePart(topic, "Other"), safePart(name, "photo"));
|
|
66
|
+
if (!path_1.default.resolve(p).startsWith(path_1.default.resolve(dest) + path_1.default.sep))
|
|
67
|
+
throw new Error("outside the folder");
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
function defaultDest() { return path_1.default.join(os_1.default.homedir(), "Pictures", "From iPhone"); }
|
|
71
|
+
function receiveStatus() {
|
|
72
|
+
if (!session)
|
|
73
|
+
return { active: false, received: 0, skipped: 0, bytes: 0 };
|
|
74
|
+
const { dest, port, hosts, token, received, skipped, bytes, last, phone, startedAt } = session;
|
|
75
|
+
return { active: true, dest, port, hosts, token, received, skipped, bytes, last, phone, startedAt };
|
|
76
|
+
}
|
|
77
|
+
function stopReceive(log) {
|
|
78
|
+
if (!session)
|
|
79
|
+
return;
|
|
80
|
+
clearTimeout(session.idle);
|
|
81
|
+
session.server.close();
|
|
82
|
+
log?.(`[receive] stopped: ${session.received} file(s) received, ${session.skipped} already there`);
|
|
83
|
+
session = null;
|
|
84
|
+
}
|
|
85
|
+
function touch(log) {
|
|
86
|
+
if (!session)
|
|
87
|
+
return;
|
|
88
|
+
session.lastAt = Date.now();
|
|
89
|
+
clearTimeout(session.idle);
|
|
90
|
+
session.idle = setTimeout(() => stopReceive(log), IDLE_MS);
|
|
91
|
+
}
|
|
92
|
+
/** Start (or restart into another folder) a receive session. Resolves once the LAN server listens. */
|
|
93
|
+
function startReceive(destIn, log) {
|
|
94
|
+
const dest = path_1.default.resolve(destIn && destIn.trim() ? destIn.trim() : defaultDest());
|
|
95
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
96
|
+
if (session && session.dest === dest) {
|
|
97
|
+
touch(log);
|
|
98
|
+
return Promise.resolve(receiveStatus());
|
|
99
|
+
}
|
|
100
|
+
stopReceive(log);
|
|
101
|
+
const token = crypto_1.default.randomBytes(12).toString("base64url");
|
|
102
|
+
const server = http_1.default.createServer((req, res) => handle(req, res, log));
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let port = exports.RECEIVE_PORT;
|
|
105
|
+
const tryListen = () => server.listen(port, "0.0.0.0");
|
|
106
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < exports.RECEIVE_PORT + 5) {
|
|
107
|
+
port++;
|
|
108
|
+
tryListen();
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
reject(e); });
|
|
112
|
+
server.on("listening", () => {
|
|
113
|
+
session = { token, dest, server, port, hosts: lanAddresses(), received: 0, skipped: 0, bytes: 0, lastAt: Date.now(), startedAt: Date.now(), idle: setTimeout(() => stopReceive(log), IDLE_MS) };
|
|
114
|
+
log(`[receive] waiting for an iPhone on ${session.hosts.join(", ") || "this network"}:${port}, saving into ${dest}`);
|
|
115
|
+
resolve(receiveStatus());
|
|
116
|
+
});
|
|
117
|
+
tryListen();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function json(res, code, body) { res.statusCode = code; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); }
|
|
121
|
+
function handle(req, res, log) {
|
|
122
|
+
const s = session;
|
|
123
|
+
const ip = String(req.socket.remoteAddress ?? "");
|
|
124
|
+
const url = new URL(req.url || "/", "http://x");
|
|
125
|
+
if (!s || !isPrivate(ip))
|
|
126
|
+
return json(res, 403, { error: "not receiving" });
|
|
127
|
+
const tok = url.searchParams.get("t") ?? String(req.headers["x-viralspin-token"] ?? "");
|
|
128
|
+
if (!tok || tok.length !== s.token.length || !crypto_1.default.timingSafeEqual(Buffer.from(tok), Buffer.from(s.token)))
|
|
129
|
+
return json(res, 403, { error: "wrong code" });
|
|
130
|
+
touch(log);
|
|
131
|
+
s.phone = ip.replace(/^::ffff:/, "");
|
|
132
|
+
// the phone checks it found the right computer
|
|
133
|
+
if (url.pathname === "/hello" && req.method === "GET")
|
|
134
|
+
return json(res, 200, { ok: true, computer: os_1.default.hostname(), folder: path_1.default.basename(s.dest) });
|
|
135
|
+
// which of these are here already: [{ topic, name, size? }] -> [true/false]. A file of that name in that topic (and size, when given) is skipped
|
|
136
|
+
if (url.pathname === "/have" && req.method === "POST") {
|
|
137
|
+
let raw = "";
|
|
138
|
+
req.on("data", (c) => { raw += c; if (raw.length > 4000000)
|
|
139
|
+
req.destroy(); });
|
|
140
|
+
req.on("end", () => {
|
|
141
|
+
try {
|
|
142
|
+
const files = (JSON.parse(raw).files ?? []);
|
|
143
|
+
const have = files.slice(0, 5000).map((f) => {
|
|
144
|
+
try {
|
|
145
|
+
const st = fs_1.default.statSync(targetPath(s.dest, f.topic, f.name));
|
|
146
|
+
return !f.size || st.size === f.size;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
s.skipped += have.filter(Boolean).length;
|
|
153
|
+
json(res, 200, { have });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
json(res, 400, { error: "bad list" });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// one file: PUT /file?topic=&name=&taken=<unix seconds>, the bytes as the body. Written to a .part file and renamed when complete
|
|
162
|
+
if (url.pathname === "/file" && req.method === "PUT") {
|
|
163
|
+
let to;
|
|
164
|
+
try {
|
|
165
|
+
to = targetPath(s.dest, url.searchParams.get("topic") ?? "", url.searchParams.get("name") ?? "");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return json(res, 400, { error: "bad name" });
|
|
169
|
+
}
|
|
170
|
+
fs_1.default.mkdirSync(path_1.default.dirname(to), { recursive: true });
|
|
171
|
+
const part = `${to}.part`;
|
|
172
|
+
const out = fs_1.default.createWriteStream(part);
|
|
173
|
+
let n = 0;
|
|
174
|
+
req.on("data", (c) => { n += c.length; });
|
|
175
|
+
req.pipe(out);
|
|
176
|
+
out.on("finish", () => {
|
|
177
|
+
try {
|
|
178
|
+
fs_1.default.renameSync(part, to);
|
|
179
|
+
const taken = Number(url.searchParams.get("taken"));
|
|
180
|
+
if (taken > 0) {
|
|
181
|
+
const d = new Date(taken * 1000);
|
|
182
|
+
fs_1.default.utimesSync(to, d, d);
|
|
183
|
+
} // the photo's own date, so the folder sorts by when it was taken
|
|
184
|
+
s.received++;
|
|
185
|
+
s.bytes += n;
|
|
186
|
+
s.last = path_1.default.relative(s.dest, to);
|
|
187
|
+
json(res, 200, { ok: true });
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
json(res, 500, { error: e.message });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
const fail = () => { out.destroy(); fs_1.default.rm(part, { force: true }, () => undefined); };
|
|
194
|
+
req.on("aborted", fail);
|
|
195
|
+
out.on("error", () => { fail(); json(res, 500, { error: "could not write" }); });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
json(res, 404, { error: "unknown" });
|
|
199
|
+
}
|
package/dist/tools/organize.js
CHANGED
|
@@ -127,6 +127,7 @@ exports.organizeAddToStoryTool = {
|
|
|
127
127
|
return addPlanToStory(file, { post: client_js_1.post });
|
|
128
128
|
},
|
|
129
129
|
};
|
|
130
|
+
const UPLOADS_AT_ONCE = 16;
|
|
130
131
|
/** Upload a plan's tags + 480px thumbnails to My Story (the opt-in). Used by the tool and by the connector when the web app asks. */
|
|
131
132
|
/** `only`: bucket keys ("image:Family", "video:Outfits") when the creator picked some folders; all of them when absent. */
|
|
132
133
|
const bucketKey = (b) => `${b.media ?? "any"}:${b.name}`;
|
|
@@ -154,17 +155,26 @@ async function addPlanToStory(file, io, progress, only) {
|
|
|
154
155
|
});
|
|
155
156
|
added += r.added;
|
|
156
157
|
const byRel = new Map(chunk.map((c) => [c.item.rel, c.item]));
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
// UPLOADS_AT_ONCE thumbnails in flight: one at a time left the line idle (12,700 pictures were "about 38 minutes")
|
|
159
|
+
let next = 0;
|
|
160
|
+
const worker = async () => {
|
|
161
|
+
while (next < r.uploads.length) {
|
|
162
|
+
const u = r.uploads[next++];
|
|
163
|
+
const t = byRel.get(u.rel)?.thumb;
|
|
164
|
+
if (!t)
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
const form = new FormData();
|
|
168
|
+
form.append("cacheControl", "31536000");
|
|
169
|
+
form.append("", new Blob([await fs_1.default.promises.readFile(t)], { type: "image/jpeg" }), "t.jpg");
|
|
170
|
+
const resp = await fetch(u.upload_url, { method: "PUT", body: form, headers: { "x-upsert": "true" } });
|
|
171
|
+
if (resp.ok)
|
|
172
|
+
thumbs++;
|
|
173
|
+
}
|
|
174
|
+
catch { /* one thumbnail missing never stops the rest */ }
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
await Promise.all(Array.from({ length: Math.min(UPLOADS_AT_ONCE, r.uploads.length) }, worker));
|
|
168
178
|
progress?.(Math.min(k + 200, totalN), totalN);
|
|
169
179
|
}
|
|
170
180
|
return `Added ${added} file(s) from "${label}" to My Story in ${plan.buckets.filter((b) => b.kind !== "duplicates").length} bucket(s) (${thumbs} thumbnail(s) uploaded; originals stay on this computer). They show in My Story ▸ Organized and feed carousels and 7s reels. organize_add_to_story {plan:"${plan.id}", remove:true} takes them back out.`;
|
package/package.json
CHANGED