library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// The catalogue's own copy, end to end: a roll of two frames is read, the
|
||||
// catalogue is written into a folder the visitor picked, the browser's storage is
|
||||
// taken away from under it, and the catalogue is read back out — with the frames
|
||||
// never read a second time.
|
||||
//
|
||||
// The folder of frames is a stand-in served off a local server, because the check
|
||||
// cannot hand a real directory to a page. The backup folder is not: the picker
|
||||
// answers with a directory out of the origin's own file system (OPFS), which is a
|
||||
// real `FileSystemDirectoryHandle` with a real `createWritable`, so what is
|
||||
// written is written by the code under test through the API it will use on a
|
||||
// visitor's disk — and what the check then reads back is the bytes on it.
|
||||
//
|
||||
// What the run claims, in order:
|
||||
// * the catalogue file holds the rows and neither of the two things a file
|
||||
// cannot hold — a handle and a tile;
|
||||
// * the tiles are written beside it, one per frame, under `thumbs/<frame id>`;
|
||||
// * a second run writes no tile at all;
|
||||
// * a catalogue with nothing in it gets every row back, tiles included, and the
|
||||
// frames themselves are never read from the disk again — not by the restore,
|
||||
// which takes the tiles off the backup folder, and not by the reading that
|
||||
// follows it, which matches each frame on its size and its write time.
|
||||
//
|
||||
// npm run build && node scripts/backup-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/backup-check.mjs
|
||||
//
|
||||
// Playwright is not a dependency of this package, so a run without it says SKIP
|
||||
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4198);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321);
|
||||
// Two frames of one folder, both read off the sample roll.
|
||||
const FILES = ['P1010256.JPG', 'DSCF1701.JPG'];
|
||||
const FOLDER = 'TestRoll';
|
||||
const BACKUP = 'BackupRoll';
|
||||
|
||||
let failures = 0;
|
||||
function step(name, ok, detail = '') {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
||||
if (!playwright) {
|
||||
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- the frames, off a local server (the page fetches them by CORS) ----------
|
||||
const sampleServer = createServer(async (req, res) => {
|
||||
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
||||
if (!FILES.includes(name)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, name));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': bytes.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(bytes);
|
||||
});
|
||||
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
||||
|
||||
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
|
||||
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
|
||||
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = `http://127.0.0.1:${APP_PORT}`;
|
||||
let up = false;
|
||||
for (let i = 0; i < 60 && !up; i++) {
|
||||
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
|
||||
if (!up) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!up) {
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
// Playwright's own Chromium when it has one; CHROME points at any other.
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
// The words this screen says are read below, so the run is pinned to one of the
|
||||
// two dictionaries rather than to whatever the browser's own language is.
|
||||
await context.addInitScript(() => localStorage.setItem('rc.lang', 'en'));
|
||||
// The picker, twice over: the roll of frames is a stand-in — a page cannot be
|
||||
// handed a real directory by a test — and the backup folder is the origin's own
|
||||
// file system, which is a real one. Which of the two a call is for is the `id`
|
||||
// the code passes, the same one it passes to a visitor's browser.
|
||||
await context.addInitScript(
|
||||
({ origin, files, folder, backup }) => {
|
||||
const bytes = new Map();
|
||||
const load = async (name) => {
|
||||
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(name);
|
||||
};
|
||||
// What a frame costs is its bytes, and the bytes of a frame are read through
|
||||
// `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads
|
||||
// the head of a JPEG. Only the stand-in's own files are counted: the tiles
|
||||
// that come back out of the backup folder are read the same way, and they are
|
||||
// named after the frames.
|
||||
window.__frameReads = 0;
|
||||
const off = new WeakMap();
|
||||
const part = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
const cut = part.apply(this, args);
|
||||
off.set(cut, off.get(this) ?? this);
|
||||
return cut;
|
||||
};
|
||||
const pull = Blob.prototype.arrayBuffer;
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
if ((off.get(this) ?? this).__fake) window.__frameReads++;
|
||||
return pull.apply(this);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
const file = new File([await load(name)], name, {
|
||||
type: 'image/jpeg',
|
||||
// One write time per frame, and the same one on every read: a fresh
|
||||
// `Date.now()` would make every frame look touched and hide what is
|
||||
// being checked — the reading after the restore has to match.
|
||||
lastModified: Date.UTC(2016, 1, 15, 9, 30),
|
||||
});
|
||||
Object.defineProperty(file, '__fake', { value: true });
|
||||
return file;
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label };
|
||||
Object.defineProperties(handle, {
|
||||
values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() },
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: { value: async (name) => {
|
||||
throw new Error(`no folder named ${name}`);
|
||||
} },
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = (label) => dirHandle(label, { files });
|
||||
window.showDirectoryPicker = async (opts) => {
|
||||
if (opts?.id !== 'recipescam-backup') return root(folder);
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
// A folder of the origin's own, emptied at the start of the run and named
|
||||
// as the picker would name it.
|
||||
await opfs.removeEntry(backup, { recursive: true }).catch(() => {});
|
||||
const dir = await opfs.getDirectoryHandle(backup, { create: true });
|
||||
// OPFS handles are always writable and say so only through the API the
|
||||
// picker's handles carry: the two methods are stood in where the real
|
||||
// handle leaves them out, so the code under test takes the same road it
|
||||
// would with a folder off a disk.
|
||||
for (const mode of ['queryPermission', 'requestPermission']) {
|
||||
if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
},
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
// The restore asks before it replaces anything, and a dismissed dialog is a
|
||||
// cancel — the run says yes the way a reader would.
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => '');
|
||||
const waitNote = (re) =>
|
||||
page.waitForFunction(
|
||||
(src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''),
|
||||
re.source,
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? '');
|
||||
// The bytes of a frame actually read off the disk, counted inside the page: the
|
||||
// whole claim of the catalogued size and write time is this number staying put.
|
||||
const framesRead = () => page.evaluate(() => window.__frameReads ?? 0);
|
||||
// The catalogue the page itself keeps, read straight out of the browser: what the
|
||||
// restore puts back is a row in there, not a picture on the screen.
|
||||
const rows = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open('recipescam-library');
|
||||
open.onsuccess = () => {
|
||||
const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll();
|
||||
req.onsuccess = () =>
|
||||
resolve(
|
||||
req.result.map((r) => ({
|
||||
id: r.id,
|
||||
tile: r.thumb ? r.thumb.size : 0,
|
||||
handle: !!r.handle,
|
||||
star: r.star ?? null,
|
||||
}))
|
||||
);
|
||||
req.onerror = () => resolve([]);
|
||||
};
|
||||
open.onerror = () => resolve([]);
|
||||
})
|
||||
);
|
||||
// Everything the backup folder holds, walked the way a person looking at it in a
|
||||
// file manager would.
|
||||
const backupTree = () =>
|
||||
page.evaluate(async (name) => {
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
const root = await opfs.getDirectoryHandle(name);
|
||||
const out = [];
|
||||
const walk = async (dir, at) => {
|
||||
for await (const [child, handle] of dir.entries()) {
|
||||
const rel = at ? `${at}/${child}` : child;
|
||||
if (handle.kind === 'directory') await walk(handle, rel);
|
||||
else out.push({ rel, size: (await handle.getFile()).size });
|
||||
}
|
||||
};
|
||||
await walk(root, '');
|
||||
return out;
|
||||
}, BACKUP);
|
||||
|
||||
let code = 0;
|
||||
try {
|
||||
// --- the roll is read ------------------------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await waitNote(/Scanned TestRoll/);
|
||||
const read = await rows();
|
||||
step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`);
|
||||
step('every frame has its tile', read.every((r) => r.tile > 0));
|
||||
step('the count on the bar says two', /2/.test(await count()), await count());
|
||||
const firstRead = await framesRead();
|
||||
step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`);
|
||||
|
||||
// --- the catalogue is written ---------------------------------------------
|
||||
await page.click('[data-key="lib-backup"]');
|
||||
await waitNote(/Backed up 2 frames, 2 tiles written/);
|
||||
const tree = await backupTree();
|
||||
const cat = await page.evaluate(async (name) => {
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
const dir = await opfs.getDirectoryHandle(name);
|
||||
return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text());
|
||||
}, BACKUP);
|
||||
step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`);
|
||||
step(
|
||||
'a row carries no handle and no tile',
|
||||
cat.photos.every((r) => !('handle' in r) && !('thumb' in r)),
|
||||
Object.keys(cat.photos[0]).join(',')
|
||||
);
|
||||
step(
|
||||
'a row carries the size and the write time of its frame',
|
||||
cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30))
|
||||
);
|
||||
const tiles = tree.filter((f) => f.rel.startsWith('thumbs/'));
|
||||
step(
|
||||
'the tiles are beside it, one file per frame',
|
||||
tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)),
|
||||
tiles.map((f) => f.rel).join(' ')
|
||||
);
|
||||
|
||||
// --- a second run writes no tile ------------------------------------------
|
||||
await page.click('[data-key="lib-backup"]');
|
||||
await waitNote(/Backed up 2 frames, 0 tiles written/);
|
||||
|
||||
// --- the browser's storage is taken away ----------------------------------
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${base}/`);
|
||||
const gone = await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const req = indexedDB.deleteDatabase('recipescam-library');
|
||||
req.onsuccess = () => resolve(true);
|
||||
req.onerror = req.onblocked = () => resolve(false);
|
||||
})
|
||||
);
|
||||
step('the catalogue is gone from the browser', gone === true);
|
||||
await page.goto(`${base}/library`);
|
||||
step('the library comes up empty', /^0\b/.test((await count()).trim()), await count());
|
||||
step('and has read nothing', (await framesRead()) === 0);
|
||||
|
||||
// --- and it is put back ----------------------------------------------------
|
||||
await page.click('[data-key="lib-restore"]');
|
||||
await waitNote(/Restored 2 frames/);
|
||||
const back = await rows();
|
||||
step('both rows are back', back.length === 2, `${back.length} rows`);
|
||||
step('each row came back with its tile', back.every((r) => r.tile > 0));
|
||||
step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle));
|
||||
step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`);
|
||||
step('the count on the bar says two again', /2/.test(await count()), await count());
|
||||
|
||||
// --- the folder is picked again, and no frame is read for it ---------------
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await waitNote(/Scanned TestRoll/);
|
||||
const joined = await rows();
|
||||
step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle));
|
||||
step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`);
|
||||
} catch (err) {
|
||||
step(`the run finished`, false, String(err).split('\n')[0]);
|
||||
code = 1;
|
||||
}
|
||||
|
||||
console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser');
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
process.exit(failures || code ? 1 : 0);
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
} from './engine/library';
|
||||
import {
|
||||
backupNow,
|
||||
backupStatus,
|
||||
pickBackupFolder,
|
||||
restoreNow,
|
||||
type BackupStatus,
|
||||
} from './engine/libraryBackup';
|
||||
|
||||
// The catalogue screen, laid out like the admin's picture manager: the tree of
|
||||
// folders and subfolders down the left, the frame that is up in the middle, and
|
||||
@@ -167,12 +174,108 @@ export function Library() {
|
||||
// the folders and cannot tell whether the remembered subfolder still exists.
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// The folder the catalogue is copied into, and whether the browser is still
|
||||
// letting this page write to it — which it stops doing when the tab closes, so
|
||||
// the row below says so rather than letting a write fail quietly. The ref is
|
||||
// the same value for the scan that ends behind this screen's back: it reads it
|
||||
// without being subscribed to it.
|
||||
const [back, setBack] = useState<BackupStatus | null>(null);
|
||||
const backRef = useRef<BackupStatus | null>(null);
|
||||
const [backBusy, setBackBusy] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
|
||||
setPhotos(rows);
|
||||
setEdited(edits);
|
||||
}, []);
|
||||
|
||||
// --- the catalogue's own copy ---------------------------------------------
|
||||
|
||||
const show = useCallback(async (status: BackupStatus) => {
|
||||
backRef.current = status;
|
||||
setBack(status);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void backupStatus().then((status) => {
|
||||
if (alive) void show(status);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
// Write the catalogue into the folder. With none picked yet this is also the
|
||||
// way to pick one: one button, because a backup folder is not something to
|
||||
// choose and then not use. `quiet` is the run that follows a scan — it says
|
||||
// nothing, since a note about a backup would sit over the note about the scan.
|
||||
const backup = useCallback(
|
||||
async (quiet = false) => {
|
||||
if (backBusy) return;
|
||||
if (!backRef.current?.folder) {
|
||||
try {
|
||||
await pickBackupFolder();
|
||||
} catch (err) {
|
||||
// Closing the picker is a "not now", not a failure worth a line of text.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setNote(t('lib.failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBackBusy(true);
|
||||
if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 }));
|
||||
try {
|
||||
const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total })));
|
||||
await show(await backupStatus());
|
||||
if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written }));
|
||||
} catch {
|
||||
if (!quiet) setNote(t('lib.backupFailed'));
|
||||
} finally {
|
||||
setBackBusy(false);
|
||||
}
|
||||
},
|
||||
[backBusy, show, t]
|
||||
);
|
||||
|
||||
// The other direction, and the one that replaces rows: the frames come back
|
||||
// without a file handle — a handle is not a thing that can be written down —
|
||||
// and picking the folder again afterwards is what hands every one of them back,
|
||||
// without a frame being read.
|
||||
const restore = useCallback(async () => {
|
||||
const status = backRef.current;
|
||||
if (!status?.folder) {
|
||||
setNote(t('lib.restoreNone'));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder }))) return;
|
||||
if (backBusy) return;
|
||||
setBackBusy(true);
|
||||
setNote(t('lib.restoreRun', { done: 0, total: status.photos }));
|
||||
try {
|
||||
const result = await restoreNow((p) => setNote(t('lib.restoreRun', { done: p.done, total: p.total })));
|
||||
await reload();
|
||||
setFolders(await listFolders());
|
||||
setNote(t('lib.restoreDone', { n: result.photos }));
|
||||
} catch {
|
||||
setNote(t('lib.restoreFailed'));
|
||||
} finally {
|
||||
setBackBusy(false);
|
||||
}
|
||||
}, [backBusy, reload, t]);
|
||||
|
||||
// A reading that is over is the one moment the rows are finished, so it is the
|
||||
// moment the catalogue is copied out: a timer would fire in the middle of a
|
||||
// roll or not at all. Held in a ref because the watch below subscribes once and
|
||||
// this changes with every render — and nothing is written without a folder
|
||||
// picked and the browser still allowing the write, which are the two states the
|
||||
// row on screen is already saying.
|
||||
const afterScan = useRef(() => {});
|
||||
afterScan.current = () => {
|
||||
const status = backRef.current;
|
||||
if (status?.folder && status.granted) void backup(true);
|
||||
};
|
||||
|
||||
// A scan runs for as long as the roll takes, and this screen watches it rather
|
||||
// than running it: the studio may be up instead, and the scan carries on. The
|
||||
// names a pass has walked into are drawn as they arrive, a pass ahead of the
|
||||
@@ -200,6 +303,7 @@ export function Library() {
|
||||
if (was && !folder) {
|
||||
was = null;
|
||||
void reload();
|
||||
afterScan.current();
|
||||
return;
|
||||
}
|
||||
was = folder;
|
||||
@@ -667,6 +771,39 @@ export function Library() {
|
||||
{t('lib.stop')}
|
||||
</button>
|
||||
) : null}
|
||||
{/* The catalogue's own copy: one button that picks the folder the
|
||||
first time and writes into it every time after, and one that
|
||||
reads it back. Both are furniture next to the reading itself,
|
||||
so they sit behind the hint rather than in front of it. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-backup"
|
||||
disabled={backBusy}
|
||||
onClick={() => void backup()}
|
||||
>
|
||||
{t('lib.backup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy || !back?.folder}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
<span className="hint" data-key="lib-backup-state">
|
||||
{back?.folder
|
||||
? back.granted
|
||||
? t('lib.backupAt', {
|
||||
folder: back.folder,
|
||||
n: back.photos,
|
||||
time: back.at ? new Date(back.at).toLocaleString() : '—',
|
||||
})
|
||||
: t('lib.backupBlocked', { folder: back.folder })
|
||||
: t('lib.backupNone')}
|
||||
</span>
|
||||
<span className="hint lib-bar-hint" title={t('lib.hint')}>
|
||||
{t('lib.hint')}
|
||||
</span>
|
||||
|
||||
@@ -572,7 +572,17 @@ export async function scanFolder(
|
||||
// exactly as the catalogue already holds it. This is what makes a second scan
|
||||
// of the same folder cheap, and what finishes a roll whose first scan was cut
|
||||
// short — the frames it got through are skipped, the rest are read.
|
||||
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) return;
|
||||
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
|
||||
// A row restored from a backup has no handle: a handle is not a thing that
|
||||
// can be written to a file, so the row comes back with everything but the
|
||||
// one thing that opens the frame, and the walk is where it gets it back.
|
||||
// The row is put again — the bytes of the frame are still never read.
|
||||
if (!seen.handle) {
|
||||
batch.push({ ...seen, handle });
|
||||
if (batch.length >= BATCH) await flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A RAW is read whole because that is the only way LibRaw can seek to the
|
||||
// preview the camera wrote inside it; a JPEG is handed to the decoder as it
|
||||
// is, and only its header is read for the date. Both tiles come off what the
|
||||
@@ -966,3 +976,83 @@ export async function listEditedIds(): Promise<Set<string>> {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEdits(): Promise<LibraryEdit[]> {
|
||||
try {
|
||||
return await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- restore ---------------------------------------------------------------
|
||||
|
||||
// The catalogue as it travels: the rows without the two things that cannot be
|
||||
// written to a file — a handle, which only the visitor's picker can hand out, and
|
||||
// the tile itself, which is a blob and would be a second copy of the backup
|
||||
// folder. Everything else is here, `mtime` and `size` and `star` included, which
|
||||
// is what lets the walk that follows match every frame and read none of them.
|
||||
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
|
||||
|
||||
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
|
||||
// lot, where `getPhoto` would be one per frame.
|
||||
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(PHOTOS, 'readonly');
|
||||
const store = tx.objectStore(PHOTOS);
|
||||
const out: (LibraryPhoto | undefined)[] = [];
|
||||
ids.forEach((id, i) => {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
out[i] = req.result as LibraryPhoto | undefined;
|
||||
};
|
||||
});
|
||||
tx.oncomplete = () => resolve(out);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Put a catalogue back. A frame the catalogue already holds and can open — one
|
||||
// with a handle — is left exactly as it is: the backup's copy of it would come
|
||||
// back without one, which is a working catalogue traded for a worse one. Every
|
||||
// other row goes in as it came out. `thumb` fetches the tile that was backed up
|
||||
// beside the row, and a tile that will not read leaves the row without one: the
|
||||
// grid paints a blank cell until the next scan reads the frame.
|
||||
export async function restoreCatalogue(
|
||||
photos: CatalogueRow[],
|
||||
edits: LibraryEdit[],
|
||||
thumb?: (id: string) => Promise<Blob | null>
|
||||
): Promise<number> {
|
||||
const db = await openDb();
|
||||
const size = 200;
|
||||
let put = 0;
|
||||
for (let at = 0; at < photos.length; at += size) {
|
||||
const slice = photos.slice(at, at + size);
|
||||
const had = await rowsAt(slice.map((r) => r.id));
|
||||
const want = slice.filter((_, i) => !had[i]?.handle);
|
||||
if (!want.length) continue;
|
||||
const rows = await Promise.all(
|
||||
want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto)
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(PHOTOS, 'readwrite');
|
||||
const store = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) store.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
put += rows.length;
|
||||
}
|
||||
for (let at = 0; at < edits.length; at += size) {
|
||||
const slice = edits.slice(at, at + size);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(EDITS, 'readwrite');
|
||||
const store = tx.objectStore(EDITS);
|
||||
for (const row of slice) store.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
return put;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// The catalogue's own copy, kept in a folder on the visitor's own disk. The
|
||||
// catalogue lives in the browser's storage, which is the one place a cleared
|
||||
// profile, a new machine or a reinstall takes away — and the frames themselves
|
||||
// are not in it, so what is lost is the reading: every tile, every star, every
|
||||
// recipe. This writes that reading out to a folder the visitor picks, and reads
|
||||
// it back into a catalogue that has nothing.
|
||||
//
|
||||
// The folder holds one file of rows — thumbnail, star, recipe, the frame's size
|
||||
// and write time, and no handle, which is not a thing that can be written down —
|
||||
// and the tiles beside it, one file per frame under `thumbs/<frame id>`, so the
|
||||
// folder mirrors the library exactly and a tile can be looked at, copied or
|
||||
// rsynced by hand. A frame that is being restored is matched to a frame on the
|
||||
// disk by that size and write time, so putting a catalogue back costs the tiles
|
||||
// and not one frame read.
|
||||
//
|
||||
// ponytail: no incremental tree walk, no deletes. A tile is written when the
|
||||
// frame is new or its tile changed, and one left behind by a frame that was
|
||||
// dropped from the library stays there — it is a few dozen kilobytes in a folder
|
||||
// the visitor owns. Add a sweep when a library that is edited down to a fraction
|
||||
// of its size makes the leftovers worth the walk.
|
||||
import type { LibraryEdit, LibraryFolder } from './library';
|
||||
import {
|
||||
ensurePermission,
|
||||
listEdits,
|
||||
listFolders,
|
||||
listPhotos,
|
||||
renameFolder,
|
||||
restoreCatalogue,
|
||||
type CatalogueRow,
|
||||
} from './library';
|
||||
|
||||
const DB_NAME = 'recipescam-backup';
|
||||
// Two stores of its own, not the library's: the library opens without a version
|
||||
// on purpose (see `openDb` there), and a version it has to reach for is one a
|
||||
// second tab can block. This one is nothing but a handle and a list, so it can
|
||||
// have a version and be upgraded.
|
||||
const WHERE = 'where';
|
||||
const WRITTEN = 'written';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
const CATALOGUE = 'recipescam-catalogue.json';
|
||||
const THUMBS = 'thumbs';
|
||||
// The catalogue is written whole however little changed: it is one file, and a
|
||||
// row that was left behind in it is a frame that will not come back.
|
||||
const VERSION = 1;
|
||||
|
||||
interface Meta {
|
||||
id: 'meta';
|
||||
at: number;
|
||||
photos: number;
|
||||
wrote: number;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
// The folder's own name, or null when the visitor has not chosen one.
|
||||
folder: string | null;
|
||||
// Whether the browser has let the page write to it. It comes back `prompt`
|
||||
// after a restart — a permission outlives the tab only while the tab does, and
|
||||
// Chromium will not hand it back without a click — which is why the screen
|
||||
// says so rather than failing silently.
|
||||
granted: boolean;
|
||||
at: number;
|
||||
photos: number;
|
||||
wrote: number;
|
||||
}
|
||||
|
||||
export interface BackupProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(WHERE)) db.createObjectStore(WHERE, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(WRITTEN)) db.createObjectStore(WRITTEN, { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const req = run(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function keep(store: string, rows: object[]): Promise<void> {
|
||||
if (!rows.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readwrite');
|
||||
const s = tx.objectStore(store);
|
||||
for (const row of rows) s.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const row = await ask<{ id: string; handle: FileSystemDirectoryHandle } | undefined>(WHERE, 'readonly', (s) =>
|
||||
s.get('folder')
|
||||
);
|
||||
return row?.handle ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The permission is asked for silently and never granted from here: a prompt
|
||||
// without a click behind it is a prompt the browser may refuse outright, and the
|
||||
// buttons on the screen are where the click is.
|
||||
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
|
||||
const askable = handle as FileSystemHandle & {
|
||||
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
|
||||
};
|
||||
try {
|
||||
return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupStatus(): Promise<BackupStatus> {
|
||||
const handle = await folderHandle();
|
||||
const meta = await ask<Meta | undefined>(WHERE, 'readonly', (s) => s.get('meta')).catch(() => undefined);
|
||||
return {
|
||||
folder: handle?.name ?? null,
|
||||
granted: handle ? await granted(handle, true) : false,
|
||||
at: meta?.at ?? 0,
|
||||
photos: meta?.photos ?? 0,
|
||||
wrote: meta?.wrote ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The picker's own dialog, the same way a folder of frames is picked — a second
|
||||
// `id` so the browser remembers this folder apart from the library's.
|
||||
export async function pickBackupFolder(): Promise<string> {
|
||||
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-backup' });
|
||||
const before = await folderHandle();
|
||||
await keep(WHERE, [{ id: 'folder', handle }]);
|
||||
// A folder that is not the one the last run wrote into starts empty, and the
|
||||
// list of tiles already written is about that folder — kept, it would say every
|
||||
// tile is there and the new folder would come out with none of them.
|
||||
if (before?.name !== handle.name) await ask(WRITTEN, 'readwrite', (s) => s.clear());
|
||||
return handle.name;
|
||||
}
|
||||
|
||||
// A handle comes back from IndexedDB without its permission, so every write goes
|
||||
// through this: the visitor's click is the one thing that can hand it back.
|
||||
async function writable(): Promise<FileSystemDirectoryHandle> {
|
||||
const handle = await folderHandle();
|
||||
if (!handle) throw new Error('no-backup-folder');
|
||||
if (!(await ensurePermission(handle, true))) throw new Error('no-backup-permission');
|
||||
return handle;
|
||||
}
|
||||
|
||||
// The directories are asked for by the path in the frame id, and the id of every
|
||||
// frame in a folder starts with that folder's: a cache of what has been opened
|
||||
// turns a folder of 220 000 frames into a walk of the folders it is made of.
|
||||
const opened = new WeakMap<FileSystemDirectoryHandle, Map<string, FileSystemDirectoryHandle>>();
|
||||
|
||||
async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise<FileSystemDirectoryHandle> {
|
||||
let cache = opened.get(root);
|
||||
if (!cache) opened.set(root, (cache = new Map()));
|
||||
let node = root;
|
||||
let path = '';
|
||||
for (const part of rel.split('/')) {
|
||||
if (!part) continue;
|
||||
path = path ? `${path}/${part}` : part;
|
||||
const had = cache.get(path);
|
||||
if (had) {
|
||||
node = had;
|
||||
continue;
|
||||
}
|
||||
node = await node.getDirectoryHandle(part, { create });
|
||||
cache.set(path, node);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> {
|
||||
const cut = rel.lastIndexOf('/');
|
||||
const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true);
|
||||
const file = await dir.getFileHandle(cut < 0 ? rel : rel.slice(cut + 1), { create: true });
|
||||
// One write at a time and closed at once: a writable that is left open is a
|
||||
// file that is not on the disk yet, and a backup that is interrupted is one the
|
||||
// next run has to write again.
|
||||
const writer = await file.createWritable();
|
||||
await writer.write(blob);
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
// What the last run left in the folder: the tile's own size, per frame. A tile
|
||||
// that has not changed is not written again, which is the whole of what makes a
|
||||
// second run cheap.
|
||||
async function already(): Promise<Map<string, number>> {
|
||||
const rows = await ask<{ id: string; size: number }[]>(WRITTEN, 'readonly', (s) => s.getAll()).catch(() => []);
|
||||
return new Map(rows.map((r) => [r.id, r.size]));
|
||||
}
|
||||
|
||||
export interface BackupResult extends BackupStatus {
|
||||
// The tiles this run actually wrote, and the frames it found.
|
||||
written: number;
|
||||
}
|
||||
|
||||
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
|
||||
const root = await writable();
|
||||
const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
const had = await already();
|
||||
const at0 = Date.now();
|
||||
const cat = {
|
||||
version: VERSION,
|
||||
at: at0,
|
||||
folders: folders.map(({ name, label }) => ({ name, label })),
|
||||
// The two things a file cannot hold: the handle, and the tile, which is
|
||||
// written beside this as a file of its own.
|
||||
photos: photos.map(({ handle, thumb, ...row }) => row) as CatalogueRow[],
|
||||
edits,
|
||||
};
|
||||
await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' }));
|
||||
|
||||
let written = 0;
|
||||
let done = 0;
|
||||
const rows: { id: string; size: number }[] = [];
|
||||
for (const photo of photos) {
|
||||
// Progress counts frames and not tiles: a folder of frames read without one
|
||||
// is still a reading, and a bar that does not move is a bar that reads as a
|
||||
// screen that has stopped.
|
||||
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
|
||||
if (!photo.thumb) continue;
|
||||
if (had.get(photo.id) === photo.thumb.size) continue;
|
||||
const parts = photo.id.split('/');
|
||||
parts.pop();
|
||||
const dir = await at(root, [THUMBS, ...parts].join('/'), true);
|
||||
const file = await dir.getFileHandle(photo.name, { create: true });
|
||||
const writer = await file.createWritable();
|
||||
await writer.write(photo.thumb);
|
||||
await writer.close();
|
||||
rows.push({ id: photo.id, size: photo.thumb.size });
|
||||
written++;
|
||||
// The list is kept in step with the writes: a run that is interrupted
|
||||
// continues from the frame it stopped at instead of writing the folder
|
||||
// again.
|
||||
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
|
||||
}
|
||||
await keep(WRITTEN, rows);
|
||||
onProgress?.({ done: photos.length, total: photos.length });
|
||||
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
|
||||
await keep(WHERE, [meta]);
|
||||
return { folder: root.name, granted: true, at: at0, photos: photos.length, wrote: written, written };
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
photos: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
// Put a catalogue back. Nothing is deleted and nothing that is already there is
|
||||
// spared: the backup is the reading, and a frame it knows about is a frame the
|
||||
// catalogue should know about too. Folders are not in it — a folder is a handle,
|
||||
// and a handle only comes from the visitor's own picker — so the frames come back
|
||||
// without one and the folders are picked again afterwards, which is also what
|
||||
// gives every frame its handle back without reading a byte of it.
|
||||
export async function restoreNow(onProgress?: (p: BackupProgress) => void): Promise<RestoreResult> {
|
||||
const root = await folderHandle();
|
||||
if (!root) throw new Error('no-backup-folder');
|
||||
if (!(await ensurePermission(root, false))) throw new Error('no-backup-permission');
|
||||
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
|
||||
const cat = JSON.parse(text) as {
|
||||
at?: number;
|
||||
folders?: { name: string; label?: string }[];
|
||||
photos: CatalogueRow[];
|
||||
edits: LibraryEdit[];
|
||||
};
|
||||
// The folders this catalogue still has keep the names the backup remembers:
|
||||
// a rename is the reader's own, and it is not in the frame's file anywhere.
|
||||
const here = new Map<string, LibraryFolder>((await listFolders()).map((f) => [f.name, f]));
|
||||
for (const f of cat.folders ?? []) {
|
||||
const folder = here.get(f.name);
|
||||
if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label);
|
||||
}
|
||||
let done = 0;
|
||||
const photos = await restoreCatalogue(cat.photos ?? [], cat.edits ?? [], async (id) => {
|
||||
if (++done % 64 === 0) onProgress?.({ done, total: (cat.photos ?? []).length });
|
||||
try {
|
||||
const parts = id.split('/');
|
||||
const name = parts.pop() as string;
|
||||
const dir = await at(root, [THUMBS, ...parts].join('/'));
|
||||
const file = await dir.getFileHandle(name);
|
||||
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
|
||||
} catch {
|
||||
// A tile that is not there — or that has been taken away by hand — leaves
|
||||
// the row without one, and the next scan of the folder reads the frame and
|
||||
// paints it. Losing the picture is not worth losing the reading.
|
||||
return null;
|
||||
}
|
||||
});
|
||||
onProgress?.({ done: photos, total: photos });
|
||||
return { photos, at: cat.at ?? 0 };
|
||||
}
|
||||
@@ -279,6 +279,21 @@ export const en: Dict = {
|
||||
'lib.failed': 'That folder could not be read.',
|
||||
'lib.unsupported':
|
||||
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
|
||||
// The catalogue's own copy: the browser's storage is the one place a cleared
|
||||
// profile takes the reading away from, and these two are what puts it back.
|
||||
'lib.backup': 'BACK UP',
|
||||
'lib.restore': 'RESTORE',
|
||||
'lib.backupNone': 'No backup folder yet — this picks one and writes the catalogue into it.',
|
||||
'lib.backupAt': 'Backup: {folder} · {n} frames · {time}',
|
||||
'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.',
|
||||
'lib.backupRun': 'Backing up {done}/{total}…',
|
||||
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
|
||||
'lib.backupFailed': 'The backup folder could not be written to.',
|
||||
'lib.restoreConfirm': 'Restore the catalogue from {folder}? Frames the library already holds are left as they are.',
|
||||
'lib.restoreRun': 'Restoring {done}/{total}…',
|
||||
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
|
||||
'lib.restoreNone': 'Pick a backup folder first — BACK UP does that and writes the catalogue in one go.',
|
||||
'lib.restoreFailed': 'The backup folder could not be read.',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.photos': 'My photos',
|
||||
|
||||
@@ -293,6 +293,21 @@ export const vi = {
|
||||
'lib.failed': 'Không đọc được thư mục này.',
|
||||
'lib.unsupported':
|
||||
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
|
||||
// Bản sao của danh mục: bộ nhớ trình duyệt là chỗ duy nhất mà một profile bị
|
||||
// xoá lấy mất phần đã đọc, và hai nút này là cách lấy lại.
|
||||
'lib.backup': 'SAO LƯU',
|
||||
'lib.restore': 'PHỤC HỒI',
|
||||
'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.',
|
||||
'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}',
|
||||
'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.',
|
||||
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
|
||||
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
|
||||
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu.',
|
||||
'lib.restoreConfirm': 'Phục hồi danh mục từ {folder}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
|
||||
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
|
||||
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
|
||||
'lib.restoreNone': 'Chọn thư mục sao lưu trước — nút SAO LƯU làm việc đó và ghi danh mục luôn.',
|
||||
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
|
||||
Reference in New Issue
Block a user