/**************************************************************************************** * Copyright (c) 2011 Kostas Spyropoulos <inigo.aldana@gmail.com> * * * * This program is free software; you can redistribute it and/or modify it under * * the terms of the GNU General Public License as published by the Free Software * * Foundation; either version 3 of the License, or (at your option) any later * * version. * * * * This program is distributed in the hope that it will be useful, but WITHOUT ANY * * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License along with * * this program. If not, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.anki.model; import java.io.File; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.regex.Pattern; import java.util.regex.Matcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.ichi2.anki.Utils; import com.ichi2.anki.db.AnkiDb; /** * Class with static functions related with media handling (images and sounds). */ public class Media { // TODO: Javadoc. public static Logger log = LoggerFactory.getLogger(Media.class); private static final Pattern mMediaRegexps[] = { Pattern.compile("(?i)(\\[sound:([^]]+)\\])"), Pattern.compile("(?i)(<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>)") }; private static final Pattern regPattern = Pattern.compile("\\((\\d+)\\)$"); // File Handling // ************* /** * Copy PATH to MEDIADIR, and return new filename. * If a file with the same md5sum exists in the DB, return that. * If a file with the same name exists, return a unique name. * This does not modify the media table. * * @param deck The deck whose media we are dealing with * @param path The path and filename of the media file we are adding * @return The new filename. */ public static String copyToMedia(Deck deck, String path) { // See if have duplicate contents String newpath = null; ResultSet result = null; try { result = deck.getDB().rawQuery("SELECT filename FROM media WHERE originalPath = '" + Utils.fileChecksum(path) + "'"); if (result.next()) { newpath = result.getString(1); } } catch (SQLException e) { e.printStackTrace(); } finally { if (result != null) { try { result.close(); } catch (SQLException e) { } } } if (newpath == null) { File file = new File(path); String base = file.getName(); String mdir = deck.mediaDir(true); newpath = uniquePath(mdir, base); if (!file.renameTo(new File(newpath))) { log.error("Couldn't move media file " + path + " to " + newpath); } } return newpath.substring(newpath.lastIndexOf("/") + 1); } /** * Makes sure the filename of the media is unique. * If the filename matches an existing file, then a counter of the form " (x)" is appended before the media file * extension, where x = 1, 2, 3... as needed so that the filename is unique. * * @param dir The path to the media file, excluding the filename * @param base The filename of the file without the path */ private static String uniquePath(String dir, String base) { // Remove any dangerous characters base = base.replaceAll("[][<>:/\\", ""); // Find a unique name int extensionOffset = base.lastIndexOf("."); String root = base.substring(0, extensionOffset); String ext = base.substring(extensionOffset); File file = null; while (true) { file = new File(dir, root + ext); if (!file.exists()) { break; } Matcher regMatcher = regPattern.matcher(root); if (!regMatcher.find()) { root = root + " (1)"; } else { int num = Integer.parseInt(regMatcher.group(1)); root = root.substring(regMatcher.start()) + " (" + num + ")"; } } return dir + "/" + root + ext; } // DB Routines // *********** /** * Updates the field size of a media record. * The field size is used to store the count of how many times is this media referenced in question and answer * fields of the cards in the deck. * * @param deck The deck that contains the media we are dealing with * @param file The full path of the media in question */ public static void updateMediaCount(Deck deck, String file) { updateMediaCount(deck, file, 1); } public static void updateMediaCount(Deck deck, String file, int count) { if (deck.getDB().queryScalar("SELECT 1 FROM media WHERE filename = '" + file + "'") == 1l) { deck.getDB().execSQL(String.format(Utils.ENGLISH_LOCALE, "UPDATE media SET size = size + %d, created = %f WHERE filename = '%s'", count, Utils.now(), file)); } else if (count > 0) { String sum = Utils.fileChecksum(file); deck.getDB().execSQL(String.format(Utils.ENGLISH_LOCALE, "INSERT INTO media " + "(id, filename, size, created, originalPath, description) " + "VALUES (%d, '%s', %d, %f, '%s', '')", Utils.genID(), file, count, Utils.now(), sum)); } } /** * Deletes from media table any entries that are not referenced in question or answer of any card. * * @param deck The deck that this operation will be performed on */ public static void removeUnusedMedia(Deck deck) { ArrayList<Long> ids = deck.getDB().queryColumn(Long.class, "SELECT id FROM media WHERE size = 0", 1); for (Long id : ids) { deck.getDB().execSQL(String.format(Utils.ENGLISH_LOCALE, "INSERT INTO mediaDeleted " + "VALUES (%d, %f)", id.longValue(), Utils.now())); } deck.getDB().execSQL("DELETE FROM media WHERE size = 0"); } // String manipulation // ******************* public static ArrayList<String> mediaFiles(String string) { return mediaFiles(string, false); } public static ArrayList<String> mediaFiles(String string, boolean remote) { boolean isLocal = false; ArrayList<String> l = new ArrayList<String>(); for (Pattern reg : mMediaRegexps) { Matcher m = reg.matcher(string); while (m.find()) { isLocal = !m.group(2).toLowerCase().matches("(https?|ftp)://"); if (!remote && isLocal) { l.add(m.group(2)); } else if (remote && !isLocal) { l.add(m.group(2)); } } } return l; } /** * Removes references of media from a string. * * @param txt The string to be cleared of any media references * @return The cleared string without any media references */ public static String stripMedia(String txt) { for (Pattern reg : mMediaRegexps) { txt = reg.matcher(txt).replaceAll(""); } return txt; } // Rebuilding DB // ************* /** * Rebuilds the reference counts, potentially deletes unused media files, * * @param deck The deck to perform the operation on * @param delete If true, then unused (unreferenced in question/answer fields) media files will be deleted * @param dirty If true, then the modified field of deck will be updated * @return Nothing, but the original python code returns a list of unreferenced media files and a list * of missing media files (referenced in question/answer fields, but with the actual files missing) */ public static void rebuildMediaDir(Deck deck) { rebuildMediaDir(deck, false, true); } public static void rebuildMediaDir(Deck deck, boolean delete) { rebuildMediaDir(deck, delete, true); } public static void rebuildMediaDir(Deck deck, boolean delete, boolean dirty) { String mdir = deck.mediaDir(); if (mdir == null) { return; } //Set all ref counts to 0 deck.getDB().execSQL("UPDATE media SET size = 0"); // Look through the cards for media references ResultSet result = null; String txt = null; Map<String, Integer> refs = new HashMap<String, Integer>(); Set<String> normrefs = new HashSet<String>(); try { result = deck.getDB().rawQuery("SELECT question, answer FROM cards"); while (result.next()) { for (int i = 0; i < 2; i++) { txt = result.getString(i + 1); for (String f : mediaFiles(txt)) { if (refs.containsKey(f)) { refs.put(f, refs.get(f) + 1); } else { refs.put(f, 1); // normrefs.add(Normalizer.normalize(f, Normalizer.Form.NFC)); normrefs.add(f); } } } } } catch (SQLException e) { e.printStackTrace(); return; } finally { if (result != null) { try { result.close(); } catch (SQLException e) { } } } // Update ref counts for (Entry<String, Integer> entry : refs.entrySet()) { updateMediaCount(deck, entry.getKey(), entry.getValue()); } String fname = null; //If there is no media dir, then there is nothing to find. if(mdir != null) { // Find unused media Set<String> unused = new HashSet<String>(); File mdirfile = new File(mdir); if (mdirfile.exists()) { fname = null; for (File f : mdirfile.listFiles()) { if (!f.isFile()) { // Ignore directories continue; } // fname = Normalizer.normalize(f.getName(), Normalizer.Form.NFC); fname = f.getName(); if (!normrefs.contains(fname)) { unused.add(fname); } } } // Optionally delete if (delete) { for (String fn : unused) { File file = new File(mdir + "/" + fn); try { if (!file.delete()) { log.error("Couldn't delete unused media file " + mdir + "/" + fn); } } catch (SecurityException e) { log.error("Security exception while deleting unused media file " + mdir + "/" + fn); } } } } // Remove entries in db for unused media removeUnusedMedia(deck); // Check md5s are up to date result = null; String path = null; fname = null; String md5 = null; AnkiDb db = deck.getDB(); //db.beginTransaction(); try { result = db.query("media", new String[] {"filename", "created", "originalPath"}, null); while (result.next()) { fname = result.getString(1); md5 = result.getString(3); path = mdir + "/" + fname; File file = new File(path); if (!file.exists()) { if (!md5.equals("")) { db.execSQL(String.format(Utils.ENGLISH_LOCALE, "UPDATE media SET originalPath = '', created = %f where filename = '%s'", Utils.now(), fname)); } } else { String sum = Utils.fileChecksum(path); if (!md5.equals(sum)) { db.execSQL(String.format(Utils.ENGLISH_LOCALE, "UPDATE media SET originalPath = '%s', created = %f where filename = '%s'", sum, Utils.now(), fname)); } } } } catch (SQLException e) { e.printStackTrace(); return; } finally { if (result != null) { try { result.close(); } catch (SQLException e) { } } } //db.setTransactionSuccessful(); //db.endTransaction(); // Update deck and get return info if (dirty) { deck.flushMod(); } // In contrast to the python code we don't return anything. In the original python code, the return // values are used in a function (media.onCheckMediaDB()) that we don't have in AnkiDroid. } }