Support folder in password view

- change core data
  - change data struct to store table view entry
  - delete unnecessary functions
This commit is contained in:
Bob Sun 2017-03-02 14:51:40 +08:00
parent 98b01d16cf
commit 050a960167
No known key found for this signature in database
GPG key ID: 1F86BA2052FED3B4
7 changed files with 177 additions and 89 deletions

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12106.1" systemVersion="16E163f" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="YoR-iB-XAd">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12106.1" systemVersion="16E175b" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="YoR-iB-XAd">
<device id="retina5_5" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
@ -43,9 +43,6 @@
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="tW4-E9-CGv" kind="show" identifier="showPasswordDetail" id="26n-ZD-G0k"/>
</connections>
</tableViewCell>
</prototypes>
<sections/>
@ -63,6 +60,7 @@
</navigationItem>
<connections>
<outlet property="tableView" destination="Tn1-q5-vaJ" id="UHc-AS-gXh"/>
<segue destination="tW4-E9-CGv" kind="show" identifier="showPasswordDetail" id="gXF-zd-527"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="6ju-JT-yds" userLabel="First Responder" sceneMemberID="firstResponder"/>

View file

@ -31,7 +31,7 @@ class AboutRepositoryTableViewController: BasicStaticTableViewController {
numberFormatter.numberStyle = NumberFormatter.Style.decimal
let fm = FileManager.default
let passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData()
let passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData(withDir: false)
let numberOfPasswords = numberFormatter.string(from: NSNumber(value: passwordEntities.count))!
var size = UInt64(0)

View file

@ -13,7 +13,6 @@ import SVProgressHUD
class PasswordDetailTableViewController: UITableViewController, UIGestureRecognizerDelegate {
var passwordEntity: PasswordEntity?
var passwordCategoryEntities: [PasswordCategoryEntity]?
var passwordCategoryText = ""
var password: Password?
var passwordImage: UIImage?
@ -62,13 +61,23 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
var tableData = Array<TableSection>()
private func generateCategoryText() -> String {
var passwordCategoryArray: [String] = []
var parent = passwordEntity?.parent
while parent != nil {
passwordCategoryArray.append(parent!.name!)
parent = parent!.parent
}
passwordCategoryArray.reverse()
return passwordCategoryArray.joined(separator: " > ")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "LabelTableViewCell", bundle: nil), forCellReuseIdentifier: "labelCell")
tableView.register(UINib(nibName: "PasswordDetailTitleTableViewCell", bundle: nil), forCellReuseIdentifier: "passwordDetailTitleTableViewCell")
let passwordCategoryArray = passwordCategoryEntities?.map { $0.category! }
passwordCategoryText = (passwordCategoryArray?.joined(separator: " > "))!
passwordCategoryText = generateCategoryText()
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(PasswordDetailTableViewController.tapMenu(recognizer:)))
tableView.addGestureRecognizer(tapGesture)
@ -300,7 +309,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
footerLabel.numberOfLines = 0
footerLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
footerLabel.textColor = UIColor.gray
let dateString = PasswordStore.shared.getLatestCommitDate(filename: (passwordEntity?.rawPath)!)
let dateString = PasswordStore.shared.getLatestCommitDate(filename: (passwordEntity?.path)!)
footerLabel.text = "Last Updated: \(dateString ?? "Unknown")"
view.addSubview(footerLabel)
return view

View file

@ -11,9 +11,29 @@ import SVProgressHUD
import SwiftyUserDefaults
import PasscodeLock
enum PasswordsTableEntryType {
case password, dir
}
struct PasswordsTableEntry {
var title: String
var isDir: Bool
var passwordEntity: PasswordEntity?
}
class PasswordsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
private var passwordEntities: [PasswordEntity]?
var filteredPasswordEntities = [PasswordEntity]()
private var passwordsTableEntries: [PasswordsTableEntry] = []
private var filteredPasswordsTableEntries: [PasswordsTableEntry] = []
private var parentPasswordEntity: PasswordEntity? = nil
private func initPasswordsTableEntries() {
passwordsTableEntries.removeAll()
filteredPasswordsTableEntries.removeAll()
passwordsTableEntries = PasswordStore.shared.fetchPasswordEntityCoreData(parent: parentPasswordEntity).map {
PasswordsTableEntry(title: $0.name!, isDir: $0.isDir, passwordEntity: $0)
}
}
var sections : [(index: Int, length :Int, title: String)] = Array()
var searchActive : Bool = false
let searchController = UISearchController(searchResultsController: nil)
@ -23,6 +43,10 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
return refreshControl
}()
let searchBarView = UIView(frame: CGRect(x: 0, y: 64, width: UIScreen.main.bounds.width, height: 44))
lazy var backUIBarButtonItem: UIBarButtonItem = {
let backUIBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(self.backAction(_:)))
return backUIBarButtonItem
}()
@IBOutlet weak var tableView: UITableView!
@ -48,6 +72,7 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
}
}
}
func syncPasswords() {
SVProgressHUD.setDefaultMaskType(.black)
SVProgressHUD.setDefaultStyle(.light)
@ -69,8 +94,9 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
}
DispatchQueue.main.async {
PasswordStore.shared.updatePasswordEntityCoreData()
self.passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData()
self.reloadTableView(data: self.passwordEntities!)
self.parentPasswordEntity = nil
self.initPasswordsTableEntries()
self.reloadTableView(data: self.passwordsTableEntries)
PasswordStore.shared.setAllSynced()
self.setNavigationItemTitle()
Defaults[.lastUpdatedTime] = Date()
@ -90,12 +116,12 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
override func viewDidLoad() {
super.viewDidLoad()
setNavigationItemTitle()
passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData()
initPasswordsTableEntries()
NotificationCenter.default.addObserver(self, selector: #selector(PasswordsViewController.actOnPasswordUpdatedNotification), name: NSNotification.Name(rawValue: "passwordUpdated"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(PasswordsViewController.actOnPasswordStoreErasedNotification), name: NSNotification.Name(rawValue: "passwordStoreErased"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(PasswordsViewController.actOnSearchNotification), name: NSNotification.Name(rawValue: "search"), object: nil)
generateSections(item: passwordEntities!)
generateSections(item: passwordsTableEntries)
tableView.delegate = self
tableView.dataSource = self
searchController.searchResultsUpdater = self
@ -134,17 +160,15 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "passwordTableViewCell", for: indexPath)
var password: PasswordEntity
let index = sections[indexPath.section].index + indexPath.row
if searchController.isActive && searchController.searchBar.text != "" {
password = filteredPasswordEntities[index]
let entry = getPasswordEntry(by: indexPath)
if !entry.isDir {
if entry.passwordEntity!.synced {
cell.textLabel?.text = entry.title
} else {
password = passwordEntities![index]
cell.textLabel?.text = "\(entry.title)"
}
if password.synced {
cell.textLabel?.text = password.name!
} else {
cell.textLabel?.text = "\(password.name!)"
cell.textLabel?.text = "\(entry.title)/"
}
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressAction(_:)))
longPressGestureRecognizer.minimumPressDuration = 0.6
@ -152,6 +176,35 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
return cell
}
private func getPasswordEntry(by indexPath: IndexPath) -> PasswordsTableEntry{
var entry: PasswordsTableEntry
let index = sections[indexPath.section].index + indexPath.row
if searchController.isActive && searchController.searchBar.text != "" {
entry = filteredPasswordsTableEntries[index]
} else {
entry = passwordsTableEntries[index]
}
return entry
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let entry = getPasswordEntry(by: indexPath)
if !entry.isDir {
performSegue(withIdentifier: "showPasswordDetail", sender: tableView.cellForRow(at: indexPath))
} else {
tableView.deselectRow(at: indexPath, animated: true)
parentPasswordEntity = entry.passwordEntity
initPasswordsTableEntries()
reloadTableView(data: passwordsTableEntries)
}
}
func backAction(_ sender: Any?) {
parentPasswordEntity = parentPasswordEntity?.parent
initPasswordsTableEntries()
reloadTableView(data: passwordsTableEntries)
}
func longPressAction(_ gesture: UILongPressGestureRecognizer) {
if gesture.state == UIGestureRecognizerState.began {
let touchPoint = gesture.location(in: tableView)
@ -185,9 +238,9 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
let index = sections[indexPath.section].index + indexPath.row
let password: PasswordEntity
if searchController.isActive && searchController.searchBar.text != "" {
password = filteredPasswordEntities[index]
password = passwordsTableEntries[index].passwordEntity!
} else {
password = passwordEntities![index]
password = filteredPasswordsTableEntries[index].passwordEntity!
}
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
var passphrase = ""
@ -232,33 +285,34 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
}
}
func generateSections(item: [PasswordEntity]) {
func generateSections(item: [PasswordsTableEntry]) {
sections.removeAll()
if item.count == 0 {
guard item.count != 0 else {
return
}
var index = 0
for i in 0 ..< item.count {
let name = item[index].name!.uppercased()
let commonPrefix = item[i].name!.commonPrefix(with: name, options: .caseInsensitive)
let title = item[index].title.uppercased()
let commonPrefix = item[i].title.commonPrefix(with: title, options: .caseInsensitive)
if commonPrefix.characters.count == 0 {
let firstCharacter = name[name.startIndex]
let firstCharacter = title[title.startIndex]
let newSection = (index: index, length: i - index, title: "\(firstCharacter)")
sections.append(newSection)
index = i
}
}
let name = item[index].name!.uppercased()
let firstCharacter = name[name.startIndex]
let title = item[index].title.uppercased()
let firstCharacter = title[title.startIndex]
let newSection = (index: index, length: item.count - index, title: "\(firstCharacter)")
sections.append(newSection)
}
func actOnPasswordUpdatedNotification() {
passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData()
reloadTableView(data: passwordEntities!)
initPasswordsTableEntries()
reloadTableView(data: passwordsTableEntries)
setNavigationItemTitle()
}
private func setNavigationItemTitle() {
let numberOfUnsynced = PasswordStore.shared.getNumberOfUnsyncedPasswords()
if numberOfUnsynced == 0 {
@ -269,8 +323,8 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
}
func actOnPasswordStoreErasedNotification() {
passwordEntities = PasswordStore.shared.fetchPasswordEntityCoreData()
reloadTableView(data: passwordEntities!)
initPasswordsTableEntries()
reloadTableView(data: passwordsTableEntries)
setNavigationItemTitle()
}
@ -297,28 +351,20 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
if segue.identifier == "showPasswordDetail" {
if let viewController = segue.destination as? PasswordDetailTableViewController {
let selectedIndexPath = self.tableView.indexPath(for: sender as! UITableViewCell)!
let index = sections[selectedIndexPath.section].index + selectedIndexPath.row
let passwordEntity: PasswordEntity
if searchController.isActive && searchController.searchBar.text != "" {
passwordEntity = filteredPasswordEntities[index]
} else {
passwordEntity = passwordEntities![index]
}
let passwordEntity = getPasswordEntry(by: selectedIndexPath).passwordEntity!
viewController.passwordEntity = passwordEntity
let passwordCategoryEntities = PasswordStore.shared.fetchPasswordCategoryEntityCoreData(password: passwordEntity)
viewController.passwordCategoryEntities = passwordCategoryEntities
}
}
}
func filterContentForSearchText(searchText: String, scope: String = "All") {
filteredPasswordEntities = passwordEntities!.filter { password in
return password.name!.lowercased().contains(searchText.lowercased())
filteredPasswordsTableEntries = passwordsTableEntries.filter { entry in
return entry.title.lowercased().contains(searchText.lowercased())
}
if searchController.isActive && searchController.searchBar.text != "" {
reloadTableView(data: filteredPasswordEntities)
reloadTableView(data: filteredPasswordsTableEntries)
} else {
reloadTableView(data: passwordEntities!)
reloadTableView(data: passwordsTableEntries)
}
}
@ -328,7 +374,12 @@ class PasswordsViewController: UIViewController, UITableViewDataSource, UITableV
refreshControl.attributedTitle = NSAttributedString(string: atribbutedTitle)
}
func reloadTableView (data: [PasswordEntity]) {
func reloadTableView(data: [PasswordsTableEntry]) {
if parentPasswordEntity != nil {
navigationItem.leftBarButtonItem = backUIBarButtonItem
} else {
navigationItem.leftBarButtonItem = nil
}
generateSections(item: data)
tableView.reloadData()
updateRefreshControlTitle()

View file

@ -12,7 +12,7 @@ import SwiftyUserDefaults
extension PasswordEntity {
func decrypt(passphrase: String) throws -> Password? {
var password: Password?
let encryptedDataPath = URL(fileURLWithPath: "\(Globals.repositoryPath)/\(rawPath!)")
let encryptedDataPath = URL(fileURLWithPath: "\(Globals.repositoryPath)/\(path!)")
let encryptedData = try Data(contentsOf: encryptedDataPath)
let decryptedData = try PasswordStore.shared.pgp.decryptData(encryptedData, passphrase: passphrase)
let plainText = String(data: decryptedData, encoding: .utf8) ?? ""

View file

@ -237,27 +237,56 @@ class PasswordStore {
try storeRepository?.pull((storeRepository?.currentBranch())!, from: remote, withOptions: options, progress: transferProgressBlock)
}
func updatePasswordEntityCoreData() {
deleteCoreData(entityName: "PasswordEntity")
deleteCoreData(entityName: "PasswordCategoryEntity")
let fm = FileManager.default
fm.enumerator(atPath: self.storeURL.path)?.forEach({ (e) in
if let e = e as? String, let url = URL(string: e) {
if url.pathExtension == "gpg" {
do {
var q = try fm.contentsOfDirectory(atPath: self.storeURL.path).filter{
!$0.hasPrefix(".")
}.map { (filename) -> PasswordEntity in
let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity
let endIndex = url.lastPathComponent.index(url.lastPathComponent.endIndex, offsetBy: -4)
passwordEntity.name = url.lastPathComponent.substring(to: endIndex)
passwordEntity.rawPath = "\(url.path)"
let items = url.path.characters.split(separator: "/").map(String.init)
for i in 0 ..< items.count - 1 {
let passwordCategoryEntity = PasswordCategoryEntity(context: context)
passwordCategoryEntity.category = items[i]
passwordCategoryEntity.level = Int16(i)
passwordCategoryEntity.password = passwordEntity
if filename.hasSuffix(".gpg") {
passwordEntity.name = filename.substring(to: filename.index(filename.endIndex, offsetBy: -4))
} else {
passwordEntity.name = filename
}
passwordEntity.path = filename
passwordEntity.parent = nil
return passwordEntity
}
while q.count > 0 {
let e = q.first!
q.remove(at: 0)
guard !e.name!.hasPrefix(".") else {
continue
}
var isDirectory: ObjCBool = false
let filePath = storeURL.appendingPathComponent(e.path!).path
if fm.fileExists(atPath: filePath, isDirectory: &isDirectory) {
if isDirectory.boolValue {
e.isDir = true
let files = try fm.contentsOfDirectory(atPath: filePath).map { (filename) -> PasswordEntity in
let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity
if filename.hasSuffix(".gpg") {
passwordEntity.name = filename.substring(to: filename.index(filename.endIndex, offsetBy: -4))
} else {
passwordEntity.name = filename
}
passwordEntity.path = "\(e.path!)/\(filename)"
passwordEntity.parent = e
return passwordEntity
}
q += files
} else {
e.isDir = false
}
}
}
})
} catch {
print(error)
}
do {
try context.save()
} catch {
@ -281,9 +310,10 @@ class PasswordStore {
return commits
}
func fetchPasswordEntityCoreData() -> [PasswordEntity] {
func fetchPasswordEntityCoreData(parent: PasswordEntity?) -> [PasswordEntity] {
let passwordEntityFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
do {
passwordEntityFetch.predicate = NSPredicate(format: "parent = %@", parent ?? 0)
let fetchedPasswordEntities = try context.fetch(passwordEntityFetch) as! [PasswordEntity]
return fetchedPasswordEntities.sorted { $0.name!.caseInsensitiveCompare($1.name!) == .orderedAscending }
} catch {
@ -291,18 +321,21 @@ class PasswordStore {
}
}
func fetchPasswordCategoryEntityCoreData(password: PasswordEntity) -> [PasswordCategoryEntity] {
let passwordCategoryEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordCategoryEntity")
passwordCategoryEntityFetchRequest.predicate = NSPredicate(format: "password = %@", password)
passwordCategoryEntityFetchRequest.sortDescriptors = [NSSortDescriptor(key: "level", ascending: true)]
func fetchPasswordEntityCoreData(withDir: Bool) -> [PasswordEntity] {
let passwordEntityFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
do {
let passwordCategoryEntities = try context.fetch(passwordCategoryEntityFetchRequest) as! [PasswordCategoryEntity]
return passwordCategoryEntities
if !withDir {
passwordEntityFetch.predicate = NSPredicate(format: "isDir = false")
}
let fetchedPasswordEntities = try context.fetch(passwordEntityFetch) as! [PasswordEntity]
return fetchedPasswordEntities.sorted { $0.name!.caseInsensitiveCompare($1.name!) == .orderedAscending }
} catch {
fatalError("Failed to fetch password categories: \(error)")
fatalError("Failed to fetch passwords: \(error)")
}
}
func fetchUnsyncedPasswords() -> [PasswordEntity] {
let passwordEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
passwordEntityFetchRequest.predicate = NSPredicate(format: "synced = %i", 0)
@ -452,7 +485,9 @@ class PasswordStore {
progressBlock(0.3)
let saveURL = storeURL.appendingPathComponent("\(password.name).gpg")
try encryptedData.write(to: saveURL)
passwordEntity.rawPath = "\(password.name).gpg"
passwordEntity.name = password.name
passwordEntity.path = "\(password.name).gpg"
passwordEntity.parent = nil
passwordEntity.synced = false
try context.save()
print(saveURL.path)
@ -466,7 +501,7 @@ class PasswordStore {
func update(passwordEntity: PasswordEntity, password: Password, progressBlock: (_ progress: Float) -> Void) {
do {
let encryptedData = try passwordEntity.encrypt(password: password)
let saveURL = storeURL.appendingPathComponent(passwordEntity.rawPath!)
let saveURL = storeURL.appendingPathComponent(passwordEntity.path!)
try encryptedData.write(to: saveURL)
progressBlock(0.3)
let _ = createAddCommitInRepository(message: "Update password by pass for iOS", fileData: encryptedData, filename: saveURL.lastPathComponent, progressBlock: progressBlock)

View file

@ -1,20 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="11759" systemVersion="16D32" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="PasswordCategoryEntity" representedClassName="PasswordCategoryEntity" syncable="YES" codeGenerationType="class">
<attribute name="category" attributeType="String" syncable="YES"/>
<attribute name="level" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" indexed="YES" syncable="YES"/>
<relationship name="password" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="categories" inverseEntity="PasswordEntity" syncable="YES"/>
</entity>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="12124.1" systemVersion="16E175b" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="PasswordEntity" representedClassName="PasswordEntity" syncable="YES" codeGenerationType="class">
<attribute name="image" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" syncable="YES"/>
<attribute name="isDir" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="raw" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" syncable="YES"/>
<attribute name="rawPath" attributeType="String" syncable="YES"/>
<attribute name="path" attributeType="String" syncable="YES"/>
<attribute name="synced" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<relationship name="categories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PasswordCategoryEntity" inverseName="password" inverseEntity="PasswordCategoryEntity" syncable="YES"/>
<relationship name="children" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="parent" inverseEntity="PasswordEntity" syncable="YES"/>
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="children" inverseEntity="PasswordEntity" syncable="YES"/>
</entity>
<elements>
<element name="PasswordCategoryEntity" positionX="115" positionY="-9" width="128" height="90"/>
<element name="PasswordEntity" positionX="-63" positionY="-18" width="128" height="135"/>
<element name="PasswordEntity" positionX="36" positionY="81" width="128" height="150"/>
</elements>
</model>