# This file is part of ranger, the console file manager.
# License: GNU GPL version 3, see the file "AUTHORS" for details.
"""
A Metadata Manager that reads information about files from a json database.
The database is contained in a local .metadata.json file.
"""
# TODO: Better error handling if a json file can't be decoded
# TODO: Update metadata keys if a file gets renamed/moved
# TODO: A global metadata file, maybe as a replacement for tags
from __future__ import (absolute_import, division, print_function)
import copy
from os.path import join, dirname, exists, basename
from ranger.ext.openstruct import DefaultOpenStruct as ostruct
METADATA_FILE_NAME = ".metadata.json"
DEEP_SEARCH_DEFAULT = False
class MetadataManager(object):
def __init__(self):
# metadata_cache maps filenames to dicts containing their metadata
self.metadata_cache = dict()
# metafile_cache maps .metadata.json filenames to their entries
self.metafile_cache = dict()
self.deep_search = DEEP_SEARCH_DEFAULT
def reset(self):
self.metadata_cache.clear()
self.metafile_cache.clear()
def get_metadata(self, filename):
try:
return ostruct(copy.deepcopy(self.metadata_cache[filename]))
except KeyError:
try:
return ostruct(copy.deepcopy(self._get_entry(filename)))
except KeyError:
return ostruct()
def set_metadata(self, filename, update_dict):
if not self.deep_search:
metafile = next(self._get_metafile_names(filename))
return self._set_metadata_raw(filename, update_dict, metafile)
metafile = self._get_metafile_name(filename)
return self._set_metadata_raw(filename, update_dict, metafile)
def _set_metadata_raw(self, filename, update_dict, metafile):
import json
entries = self._get_metafile_content(metafile)
try:
entry = entries[filename]
except KeyError:
try:
entry = entries[basename(filename)]
except KeyError:
entry = entries[basename(filename)] = {}
entry.update(update_dict)
# Delete key if the value is empty
for key, value in update_dict.items():
if value == "":
del entry[key]
# If file's metadata become empty after an update, remove it entirely
if entry == {}:
try:
del entries[filename]
except KeyError:
try:
del entries[basename(filename)]
except KeyError:
pass
# Full update of the cache, to be on the safe side:
self.metadata_cache[filename] = entry
self.metafile_cache[metafile] = entries
with open(metafile, "w") as fobj:
json.dump(entries, fobj, check_circular=True, indent=2)
def _get_entry(self, filename):
if filename in self.metadata_cache:
return self.metadata_cache[filename]
# Try to find an entry for this file in any of
# the applicable .metadata.json files
for metafile in self._get_metafile_names(filename):
entries = self._get_metafile_content(metafile)
# Check for a direct match:
if filename in entries:
entry = entries[filename]
# Check for a match of the base name:
elif basename(filename) in entries:
entry = entries[basename(filename)]
else:
# No match found, try another entry
continue
self.metadata_cache[filename] = entry
return entry
raise KeyError
def _get_metafile_content(self, metafile):
import json
if metafile in self.metafile_cache:
return self.metafile_cache[metafile]
if exists(metafile):
with open(metafile, "r") as fobj:
try:
entries = json.load(fobj)
except ValueError:
raise ValueError("Failed decoding JSON file %s" % metafile)
self.metafile_cache[metafile] = entries
return entries
return {}
def _get_metafile_names(self, path):
# Iterates through the paths of all .metadata.json files that could
# influence the metadata of the given file.
# When deep_search is deactivated, this only yields the .metadata.json
# file in the same directory as the given file.
base = dirname(path)
yield join(base, METADATA_FILE_NAME)
if self.deep_search:
dirs = base.split("/")[1:]
for i in reversed(range(len(dirs))):
yield join("/" + "/".join(dirs[0:i]), METADATA_FILE_NAME)
def _get_metafile_name(self, filename):
first = None
for metafile in self._get_metafile_names(filename):
if first is None:
first = metafile
entries = self._get_metafile_content(metafile)
if filename in entries or basename(filename) in entries:
return metafile
# _get_metafile_names should return >0 names, but just in case...:
assert first is not None, "failed finding location for .metadata.json"
return first