package be.nikiroo.fanfix.library; import java.io.File; import java.io.IOException; import java.net.URL; import java.net.UnknownHostException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import be.nikiroo.fanfix.Instance; import be.nikiroo.fanfix.data.MetaData; import be.nikiroo.fanfix.data.Story; import be.nikiroo.fanfix.output.BasicOutput; import be.nikiroo.fanfix.output.BasicOutput.OutputType; import be.nikiroo.fanfix.supported.BasicSupport; import be.nikiroo.fanfix.supported.SupportType; import be.nikiroo.utils.Image; import be.nikiroo.utils.Progress; import be.nikiroo.utils.StringUtils; /** * Manage a library of Stories: import, export, list, modify. *

* Each {@link Story} object will be associated with a (local to the library) * unique ID, the LUID, which will be used to identify the {@link Story}. *

* Most of the {@link BasicLibrary} functions work on a partial (cover * MAY not be included) {@link MetaData} object. * * @author niki */ abstract public class BasicLibrary { /** * A {@link BasicLibrary} status. * * @author niki */ public enum Status { /** The library is ready and r/w. */ READ_WRITE, /** The library is ready, but read-only. */ READ_ONLY, /** You are not allowed to access this library. */ UNAUTHORIZED, /** The library is invalid, and will never work as is. */ INVALID, /** The library is currently out of commission, but may work later. */ UNAVAILABLE; /** * The library is available (you can query it). *

* It does not specify if it is read-only or not. * * @return TRUE if it is */ public boolean isReady() { return (this == READ_WRITE || this == READ_ONLY); } /** * This library can be modified (= you are allowed to modify it). * * @return TRUE if it is */ public boolean isWritable() { return (this == READ_WRITE); } } /** * Return a name for this library (the UI may display this). *

* Must not be NULL. * * @return the name, or an empty {@link String} if none */ public String getLibraryName() { return ""; } /** * The library status. * * @return the current status */ public Status getStatus() { return Status.READ_WRITE; } /** * Retrieve the main {@link File} corresponding to the given {@link Story}, * which can be passed to an external reader or instance. *

* Do NOT alter this file. * * @param luid * the Library UID of the story, can be NULL * @param pg * the optional {@link Progress} * * @return the corresponding {@link Story} * * @throws IOException * in case of IOException */ public abstract File getFile(String luid, Progress pg) throws IOException; /** * Return the cover image associated to this story. * * @param luid * the Library UID of the story * * @return the cover image * * @throws IOException * in case of IOException */ public abstract Image getCover(String luid) throws IOException; /** * Retrieve the list of {@link MetaData} known by this {@link BasicLibrary} * in a easy-to-filter version. * * @param pg * the optional {@link Progress} * @return the list of {@link MetaData} as a {@link MetaResultList} you can * query * @throws IOException * in case of I/O eror */ public MetaResultList getList(Progress pg) throws IOException { // TODO: ensure it is the main used interface return new MetaResultList(getMetas(pg)); } // TODO: make something for (normal and custom) non-story covers /** * Return the cover image associated to this source. *

* By default, return the custom cover if any, and if not, return the cover * of the first story with this source. * * @param source * the source * * @return the cover image or NULL * * @throws IOException * in case of IOException */ public Image getSourceCover(String source) throws IOException { Image custom = getCustomSourceCover(source); if (custom != null) { return custom; } List metas = getList().filter(source, null, null); if (metas.size() > 0) { return getCover(metas.get(0).getLuid()); } return null; } /** * Return the cover image associated to this author. *

* By default, return the custom cover if any, and if not, return the cover * of the first story with this author. * * @param author * the author * * @return the cover image or NULL * * @throws IOException * in case of IOException */ public Image getAuthorCover(String author) throws IOException { Image custom = getCustomAuthorCover(author); if (custom != null) { return custom; } List metas = getList().filter(null, author, null); if (metas.size() > 0) { return getCover(metas.get(0).getLuid()); } return null; } /** * Return the custom cover image associated to this source. *

* By default, return NULL. * * @param source * the source to look for * * @return the custom cover or NULL if none * * @throws IOException * in case of IOException */ @SuppressWarnings("unused") public Image getCustomSourceCover(String source) throws IOException { return null; } /** * Return the custom cover image associated to this author. *

* By default, return NULL. * * @param author * the author to look for * * @return the custom cover or NULL if none * * @throws IOException * in case of IOException */ @SuppressWarnings("unused") public Image getCustomAuthorCover(String author) throws IOException { return null; } /** * Set the source cover to the given story cover. * * @param source * the source to change * @param luid * the story LUID * * @throws IOException * in case of IOException */ public abstract void setSourceCover(String source, String luid) throws IOException; /** * Set the author cover to the given story cover. * * @param author * the author to change * @param luid * the story LUID * * @throws IOException * in case of IOException */ public abstract void setAuthorCover(String author, String luid) throws IOException; /** * Return the list of stories (represented by their {@link MetaData}, which * MAY not have the cover included). *

* The returned list MUST be a copy, not the original one. * * @param pg * the optional {@link Progress} * * @return the list (can be empty but not NULL) * * @throws IOException * in case of IOException */ protected abstract List getMetas(Progress pg) throws IOException; /** * Invalidate the {@link Story} cache (when the content should be re-read * because it was changed). */ protected void invalidateInfo() { invalidateInfo(null); } /** * Invalidate the {@link Story} cache (when the content is removed). *

* All the cache can be deleted if NULL is passed as meta. * * @param luid * the LUID of the {@link Story} to clear from the cache, or NULL * for all stories */ protected abstract void invalidateInfo(String luid); /** * Invalidate the {@link Story} cache (when the content has changed, but we * already have it) with the new given meta. * * @param meta * the {@link Story} to clear from the cache * * @throws IOException * in case of IOException */ protected abstract void updateInfo(MetaData meta) throws IOException; /** * Return the next LUID that can be used. * * @return the next luid */ protected abstract String getNextId(); /** * Delete the target {@link Story}. * * @param luid * the LUID of the {@link Story} * * @throws IOException * in case of I/O error or if the {@link Story} wa not found */ protected abstract void doDelete(String luid) throws IOException; /** * Actually save the story to the back-end. * * @param story * the {@link Story} to save * @param pg * the optional {@link Progress} * * @return the saved {@link Story} (which may have changed, especially * regarding the {@link MetaData}) * * @throws IOException * in case of I/O error */ protected abstract Story doSave(Story story, Progress pg) throws IOException; /** * Refresh the {@link BasicLibrary}, that is, make sure all metas are * loaded. * * @param pg * the optional progress reporter */ public void refresh(Progress pg) { try { getMetas(pg); } catch (IOException e) { // We will let it fail later } } /** * Check if the {@link Story} denoted by this Library UID is present in the * cache (if we have no cache, we default to true). * * @param luid * the Library UID * * @return TRUE if it is */ public boolean isCached(@SuppressWarnings("unused") String luid) { // By default, everything is cached return true; } /** * Clear the {@link Story} from the cache, if needed. *

* The next time we try to retrieve the {@link Story}, it may be required to * cache it again. * * @param luid * the story to clear * * @throws IOException * in case of I/O error */ @SuppressWarnings("unused") public void clearFromCache(String luid) throws IOException { // By default, this is a noop. } /** * @return the same as getList() * @throws IOException * in case of I/O error * @deprecated please use {@link BasicLibrary#getList()} and * {@link MetaResultList#getSources()} instead. */ @Deprecated public List getSources() throws IOException { return getList().getSources(); } /** * @return the same as getList() * @throws IOException * in case of I/O error * @deprecated please use {@link BasicLibrary#getList()} and * {@link MetaResultList#getSourcesGrouped()} instead. */ @Deprecated public Map> getSourcesGrouped() throws IOException { return getList().getSourcesGrouped(); } /** * @return the same as getList() * @throws IOException * in case of I/O error * @deprecated please use {@link BasicLibrary#getList()} and * {@link MetaResultList#getAuthors()} instead. */ @Deprecated public List getAuthors() throws IOException { return getList().getAuthors(); } /** * @return the same as getList() * @throws IOException * in case of I/O error * @deprecated please use {@link BasicLibrary#getList()} and * {@link MetaResultList#getAuthorsGrouped()} instead. */ @Deprecated public Map> getAuthorsGrouped() throws IOException { return getList().getAuthorsGrouped(); } /** * List all the stories in the {@link BasicLibrary}. *

* Cover images MAYBE not included. * * @return the stories * * @throws IOException * in case of IOException */ public MetaResultList getList() throws IOException { return getList(null); } /** * Retrieve a {@link MetaData} corresponding to the given {@link Story}, * cover image MAY not be included. * * @param luid * the Library UID of the story, can be NULL * * @return the corresponding {@link Story} or NULL if not found * * @throws IOException * in case of IOException */ public MetaData getInfo(String luid) throws IOException { if (luid != null) { for (MetaData meta : getMetas(null)) { if (luid.equals(meta.getLuid())) { return meta; } } } return null; } /** * Retrieve a specific {@link Story}. * * @param luid * the Library UID of the story * @param pg * the optional progress reporter * * @return the corresponding {@link Story} or NULL if not found * * @throws IOException * in case of IOException */ public Story getStory(String luid, Progress pg) throws IOException { Progress pgMetas = new Progress(); Progress pgStory = new Progress(); if (pg != null) { pg.setMinMax(0, 100); pg.addProgress(pgMetas, 10); pg.addProgress(pgStory, 90); } MetaData meta = null; for (MetaData oneMeta : getMetas(pgMetas)) { if (oneMeta.getLuid().equals(luid)) { meta = oneMeta; break; } } pgMetas.done(); Story story = getStory(luid, meta, pgStory); pgStory.done(); return story; } /** * Retrieve a specific {@link Story}. * * @param luid * the LUID of the story * @param meta * the meta of the story * @param pg * the optional progress reporter * * @return the corresponding {@link Story} or NULL if not found * * @throws IOException * in case of IOException */ public synchronized Story getStory(String luid, MetaData meta, Progress pg) throws IOException { if (pg == null) { pg = new Progress(); } Progress pgGet = new Progress(); Progress pgProcess = new Progress(); pg.setMinMax(0, 2); pg.addProgress(pgGet, 1); pg.addProgress(pgProcess, 1); Story story = null; File file = null; if (luid != null && meta != null) { file = getFile(luid, pgGet); } pgGet.done(); try { if (file != null) { SupportType type = SupportType.valueOfAllOkUC(meta.getType()); if (type == null) { throw new IOException("Unknown type: " + meta.getType()); } URL url = file.toURI().toURL(); story = BasicSupport.getSupport(type, url) // .process(pgProcess); // Because we do not want to clear the meta cache: meta.setCover(story.getMeta().getCover()); meta.setResume(story.getMeta().getResume()); story.setMeta(meta); } } catch (IOException e) { // We should not have not-supported files in the library Instance.getInstance().getTraceHandler() .error(new IOException(String.format( "Cannot load file of type '%s' from library: %s", meta.getType(), file), e)); } finally { pgProcess.done(); pg.done(); } return story; } /** * Import the {@link Story} at the given {@link URL} into the * {@link BasicLibrary}. * * @param url * the {@link URL} to import * @param pg * the optional progress reporter * * @return the imported Story {@link MetaData} * * @throws UnknownHostException * if the host is not supported * @throws IOException * in case of I/O error */ public MetaData imprt(URL url, Progress pg) throws IOException { if (pg == null) pg = new Progress(); pg.setMinMax(0, 1000); Progress pgProcess = new Progress(); Progress pgSave = new Progress(); pg.addProgress(pgProcess, 800); pg.addProgress(pgSave, 200); BasicSupport support = BasicSupport.getSupport(url); if (support == null) { throw new UnknownHostException("" + url); } Story story = save(support.process(pgProcess), pgSave); pg.done(); return story.getMeta(); } /** * Import the story from one library to another, and keep the same LUID. * * @param other * the other library to import from * @param luid * the Library UID * @param pg * the optional progress reporter * * @throws IOException * in case of I/O error */ public void imprt(BasicLibrary other, String luid, Progress pg) throws IOException { Progress pgGetStory = new Progress(); Progress pgSave = new Progress(); if (pg == null) { pg = new Progress(); } pg.setMinMax(0, 2); pg.addProgress(pgGetStory, 1); pg.addProgress(pgSave, 1); Story story = other.getStory(luid, pgGetStory); if (story != null) { story = this.save(story, luid, pgSave); pg.done(); } else { pg.done(); throw new IOException("Cannot find story in Library: " + luid); } } /** * Export the {@link Story} to the given target in the given format. * * @param luid * the {@link Story} ID * @param type * the {@link OutputType} to transform it to * @param target * the target to save to * @param pg * the optional progress reporter * * @return the saved resource (the main saved {@link File}) * * @throws IOException * in case of I/O error */ public File export(String luid, OutputType type, String target, Progress pg) throws IOException { Progress pgGetStory = new Progress(); Progress pgOut = new Progress(); if (pg != null) { pg.setMax(2); pg.addProgress(pgGetStory, 1); pg.addProgress(pgOut, 1); } BasicOutput out = BasicOutput.getOutput(type, false, false); if (out == null) { throw new IOException("Output type not supported: " + type); } Story story = getStory(luid, pgGetStory); if (story == null) { throw new IOException("Cannot find story to export: " + luid); } return out.process(story, target, pgOut); } /** * Save a {@link Story} to the {@link BasicLibrary}. * * @param story * the {@link Story} to save * @param pg * the optional progress reporter * * @return the same {@link Story}, whose LUID may have changed * * @throws IOException * in case of I/O error */ public Story save(Story story, Progress pg) throws IOException { return save(story, null, pg); } /** * Save a {@link Story} to the {@link BasicLibrary} -- the LUID must * be correct, or NULL to get the next free one. *

* Will override any previous {@link Story} with the same LUID. * * @param story * the {@link Story} to save * @param luid * the correct LUID or NULL to get the next free one * @param pg * the optional progress reporter * * @return the same {@link Story}, whose LUID may have changed * * @throws IOException * in case of I/O error */ public synchronized Story save(Story story, String luid, Progress pg) throws IOException { if (pg == null) { pg = new Progress(); } Instance.getInstance().getTraceHandler().trace( this.getClass().getSimpleName() + ": saving story " + luid); // Do not change the original metadata, but change the original story MetaData meta = story.getMeta().clone(); story.setMeta(meta); pg.setName("Saving story"); if (luid == null || luid.isEmpty()) { meta.setLuid(getNextId()); } else { meta.setLuid(luid); } if (luid != null && getInfo(luid) != null) { delete(luid); } story = doSave(story, pg); updateInfo(story.getMeta()); Instance.getInstance().getTraceHandler() .trace(this.getClass().getSimpleName() + ": story saved (" + luid + ")"); pg.setName(meta.getTitle()); pg.done(); return story; } /** * Delete the given {@link Story} from this {@link BasicLibrary}. * * @param luid * the LUID of the target {@link Story} * * @throws IOException * in case of I/O error */ public synchronized void delete(String luid) throws IOException { Instance.getInstance().getTraceHandler().trace( this.getClass().getSimpleName() + ": deleting story " + luid); doDelete(luid); invalidateInfo(luid); Instance.getInstance().getTraceHandler() .trace(this.getClass().getSimpleName() + ": story deleted (" + luid + ")"); } /** * Change the type (source) of the given {@link Story}. * * @param luid * the {@link Story} LUID * @param newSource * the new source * @param pg * the optional progress reporter * * @throws IOException * in case of I/O error or if the {@link Story} was not found */ public synchronized void changeSource(String luid, String newSource, Progress pg) throws IOException { MetaData meta = getInfo(luid); if (meta == null) { throw new IOException("Story not found: " + luid); } changeSTA(luid, newSource, meta.getTitle(), meta.getAuthor(), pg); } /** * Change the title (name) of the given {@link Story}. * * @param luid * the {@link Story} LUID * @param newTitle * the new title * @param pg * the optional progress reporter * * @throws IOException * in case of I/O error or if the {@link Story} was not found */ public synchronized void changeTitle(String luid, String newTitle, Progress pg) throws IOException { MetaData meta = getInfo(luid); if (meta == null) { throw new IOException("Story not found: " + luid); } changeSTA(luid, meta.getSource(), newTitle, meta.getAuthor(), pg); } /** * Change the author of the given {@link Story}. * * @param luid * the {@link Story} LUID * @param newAuthor * the new author * @param pg * the optional progress reporter * * @throws IOException * in case of I/O error or if the {@link Story} was not found */ public synchronized void changeAuthor(String luid, String newAuthor, Progress pg) throws IOException { MetaData meta = getInfo(luid); if (meta == null) { throw new IOException("Story not found: " + luid); } changeSTA(luid, meta.getSource(), meta.getTitle(), newAuthor, pg); } /** * Change the Source, Title and Author of the {@link Story} in one single * go. * * @param luid * the {@link Story} LUID * @param newSource * the new source * @param newTitle * the new title * @param newAuthor * the new author * @param pg * the optional progress reporter * * @throws IOException * in case of I/O error or if the {@link Story} was not found */ protected synchronized void changeSTA(String luid, String newSource, String newTitle, String newAuthor, Progress pg) throws IOException { MetaData meta = getInfo(luid); if (meta == null) { throw new IOException("Story not found: " + luid); } meta.setSource(newSource); meta.setTitle(newTitle); meta.setAuthor(newAuthor); saveMeta(meta, pg); } /** * Save back the current state of the {@link MetaData} (LUID MUST NOT * change) for this {@link Story}. *

* By default, delete the old {@link Story} then recreate a new * {@link Story}. *

* Note that this behaviour can lead to data loss in case of problems! * * @param meta * the new {@link MetaData} (LUID MUST NOT change) * @param pg * the optional {@link Progress} * * @throws IOException * in case of I/O error or if the {@link Story} was not found */ protected synchronized void saveMeta(MetaData meta, Progress pg) throws IOException { if (pg == null) { pg = new Progress(); } Progress pgGet = new Progress(); Progress pgSet = new Progress(); pg.addProgress(pgGet, 50); pg.addProgress(pgSet, 50); Story story = getStory(meta.getLuid(), pgGet); if (story == null) { throw new IOException("Story not found: " + meta.getLuid()); } // TODO: this is not safe! delete(meta.getLuid()); story.setMeta(meta); save(story, meta.getLuid(), pgSet); pg.done(); } /** * Describe a {@link Story} from its {@link MetaData} and return a list of * title/value that represent this {@link Story}. * * @param meta * the {@link MetaData} to represent * * @return the information, translated and sorted */ static public Map getMetaDesc(MetaData meta) { Map metaDesc = new LinkedHashMap(); // TODO: i18n StringBuilder tags = new StringBuilder(); for (String tag : meta.getTags()) { if (tags.length() > 0) { tags.append(", "); } tags.append(tag); } // TODO: i18n metaDesc.put("Author", meta.getAuthor()); metaDesc.put("Published on", meta.getPublisher()); metaDesc.put("Publication date", meta.getDate()); metaDesc.put("Creation date", meta.getCreationDate()); String count = ""; if (meta.getWords() > 0) { count = StringUtils.formatNumber(meta.getWords()); } if (meta.isImageDocument()) { metaDesc.put("Number of images", count); } else { metaDesc.put("Number of words", count); } metaDesc.put("Source", meta.getSource()); metaDesc.put("Subject", meta.getSubject()); metaDesc.put("Language", meta.getLang()); metaDesc.put("Tags", tags.toString()); metaDesc.put("URL", meta.getUrl()); return metaDesc; } }