can now use '/' in sources
[fanfix.git] / src / be / nikiroo / fanfix / library / LocalLibrary.java
1 package be.nikiroo.fanfix.library;
2
3 import java.io.File;
4 import java.io.FileFilter;
5 import java.io.FileInputStream;
6 import java.io.FileNotFoundException;
7 import java.io.IOException;
8 import java.io.InputStream;
9 import java.util.ArrayList;
10 import java.util.HashMap;
11 import java.util.List;
12 import java.util.Map;
13
14 import be.nikiroo.fanfix.Instance;
15 import be.nikiroo.fanfix.bundles.Config;
16 import be.nikiroo.fanfix.data.MetaData;
17 import be.nikiroo.fanfix.data.Story;
18 import be.nikiroo.fanfix.output.BasicOutput;
19 import be.nikiroo.fanfix.output.BasicOutput.OutputType;
20 import be.nikiroo.fanfix.output.InfoCover;
21 import be.nikiroo.fanfix.supported.InfoReader;
22 import be.nikiroo.utils.IOUtils;
23 import be.nikiroo.utils.Image;
24 import be.nikiroo.utils.Progress;
25
26 /**
27 * This {@link BasicLibrary} will store the stories locally on disk.
28 *
29 * @author niki
30 */
31 public class LocalLibrary extends BasicLibrary {
32 private int lastId;
33 private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
34 private Map<String, Image> sourceCovers;
35
36 private File baseDir;
37 private OutputType text;
38 private OutputType image;
39
40 /**
41 * Create a new {@link LocalLibrary} with the given back-end directory.
42 *
43 * @param baseDir
44 * the directory where to find the {@link Story} objects
45 */
46 public LocalLibrary(File baseDir) {
47 this(baseDir, Instance.getConfig().getString(
48 Config.NON_IMAGES_DOCUMENT_TYPE), Instance.getConfig()
49 .getString(Config.IMAGES_DOCUMENT_TYPE), false);
50 }
51
52 /**
53 * Create a new {@link LocalLibrary} with the given back-end directory.
54 *
55 * @param baseDir
56 * the directory where to find the {@link Story} objects
57 * @param text
58 * the {@link OutputType} to use for non-image documents
59 * @param image
60 * the {@link OutputType} to use for image documents
61 * @param defaultIsHtml
62 * if the given text or image is invalid, use HTML by default (if
63 * not, it will be INFO_TEXT/CBZ by default)
64 */
65 public LocalLibrary(File baseDir, String text, String image,
66 boolean defaultIsHtml) {
67 this(baseDir, OutputType.valueOfAllOkUC(text,
68 defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
69 OutputType.valueOfAllOkUC(image,
70 defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
71 }
72
73 /**
74 * Create a new {@link LocalLibrary} with the given back-end directory.
75 *
76 * @param baseDir
77 * the directory where to find the {@link Story} objects
78 * @param text
79 * the {@link OutputType} to use for non-image documents
80 * @param image
81 * the {@link OutputType} to use for image documents
82 */
83 public LocalLibrary(File baseDir, OutputType text, OutputType image) {
84 this.baseDir = baseDir;
85 this.text = text;
86 this.image = image;
87
88 this.lastId = 0;
89 this.stories = null;
90 this.sourceCovers = null;
91
92 baseDir.mkdirs();
93 }
94
95 @Override
96 protected List<MetaData> getMetas(Progress pg) {
97 return new ArrayList<MetaData>(getStories(pg).keySet());
98 }
99
100 @Override
101 public File getFile(String luid, Progress pg) {
102 Instance.getTraceHandler().trace(
103 this.getClass().getSimpleName() + ": get file for " + luid);
104
105 File file = null;
106 String mess = "no file found for ";
107
108 MetaData meta = getInfo(luid);
109 File[] files = getStories(pg).get(meta);
110 if (files != null) {
111 mess = "file retrieved for ";
112 file = files[1];
113 }
114
115 Instance.getTraceHandler().trace(
116 this.getClass().getSimpleName() + ": " + mess + luid + " ("
117 + meta.getTitle() + ")");
118
119 return file;
120 }
121
122 @Override
123 public Image getCover(String luid) {
124 MetaData meta = getInfo(luid);
125 if (meta != null) {
126 if (meta.getCover() != null) {
127 return meta.getCover();
128 }
129
130 File[] files = getStories(null).get(meta);
131 if (files != null) {
132 File infoFile = files[0];
133
134 try {
135 meta = InfoReader.readMeta(infoFile, true);
136 return meta.getCover();
137 } catch (IOException e) {
138 Instance.getTraceHandler().error(e);
139 }
140 }
141 }
142
143 return null;
144 }
145
146 @Override
147 protected synchronized void updateInfo(MetaData meta) {
148 invalidateInfo();
149 }
150
151 @Override
152 protected void invalidateInfo(String luid) {
153 stories = null;
154 sourceCovers = null;
155 }
156
157 @Override
158 protected synchronized int getNextId() {
159 getStories(null); // make sure lastId is set
160 return ++lastId;
161 }
162
163 @Override
164 protected void doDelete(String luid) throws IOException {
165 for (File file : getRelatedFiles(luid)) {
166 // TODO: throw an IOException if we cannot delete the files?
167 IOUtils.deltree(file);
168 file.getParentFile().delete();
169 }
170 }
171
172 @Override
173 protected Story doSave(Story story, Progress pg) throws IOException {
174 MetaData meta = story.getMeta();
175
176 File expectedTarget = getExpectedFile(meta);
177 expectedTarget.getParentFile().mkdirs();
178
179 BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
180 it.process(story, expectedTarget.getPath(), pg);
181
182 return story;
183 }
184
185 @Override
186 protected synchronized void saveMeta(MetaData meta, Progress pg)
187 throws IOException {
188 File newDir = getExpectedDir(meta.getSource());
189 if (!newDir.exists()) {
190 newDir.mkdirs();
191 }
192
193 List<File> relatedFiles = getRelatedFiles(meta.getLuid());
194 for (File relatedFile : relatedFiles) {
195 // TODO: this is not safe at all.
196 // We should copy all the files THEN delete them
197 // Maybe also adding some rollback cleanup if possible
198 if (relatedFile.getName().endsWith(".info")) {
199 try {
200 String name = relatedFile.getName().replaceFirst(
201 "\\.info$", "");
202 relatedFile.delete();
203 InfoCover.writeInfo(newDir, name, meta);
204 relatedFile.getParentFile().delete();
205 } catch (IOException e) {
206 Instance.getTraceHandler().error(e);
207 }
208 } else {
209 relatedFile.renameTo(new File(newDir, relatedFile.getName()));
210 relatedFile.getParentFile().delete();
211 }
212 }
213
214 invalidateInfo();
215 }
216
217 @Override
218 public synchronized Image getCustomSourceCover(String source) {
219 if (sourceCovers == null) {
220 sourceCovers = new HashMap<String, Image>();
221 }
222
223 Image img = sourceCovers.get(source);
224 if (img != null) {
225 return img;
226 }
227
228 File coverDir = getExpectedDir(source);
229 if (coverDir.isDirectory()) {
230 File cover = new File(coverDir, ".cover.png");
231 if (cover.exists()) {
232 InputStream in;
233 try {
234 in = new FileInputStream(cover);
235 try {
236 sourceCovers.put(source, new Image(in));
237 } finally {
238 in.close();
239 }
240 } catch (FileNotFoundException e) {
241 e.printStackTrace();
242 } catch (IOException e) {
243 Instance.getTraceHandler().error(
244 new IOException(
245 "Cannot load the existing custom source cover: "
246 + cover, e));
247 }
248 }
249 }
250
251 return sourceCovers.get(source);
252 }
253
254 @Override
255 public void setSourceCover(String source, String luid) {
256 setSourceCover(source, getCover(luid));
257 }
258
259 /**
260 * Fix the source cover to the given story cover.
261 *
262 * @param source
263 * the source to change
264 * @param coverImage
265 * the cover image
266 */
267 synchronized void setSourceCover(String source, Image coverImage) {
268 File dir = getExpectedDir(source);
269 dir.mkdirs();
270 File cover = new File(dir, ".cover");
271 try {
272 Instance.getCache().saveAsImage(coverImage, cover, true);
273 if (sourceCovers != null) {
274 sourceCovers.put(source, coverImage);
275 }
276 } catch (IOException e) {
277 Instance.getTraceHandler().error(e);
278 }
279 }
280
281 @Override
282 public void imprt(BasicLibrary other, String luid, Progress pg)
283 throws IOException {
284 if (pg == null) {
285 pg = new Progress();
286 }
287
288 // Check if we can simply copy the files instead of the whole process
289 if (other instanceof LocalLibrary) {
290 LocalLibrary otherLocalLibrary = (LocalLibrary) other;
291
292 MetaData meta = otherLocalLibrary.getInfo(luid);
293 String expectedType = ""
294 + (meta != null && meta.isImageDocument() ? image : text);
295 if (meta != null && meta.getType().equals(expectedType)) {
296 File from = otherLocalLibrary.getExpectedDir(meta.getSource());
297 File to = this.getExpectedDir(meta.getSource());
298 List<File> relatedFiles = otherLocalLibrary
299 .getRelatedFiles(luid);
300 if (!relatedFiles.isEmpty()) {
301 pg.setMinMax(0, relatedFiles.size());
302 }
303
304 for (File relatedFile : relatedFiles) {
305 File target = new File(relatedFile.getAbsolutePath()
306 .replace(from.getAbsolutePath(),
307 to.getAbsolutePath()));
308 if (!relatedFile.equals(target)) {
309 target.getParentFile().mkdirs();
310 InputStream in = null;
311 try {
312 in = new FileInputStream(relatedFile);
313 IOUtils.write(in, target);
314 } catch (IOException e) {
315 if (in != null) {
316 try {
317 in.close();
318 } catch (Exception ee) {
319 }
320 }
321
322 pg.done();
323 throw e;
324 }
325 }
326
327 pg.add(1);
328 }
329
330 invalidateInfo();
331 pg.done();
332 return;
333 }
334 }
335
336 super.imprt(other, luid, pg);
337 }
338
339 /**
340 * Return the {@link OutputType} for this {@link Story}.
341 *
342 * @param meta
343 * the {@link Story} {@link MetaData}
344 *
345 * @return the type
346 */
347 private OutputType getOutputType(MetaData meta) {
348 if (meta != null && meta.isImageDocument()) {
349 return image;
350 }
351
352 return text;
353 }
354
355 /**
356 * Get the target {@link File} related to the given <tt>.info</tt>
357 * {@link File} and {@link MetaData}.
358 *
359 * @param meta
360 * the meta
361 * @param infoFile
362 * the <tt>.info</tt> {@link File}
363 *
364 * @return the target {@link File}
365 */
366 private File getTargetFile(MetaData meta, File infoFile) {
367 // Replace .info with whatever is needed:
368 String path = infoFile.getPath();
369 path = path.substring(0, path.length() - ".info".length());
370 String newExt = getOutputType(meta).getDefaultExtension(true);
371
372 return new File(path + newExt);
373 }
374
375 /**
376 * The target (full path) where the {@link Story} related to this
377 * {@link MetaData} should be located on disk for a new {@link Story}.
378 *
379 * @param key
380 * the {@link Story} {@link MetaData}
381 *
382 * @return the target
383 */
384 private File getExpectedFile(MetaData key) {
385 String title = key.getTitle();
386 if (title == null) {
387 title = "";
388 }
389 title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
390 return new File(getExpectedDir(key.getSource()), key.getLuid() + "_"
391 + title);
392 }
393
394 /**
395 * The directory (full path) where the new {@link Story} related to this
396 * {@link MetaData} should be located on disk.
397 *
398 * @param source
399 * the type (source)
400 *
401 * @return the target directory
402 */
403 private File getExpectedDir(String source) {
404 String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
405
406 while (sanitizedSource.startsWith("/")) {
407 if (sanitizedSource.length() > 1) {
408 sanitizedSource = sanitizedSource.substring(1);
409 } else {
410 sanitizedSource = "";
411 }
412 }
413
414 sanitizedSource = sanitizedSource.replace("/", File.separator);
415
416 if (sanitizedSource.isEmpty()) {
417 sanitizedSource = "EMPTY";
418 }
419
420 return new File(baseDir, sanitizedSource);
421 }
422
423 /**
424 * Return the list of files/directories on disk for this {@link Story}.
425 * <p>
426 * If the {@link Story} is not found, and empty list is returned.
427 *
428 * @param luid
429 * the {@link Story} LUID
430 *
431 * @return the list of {@link File}s
432 *
433 * @throws IOException
434 * if the {@link Story} was not found
435 */
436 private List<File> getRelatedFiles(String luid) throws IOException {
437 List<File> files = new ArrayList<File>();
438
439 MetaData meta = getInfo(luid);
440 if (meta == null) {
441 throw new IOException("Story not found: " + luid);
442 }
443
444 File infoFile = getStories(null).get(meta)[0];
445 File targetFile = getStories(null).get(meta)[1];
446
447 files.add(infoFile);
448 files.add(targetFile);
449
450 String readerExt = getOutputType(meta).getDefaultExtension(true);
451 String fileExt = getOutputType(meta).getDefaultExtension(false);
452
453 String path = targetFile.getAbsolutePath();
454 if (readerExt != null && !readerExt.equals(fileExt)) {
455 path = path.substring(0, path.length() - readerExt.length())
456 + fileExt;
457 File relatedFile = new File(path);
458
459 if (relatedFile.exists()) {
460 files.add(relatedFile);
461 }
462 }
463
464 String coverExt = "."
465 + Instance.getConfig().getString(Config.IMAGE_FORMAT_COVER)
466 .toLowerCase();
467 File coverFile = new File(path + coverExt);
468 if (!coverFile.exists()) {
469 coverFile = new File(path.substring(0,
470 path.length() - fileExt.length())
471 + coverExt);
472 }
473
474 if (coverFile.exists()) {
475 files.add(coverFile);
476 }
477
478 return files;
479 }
480
481 /**
482 * Fill the list of stories by reading the content of the local directory
483 * {@link LocalLibrary#baseDir}.
484 * <p>
485 * Will use a cached list when possible (see
486 * {@link BasicLibrary#invalidateInfo()}).
487 *
488 * @param pg
489 * the optional {@link Progress}
490 *
491 * @return the list of stories (for each item, the first {@link File} is the
492 * info file, the second file is the target {@link File})
493 */
494 private synchronized Map<MetaData, File[]> getStories(Progress pg) {
495 if (pg == null) {
496 pg = new Progress();
497 } else {
498 pg.setMinMax(0, 100);
499 }
500
501 if (stories == null) {
502 stories = new HashMap<MetaData, File[]>();
503
504 lastId = 0;
505
506 File[] dirs = baseDir.listFiles(new FileFilter() {
507 @Override
508 public boolean accept(File file) {
509 return file != null && file.isDirectory();
510 }
511 });
512
513 if (dirs != null) {
514 Progress pgDirs = new Progress(0, 100 * dirs.length);
515 pg.addProgress(pgDirs, 100);
516
517 for (File dir : dirs) {
518 Progress pgFiles = new Progress();
519 pgDirs.addProgress(pgFiles, 100);
520 pgDirs.setName("Loading from: " + dir.getName());
521
522 addToStories(pgFiles, dir);
523
524 pgFiles.setName(null);
525 }
526
527 pgDirs.setName("Loading directories");
528 }
529 }
530
531 pg.done();
532 return stories;
533 }
534
535 private void addToStories(Progress pgFiles, File dir) {
536 File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
537 @Override
538 public boolean accept(File file) {
539 boolean info = file != null && file.isFile()
540 && file.getPath().toLowerCase().endsWith(".info");
541 boolean dir = file != null && file.isDirectory();
542 return info || dir;
543 }
544 });
545
546 if (pgFiles != null) {
547 pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
548 }
549
550 for (File infoFileOrSubdir : infoFilesAndSubdirs) {
551 if (pgFiles != null) {
552 pgFiles.setName(infoFileOrSubdir.getName());
553 }
554
555 if (infoFileOrSubdir.isDirectory()) {
556 addToStories(null, infoFileOrSubdir);
557 } else {
558 try {
559 MetaData meta = InfoReader
560 .readMeta(infoFileOrSubdir, false);
561 try {
562 int id = Integer.parseInt(meta.getLuid());
563 if (id > lastId) {
564 lastId = id;
565 }
566
567 stories.put(meta, new File[] { infoFileOrSubdir,
568 getTargetFile(meta, infoFileOrSubdir) });
569 } catch (Exception e) {
570 // not normal!!
571 throw new IOException("Cannot understand the LUID of "
572 + infoFileOrSubdir + ": " + meta.getLuid(), e);
573 }
574 } catch (IOException e) {
575 // We should not have not-supported files in the
576 // library
577 Instance.getTraceHandler().error(
578 new IOException("Cannot load file from library: "
579 + infoFileOrSubdir, e));
580 }
581 }
582
583 if (pgFiles != null) {
584 pgFiles.add(1);
585 }
586 }
587 }
588 }