/*
This file is part of leafdigital leafChat.
leafChat 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.
leafChat 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 leafChat. If not, see <http://www.gnu.org/licenses/>.
Copyright 2011 Samuel Marshall.
*/
package com.leafdigital.prefs;
import java.awt.Font;
import java.io.*;
import java.util.*;
import org.w3c.dom.*;
import util.PlatformUtils;
import util.xml.*;
import com.leafdigital.prefs.api.*;
import leafchat.core.api.*;
/** Provides preferences support (singleton) */
public class PreferencesImp implements Preferences,MsgOwner
{
/** If the document is dirty, store the time at which it was made so */
private long dirtyTime=0;
/** File where prefs are located */
private File prefsFile;
/** Owning context */
private PluginContext context;
/** True if preferences have been closed and cannot now be changed */
private boolean closed=false;
/** Message dispatcher */
private MessageDispatch mdp;
/** Root-level groups */
private Map<String, PreferencesGroupImp> rootGroups =
new HashMap<String, PreferencesGroupImp>();
// Preferences implementation
/////////////////////////////
@Override
public synchronized PreferencesGroup getGroup(String owner)
{
checkValid(owner);
PreferencesGroupImp pg = rootGroups.get(owner);
if(pg == null)
{
pg = new PreferencesGroupImp(null,owner);
rootGroups.put(owner,pg);
}
return pg;
}
@Override
public PreferencesGroup getGroup(Plugin p)
{
return getGroup(getPluginOwner(p));
}
@Override
public int toInt(String value) throws BugException
{
try
{
return Integer.parseInt(value);
}
catch(NumberFormatException nfe)
{
throw new BugException(
"Could not convert preferences value '"+value+"' to int");
}
}
@Override
public String fromInt(int value)
{
return value+"";
}
@Override
public Font toFont(String value) throws BugException
{
String[] parts=value.split(":");
if(parts.length!=3) throw new BugException(
"Could not convert preferences value '"+value+"' to Font");
return new Font(parts[0],
toBoolean(parts[1]) ? Font.BOLD : Font.PLAIN,toInt(parts[2]));
}
@Override
public String fromFont(Font value)
{
return value.getFamily()+":"+
fromBoolean(((value.getStyle() & Font.BOLD)!=0) ? true : false)+":"+
fromInt(value.getSize());
}
@Override
public boolean toBoolean(String value) throws BugException
{
if(value.equals("t")) return true;
else if(value.equals("f")) return false;
else throw new BugException(
"Could not convert preferences value '"+value+"' to boolean");
}
@Override
public String fromBoolean(boolean value)
{
return value ? "t" : "f";
}
@Override
public String getPluginOwner(Plugin p)
{
return getPluginOwner(p.getClass().getName());
}
@Override
public String getPluginOwner(String className)
{
// Make up a name based on the classname
return "Plugin_"+className.replaceAll("[^A-Za-z0-9._\\-]","_");
}
@Override
public String getSafeToken(String name)
{
StringBuffer sb=new StringBuffer("t_");
for(int i=0;i<name.length();i++)
{
char c=name.charAt(i);
if(Character.isLetterOrDigit(c) || c=='_' || c=='-')
sb.append(c);
else
{
sb.append('.');
String fourDigit=""+Integer.toHexString(c);
while(fourDigit.length()<4) fourDigit="0"+fourDigit;
sb.append(fourDigit);
}
}
return sb.toString();
}
// MessageOwner implementation
//////////////////////////////
@Override
public void init(MessageDispatch mdp)
{
this.mdp=mdp;
}
@Override
public String getFriendlyName()
{
return "Preferences change";
}
@Override
public Class<? extends Msg> getMessageClass()
{
return PreferencesChangeMsg.class;
}
@Override
public boolean registerTarget(Object target, Class<? extends Msg> message,
MessageFilter mf, int requestID, int priority)
{
// Handle everything automatically
return true;
}
@Override
public void unregisterTarget(Object target, int requestID)
{
// Don't care
}
@Override
public void manualDispatch(Msg m)
{
// Still don't care
}
@Override
public boolean allowExternalDispatch(Msg m)
{
// Nope, we're the only people who can send preferences changes
return false;
}
// Own methods
//////////////
/**
* Construct and load existing preferences
* @param context Plugin context
* @throws GeneralException Any failure
*/
PreferencesImp(PluginContext context) throws GeneralException
{
try
{
this.context=context;
prefsFile = new File(PlatformUtils.getUserFolder(), "preferences.xml");
boolean gotPrefs = prefsFile.exists();
// If leafChat crashed partway through saving prefs, there might be a
// .old or .new version
if(!gotPrefs)
{
for(String suffix : new String[] {".new", ".old"})
{
File otherCopy = new File(prefsFile.getPath() + suffix);
if(otherCopy.exists())
{
renameFileRepeated(otherCopy, prefsFile);
gotPrefs = true;
break;
}
}
}
if(gotPrefs)
{
// Load file
Document d=XML.parse(prefsFile);
Element[] groups=XML.getChildren(
d.getDocumentElement(),"group");
for(int i=0;i<groups.length;i++)
{
rootGroups.put(groups[i].getAttribute("name"),
new PreferencesGroupImp(null,groups[i]));
}
}
}
catch(IOException e)
{
throw new GeneralException(e);
}
}
/**
* Sets dirty flag and, if necessary, a new thread that'll save this at
* some later point.
*/
private synchronized void markDirty()
{
if(closed) throw new BugException("Cannot set preferences after close");
// If it's not marked dirty, need to set up a new watch thread
if(dirtyTime==0)
{
final Runnable r=new Runnable()
{
@Override
public void run()
{
// Keep going until we manage to save it
while(true)
{
try
{
Thread.sleep(5000);
}
catch (InterruptedException e)
{
}
synchronized(PreferencesImp.this)
{
// Already saved? Then forget it
if(dirtyTime==0) return;
// If 4 seconds went by without doing anything
if(System.currentTimeMillis()-dirtyTime > 4000)
{
flush();
return;
}
}
}
}
};
(new Thread(r,"PreferencesSaver")).start();
}
dirtyTime=System.currentTimeMillis();
}
/** Flush any unsaved changes */
synchronized void flush()
{
if(dirtyTime!=0)
{
try
{
// Note: There were problems with this code on Windows where in rare
// cases it doesn't work. Since it's so important (you could lose
// preferences) I have added a lot of defensive code.
// Save new preferences in .new file
File saveTemp = new File(prefsFile.getPath() + ".new");
XML.save(saveTemp, buildPreferencesDoc());
// Check if we already have a file
boolean gotOldFile = prefsFile.exists();
File oldTemp = null;
if(gotOldFile)
{
// Delete the old '.old' copy if present
oldTemp = new File(prefsFile.getPath() + ".old");
if(oldTemp.exists())
{
deleteFileRepeated(oldTemp);
}
// For safety, rename away the old file first instead of deleting it
renameFileRepeated(prefsFile, oldTemp);
}
// Rename new file into place
renameFileRepeated(saveTemp, prefsFile);
// Delete temp old file
if(gotOldFile)
{
deleteFileRepeated(oldTemp);
}
context.getSingle(SystemLog.class).log(
context.getPlugin(),"Preferences saved");
dirtyTime = 0;
}
catch(Exception e)
{
ErrorMsg.report("Error while saving preferences", e);
}
}
}
/**
* Renames a file, making repeated attempts if necessary.
* @param from Current file
* @param to New file
* @throws IOException If rename fails 20 times
*/
private static void renameFileRepeated(File from, File to) throws IOException
{
int retry = 0;
while(!from.renameTo(to))
{
retry++;
if(retry >= 20)
{
throw new IOException("Unable to rename file " + from +
" to " + from);
}
try
{
Thread.sleep(100);
}
catch(InterruptedException ie)
{
}
}
}
/**
* Deletes a file, making repeated attempts if necessary.
* @param file File to delete
* @throws IOException If delete fails 20 times
*/
private static void deleteFileRepeated(File file) throws IOException
{
int retry = 0;
while(!file.delete())
{
retry++;
if(retry >= 20)
{
throw new IOException("Unable to delete file " + file);
}
try
{
Thread.sleep(100);
}
catch(InterruptedException ie)
{
}
}
}
private synchronized Document buildPreferencesDoc() throws XMLException
{
Document d=XML.newDocument("preferences");
addLF(d.getDocumentElement());
for(PreferencesGroupImp pg : rootGroups.values())
{
pg.buildDoc(d.getDocumentElement());
}
return d;
}
/**
* Make sure that string is a valid owner or preference name.
* @param s String to check
* @throws BugException If the string is not valid
*/
private static void checkValid(String s)
{
if(s.length()<1)
throw new BugException("Property name/owner name may not be empty");
if(!Character.isLetter(s.charAt(0)))
throw new BugException("Property name/owner name must start with letter");
for(int i=1;i<s.length();i++)
{
char c=s.charAt(i);
if(c!='-' && c!='_' && c!='.' && !Character.isLetterOrDigit(c))
throw new BugException(
"Property name/owner may only contain letters, digits, . _ and -");
}
}
/**
* Flush any data, and don't allow future sets
*/
public synchronized void close()
{
flush();
closed=true;
}
/** Implementation of public API */
public class PreferencesGroupImp implements PreferencesGroup
{
private final static String NAME_ANON="*ANON*";
/** Values; map of String -> String */
private Map<String, String> values = new HashMap<String, String>();
/** Named groups; map of String -> PreferencesGroupImp */
private Map<String, PreferencesGroupImp> groups =
new HashMap<String, PreferencesGroupImp>();
/** Indexed anonymous groups */
private PreferencesGroupImp[] anon=new PreferencesGroupImp[0];
/** Parent */
private PreferencesGroupImp parent;
/** Group name */
private String groupName;
@Override
public Preferences getPreferences()
{
return PreferencesImp.this;
}
private PreferencesGroupImp(PreferencesGroupImp parent,String name)
{
this.parent=parent;
this.groupName=name;
}
private PreferencesGroupImp(PreferencesGroupImp parent,Element e)
{
this.parent=parent;
if(e.hasAttribute("name"))
groupName=e.getAttribute("name");
else
groupName=NAME_ANON;
Element[] aePrefs=XML.getChildren(e,"pref");
for(int i=0;i<aePrefs.length;i++)
{
values.put(aePrefs[i].getAttribute("name"),aePrefs[i].getAttribute("value"));
}
try
{
if(XML.hasChild(e,"children"))
{
Element[] aeGroups=XML.getChildren(XML.getChild(e,"children"),"group");
for(int i=0;i<aeGroups.length;i++)
{
String sName=aeGroups[i].getAttribute("name");
groups.put(sName,new PreferencesGroupImp(this,aeGroups[i]));
}
}
if(XML.hasChild(e,"anon"))
{
Element[] aeAnon=XML.getChildren(XML.getChild(e,"anon"),"group");
anon=new PreferencesGroupImp[aeAnon.length];
for(int i=0;i<anon.length;i++)
{
anon[i]=new PreferencesGroupImp(this,aeAnon[i]);
}
}
}
catch(XMLException xe)
{
// Can't really happen
throw new AssertionError(xe);
}
}
private void buildDoc(Element parent)
{
Element group=XML.createChild(parent,"group");
if(groupName!=NAME_ANON) // != is ok because we only ever use constant
group.setAttribute("name",groupName);
// Save prefs
for(Map.Entry<String, String> me : values.entrySet())
{
addLF(group);
Element eValue=XML.createChild(group,"pref");
eValue.setAttribute("name",me.getKey());
eValue.setAttribute("value",me.getValue());
}
// Save children
if(groups.values().size()>0)
{
addLF(group);
Element children=XML.createChild(group,"children");
addLF(children);
for(PreferencesGroupImp pgi : groups.values())
{
pgi.buildDoc(children);
}
}
if(anon.length>0)
{
addLF(group);
Element eAnon=XML.createChild(group,"anon");
addLF(eAnon);
for(int iAnon=0;iAnon<anon.length;iAnon++)
{
anon[iAnon].buildDoc(eAnon);
}
}
addLF(group);
addLF(parent);
}
/**
* @return Index (0-based position) of anonymous group within parent
*/
public int getIndex()
{
if(parent==null)
throw new BugException("Not inside parent");
if(groupName!=NAME_ANON)
throw new BugException("Not anonymous group");
for(int i=0;i<parent.anon.length;i++)
{
if(parent.anon[i]==this) return i;
}
throw new BugException("Not found in parent");
}
@Override
public String toString()
{
StringBuffer sb=new StringBuffer();
if(parent!=null)
{
sb.append(parent.toString());
if(groupName==NAME_ANON)
{
sb.append("["+getIndex()+"]");
}
}
if(groupName!=NAME_ANON)
sb.append("/"+groupName);
return sb.toString();
}
@Override
public synchronized String get(String name)
{
String sValue=get(name,null);
if(sValue==null)
throw new BugException("No such preference: "+toString()+":"+name);
else
return sValue;
}
@Override
public synchronized String get(String name,String defaultValue)
{
String value=values.get(name);
if(value==null)
return defaultValue;
else
return value;
}
@Override
public synchronized PreferencesGroup getChild(String name)
{
checkValid(name);
PreferencesGroupImp pgi=groups.get(name);
if(pgi==null)
{
pgi=new PreferencesGroupImp(this,name);
groups.put(name,pgi);
markDirty();
}
return pgi;
}
@Override
public synchronized boolean exists(String name)
{
return values.containsKey(name);
}
@Override
public synchronized String set(String name,String value)
{
checkValid(name);
String old=values.put(name,value);
if(old==null || !old.equals(value))
{
markDirty();
mdp.dispatchMessageHandleErrors(new PreferencesChangeMsg(this,name,old,value),false);
}
return old;
}
@Override
public synchronized void set(String name,String value,String defaultValue)
{
if(value.equals(defaultValue))
unset(name);
else
set(name,value);
}
@Override
public synchronized boolean unset(String name)
{
String old=values.remove(name);
if(old!=null)
{
markDirty();
mdp.dispatchMessageHandleErrors(new PreferencesChangeMsg(this,name,old,null),false);
return true;
}
return false;
}
@Override
public PreferencesGroup getAnonParent()
{
if(groupName==NAME_ANON)
return parent;
else
return null;
}
@Override
public String getAnonHierarchical(String name)
{
String value=getAnonHierarchical(name,null);
if(value==null) throw new BugException(
"No such hierarchical preference: "+toString()+":"+name);
return value;
}
@Override
public String getAnonHierarchical(String name,String defaultValue)
{
return getAnonHierarchical(name,defaultValue,true);
}
@Override
public String getAnonHierarchical(String name,String defaultValue,boolean includeThis)
{
if(includeThis)
{
String value=get(name,null);
if(value!=null) return value;
}
if(groupName==NAME_ANON && parent!=null)
return parent.getAnonHierarchical(name,defaultValue);
return defaultValue;
}
@Override
public synchronized PreferencesGroup findAnonGroup(
String pref, String value, boolean recursive, boolean ignoreCase)
{
for(int i=0; i<anon.length; i++)
{
String local = anon[i].get(pref, null);
if(ignoreCase)
{
if(local!=null && local.equalsIgnoreCase(value))
{
return anon[i];
}
}
else
{
if(local!=null && local.equals(value))
{
return anon[i];
}
}
if(recursive)
{
PreferencesGroup pg = anon[i].findAnonGroup(
pref, value, true, ignoreCase);
if(pg != null)
{
return pg;
}
}
}
return null;
}
@Override
public synchronized PreferencesGroup findAnonGroup(
String pref, String value,boolean recursive)
{
return findAnonGroup(pref, value, recursive, false);
}
private synchronized void removeAnon(PreferencesGroupImp child)
{
for(int i=0;i<anon.length;i++)
{
if(anon[i]==child)
{
PreferencesGroupImp[] changed=new PreferencesGroupImp[anon.length-1];
System.arraycopy(anon,0,changed,0,i);
System.arraycopy(anon,i+1,changed,i,anon.length-(i+1));
anon=changed;
markDirty();
return;
}
}
}
private synchronized void removeNamed(String name)
{
if(groups.remove(name)!=null) markDirty();
}
@Override
public void remove()
{
if(parent==null) return;
if(groupName==NAME_ANON)
{
parent.removeAnon(this);
}
else
{
parent.removeNamed(groupName);
}
synchronized(this)
{
parent=null;
}
}
@Override
public synchronized PreferencesGroup[] getAnon()
{
PreferencesGroupImp[] result=new PreferencesGroupImp[anon.length];
System.arraycopy(anon,0,result,0,result.length);
return result;
}
@Override
public synchronized PreferencesGroup addAnon()
{
PreferencesGroupImp[] changed=new PreferencesGroupImp[anon.length+1];
System.arraycopy(anon,0,changed,0,anon.length);
anon=changed;
anon[anon.length-1]=new PreferencesGroupImp(this,NAME_ANON);
markDirty();
return anon[anon.length-1];
}
@Override
public synchronized int addAnon(PreferencesGroup pg,int position)
{
pg.remove();
((PreferencesGroupImp)pg).parent=this;
if(position<0 || position>anon.length)
position=anon.length;
PreferencesGroupImp[] changed=new PreferencesGroupImp[anon.length+1];
System.arraycopy(anon,0,changed,0,position);
changed[position]=(PreferencesGroupImp)pg;
System.arraycopy(anon,position,changed,position+1,anon.length-position);
anon=changed;
markDirty();
return position;
}
@Override
public synchronized void clearAnon()
{
if(anon.length==0) return;
anon=new PreferencesGroupImp[0];
markDirty();
}
}
private static void addLF(Element parent)
{
parent.appendChild(parent.getOwnerDocument().createTextNode("\n"));
}
}