feat(library): give a folder its missing thumbnails, and the wall its stars
Four asks, one screen: a folder can be given the tiles it is short of, by hand or on the way in; the rescan row says RESCAN...; and the wall of thumbnails scores a frame without the trip to the stage. - Library.tsx + i18n: "RESCAN FROM THE TOP" is "RESCAN..." in both languages. The reading is unchanged — a row's roll is still read from the top, its subfolders included. - engine/library.ts + Library.tsx: the tile of one frame is one function now (`makeThumb`: the store, the backup behind it, then the file's own preview for a RAW or a HEIC and a decode for anything else). The wall's lazy path calls it instead of keeping its own copy of the same branches. - engine/library.ts: `missingThumbs` asks which of a folder's rows no tile was ever drawn for, in one transaction, and `makeThumbs` makes those — a few frames at a time, and giving up when the caller has moved on. - Library.tsx: the folder menu carries MAKE THUMBS, which reads the row's folder and what lies under it; and opening a folder asks for its own frames' tiles by itself, silently, in the background — the branch too when the screen is drawing the branch. A frame that lands after the ask is asked for again, which is why the frame count is a dependency of the effect. - Library.tsx: a wall card carries the same five stars the stage does, under the picture, and rates the frame on the click. The click stops at the star, so scoring a frame is not picking it. Verified in Chrome at 1440x900 against OPFS standing in for the disk, three frames with one of them in a subfolder: opening the folder leaves two tiles (the node's own frames) and MAKE THUMBS takes the roll to three, the frame in the subfolder included; clearing the store and reloading brings the two back on the open; the menu reads RESCAN... and MAKE THUMBS; and the wall's fourth star lights four, writes star 4 to the catalogue row, and takes it back on the second click. The same checks pass on the built bundle at :8090.
This commit is contained in:
+109
-52
@@ -5,7 +5,7 @@ import { api } from './api';
|
|||||||
import { keep, recall } from './remember';
|
import { keep, recall } from './remember';
|
||||||
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||||
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
||||||
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop';
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import {
|
import {
|
||||||
cachedFolders,
|
cachedFolders,
|
||||||
cachedPhotos,
|
cachedPhotos,
|
||||||
@@ -20,11 +20,11 @@ import {
|
|||||||
listEditedIds,
|
listEditedIds,
|
||||||
listFolders,
|
listFolders,
|
||||||
listWalkedDirs,
|
listWalkedDirs,
|
||||||
makeTile,
|
makeThumb,
|
||||||
|
makeThumbs,
|
||||||
normPath,
|
normPath,
|
||||||
onLibraryBlocked,
|
onLibraryBlocked,
|
||||||
pickFolder,
|
pickFolder,
|
||||||
putPhotoThumb,
|
|
||||||
readPhotoFile,
|
readPhotoFile,
|
||||||
readPhotos,
|
readPhotos,
|
||||||
reconnectPhotosForFolders,
|
reconnectPhotosForFolders,
|
||||||
@@ -1057,6 +1057,52 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
[rootName]
|
[rootName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MAKING THUMBS: the tiles a folder's frames are short of, made from the frames
|
||||||
|
// themselves. One job at a time — the target the reader is on — and a job the
|
||||||
|
// reader has moved away from gives up at its next frame: a tile is a decode, and
|
||||||
|
// two jobs over the same frames read the same disk twice for nothing.
|
||||||
|
const thumbJob = useRef(0);
|
||||||
|
const thumbTarget = useRef<string | null>(null);
|
||||||
|
const makeThumbsFor = useCallback(
|
||||||
|
async (folder: LibraryFolder, rel: string, opts: { below?: boolean; loud?: boolean } = {}) => {
|
||||||
|
const key = `${normPath(folder.name)}|${normPath(rel)}|${opts.below === false ? 0 : 1}`;
|
||||||
|
// A quiet ask for the target already being worked is the work in hand: the
|
||||||
|
// catalogue lands a batch at a time, and every batch is not a reason to drop
|
||||||
|
// the frames the job has queued and read them again.
|
||||||
|
if (!opts.loud && thumbTarget.current === key) return 0;
|
||||||
|
const job = ++thumbJob.current;
|
||||||
|
thumbTarget.current = key;
|
||||||
|
try {
|
||||||
|
const made = await makeThumbs(folder, rel, {
|
||||||
|
below: opts.below,
|
||||||
|
shouldStop: () => thumbJob.current !== job,
|
||||||
|
});
|
||||||
|
if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name }));
|
||||||
|
return made;
|
||||||
|
} finally {
|
||||||
|
// The target is let go by the job that stood for it: one cut off in the
|
||||||
|
// middle is not the one that says the target has been looked at.
|
||||||
|
if (thumbJob.current === job) thumbTarget.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selecting a folder asks for its frames' tiles: whatever the catalogue is short
|
||||||
|
// of is made in the background, so the wall the reader scrolls to is painted
|
||||||
|
// rather than decoded a frame at a time. The open node's own frames — and the
|
||||||
|
// branch under it when the screen is drawing the branch — which is the list the
|
||||||
|
// wall would scroll and no more. Silent: the tiles filling in are the word. The
|
||||||
|
// count is a dependency because a reading lands a folder's frames a batch at a
|
||||||
|
// time, and a frame that arrives after the tiles were asked for is a frame with
|
||||||
|
// none.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || !node || !rootFolder) return;
|
||||||
|
const rel = relOf(node);
|
||||||
|
if (rel === null) return;
|
||||||
|
void makeThumbsFor(rootFolder, rel, { below: deep });
|
||||||
|
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
|
||||||
|
|
||||||
// The tree: one row per picked folder, then one per subfolder, indented by its
|
// The tree: one row per picked folder, then one per subfolder, indented by its
|
||||||
// depth — the folders the walk has named, whether or not a frame has been read
|
// depth — the folders the walk has named, whether or not a frame has been read
|
||||||
// out of them, each carrying how many frames sit at or under it. While a scan is
|
// out of them, each carrying how many frames sit at or under it. While a scan is
|
||||||
@@ -2221,6 +2267,35 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||||
>
|
>
|
||||||
{tile(photo)}
|
{tile(photo)}
|
||||||
|
{/* The score under the picture, the same five stars the
|
||||||
|
stage gives: the wall is where a roll is gone
|
||||||
|
through, and going back to the stage for every
|
||||||
|
number is the trip this saves. The click stops at
|
||||||
|
the star — a score is not a pick, and the card's
|
||||||
|
own click is what selects the frame. */}
|
||||||
|
<div
|
||||||
|
className="lib-stars"
|
||||||
|
data-key={`lib-stars-${photo.id}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('lib.rating')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
|
||||||
|
data-key={`lib-star-${photo.id}-${n}`}
|
||||||
|
aria-pressed={(photo.star ?? 0) >= n}
|
||||||
|
aria-label={t('lib.starHint', { n })}
|
||||||
|
title={t('lib.starHint', { n })}
|
||||||
|
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="adm-meta">
|
<div className="adm-meta">
|
||||||
<b>{photo.name}</b>
|
<b>{photo.name}</b>
|
||||||
<span>
|
<span>
|
||||||
@@ -2481,12 +2556,12 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
{t('lib.rename')}
|
{t('lib.rename')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Three things can be asked of a roll: bring it up to date, read it
|
{/* Four things can be asked of a roll: bring it up to date, read it
|
||||||
from the top again, or leave it — and the last only of the
|
from the top again, give it the tiles it is short of, or leave
|
||||||
reading that is this roll's, or of the request for it that is
|
it — and the last only of the reading that is this roll's, or of
|
||||||
still waiting its turn. The first two are always there, because
|
the request for it that is still waiting its turn. The others are
|
||||||
a reading already running is not a reason a reader cannot ask
|
always there, because a reading already running is not a reason a
|
||||||
for the next one: it queues. */}
|
reader cannot ask for the next one: it queues. */}
|
||||||
{live?.folder === menu.folder.name || queued ? (
|
{live?.folder === menu.folder.name || queued ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2525,6 +2600,24 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
|
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
|
||||||
{t('lib.rescan')}
|
{t('lib.rescan')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* The tiles this roll is short of, made from the frames themselves —
|
||||||
|
the same job the screen runs when a folder is opened, asked for by
|
||||||
|
hand on the row under the pointer, and over what lies under that
|
||||||
|
row: a folder is read whole or not at all. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lib-menu-item"
|
||||||
|
data-key={`lib-thumbs-${menu.folder.name}`}
|
||||||
|
onClick={() => {
|
||||||
|
const folder = menu.folder!;
|
||||||
|
const rel = menu.rel ?? '';
|
||||||
|
setMenu(null);
|
||||||
|
void makeThumbsFor(folder, rel, { loud: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lib-menu-icon" aria-hidden="true">▤</span>
|
||||||
|
{t('lib.makeThumbs')}
|
||||||
|
</button>
|
||||||
{/* A row with folders under it is a row that folds, and the branch
|
{/* A row with folders under it is a row that folds, and the branch
|
||||||
it holds goes with it: a reader who is done with a tree means
|
it holds goes with it: a reader who is done with a tree means
|
||||||
the whole of it shut, not the one row they happened to point
|
the whole of it shut, not the one row they happened to point
|
||||||
@@ -2834,52 +2927,16 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
|||||||
|
|
||||||
if (idsToFetch.length === 0) return;
|
if (idsToFetch.length === 0) return;
|
||||||
|
|
||||||
// The strip draws one square whatever the frame behind it, so the preview a RAW
|
// The tile is `makeThumb`'s business: the store first, the backup behind it,
|
||||||
// or a HEIC hands back — a whole develop, seconds of it — is shrunk to the
|
// and only then the file — a RAW's own preview, or a decode. A frame the disk
|
||||||
// wall's size the one time it is paid for and then kept: every later visit
|
// will not hand over is a frame with no tile rather than a strip that stops.
|
||||||
// reads the tile back instead of the file.
|
|
||||||
const tileOf = async (id: string, bytes: Uint8Array) => {
|
|
||||||
const preview = new Blob([bytes as BlobPart], { type: 'image/jpeg' });
|
|
||||||
const made = (await makeTile(preview)) ?? preview;
|
|
||||||
void putPhotoThumb(id, made);
|
|
||||||
return made;
|
|
||||||
};
|
|
||||||
|
|
||||||
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
||||||
if (!alive) return;
|
|
||||||
const updates: Record<string, string> = {};
|
const updates: Record<string, string> = {};
|
||||||
for (const photo of list) {
|
for (const photo of list) {
|
||||||
let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
if (!alive) return;
|
||||||
if (!blob && photo.handle) {
|
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||||
try {
|
const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
|
||||||
const file = await readPhotoFile(photo);
|
if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
||||||
if (!alive) return;
|
|
||||||
const name = file.name;
|
|
||||||
if (isHeicName(name)) {
|
|
||||||
const fullBytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
let prevBytes = await heicThumbnail(fullBytes);
|
|
||||||
if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320);
|
|
||||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
|
||||||
} else if (isRawName(name) || isTiffName(name)) {
|
|
||||||
const fullBytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const prevBytes = await rawThumbnail(fullBytes, name);
|
|
||||||
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
|
|
||||||
} else {
|
|
||||||
// A frame the wall can decode itself: the tile is made here, at the
|
|
||||||
// size the wall paints, because this is the first time anyone has
|
|
||||||
// looked at the frame. The walk never made one — a roll reads in a
|
|
||||||
// fraction of the time without it — so the decode is paid on the
|
|
||||||
// frame the reader draws and kept for every visit after.
|
|
||||||
blob = (await makeTile(file)) ?? file;
|
|
||||||
if (blob !== file) void putPhotoThumb(photo.id, blob);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
blob = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (blob && alive) {
|
|
||||||
updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (alive && Object.keys(updates).length > 0) {
|
if (alive && Object.keys(updates).length > 0) {
|
||||||
setUrls((prev) => ({ ...prev, ...updates }));
|
setUrls((prev) => ({ ...prev, ...updates }));
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
// smart collections) that hand-rolled cursors would make ugly.
|
// smart collections) that hand-rolled cursors would make ugly.
|
||||||
import type { Recipe } from '../../shared/types';
|
import type { Recipe } from '../../shared/types';
|
||||||
import { readCapturedAt } from './imageOps';
|
import { readCapturedAt } from './imageOps';
|
||||||
import { isRawName, isTiffName } from './rawDevelop';
|
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
||||||
import { isHeicName } from './heicDevelop';
|
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
||||||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||||
|
|
||||||
const DB_NAME = 'recipescam-library';
|
const DB_NAME = 'recipescam-library';
|
||||||
@@ -1919,6 +1919,123 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A RAW or a HEIC hands back a preview that is a whole develop, seconds of it:
|
||||||
|
// shrunk to the tile's size the one time it is paid for and then kept, so every
|
||||||
|
// later visit reads the tile back instead of the file.
|
||||||
|
async function tileOf(id: string, preview: Blob): Promise<Blob> {
|
||||||
|
const made = (await makeTile(preview)) ?? preview;
|
||||||
|
void putPhotoThumb(id, made);
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tile of one frame, made there and then — the folder the reader just opened,
|
||||||
|
// or the one frame the wall is drawing. The store is asked first and the backup
|
||||||
|
// behind it, so a tile that is somewhere already is not decoded a second time;
|
||||||
|
// only a frame nothing has is read off the disk, and which way that goes is the
|
||||||
|
// file's own: a RAW or a HEIC gives its preview, anything else is decoded.
|
||||||
|
//
|
||||||
|
// ponytail: a TIFF has no preview reader here, so it is decoded whole through the
|
||||||
|
// browser (`makeTile`) — `tiffDevelop` is the way in if a folder of scans ever
|
||||||
|
// makes that wait a real one.
|
||||||
|
export async function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||||
|
const kept = await getPhotoThumb(photo.id);
|
||||||
|
if (kept) return kept;
|
||||||
|
if (!photo.handle) return null;
|
||||||
|
try {
|
||||||
|
const file = await readPhotoFile(photo);
|
||||||
|
const name = file.name;
|
||||||
|
let blob: Blob | null = null;
|
||||||
|
if (isHeicName(name)) {
|
||||||
|
const full = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const preview = (await heicThumbnail(full)) ?? (await heicToJpeg(full, 320));
|
||||||
|
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||||
|
} else if (isRawName(name) || isTiffName(name)) {
|
||||||
|
const full = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const preview = await rawThumbnail(full, name);
|
||||||
|
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||||
|
} else {
|
||||||
|
// A frame the browser decodes itself: the tile is made here, at the size the
|
||||||
|
// wall paints, and kept — the walk never made one, so the decode is paid on
|
||||||
|
// the visit that first draws the frame. A frame the decoder would not shrink
|
||||||
|
// is drawn as it is rather than written to the store whole.
|
||||||
|
const made = await makeTile(file);
|
||||||
|
blob = made ?? file;
|
||||||
|
if (made) await putPhotoThumb(photo.id, made);
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
} catch {
|
||||||
|
// A frame the disk will not hand over is a frame with no tile, not a job that
|
||||||
|
// stopped: the rest of the folder is still read.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which of these frames the catalogue is short of a tile for: the rows the walk
|
||||||
|
// wrote down that nothing has drawn yet. One transaction for the lot — asking a
|
||||||
|
// frame at a time is a thousand transactions over a folder of a thousand.
|
||||||
|
export async function missingThumbs(ids: string[]): Promise<string[]> {
|
||||||
|
const missing: string[] = [];
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db.objectStoreNames.contains(THUMBS)) return ids;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(THUMBS, 'readonly');
|
||||||
|
const store = tx.objectStore(THUMBS);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (getCachedThumb(id)) continue;
|
||||||
|
const req = store.get(id);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
if (!(req.result as { thumb?: Blob } | undefined)?.thumb) missing.push(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A store that will not answer is not a folder with its tiles: nothing is
|
||||||
|
// reported missing, and nothing is made again for a read that failed.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAKE THUMBS: every frame of the folder — and, unless told otherwise, of what
|
||||||
|
// lies under it — given a tile, and only the frames the catalogue is short of one.
|
||||||
|
// A tile is a decode, so the frames are read a few at a time, and a job the
|
||||||
|
// caller has moved on from gives up at its next frame: a reader who has walked to
|
||||||
|
// another folder is not waiting for this one.
|
||||||
|
export async function makeThumbs(
|
||||||
|
folder: LibraryFolder,
|
||||||
|
rel = '',
|
||||||
|
opts: { below?: boolean; shouldStop?: () => boolean } = {}
|
||||||
|
): Promise<number> {
|
||||||
|
const below = opts.below !== false;
|
||||||
|
const dir = normPath(rel);
|
||||||
|
const rows = (await readPhotos(normPath(folder.name))) ?? [];
|
||||||
|
const want = rows.filter((p) => {
|
||||||
|
const pDir = normPath(p.dir ?? '');
|
||||||
|
if (pDir === dir) return true;
|
||||||
|
// `''` is the picked folder itself, so a row asked of the whole roll takes
|
||||||
|
// every row of it and a row asked of a subfolder takes what lies under that.
|
||||||
|
return below && pDir.startsWith(dir === '' ? '' : `${dir}/`);
|
||||||
|
});
|
||||||
|
const missing = await missingThumbs(want.map((p) => p.id));
|
||||||
|
if (!missing.length) return 0;
|
||||||
|
const byId = new Map(want.map((p) => [p.id, p]));
|
||||||
|
const queue = [...missing];
|
||||||
|
let made = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(LANES, queue.length) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
if (opts.shouldStop?.()) return;
|
||||||
|
const photo = byId.get(queue.shift()!);
|
||||||
|
if (photo && (await makeThumb(photo))) made++;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
||||||
try {
|
try {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
|
|||||||
@@ -248,12 +248,14 @@ export const en: Dict = {
|
|||||||
'lib.rename': 'RENAME FOLDER',
|
'lib.rename': 'RENAME FOLDER',
|
||||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||||
'lib.update': 'UPDATE...',
|
'lib.update': 'UPDATE...',
|
||||||
'lib.rescan': 'RESCAN FROM THE TOP',
|
'lib.rescan': 'RESCAN...',
|
||||||
|
'lib.makeThumbs': 'MAKE THUMBS',
|
||||||
|
'lib.thumbsMade': 'Made {n} thumbnails for {folder}.',
|
||||||
'lib.resume': 'KEEP READING {folder}',
|
'lib.resume': 'KEEP READING {folder}',
|
||||||
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
|
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
|
||||||
'lib.remove': 'REMOVE',
|
'lib.remove': 'REMOVE',
|
||||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||||
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
|
'lib.nodeHint': 'Right-click to rename, update, rescan, make the missing thumbnails, collapse or remove the folder.',
|
||||||
'lib.collapseAll': 'COLLAPSE ALL',
|
'lib.collapseAll': 'COLLAPSE ALL',
|
||||||
'lib.collapse': 'COLLAPSE',
|
'lib.collapse': 'COLLAPSE',
|
||||||
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
||||||
|
|||||||
@@ -260,12 +260,14 @@ export const vi = {
|
|||||||
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
||||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||||
'lib.update': 'CẬP NHẬT...',
|
'lib.update': 'CẬP NHẬT...',
|
||||||
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
|
'lib.rescan': 'QUÉT LẠI...',
|
||||||
|
'lib.makeThumbs': 'TẠO ẢNH THU NHỎ',
|
||||||
|
'lib.thumbsMade': 'Đã tạo {n} ảnh thu nhỏ cho {folder}.',
|
||||||
'lib.resume': 'ĐỌC TIẾP {folder}',
|
'lib.resume': 'ĐỌC TIẾP {folder}',
|
||||||
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
|
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
|
||||||
'lib.remove': 'BỎ',
|
'lib.remove': 'BỎ',
|
||||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
|
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, tạo ảnh thu nhỏ còn thiếu, thu gọn hay bỏ thư mục.',
|
||||||
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
||||||
'lib.collapse': 'THU GỌN',
|
'lib.collapse': 'THU GỌN',
|
||||||
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
||||||
|
|||||||
Reference in New Issue
Block a user