-
Notifications
You must be signed in to change notification settings - Fork 61
Expand file tree
/
Copy pathplaceholders.ts
More file actions
247 lines (217 loc) · 8.69 KB
/
Copy pathplaceholders.ts
File metadata and controls
247 lines (217 loc) · 8.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import type { VolumeMetadata } from '$lib/types';
import type { CloudVolumeWithProvider } from '$lib/util/sync/unified-cloud-manager';
import { browser } from '$app/environment';
import { generateDeterministicUUID } from '$lib/util/series-extraction';
import { enqueueCloudOcrUpgrade } from '$lib/util/libraries/library-ocr-upgrade-queue';
/**
* Extract series title from description field
* Format: "Series: <series name>" on the first line (case-insensitive)
* Allows user to add their own notes on subsequent lines
*/
function extractSeriesTitleFromDescription(description: string | undefined): string | null {
if (!description) return null;
const lines = description.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Match "Series:" prefix (case-insensitive)
const match = trimmed.match(/^series:\s*(.+)$/i);
if (match && match[1]) {
return match[1].trim();
}
}
return null;
}
/**
* Parse series and volume title from cloud file path and description
* Expected format: "SeriesTitle/VolumeTitle.cbz"
* Description overrides folder name if present
*/
function parseCloudPath(
path: string,
description?: string
): { seriesTitle: string; volumeTitle: string } | null {
const parts = path.split('/');
if (parts.length !== 2) return null;
const folderName = parts[0];
const volumeWithExt = parts[1];
if (!volumeWithExt.toLowerCase().endsWith('.cbz')) return null;
// Remove .cbz extension
const volumeTitle = volumeWithExt.replace(/\.cbz$/i, '');
// Prefer verified series title from description over folder name
const seriesTitle = extractSeriesTitleFromDescription(description) || folderName;
return { seriesTitle, volumeTitle };
}
/**
* Generate placeholder VolumeMetadata for a cloud-only file
*/
function createPlaceholder(
cloudFile: CloudVolumeWithProvider,
seriesUuid: string
): VolumeMetadata | null {
const parsed = parseCloudPath(cloudFile.path, cloudFile.description);
if (!parsed) return null;
const { seriesTitle, volumeTitle } = parsed;
// Generate deterministic volume UUID from series + volume name
// This ensures the same volume gets the same UUID across devices
const volumeUuid = generateDeterministicUUID(`${seriesTitle}/${volumeTitle}`);
return {
mokuro_version: 'unknown', // Will be filled in after download
series_title: seriesTitle,
series_uuid: seriesUuid,
volume_title: volumeTitle,
volume_uuid: volumeUuid,
page_count: 0, // Unknown until downloaded
character_count: 0, // Unknown until downloaded
page_char_counts: [], // Empty until downloaded and calculated
// Placeholder-specific fields
isPlaceholder: true,
cloudProvider: cloudFile.provider,
cloudFileId: cloudFile.fileId,
cloudModifiedTime: cloudFile.modifiedTime,
cloudSize: cloudFile.size,
cloudPath: cloudFile.path // Store path for series extraction during download
};
}
/**
* Identify cloud-only files by comparing cloud files with local volumes
* Returns placeholder VolumeMetadata for files that exist in cloud but not locally
*/
export function generatePlaceholders(
cloudFilesMap: Map<string, CloudVolumeWithProvider[]>,
localVolumes: VolumeMetadata[]
): VolumeMetadata[] {
// Skip during SSR/build
if (!browser) {
return [];
}
// Create a set of local volume paths for fast lookup
const localPaths = new Set(localVolumes.map((vol) => `${vol.series_title}/${vol.volume_title}.cbz`));
const localVolumeByPath = new Map<string, VolumeMetadata>();
const localImageOnlyByVolumeTitle = new Map<string, VolumeMetadata[]>();
for (const vol of localVolumes) {
const key = `${vol.series_title}/${vol.volume_title}.cbz`.toLowerCase();
if (!vol.isPlaceholder && !localVolumeByPath.has(key)) {
localVolumeByPath.set(key, vol);
}
const currentMokuroVersion =
typeof vol.mokuro_version === 'string' ? vol.mokuro_version.trim() : '';
if (!vol.isPlaceholder && currentMokuroVersion === '') {
const titleKey = vol.volume_title.toLowerCase();
const existing = localImageOnlyByVolumeTitle.get(titleKey) || [];
existing.push(vol);
localImageOnlyByVolumeTitle.set(titleKey, existing);
}
}
// Create a map of series titles to their UUIDs from local volumes
const seriesTitleToUuid = new Map<string, string>();
for (const vol of localVolumes) {
const lowerTitle = vol.series_title.toLowerCase();
if (!seriesTitleToUuid.has(lowerTitle)) {
seriesTitleToUuid.set(lowerTitle, vol.series_uuid);
}
}
// Flatten Map values into a single array and split out .webp sidecars
const cloudFiles: CloudVolumeWithProvider[] = [];
const thumbnailMap = new Map<string, string>(); // basePath -> fileId
const mokuroMap = new Map<string, CloudVolumeWithProvider>(); // basePath -> sidecar metadata
for (const files of cloudFilesMap.values()) {
for (const file of files) {
const lowerPath = file.path.toLowerCase();
if (lowerPath.endsWith('.webp')) {
const basePath = file.path.replace(/\.webp$/i, '');
thumbnailMap.set(basePath, file.fileId);
} else if (lowerPath.endsWith('.mokuro.gz')) {
const basePath = file.path.replace(/\.mokuro\.gz$/i, '');
// Prefer plain .mokuro over .mokuro.gz when both exist.
if (!mokuroMap.has(basePath)) {
mokuroMap.set(basePath, file);
}
} else if (lowerPath.endsWith('.mokuro')) {
const basePath = file.path.replace(/\.mokuro$/i, '');
mokuroMap.set(basePath, file);
} else {
cloudFiles.push(file);
}
}
}
// Find cloud-only files
const cloudOnlyFiles = cloudFiles.filter((file) => !localPaths.has(file.path));
// Generate placeholders
const placeholders: VolumeMetadata[] = [];
for (const cloudFile of cloudOnlyFiles) {
const parsed = parseCloudPath(cloudFile.path, cloudFile.description);
if (!parsed) continue;
// Use existing series UUID if we have local volumes with this series title
// Otherwise generate a deterministic UUID for a new series
const seriesUuid =
seriesTitleToUuid.get(parsed.seriesTitle.toLowerCase()) ||
generateDeterministicUUID(parsed.seriesTitle);
const placeholder = createPlaceholder(cloudFile, seriesUuid);
if (placeholder) {
const basePath = cloudFile.path.replace(/\.cbz$/i, '');
const thumbnailFileId = thumbnailMap.get(basePath);
if (thumbnailFileId) {
placeholder.cloudThumbnailFileId = thumbnailFileId;
}
placeholders.push(placeholder);
}
}
console.log(
'[Cloud OCR Upgrade] Placeholder matcher scan:',
`cbz=${cloudFiles.length}`,
`mokuro=${mokuroMap.size}`,
`locals=${localPaths.size}`
);
// Auto-upgrade local image-only volumes when matching remote .mokuro sidecar exists.
for (const cloudFile of cloudFiles) {
const parsed = parseCloudPath(cloudFile.path, cloudFile.description);
if (!parsed) continue;
const cloudPathKey = cloudFile.path.toLowerCase();
let localVolume = localVolumeByPath.get(cloudPathKey);
if (!localVolume) {
// Fallback only when series title also matches; never pair by volume title alone.
const candidates = localImageOnlyByVolumeTitle.get(parsed.volumeTitle.toLowerCase()) || [];
const seriesMatches = candidates.filter(
(candidate) => candidate.series_title.toLowerCase() === parsed.seriesTitle.toLowerCase()
);
if (seriesMatches.length === 1) {
localVolume = seriesMatches[0];
} else if (seriesMatches.length > 1 || candidates.length > 0) {
console.log(
'[Cloud OCR Upgrade] Ambiguous local image-only match; skipping fallback:',
parsed.seriesTitle,
parsed.volumeTitle,
`seriesMatches=${seriesMatches.length}`,
`candidates=${candidates.length}`
);
}
}
if (!localVolume) continue;
const currentMokuroVersion =
typeof localVolume.mokuro_version === 'string' ? localVolume.mokuro_version.trim() : '';
if (currentMokuroVersion !== '') continue;
const basePath = cloudFile.path.replace(/\.cbz$/i, '');
const remoteMokuro = mokuroMap.get(basePath);
if (remoteMokuro) {
console.log(
'[Cloud OCR Upgrade] Match found, enqueueing upgrade:',
`${localVolume.series_title}/${localVolume.volume_title}`,
'using',
remoteMokuro.path
);
enqueueCloudOcrUpgrade(localVolume, remoteMokuro);
} else {
console.log(
'[Cloud OCR Upgrade] Local image-only match has no remote mokuro sidecar:',
`${localVolume.series_title}/${localVolume.volume_title}`
);
}
}
return placeholders;
}
/**
* Check if a volume is a placeholder
*/
export function isPlaceholder(volume: VolumeMetadata): boolean {
return volume.isPlaceholder === true;
}