release: v0.20.0
This commit is contained in:
@@ -60,6 +60,19 @@ export async function fileVersion(filePath: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a save of `content` has to back up the file at `filePath` first:
|
||||
* not when there is no file yet, and not when the file already holds exactly
|
||||
* these bytes. A save that changes nothing used to snapshot anyway, and with
|
||||
* ten backups kept per file, a few no-op saves pushed out the real ones
|
||||
* (QA A-19). Only ENOENT counts as "no file"; any other failure to read it
|
||||
* throws, so the save stops before it overwrites the live file (#630).
|
||||
*/
|
||||
export async function needsBackup(filePath: string, content: string): Promise<boolean> {
|
||||
const onDisk = await fileVersion(filePath);
|
||||
return onDisk !== ABSENT_VERSION && onDisk !== versionOf(content);
|
||||
}
|
||||
|
||||
/** The file changed on disk since the editor loaded it. */
|
||||
export class VersionConflictError extends Error {
|
||||
constructor(readonly currentVersion: string) {
|
||||
|
||||
@@ -314,6 +314,124 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
|
||||
};
|
||||
}
|
||||
|
||||
/** What the doctor needs to know about one album that offers downloads. */
|
||||
export interface DownloadAlbumRef {
|
||||
id: string;
|
||||
albumName: string;
|
||||
/** Assets in a format whose metadata the download cannot clean (RAW, PNG, video, …). */
|
||||
uncleanable: number;
|
||||
/** Of those, how many Immich has coordinates for. */
|
||||
uncleanableWithLocation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads remove GPS from JPEG, HEIC/HEIF and AVIF originals
|
||||
* (lib/locationScrub.ts). Every other format is served exactly as Immich
|
||||
* stores it, so an album that offers downloads and holds such files hands out
|
||||
* whatever location they carry. The route decides which assets are cleanable;
|
||||
* this module stays import-free for `npm run doctor`. Returns null while no
|
||||
* album offers downloads.
|
||||
*/
|
||||
export function checkDownloadMetadata(albums: DownloadAlbumRef[]): DoctorFinding | null {
|
||||
if (!albums.length) return null;
|
||||
const affected = albums.filter((a) => a.uncleanable > 0);
|
||||
|
||||
if (!affected.length) {
|
||||
return {
|
||||
id: 'download-metadata',
|
||||
level: 'ok',
|
||||
title: 'Downloads go out without GPS',
|
||||
detail: `Every original in the ${albums.length === 1 ? 'download album' : `${albums.length} download albums`} is JPEG, HEIC or AVIF, and is served with its location removed.`,
|
||||
};
|
||||
}
|
||||
|
||||
const files = affected.reduce((n, a) => n + a.uncleanable, 0);
|
||||
const located = affected.reduce((n, a) => n + a.uncleanableWithLocation, 0);
|
||||
return {
|
||||
id: 'download-metadata',
|
||||
level: 'warn',
|
||||
title: `${files} downloadable ${files === 1 ? 'original keeps' : 'originals keep'} all metadata`,
|
||||
detail:
|
||||
'RAW, DNG, PNG, TIFF, WebP and video files are served exactly as stored, with any GPS ' +
|
||||
`coordinates${located ? ` (Immich has a location for ${located} of them)` : ''}. ` +
|
||||
'Turn off downloads or remove these files if their location should stay private: ' +
|
||||
affected.map((a) => `${a.albumName} (${a.uncleanable})`).join(', '),
|
||||
albumIds: affected.map((a) => a.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** What the doctor needs to know about one album with lightbox zoom on (#467). */
|
||||
export interface ZoomAlbumRef {
|
||||
id: string;
|
||||
albumName: string;
|
||||
/** Photos zoomed through Immich's full-size rendition (HEIC, RAW, TIFF, …). */
|
||||
needRendition: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sampled full-size rendition turned out to be: `ok` (JPEG, which the
|
||||
* zoom route serves), `missing` (Immich has none), or the content type of one
|
||||
* the route refuses — a WebP rendition, when Immich's full-size format is set
|
||||
* to WebP, since the location scrubber cannot clean WebP.
|
||||
*/
|
||||
export type ZoomRenditionSample = 'ok' | 'missing' | { contentType: string };
|
||||
|
||||
/**
|
||||
* Zoom shows JPEG and AVIF originals directly, but everything a browser cannot
|
||||
* display needs Immich's full-size rendition, which Immich only generates while
|
||||
* "Full-size image" is on in its image settings, and which the zoom route only
|
||||
* serves as JPEG. Immich does not say whether a rendition exists until one is
|
||||
* asked for, so the route samples one photo and passes what it found (null
|
||||
* when it could not ask).
|
||||
*
|
||||
* Returns null when no zoom album holds such photos, or nothing is known.
|
||||
*/
|
||||
export function checkZoomRenditions(
|
||||
albums: ZoomAlbumRef[],
|
||||
sample: ZoomRenditionSample | null,
|
||||
): DoctorFinding | null {
|
||||
const affected = albums.filter((a) => a.needRendition > 0);
|
||||
if (!affected.length || sample === null) return null;
|
||||
const photos = affected.reduce((n, a) => n + a.needRendition, 0);
|
||||
const noun = photos === 1 ? 'photo' : 'photos';
|
||||
const albumList = affected.map((a) => `${a.albumName} (${a.needRendition})`).join(', ');
|
||||
|
||||
if (sample === 'ok') {
|
||||
return {
|
||||
id: 'zoom-renditions',
|
||||
level: 'ok',
|
||||
title: 'Immich has full-size previews for zoom',
|
||||
detail: `${photos} HEIC, RAW or similar ${noun} in zoom albums are zoomed through Immich's full-size rendition, with the location removed.`,
|
||||
};
|
||||
}
|
||||
if (sample === 'missing') {
|
||||
return {
|
||||
id: 'zoom-renditions',
|
||||
level: 'warn',
|
||||
title: `${photos} ${noun} cannot be zoomed: Immich has no full-size preview`,
|
||||
detail:
|
||||
'HEIC, RAW and other formats a browser cannot show are zoomed through Immich’s full-size ' +
|
||||
'rendition, and Immich has none (one photo sampled). Visitors see the zoom button and then ' +
|
||||
'"not available". In Immich, turn on Administration › Settings › Image Settings › Full-size ' +
|
||||
'image (JPEG), then run the Generate Thumbnails job for missing assets. Albums: ' +
|
||||
albumList,
|
||||
albumIds: affected.map((a) => a.id),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'zoom-renditions',
|
||||
level: 'warn',
|
||||
title: `${photos} ${noun} cannot be zoomed: Immich's full-size format is not JPEG`,
|
||||
detail:
|
||||
`Immich renders its full-size previews as ${sample.contentType || 'an unknown type'}, and ` +
|
||||
'zoom only serves JPEG renditions — their location metadata is removed on the way out, which ' +
|
||||
'Folio cannot do for WebP. Set Administration › Settings › Image Settings › Full-size image ' +
|
||||
'› Format to JPEG, then run the Generate Thumbnails job. Albums: ' +
|
||||
albumList,
|
||||
albumIds: affected.map((a) => a.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** One set of albums that will all be reachable under the same URL prefix. */
|
||||
export interface AlbumSlugGroup {
|
||||
/** e.g. "gallery.yaml albums" or `subpage "Trips"` */
|
||||
@@ -567,18 +685,29 @@ export function checkContact(
|
||||
const SETTING_SECTIONS: Record<string, string> = {
|
||||
url: 'seo',
|
||||
'theme.accent': 'theme',
|
||||
'theme.radius': 'theme',
|
||||
'grid.columns': 'grid',
|
||||
'grid.gap': 'grid',
|
||||
'contact.retentionDays': 'legal',
|
||||
navLinks: 'footer',
|
||||
};
|
||||
|
||||
/** Human names for the same fields, for the finding's text. */
|
||||
const SETTING_LABELS: Record<string, string> = {
|
||||
url: 'Site URL',
|
||||
'theme.accent': 'accent colour',
|
||||
'theme.radius': 'corner radius',
|
||||
'grid.columns': 'grid columns',
|
||||
'grid.gap': 'grid gap',
|
||||
'contact.retentionDays': 'message retention',
|
||||
navLinks: 'header links',
|
||||
};
|
||||
|
||||
/** `navLinks.2.url` → `navLinks`: every header-link error is one setting. */
|
||||
function settingKey(field: string): string {
|
||||
return field.startsWith('navLinks.') ? 'navLinks' : field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values in settings.yaml the site does not use as written — an accent that is
|
||||
* not hex, a column count outside 1–6, a site URL that is not a full address.
|
||||
@@ -592,21 +721,23 @@ export function checkSettingValues(errors: Array<{ field: string }>): DoctorFind
|
||||
id: 'settings-values',
|
||||
level: 'ok',
|
||||
title: 'Settings are used as written',
|
||||
detail: 'Site URL, accent colour and grid values in settings.yaml are all valid.',
|
||||
detail:
|
||||
'Site URL, theme, grid, message retention and header links in settings.yaml are all valid.',
|
||||
};
|
||||
}
|
||||
const names = errors.map((e) => SETTING_LABELS[e.field] ?? e.field);
|
||||
const keys = [...new Set(errors.map((e) => settingKey(e.field)))];
|
||||
const names = keys.map((key) => SETTING_LABELS[key] ?? key);
|
||||
return {
|
||||
id: 'settings-values',
|
||||
level: 'warn',
|
||||
title:
|
||||
errors.length === 1
|
||||
names.length === 1
|
||||
? `The ${names[0]} setting is ignored`
|
||||
: `${errors.length} settings are ignored`,
|
||||
: `${names.length} settings are ignored`,
|
||||
detail:
|
||||
`settings.yaml holds a value the site cannot use for: ${names.join(', ')}. ` +
|
||||
'It falls back to the default instead. Open the setting to see what it expects.',
|
||||
settingsSection: SETTING_SECTIONS[errors[0].field],
|
||||
'The site uses a default or the nearest allowed value instead. Open the setting to see what it expects.',
|
||||
settingsSection: SETTING_SECTIONS[keys[0]],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
import {
|
||||
assertVersion,
|
||||
needsBackup,
|
||||
readVersioned,
|
||||
serializeContentWrite,
|
||||
versionOf,
|
||||
} from './contentVersion';
|
||||
import { ParsedFileCache } from './parsedFileCache';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
@@ -296,16 +302,9 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
|
||||
// catch, so a `.backups/` a save couldn't write to looked exactly like a
|
||||
// brand-new entry: the save went ahead with no snapshot taken (#630). Only
|
||||
// ENOENT means there is nothing to back up; anything else aborts the save
|
||||
// before it overwrites the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
// before it overwrites the live file. A save that changes nothing takes no
|
||||
// backup either (needsBackup).
|
||||
if (await needsBackup(filePath, rawMarkdown)) {
|
||||
await snapshotEntry(filePath, filename);
|
||||
await pruneEntryBackups(filename);
|
||||
}
|
||||
@@ -378,12 +377,56 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
|
||||
return deletedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an entry to a new slug with new content: snapshot the old file under
|
||||
* the new name, write the new file, then remove the old one from both
|
||||
* content/journal/ and the legacy content/essays/. The route has already
|
||||
* refused a rename onto an existing entry.
|
||||
*/
|
||||
async function renameJournalEntry(
|
||||
fromSlug: string,
|
||||
slug: string,
|
||||
rawMarkdown: string,
|
||||
): Promise<void> {
|
||||
if (!isValidSlug(fromSlug)) throw new Error(`Invalid journal slug: "${fromSlug}"`);
|
||||
const fromFilename = `${fromSlug}.md`;
|
||||
const fromPaths = [
|
||||
containedPath(JOURNAL_DIR, fromFilename),
|
||||
containedPath(LEGACY_ESSAYS_DIR, fromFilename),
|
||||
].filter((p): p is string => p !== null);
|
||||
const existing: string[] = [];
|
||||
for (const p of fromPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
existing.push(p);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
// The copy resolveJournalFilePath reads — content/journal/ over essays/.
|
||||
if (existing.length > 0) {
|
||||
await snapshotEntry(existing[0], `${slug}.md`);
|
||||
await pruneEntryBackups(`${slug}.md`);
|
||||
}
|
||||
await writeJournalEntry(slug, rawMarkdown);
|
||||
for (const p of existing) {
|
||||
await fs.unlink(p).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
if (existing.length > 0) console.log(`[Journal] ✏️ Renamed ${fromFilename} to ${slug}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's save (#601): write `slug`, refusing with a VersionConflictError
|
||||
* when the file the editor loaded — `fromSlug`'s, for a rename — is no longer
|
||||
* at `baseVersion`. A rename writes the new file and then deletes the old one.
|
||||
* Check, write and delete run in the content write queue, so another save
|
||||
* cannot land between them. Returns the version of what was written.
|
||||
*
|
||||
* A rename takes the entry's history along: the version before it is kept as
|
||||
* a save backup of the new slug, not as a `.deleted` snapshot of the old one,
|
||||
* which the Backup Manager listed as a deleted entry (QA A-19).
|
||||
*/
|
||||
export function saveJournalEntry(
|
||||
slug: string,
|
||||
@@ -397,8 +440,11 @@ export function saveJournalEntry(
|
||||
if (!loaded) throw new Error(`Invalid journal entry slug: "${fromSlug}"`);
|
||||
await assertVersion(loaded, options.baseVersion);
|
||||
}
|
||||
await writeJournalEntry(slug, rawMarkdown);
|
||||
if (fromSlug !== slug) await deleteJournalEntry(fromSlug);
|
||||
if (fromSlug === slug) {
|
||||
await writeJournalEntry(slug, rawMarkdown);
|
||||
} else {
|
||||
await renameJournalEntry(fromSlug, slug, rawMarkdown);
|
||||
}
|
||||
return versionOf(rawMarkdown);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
import {
|
||||
assertVersion,
|
||||
needsBackup,
|
||||
readVersioned,
|
||||
serializeContentWrite,
|
||||
versionOf,
|
||||
} from './contentVersion';
|
||||
import { isValidSlug, parseJournalMarkdown, type ParsedJournal } from '../journal';
|
||||
import type { PageSummary } from '../pages';
|
||||
import { ParsedFileCache } from './parsedFileCache';
|
||||
@@ -199,15 +205,9 @@ export async function writePage(slug: string, rawMarkdown: string): Promise<void
|
||||
await fs.mkdir(pagesDir(), { recursive: true });
|
||||
|
||||
// Only ENOENT means "nothing to back up"; any other failure aborts the save
|
||||
// before it overwrites the live file (the journal's #630).
|
||||
let exists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
exists = false;
|
||||
}
|
||||
if (exists) {
|
||||
// before it overwrites the live file (the journal's #630). A save that
|
||||
// changes nothing takes no backup either (needsBackup).
|
||||
if (await needsBackup(filePath, rawMarkdown)) {
|
||||
await snapshotPage(filePath, filename);
|
||||
await prunePageBackups(filename);
|
||||
}
|
||||
@@ -234,6 +234,33 @@ export async function deletePage(slug: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a page to a new slug with new content. The old file is snapshotted
|
||||
* under the new name first, then the new file is written, then the old one
|
||||
* removed — a failure part way leaves the old page in place. The route has
|
||||
* already refused a rename onto an existing page.
|
||||
*/
|
||||
async function renamePage(fromSlug: string, slug: string, rawMarkdown: string): Promise<void> {
|
||||
const fromPath = resolvePageFilePath(fromSlug);
|
||||
if (!fromPath) throw new Error(`Invalid page slug: "${fromSlug}"`);
|
||||
let fromExists = true;
|
||||
try {
|
||||
await fs.access(fromPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fromExists = false;
|
||||
}
|
||||
if (fromExists) {
|
||||
await snapshotPage(fromPath, `${slug}.md`);
|
||||
await prunePageBackups(`${slug}.md`);
|
||||
}
|
||||
await writePage(slug, rawMarkdown);
|
||||
if (fromExists) {
|
||||
await fs.unlink(fromPath);
|
||||
console.log(`[Pages] ✏️ Renamed ${fromSlug}.md to ${slug}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a page from one of its backups, returning the restored slug. */
|
||||
export async function restorePageBackup(backupFilename: string): Promise<string> {
|
||||
const match = PAGE_BACKUP_NAME.exec(backupFilename);
|
||||
@@ -265,6 +292,11 @@ export async function restorePageBackup(backupFilename: string): Promise<string>
|
||||
* at `baseVersion`. A rename writes the new file and then deletes the old one.
|
||||
* Check, write and delete run in the content write queue, so another save
|
||||
* cannot land between them. Returns the version of what was written.
|
||||
*
|
||||
* A rename takes the page's history along: the version before it is kept as
|
||||
* a save backup of the new slug, not as a `.deleted` snapshot of the old one.
|
||||
* The Backup Manager used to list every rename as a deleted page, whose
|
||||
* restore brought the old slug back next to the renamed page (QA A-19).
|
||||
*/
|
||||
export function savePage(
|
||||
slug: string,
|
||||
@@ -278,8 +310,11 @@ export function savePage(
|
||||
if (!loaded) throw new Error(`Invalid page slug: "${fromSlug}"`);
|
||||
await assertVersion(loaded, options.baseVersion);
|
||||
}
|
||||
await writePage(slug, rawMarkdown);
|
||||
if (fromSlug !== slug) await deletePage(fromSlug);
|
||||
if (fromSlug === slug) {
|
||||
await writePage(slug, rawMarkdown);
|
||||
} else {
|
||||
await renamePage(fromSlug, slug, rawMarkdown);
|
||||
}
|
||||
return versionOf(rawMarkdown);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function picksFor(session: ProofSession, album: ImmichAlbum | null): Admi
|
||||
assetId: asset.id,
|
||||
position: index + 1,
|
||||
fileName: asset.originalFileName,
|
||||
thumbUrl: imageUrl(asset.id, 'thumbnail'),
|
||||
thumbUrl: imageUrl(asset, 'thumbnail'),
|
||||
...(asset.localDateTime || asset.fileCreatedAt
|
||||
? { takenAt: asset.localDateTime ?? asset.fileCreatedAt }
|
||||
: {}),
|
||||
|
||||
@@ -8,7 +8,13 @@ import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import type { GalleryYaml, SettingsYaml } from '../config/schema';
|
||||
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
|
||||
import {
|
||||
assertVersion,
|
||||
needsBackup,
|
||||
readVersioned,
|
||||
serializeContentWrite,
|
||||
versionOf,
|
||||
} from './contentVersion';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const MAX_BACKUPS = 10; // Keep last 10 backups per file
|
||||
@@ -73,32 +79,6 @@ async function writeYamlFileNow(
|
||||
// Ensure content directory exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to (owned by root after a
|
||||
// first start as root, say) looked exactly like a brand-new file: the save
|
||||
// went ahead with no snapshot taken (#630). Only ENOENT means there is
|
||||
// nothing to back up; anything else aborts the save before it overwrites
|
||||
// the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `${filename}.${timestamp}.bak`;
|
||||
await fs.copyFile(filePath, path.join(backupDir, backupName));
|
||||
|
||||
// Prune old backups
|
||||
await pruneBackups(backupDir, filename);
|
||||
}
|
||||
|
||||
// Generate YAML content with header comment
|
||||
const header =
|
||||
filename === 'gallery.yaml'
|
||||
@@ -114,6 +94,25 @@ async function writeYamlFileNow(
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to (owned by root after a
|
||||
// first start as root, say) looked exactly like a brand-new file: the save
|
||||
// went ahead with no snapshot taken (#630). Only ENOENT means there is
|
||||
// nothing to back up; anything else aborts the save before it overwrites
|
||||
// the live file.
|
||||
// A save that changes nothing takes no backup either (needsBackup).
|
||||
if (await needsBackup(filePath, content)) {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `${filename}.${timestamp}.bak`;
|
||||
await fs.copyFile(filePath, path.join(backupDir, backupName));
|
||||
|
||||
// Prune old backups
|
||||
await pruneBackups(backupDir, filename);
|
||||
}
|
||||
|
||||
await atomicWrite(filePath, content);
|
||||
|
||||
console.log(`[Admin] ✅ Saved ${filename}`);
|
||||
|
||||
Reference in New Issue
Block a user