u
This commit is contained in:
2
API.md
2
API.md
@@ -62,7 +62,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
|
|||||||
- **ordersummary**: `id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0`
|
- **ordersummary**: `id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0`
|
||||||
- **package**: `id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt`
|
- **package**: `id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt`
|
||||||
- **payment**: `id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f`
|
- **payment**: `id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f`
|
||||||
- **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin`
|
- **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), smallthumbw:i, smallthumbh:i, smallthumbbinary:bin, source:txt`
|
||||||
- **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt`
|
- **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt`
|
||||||
- **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)`
|
- **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)`
|
||||||
- **posterminalobituary**: `id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt`
|
- **posterminalobituary**: `id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt`
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
|
|||||||
|
|
||||||
`move` moves a category to a new parent. Both `id` (source) and `parentId` (target) are validated via `resolveCategoryAccess` — the target must be a real category of the same authorized company, else `Parent not found`. Moving into itself or into a descendant (cycle) → `Invalid parent`. The moved category gets the next sibling `sort` under the new parent. Returns `{ id, parentId, sort }`. For `POST /api/category/:id/move`.
|
`move` moves a category to a new parent. Both `id` (source) and `parentId` (target) are validated via `resolveCategoryAccess` — the target must be a real category of the same authorized company, else `Parent not found`. Moving into itself or into a descendant (cycle) → `Invalid parent`. The moved category gets the next sibling `sort` under the new parent. Returns `{ id, parentId, sort }`. For `POST /api/category/:id/move`.
|
||||||
|
|
||||||
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`.
|
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`.
|
||||||
|
|
||||||
`addImage` returns:
|
`addImage` returns:
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
|
|||||||
| `orderImages` | `(googleAuthId: string, sku: string, { fullHashes: string[] })` | `{ fullHashes: string[] }` |
|
| `orderImages` | `(googleAuthId: string, sku: string, { fullHashes: string[] })` | `{ fullHashes: string[] }` |
|
||||||
| `delete` | `(googleAuthId: string, sku: string)` | `void` |
|
| `delete` | `(googleAuthId: string, sku: string)` | `void` |
|
||||||
|
|
||||||
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`.
|
`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`.
|
||||||
|
|
||||||
`addImage` returns `ImageResult`:
|
`addImage` returns `ImageResult`:
|
||||||
|
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ const SCHEMA_TABLES = [
|
|||||||
['ordersummary', 'id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0'],
|
['ordersummary', 'id PK, company_id->company, manifest_id->manifest UQ, order_number:s(100), order_date:dt, created_at:dt, entity_id?->entity, customer_name?:s(255), positions:i=0, gross_total:f=0, paid_total:f=0, ordered_quantity:f=0, shipped_quantity:f=0, is_paid:b=0, is_shipped:b=0'],
|
||||||
['package', 'id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt'],
|
['package', 'id PK, shipping_id->shipping, entityaddress_id->entityaddress, number:s(100), type:s(100), state:s(100), details?:txt'],
|
||||||
['payment', 'id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f'],
|
['payment', 'id PK, account_id->account, manifest_id->manifest, type:s(100), amount:f'],
|
||||||
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin'],
|
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), smallthumbw:i, smallthumbh:i, smallthumbbinary:bin, source:txt'],
|
||||||
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'],
|
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), maxknownobituaryid?:i, root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'],
|
||||||
['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'],
|
['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'],
|
||||||
['posterminalobituary', 'id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt'],
|
['posterminalobituary', 'id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt'],
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
* { rows, rowsAffected, insertId }
|
* { rows, rowsAffected, insertId }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdirSync, promises as fsPromises } from 'node:fs';
|
||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
import { logSlowQuery } from '../provider.js';
|
import { logSlowQuery } from '../provider.js';
|
||||||
|
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
|
||||||
|
|
||||||
function normalizeRows(result) {
|
function normalizeRows(result) {
|
||||||
const recordset = result?.recordset ?? [];
|
const recordset = result?.recordset ?? [];
|
||||||
@@ -16,13 +19,57 @@ function normalizeRows(result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MssqlExecutor {
|
export class MssqlExecutor {
|
||||||
/** @param {() => import('mssql').Request} requestFactory */
|
/**
|
||||||
constructor(requestFactory) {
|
* @param {() => import('mssql').Request} requestFactory
|
||||||
|
* @param {string|null} [storageDir=null]
|
||||||
|
*/
|
||||||
|
constructor(requestFactory, storageDir = null) {
|
||||||
this.#requestFactory = requestFactory;
|
this.#requestFactory = requestFactory;
|
||||||
|
this.storageDir = storageDir;
|
||||||
|
this.dialect = 'mssql';
|
||||||
}
|
}
|
||||||
|
|
||||||
#requestFactory;
|
#requestFactory;
|
||||||
|
|
||||||
|
async writePicFull(hash, buffer) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeWrite(join(this.storageDir, 'fullbinary', hash + '.avif'), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPicFull(hash) {
|
||||||
|
if (!this.storageDir || !hash) return null;
|
||||||
|
return safeReadByHash(join(this.storageDir, 'fullbinary'), hash, '.avif');
|
||||||
|
}
|
||||||
|
|
||||||
|
async writePicThumb(hash, buffer) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeWrite(join(this.storageDir, 'thumbbinary', hash + '.avif'), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPicThumb(hash) {
|
||||||
|
if (!this.storageDir || !hash) return null;
|
||||||
|
return safeReadByHash(join(this.storageDir, 'thumbbinary'), hash, '.avif');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePic(hash) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash);
|
||||||
|
await safeDeleteByHash(join(this.storageDir, 'thumbbinary'), hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeFiles() {
|
||||||
|
if (!this.storageDir) return;
|
||||||
|
for (const sub of ['fullbinary', 'thumbbinary']) {
|
||||||
|
const dir = join(this.storageDir, sub);
|
||||||
|
try {
|
||||||
|
const files = await fsPromises.readdir(dir);
|
||||||
|
await Promise.all(files.map((f) => fsPromises.unlink(join(dir, f))));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @returns {Promise<import('../provider.js').QueryResult>} */
|
/** @returns {Promise<import('../provider.js').QueryResult>} */
|
||||||
async query(sql, params = {}) {
|
async query(sql, params = {}) {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -73,8 +120,9 @@ export class MssqlProvider {
|
|||||||
* Either an already-connected mssql ConnectionPool, or a plain mssql
|
* Either an already-connected mssql ConnectionPool, or a plain mssql
|
||||||
* config object — in which case the pool is created lazily on first use
|
* config object — in which case the pool is created lazily on first use
|
||||||
* (and closed by {@link MssqlProvider#close}).
|
* (and closed by {@link MssqlProvider#close}).
|
||||||
|
* @param {{ storageDir?: string|null }} [options]
|
||||||
*/
|
*/
|
||||||
constructor(poolOrConfig) {
|
constructor(poolOrConfig, { storageDir = null } = {}) {
|
||||||
const isPool =
|
const isPool =
|
||||||
poolOrConfig && typeof poolOrConfig.request === 'function';
|
poolOrConfig && typeof poolOrConfig.request === 'function';
|
||||||
if (!poolOrConfig || (!isPool && !poolOrConfig.server)) {
|
if (!poolOrConfig || (!isPool && !poolOrConfig.server)) {
|
||||||
@@ -87,6 +135,7 @@ export class MssqlProvider {
|
|||||||
// a different object after a lazy connection would leave them with null.
|
// a different object after a lazy connection would leave them with null.
|
||||||
this.pool = isPool ? poolOrConfig : new sql.ConnectionPool(poolOrConfig);
|
this.pool = isPool ? poolOrConfig : new sql.ConnectionPool(poolOrConfig);
|
||||||
this.#config = isPool ? null : poolOrConfig;
|
this.#config = isPool ? null : poolOrConfig;
|
||||||
|
this.storageDir = storageDir || poolOrConfig?.storageDir || null;
|
||||||
this.dialect = 'mssql';
|
this.dialect = 'mssql';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +212,12 @@ export class MssqlProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
makeExecutor(requestFactory = () => this.pool.request()) {
|
makeExecutor(requestFactory = () => this.pool.request()) {
|
||||||
return new MssqlExecutor(requestFactory);
|
return new MssqlExecutor(requestFactory, this.storageDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #withPool(fn) {
|
async #withPool(fn) {
|
||||||
const pool = await this.ensurePool();
|
const pool = await this.ensurePool();
|
||||||
return fn(new MssqlExecutor(() => pool.request()));
|
return fn(new MssqlExecutor(() => pool.request(), this.storageDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
async query(sql, params = {}) {
|
async query(sql, params = {}) {
|
||||||
@@ -179,6 +228,45 @@ export class MssqlProvider {
|
|||||||
return this.#withPool((executor) => executor.exec(procName, options));
|
return this.#withPool((executor) => executor.exec(procName, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async writePicFull(hash, buffer) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeWrite(join(this.storageDir, 'fullbinary', hash + '.avif'), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPicFull(hash) {
|
||||||
|
if (!this.storageDir || !hash) return null;
|
||||||
|
return safeReadByHash(join(this.storageDir, 'fullbinary'), hash, '.avif');
|
||||||
|
}
|
||||||
|
|
||||||
|
async writePicThumb(hash, buffer) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeWrite(join(this.storageDir, 'thumbbinary', hash + '.avif'), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPicThumb(hash) {
|
||||||
|
if (!this.storageDir || !hash) return null;
|
||||||
|
return safeReadByHash(join(this.storageDir, 'thumbbinary'), hash, '.avif');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePic(hash) {
|
||||||
|
if (!this.storageDir || !hash) return;
|
||||||
|
await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash);
|
||||||
|
await safeDeleteByHash(join(this.storageDir, 'thumbbinary'), hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeFiles() {
|
||||||
|
if (!this.storageDir) return;
|
||||||
|
for (const sub of ['fullbinary', 'thumbbinary']) {
|
||||||
|
const dir = join(this.storageDir, sub);
|
||||||
|
try {
|
||||||
|
const files = await fsPromises.readdir(dir);
|
||||||
|
await Promise.all(files.map((f) => fsPromises.unlink(join(dir, f))));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs `fn(runner)` inside a transaction. Commit on success,
|
* Runs `fn(runner)` inside a transaction. Commit on success,
|
||||||
* rollback on error. `runner` has query/exec like the provider.
|
* rollback on error. `runner` has query/exec like the provider.
|
||||||
@@ -197,6 +285,12 @@ export class MssqlProvider {
|
|||||||
runner.query = runner;
|
runner.query = runner;
|
||||||
runner.exec = (proc, opts) => executor.exec(proc, opts);
|
runner.exec = (proc, opts) => executor.exec(proc, opts);
|
||||||
runner.dialect = 'mssql';
|
runner.dialect = 'mssql';
|
||||||
|
runner.writePicFull = executor.writePicFull.bind(executor);
|
||||||
|
runner.readPicFull = executor.readPicFull.bind(executor);
|
||||||
|
runner.writePicThumb = executor.writePicThumb.bind(executor);
|
||||||
|
runner.readPicThumb = executor.readPicThumb.bind(executor);
|
||||||
|
runner.deletePic = executor.deletePic.bind(executor);
|
||||||
|
runner.purgeFiles = executor.purgeFiles.bind(executor);
|
||||||
const result = await fn(runner, tx);
|
const result = await fn(runner, tx);
|
||||||
await tx.commit();
|
await tx.commit();
|
||||||
return result;
|
return result;
|
||||||
@@ -260,12 +354,21 @@ export class MssqlProvider {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.storageDir) {
|
||||||
|
for (const sub of ['fullbinary', 'thumbbinary']) {
|
||||||
|
mkdirSync(join(this.storageDir, sub), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async purge() {
|
async purge() {
|
||||||
const pool = await this.ensurePool();
|
const pool = await this.ensurePool();
|
||||||
const { buildPurgeSql } = await import('./purgeSql.js');
|
const { buildPurgeSql } = await import('./purgeSql.js');
|
||||||
await pool.request().query(buildPurgeSql());
|
await pool.request().query(buildPurgeSql());
|
||||||
|
if (this.storageDir) {
|
||||||
|
await this.purgeFiles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ SELECT
|
|||||||
a.id AS articleId,
|
a.id AS articleId,
|
||||||
p.id AS picId,
|
p.id AS picId,
|
||||||
p.hash,
|
p.hash,
|
||||||
p.fullbinary AS buffer
|
NULL AS buffer
|
||||||
FROM categoryarticle ca
|
FROM categoryarticle ca
|
||||||
INNER JOIN article a ON a.id = ca.article_id
|
INNER JOIN article a ON a.id = ca.article_id
|
||||||
OUTER APPLY (
|
OUTER APPLY (
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
export const CREATE = `
|
export const CREATE = `
|
||||||
INSERT INTO pic (
|
INSERT INTO pic (
|
||||||
w, h, size, hash, thumbw, thumbh, thumbhash,
|
w, h, size, hash, thumbw, thumbh, thumbhash,
|
||||||
smallthumbw, smallthumbh, smallthumbbinary,
|
smallthumbw, smallthumbh, smallthumbbinary, source
|
||||||
source, fullbinary, thumbbinary
|
|
||||||
)
|
)
|
||||||
OUTPUT INSERTED.id
|
OUTPUT INSERTED.id
|
||||||
VALUES (
|
VALUES (
|
||||||
@w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash,
|
@w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash,
|
||||||
@smallthumbw, @smallthumbh, @smallthumbbinary,
|
@smallthumbw, @smallthumbh, @smallthumbbinary, @source
|
||||||
@source, @fullbinary, @thumbbinary
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -60,13 +58,13 @@ WHERE categoryId IS NOT NULL
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_FULL_BY_HASH = `
|
export const GET_FULL_BY_HASH = `
|
||||||
SELECT w, h, size, fullbinary AS buffer
|
SELECT w, h, size, hash
|
||||||
FROM pic
|
FROM pic
|
||||||
WHERE hash = @hash
|
WHERE hash = @hash
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_THUMB_BY_HASH = `
|
export const GET_THUMB_BY_HASH = `
|
||||||
SELECT thumbw AS w, thumbh AS h, thumbbinary AS buffer
|
SELECT thumbw AS w, thumbh AS h, hash
|
||||||
FROM pic
|
FROM pic
|
||||||
WHERE hash = @hash
|
WHERE hash = @hash
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -97,10 +97,8 @@ BEGIN
|
|||||||
thumbhash NVARCHAR(64) NOT NULL,
|
thumbhash NVARCHAR(64) NOT NULL,
|
||||||
smallthumbw INT NOT NULL,
|
smallthumbw INT NOT NULL,
|
||||||
smallthumbh INT NOT NULL,
|
smallthumbh INT NOT NULL,
|
||||||
source NVARCHAR(MAX) NOT NULL,
|
|
||||||
fullbinary VARBINARY(MAX) NOT NULL,
|
|
||||||
thumbbinary VARBINARY(MAX) NOT NULL,
|
|
||||||
smallthumbbinary VARBINARY(MAX) NOT NULL,
|
smallthumbbinary VARBINARY(MAX) NOT NULL,
|
||||||
|
source NVARCHAR(MAX) NOT NULL,
|
||||||
CONSTRAINT UQ_pic_hash UNIQUE (hash)
|
CONSTRAINT UQ_pic_hash UNIQUE (hash)
|
||||||
);
|
);
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -51,89 +51,15 @@ function normalizeInsertId(value) {
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs';
|
import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
extFromMimetype,
|
||||||
|
safeWrite,
|
||||||
|
findFileByHash,
|
||||||
|
safeReadByHash,
|
||||||
|
safeDeleteByHash,
|
||||||
|
} from '../storage.js';
|
||||||
|
|
||||||
export function extFromMimetype(mimetype) {
|
export { extFromMimetype };
|
||||||
if (!mimetype) return '';
|
|
||||||
const type = mimetype.trim().toLowerCase().split(';')[0];
|
|
||||||
const map = {
|
|
||||||
'application/pdf': '.pdf',
|
|
||||||
'text/plain': '.txt',
|
|
||||||
'text/csv': '.csv',
|
|
||||||
'text/html': '.html',
|
|
||||||
'text/markdown': '.md',
|
|
||||||
'image/avif': '.avif',
|
|
||||||
'image/png': '.png',
|
|
||||||
'image/jpeg': '.jpg',
|
|
||||||
'image/jpg': '.jpg',
|
|
||||||
'image/webp': '.webp',
|
|
||||||
'image/gif': '.gif',
|
|
||||||
'image/svg+xml': '.svg',
|
|
||||||
'application/json': '.json',
|
|
||||||
'application/zip': '.zip',
|
|
||||||
'application/xml': '.xml',
|
|
||||||
'application/octet-stream': '.bin',
|
|
||||||
};
|
|
||||||
if (map[type]) return map[type];
|
|
||||||
if (type.startsWith('image/')) return '.' + type.slice(6).replace('+xml', '');
|
|
||||||
if (type.startsWith('text/')) return '.' + type.slice(5);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeWrite(filePath, buffer) {
|
|
||||||
if (buffer == null) return;
|
|
||||||
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
||||||
await fsPromises.writeFile(filePath, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFileByHash(dir, hash, ext = '') {
|
|
||||||
if (!dir || !hash) return null;
|
|
||||||
if (ext) {
|
|
||||||
const candidate = join(dir, hash + ext);
|
|
||||||
try {
|
|
||||||
await fsPromises.access(candidate);
|
|
||||||
return candidate;
|
|
||||||
} catch {
|
|
||||||
// fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const candidateNoExt = join(dir, hash);
|
|
||||||
try {
|
|
||||||
await fsPromises.access(candidateNoExt);
|
|
||||||
return candidateNoExt;
|
|
||||||
} catch {
|
|
||||||
// fallback
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const files = await fsPromises.readdir(dir);
|
|
||||||
const match = files.find((f) => f === hash || f.startsWith(hash + '.'));
|
|
||||||
if (match) return join(dir, match);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeReadByHash(dir, hash, ext = '') {
|
|
||||||
const filePath = await findFileByHash(dir, hash, ext);
|
|
||||||
if (!filePath) return null;
|
|
||||||
try {
|
|
||||||
return await fsPromises.readFile(filePath);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === 'ENOENT') return null;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeDeleteByHash(dir, hash) {
|
|
||||||
if (!dir || !hash) return;
|
|
||||||
try {
|
|
||||||
const files = await fsPromises.readdir(dir);
|
|
||||||
const matches = files.filter((f) => f === hash || f.startsWith(hash + '.'));
|
|
||||||
await Promise.all(matches.map((f) => fsPromises.unlink(join(dir, f))));
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== 'ENOENT') throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BetterSqlite3Executor {
|
export class BetterSqlite3Executor {
|
||||||
/**
|
/**
|
||||||
|
|||||||
89
src/providers/storage.js
Normal file
89
src/providers/storage.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Shared filesystem storage utilities for database providers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { promises as fsPromises } from 'node:fs';
|
||||||
|
|
||||||
|
export async function safeWrite(filePath, buffer) {
|
||||||
|
if (buffer == null) return;
|
||||||
|
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||||
|
await fsPromises.writeFile(filePath, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFileByHash(dir, hash, ext = '') {
|
||||||
|
if (!dir || !hash) return null;
|
||||||
|
if (ext) {
|
||||||
|
const candidate = join(dir, hash + ext);
|
||||||
|
try {
|
||||||
|
await fsPromises.access(candidate);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidateNoExt = join(dir, hash);
|
||||||
|
try {
|
||||||
|
await fsPromises.access(candidateNoExt);
|
||||||
|
return candidateNoExt;
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const files = await fsPromises.readdir(dir);
|
||||||
|
const match = files.find((f) => f === hash || f.startsWith(hash + '.'));
|
||||||
|
if (match) return join(dir, match);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeReadByHash(dir, hash, ext = '') {
|
||||||
|
const filePath = await findFileByHash(dir, hash, ext);
|
||||||
|
if (!filePath) return null;
|
||||||
|
try {
|
||||||
|
return await fsPromises.readFile(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeDeleteByHash(dir, hash) {
|
||||||
|
if (!dir || !hash) return;
|
||||||
|
try {
|
||||||
|
const files = await fsPromises.readdir(dir);
|
||||||
|
const matches = files.filter((f) => f === hash || f.startsWith(hash + '.'));
|
||||||
|
await Promise.all(matches.map((f) => fsPromises.unlink(join(dir, f))));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extFromMimetype(mimetype) {
|
||||||
|
if (!mimetype) return '';
|
||||||
|
const type = mimetype.trim().toLowerCase().split(';')[0];
|
||||||
|
const map = {
|
||||||
|
'application/pdf': '.pdf',
|
||||||
|
'text/plain': '.txt',
|
||||||
|
'text/csv': '.csv',
|
||||||
|
'text/html': '.html',
|
||||||
|
'text/markdown': '.md',
|
||||||
|
'image/avif': '.avif',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'application/json': '.json',
|
||||||
|
'application/zip': '.zip',
|
||||||
|
'application/xml': '.xml',
|
||||||
|
'application/octet-stream': '.bin',
|
||||||
|
};
|
||||||
|
if (map[type]) return map[type];
|
||||||
|
if (type.startsWith('image/')) return '.' + type.slice(6).replace('+xml', '');
|
||||||
|
if (type.startsWith('text/')) return '.' + type.slice(5);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -57,14 +57,12 @@ export default function addImage(provider) {
|
|||||||
if (existingPic.rows[0]) {
|
if (existingPic.rows[0]) {
|
||||||
picId = existingPic.rows[0].id;
|
picId = existingPic.rows[0].id;
|
||||||
} else {
|
} else {
|
||||||
if (isSqlite) {
|
if (runner.writePicFull) {
|
||||||
if (runner.writePicFull) {
|
await runner.writePicFull(fullHash, processed.full.buffer);
|
||||||
await runner.writePicFull(fullHash, processed.full.buffer);
|
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
} else if (provider?.writePicFull) {
|
||||||
} else if (provider?.writePicFull) {
|
await provider.writePicFull(fullHash, processed.full.buffer);
|
||||||
await provider.writePicFull(fullHash, processed.full.buffer);
|
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const picResult = await runner.query(createPicSql, {
|
const picResult = await runner.query(createPicSql, {
|
||||||
@@ -79,8 +77,6 @@ export default function addImage(provider) {
|
|||||||
smallthumbh: processed.smallThumb.h,
|
smallthumbh: processed.smallThumb.h,
|
||||||
smallthumbbinary: processed.smallThumb.buffer,
|
smallthumbbinary: processed.smallThumb.buffer,
|
||||||
source: '',
|
source: '',
|
||||||
fullbinary: processed.full.buffer,
|
|
||||||
thumbbinary: processed.thumb.buffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
||||||
|
|||||||
@@ -30,26 +30,14 @@ export default function deleteImage(provider) {
|
|||||||
const runDelete = async (runner) => {
|
const runDelete = async (runner) => {
|
||||||
const { articleId, companyuserId } = await resolve(googleAuthId, sku, runner);
|
const { articleId, companyuserId } = await resolve(googleAuthId, sku, runner);
|
||||||
|
|
||||||
let thumbBuffer;
|
const exists = await runner.query(
|
||||||
if (provider?.dialect === 'sqlite3') {
|
'SELECT ap.id FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = @articleId AND p.hash = @fullHash',
|
||||||
const exists = await runner.query(
|
{ articleId, fullHash },
|
||||||
'SELECT ap.id FROM articlepic ap INNER JOIN pic p ON p.id = ap.pic_id WHERE ap.article_id = @articleId AND p.hash = @fullHash',
|
);
|
||||||
{ articleId, fullHash },
|
if (!exists.rows[0]) {
|
||||||
);
|
throw new Error('Image not found');
|
||||||
if (!exists.rows[0]) {
|
|
||||||
throw new Error('Image not found');
|
|
||||||
}
|
|
||||||
thumbBuffer = await (runner.readPicThumb ? runner.readPicThumb(fullHash) : provider.readPicThumb?.(fullHash));
|
|
||||||
} else {
|
|
||||||
const picResult = await runner.query(
|
|
||||||
'SELECT p.thumbbinary FROM pic p INNER JOIN articlepic ap ON ap.pic_id = p.id WHERE ap.article_id = @articleId AND p.hash = @fullHash',
|
|
||||||
{ articleId, fullHash },
|
|
||||||
);
|
|
||||||
if (!picResult.rows[0]) {
|
|
||||||
throw new Error('Image not found');
|
|
||||||
}
|
|
||||||
thumbBuffer = picResult.rows[0]?.thumbbinary;
|
|
||||||
}
|
}
|
||||||
|
const thumbBuffer = await (runner.readPicThumb ? runner.readPicThumb(fullHash) : provider.readPicThumb?.(fullHash));
|
||||||
|
|
||||||
let skelehash = null;
|
let skelehash = null;
|
||||||
if (thumbBuffer) {
|
if (thumbBuffer) {
|
||||||
@@ -84,12 +72,10 @@ export default function deleteImage(provider) {
|
|||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider?.dialect === 'sqlite3') {
|
if (runner.deletePic) {
|
||||||
if (runner.deletePic) {
|
await runner.deletePic(fullHash);
|
||||||
await runner.deletePic(fullHash);
|
} else if (provider?.deletePic) {
|
||||||
} else if (provider?.deletePic) {
|
await provider.deletePic(fullHash);
|
||||||
await provider.deletePic(fullHash);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordImageDeleteHistory(runner, {
|
await recordImageDeleteHistory(runner, {
|
||||||
|
|||||||
@@ -51,14 +51,12 @@ export default function addImage(provider) {
|
|||||||
if (existingPic.rows[0]) {
|
if (existingPic.rows[0]) {
|
||||||
picId = existingPic.rows[0].id;
|
picId = existingPic.rows[0].id;
|
||||||
} else {
|
} else {
|
||||||
if (isSqlite) {
|
if (runner.writePicFull) {
|
||||||
if (runner.writePicFull) {
|
await runner.writePicFull(fullHash, processed.full.buffer);
|
||||||
await runner.writePicFull(fullHash, processed.full.buffer);
|
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
} else if (provider?.writePicFull) {
|
||||||
} else if (provider?.writePicFull) {
|
await provider.writePicFull(fullHash, processed.full.buffer);
|
||||||
await provider.writePicFull(fullHash, processed.full.buffer);
|
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const picResult = await runner.query(createPicSql, {
|
const picResult = await runner.query(createPicSql, {
|
||||||
@@ -73,8 +71,6 @@ export default function addImage(provider) {
|
|||||||
smallthumbh: processed.smallThumb.h,
|
smallthumbh: processed.smallThumb.h,
|
||||||
smallthumbbinary: processed.smallThumb.buffer,
|
smallthumbbinary: processed.smallThumb.buffer,
|
||||||
source: '',
|
source: '',
|
||||||
fullbinary: processed.full.buffer,
|
|
||||||
thumbbinary: processed.thumb.buffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
||||||
|
|||||||
@@ -51,14 +51,12 @@ export default function addImage(provider) {
|
|||||||
if (existingPic.rows[0]) {
|
if (existingPic.rows[0]) {
|
||||||
picId = existingPic.rows[0].id;
|
picId = existingPic.rows[0].id;
|
||||||
} else {
|
} else {
|
||||||
if (isSqlite) {
|
if (runner.writePicFull) {
|
||||||
if (runner.writePicFull) {
|
await runner.writePicFull(fullHash, processed.full.buffer);
|
||||||
await runner.writePicFull(fullHash, processed.full.buffer);
|
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
} else if (provider?.writePicFull) {
|
||||||
} else if (provider?.writePicFull) {
|
await provider.writePicFull(fullHash, processed.full.buffer);
|
||||||
await provider.writePicFull(fullHash, processed.full.buffer);
|
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const picResult = await runner.query(createPicSql, {
|
const picResult = await runner.query(createPicSql, {
|
||||||
@@ -73,8 +71,6 @@ export default function addImage(provider) {
|
|||||||
smallthumbh: processed.smallThumb.h,
|
smallthumbh: processed.smallThumb.h,
|
||||||
smallthumbbinary: processed.smallThumb.buffer,
|
smallthumbbinary: processed.smallThumb.buffer,
|
||||||
source: '',
|
source: '',
|
||||||
fullbinary: processed.full.buffer,
|
|
||||||
thumbbinary: processed.thumb.buffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
||||||
|
|||||||
@@ -56,14 +56,12 @@ export default function addImage(provider) {
|
|||||||
if (existingPic.rows[0]) {
|
if (existingPic.rows[0]) {
|
||||||
picId = existingPic.rows[0].id;
|
picId = existingPic.rows[0].id;
|
||||||
} else {
|
} else {
|
||||||
if (isSqlite) {
|
if (runner.writePicFull) {
|
||||||
if (runner.writePicFull) {
|
await runner.writePicFull(fullHash, processed.full.buffer);
|
||||||
await runner.writePicFull(fullHash, processed.full.buffer);
|
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await runner.writePicThumb(fullHash, processed.thumb.buffer);
|
} else if (provider?.writePicFull) {
|
||||||
} else if (provider?.writePicFull) {
|
await provider.writePicFull(fullHash, processed.full.buffer);
|
||||||
await provider.writePicFull(fullHash, processed.full.buffer);
|
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
||||||
await provider.writePicThumb(fullHash, processed.thumb.buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const picResult = await runner.query(createPicSql, {
|
const picResult = await runner.query(createPicSql, {
|
||||||
@@ -78,8 +76,6 @@ export default function addImage(provider) {
|
|||||||
smallthumbh: processed.smallThumb.h,
|
smallthumbh: processed.smallThumb.h,
|
||||||
smallthumbbinary: processed.smallThumb.buffer,
|
smallthumbbinary: processed.smallThumb.buffer,
|
||||||
source: '',
|
source: '',
|
||||||
fullbinary: processed.full.buffer,
|
|
||||||
thumbbinary: processed.thumb.buffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
picId = picResult.rows[0]?.id ?? picResult.insertId;
|
||||||
|
|||||||
@@ -123,17 +123,9 @@ export default function generateImage(provider) {
|
|||||||
selected.map(async (item) => {
|
selected.map(async (item) => {
|
||||||
let buf = item.buffer;
|
let buf = item.buffer;
|
||||||
if (!buf || !buf.length) {
|
if (!buf || !buf.length) {
|
||||||
if (isSqlite) {
|
buf = runner.readPicFull ? await runner.readPicFull(item.hash) : await provider?.readPicFull?.(item.hash);
|
||||||
buf = runner.readPicFull ? await runner.readPicFull(item.hash) : await provider?.readPicFull?.(item.hash);
|
if (!buf) {
|
||||||
if (!buf) {
|
buf = runner.readPicThumb ? await runner.readPicThumb(item.hash) : await provider?.readPicThumb?.(item.hash);
|
||||||
buf = runner.readPicThumb ? await runner.readPicThumb(item.hash) : await provider?.readPicThumb?.(item.hash);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const picRes = await runner.query(
|
|
||||||
'SELECT fullbinary, thumbbinary FROM pic WHERE hash = @hash',
|
|
||||||
{ hash: item.hash },
|
|
||||||
);
|
|
||||||
buf = picRes.rows[0]?.fullbinary ?? picRes.rows[0]?.thumbbinary;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...item, buffer: buf };
|
return { ...item, buffer: buf };
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ export default function getImageByHash(provider) {
|
|||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let buffer;
|
const buffer = await (runner.readPicFull ? runner.readPicFull(fullHash) : provider.readPicFull?.(fullHash));
|
||||||
if (provider?.dialect === 'sqlite3') {
|
|
||||||
buffer = await (runner.readPicFull ? runner.readPicFull(fullHash) : provider.readPicFull?.(fullHash));
|
|
||||||
} else {
|
|
||||||
buffer = row.buffer ?? row.fullbinary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ export default function getThumbByHash(provider) {
|
|||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let buffer;
|
const buffer = await (runner.readPicThumb ? runner.readPicThumb(fullHash) : provider.readPicThumb?.(fullHash));
|
||||||
if (provider?.dialect === 'sqlite3') {
|
|
||||||
buffer = await (runner.readPicThumb ? runner.readPicThumb(fullHash) : provider.readPicThumb?.(fullHash));
|
|
||||||
} else {
|
|
||||||
buffer = row.buffer ?? row.thumbbinary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
|
|||||||
Reference in New Issue
Block a user