diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..320df10
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+*.log
+gorsync
+go-rsync
+builds/fpm_packages/packages/*
+data/assets_vfsdata.go
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..2327734
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,36 @@
+language: go
+
+go:
+# - "1.6"
+# - "1.7"
+ - "1.10"
+# - "tip"
+
+env:
+ - GOARCH=amd64
+
+sudo: false
+#dist: trusty
+dist: xenial
+
+#services:
+# - docker
+
+before_install:
+ - sudo apt-get update -qq
+ - sudo apt-get install -qq -y gtk+3.0 libgtk-3-dev libnotify-dev
+ - sudo apt-get install -qq -y xvfb
+ - "export DISPLAY=:99.0"
+ - sudo /usr/bin/Xvfb $DISPLAY 2>1 > /dev/null &
+ - "export GTK_VERSION=$(pkg-config --modversion gtk+-3.0 | tr . _| cut -d '_' -f 1-2)"
+ - "export GLib_VERSION=$(pkg-config --modversion glib-2.0 | tr . _| cut -d '_' -f 1-2)"
+ - "export Cairo_VERSION=$(pkg-config --modversion cairo)"
+ - "export Pango_VERSION=$(pkg-config --modversion pango)"
+ - echo "GTK ${GTK_VERSION}, GLib ${GLib_VERSION} (Cairo ${Cairo_VERSION}, Pango ${Pango_VERSION})"
+
+install:
+ #- go get -t -tags "gtk_${GTK_VERSION} glib_${GLib_VERSION}" github.com/d2r2/gotk3/...
+ - go get -t -tags "gtk_${GTK_VERSION} glib_${GLib_VERSION}" github.com/d2r2/go-rsync
+
+script:
+ - go test -tags "gtk_${GTK_VERSION} glib_${GLib_VERSION}" github.com/d2r2/go-rsync
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ 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 .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+ .
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cbe50bc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,159 @@
+Gorsync Backup: GTK+ RSYNC frontend
+===================================
+
+[![Build Status](https://travis-ci.org/d2r2/go-rsync.svg?branch=master)](https://travis-ci.org/d2r2/go-rsync)
+[![Go Report Card](https://goreportcard.com/badge/github.com/d2r2/go-rsync)](https://goreportcard.com/report/github.com/d2r2/go-rsync)
+[![GoDoc](https://godoc.org/github.com/d2r2/go-rsync?status.svg)](https://godoc.org/github.com/d2r2/go-rsync)
+[![GPLv3 License](http://img.shields.io/badge/License-GPLv3-yellow.svg)](./LICENSE)
+
+Gorsync Backup is a best GTK+ frontend for brilliant RSYNC console utility. Simple, but powerful.
+Written completely in [Go programming language](https://golang.org/), provides responsive GUI design and intuitive interface. Might be used as training material how to write rich multi-threaded GUI application with GTK+ in Golang.
+
+
+
+Features and benefits
+----------------------
+
+* Multiple backup profiles are supported. Moreover, each profile can be configured to get data from multiple RSYNC sources.
+* 2-pass backup session approach to estimated backup volume in 1st pass. Display predicted time of completion in 2nd pass.
+* Demonstrate "deduplication" on modern file systems, once previous backup sessions found (and significant time reduction in repeated backup processes). Works if backup destination is Ext3/Ext4/NTFS (employ file system hardlink feature).
+* [Improved GOTK3+](https://github.com/d2r2/gotk3) library (GTK+ golang bindings) used for GUI.
+
+
+
+
+Screenshots
+-----------
+Main form:
+
+![image](https://raw.github.com/d2r2/go-rsync/master/docs/gorsync_main_form.png)
+
+Preferences:
+
+![image](https://raw.github.com/d2r2/go-rsync/master/docs/gorsync_preference_dialog.png)
+
+
+
+
+Installation approaches
+-----------------------
+
+##### Build and run from sources:
+
+* Verify, that RSYNC console utility is installed.
+* Download Gorsync Backup sources (with all dependent golang libraries):
+```bash
+$ go get -u github.com/d2r2/go-rsync
+```
+* Compile and deploy application GLIB gsettings schema, with console prompt:
+```bash
+$ sudo ./ui/gtkui/gs_schema_install.sh
+```
+* Finally, run app from terminal:
+```bash
+$ ./gorsync_run.sh
+```
+
+##### Precompiled linux packages (deb, rpm and others) from releases:
+
+Alternative approach to install application is to downloads installation packages from latest release, which can be found in [release page](https://github.com/d2r2/go-rsync/releases). You may find there packages for deb (Debian, Ubuntu), rpm (Fedora, Redhat) and pkg.tar.xz (Arch linux) Linux distributives.
+
+
+##### Archlinux AUR repository:
+
+One can be found in AUR repository https://aur.archlinux.org/ by name "gorsync-git" to download, compile and install latest release. On Archlinux you can use any AUR helper to install application, for instance `yaourt -S gorsync-git`.
+
+
+
+Releases information
+--------------------
+
+##### [v0.3.1](https://github.com/d2r2/go-rsync/releases/tag/v0.3.1) (latest release):
+
+* Internationalization implemented. Localization provided for English, Russian languages.
+* Backup result's notifications: desktop notification and shell script for any kind of automation.
+* Out of disk space protection for backup destination.
+* A lot of improvements in algorithms and GUI.
+* Significant code refactoring and rewrites.
+
+
+
+
+Plans for next releases
+-----------------------
+Short list of preliminary anticipated features for next releases:
+
+* More code comments and improved documentation.
+* Installation packages for all major linux distribution and repositories.
+* Application console parameters. Perhaps some CLI modes.
+
+
+
+
+Gorsync Backup backup process explanation
+-----------------------------------------
+
+* As in regular [RSYNC](https://ss64.com/bash/rsync_options.html) session, Gorsync Backup is doing same job: copy files from one location (source) to another (backup destination). For instance, real life scenario would be: backing up your data from home NAS to flash hard drive attached to your notebook.
+
+* Gorsync Backup can copy from multiple RSYNC sources at once. It could be your pictures, movies, document's from home NAS, routers and smart Linux device configuration files (/etc/... folder) and so on... Thus Gorsync Backup profile let you specify multiple separated RSYNC URL sources to get data from in single backup session and combine them all together in one destination place.
+
+* Gorsync Backup never overwrite existing backup session destination, but use same common target root path, to put data near. For instance, your flash drive backup folder content might looks like:
+```
+$
+$ ↳ ~rsync_backup_20180801-012237~
+$ ↳ ~rsync_backup_20180802-013113~
+$ ↳ ~rsync_backup_-~
+...
+$ ↳ ~rsync_backup_20180806-014036~
+$ ↳ ~rsync_backup_(incomplete)_20180807-014024~
+```
+, where each specific backup session stored in separate folder with date and time in the name. "(incomplete)" phrase stands for backup, that occures at the moment. Once backup will be completed, "(incomplete)" will be removed from backup folder name. Another scenario is possible, when backup process has been interrupted for some reason: in this case "(incomplete)" phrase will never get out from folder name. But, in any case it's easy to understand where you have consistent backup results, and where not.
+
+* In its turn, each backup folder has next regular structure:
+```
+$
+$ ↳ ~rsync_backup_20180801-012237~
+$ ↳ ~backup_log~.log
+$ ↳ ~backup_nodes~.signatures
+$ ↳
+...
+$ ↳
+```
+, where `~backup_log~.log` file describe all the details about the steps occurred, including info/warning/error messages if any took place. `~backup_nodes~.signatures` file contains hash imprint for all source URLs, to detect in future backup sessions same data source for "deduplication" purpose.
+
+* Gorsync Backup is splitting backup process to the peaces. Application in every backup session is trying to find optimal data block size to backup at once. To reach this application download folders structure in 1st pass to analyze how to divide the whole process into parts. Ordinary single data block size selected to be not less than 300 MB and no more than 5 GB.
+
+
+
+"Deduplication" capabilities
+----------------------------
+Once you start using Gorsync Backup on regular basis (daily/weekly/monthly), you will find soon that your backup storage will be filled with sets of almost same files (of course changes over time will provides some relatively small deviation between data sets). This redundancy might quickly exhaust your free space, but there is a real magic exists in modern file systems - "hard links"! Hard links allow do not spent space for files, which have been found in previous backup sessions unchanged. Additionally it's significantly speed up backup process. The collaboration of Gorsync Backup with RSYNC know how to activate this feature. Still you have possibility to opt out this feature in application preferences, but in general scenarios you don't need to do this.
+
+Remember, that such legacy file systems as FAT, does not support "hard links", but successors, such as NTFS, Ext3, Ext4 and others have "hard links" supported. So, think in advance which file system to choose for your backup destination.
+
+>*Note*: Gorsync Backup and RSYNC has some limitations with "deduplication" - they can't track file renames and relocations inside backup directory tree to save space and time in next backup session. This is not the application problem, it's RSYNC utility limitation. There is some experimental patches exist to get rid of this limitation, but not in the public RSYNC releases: you can read [this](https://lincolnloop.com/blog/detecting-file-moves-renames-rsync/) and [this](http://javier.io/blog/en/2014/08/06/rsync-rename-move.html).
+
+
+
+
+Collaboration and contribution
+------------------------------
+
+If you want to contribute to the project, read next:
+
+* Localization. Any help is appreciated to translate application to local languages. Use file ./data/assets/translate.en.toml as a source for new language translation.
+* Ready to discuss proposals regarding application improvement and further development scenarios.
+
+
+
+Contact
+-------
+
+Please use [Github issue tracker](https://github.com/d2r2/go-rsync/issues) for filing bugs or feature requests.
+
+
+
+License
+-------
+
+Gorsync Backup is licensed under [GNU GENERAL PUBLIC LICENSE version 3](https://raw.github.com/d2r2/go-rsync/master/LICENSE) by Free Software Foundation, Inc.
diff --git a/backup/abstract.go b/backup/abstract.go
new file mode 100644
index 0000000..c17e60b
--- /dev/null
+++ b/backup/abstract.go
@@ -0,0 +1,43 @@
+package backup
+
+import (
+ "time"
+
+ "github.com/d2r2/go-rsync/core"
+)
+
+type BackupNode struct {
+ SourceRsync string `toml:"src_rsync"`
+ DestSubPath string `toml:"dst_subpath"`
+}
+
+// BackupNodePlan contain information about single rsync source backup.
+type BackupNodePlan struct {
+ BackupNode BackupNode
+ RootDir *core.Dir
+}
+
+// BackupPlan keep all necessary information obtained from
+// preferences and 1st backup pass to start backup process.
+type BackupPlan struct {
+ Config *Config
+ Nodes []BackupNodePlan
+ BackupSize core.FolderSize
+}
+
+type Notifier interface {
+ NotifyPlanStage_NodeStructureStartInquiry(sourceID int,
+ sourceRsync string) error
+ NotifyPlanStage_NodeStructureDoneInquiry(sourceID int,
+ sourceRsync string, dir *core.Dir) error
+ NotifyBackupStage_FolderStartBackup(rootDest string,
+ paths core.SrcDstPath, backupType core.FolderBackupType,
+ leftToBackup core.FolderSize,
+ timePassed time.Duration, eta *time.Duration,
+ ) error
+ NotifyBackupStage_FolderDoneBackup(rootDest string,
+ paths core.SrcDstPath, backupType core.FolderBackupType,
+ leftToBackup core.FolderSize, sizeDone core.SizeProgress,
+ timePassed time.Duration, eta *time.Duration,
+ sessionErr error) error
+}
diff --git a/backup/common.go b/backup/common.go
new file mode 100644
index 0000000..ab99678
--- /dev/null
+++ b/backup/common.go
@@ -0,0 +1,15 @@
+package backup
+
+import (
+ "fmt"
+
+ "github.com/d2r2/go-logger"
+)
+
+var LocalLog = logger.NewPackageLogger("backup",
+ // logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+var e = fmt.Errorf
+var f = fmt.Sprintf
diff --git a/backup/config.go b/backup/config.go
new file mode 100644
index 0000000..22d2b6d
--- /dev/null
+++ b/backup/config.go
@@ -0,0 +1,130 @@
+package backup
+
+import (
+ "github.com/BurntSushi/toml"
+ "github.com/d2r2/go-rsync/rsync"
+)
+
+type Config struct {
+ SigFileIgnoreBackup string `toml:"sig_file_ignore_backup"`
+ RsyncRetryCount *int `toml:"retry_count"`
+ AutoManageBackupBlockSize *bool `auto_manage_backup_block_size`
+ MaxBackupBlockSizeMb *int `toml:"max_backup_block_size_mb"`
+ UsePreviousBackup *bool `toml:"use_previous_backup"`
+ NumberOfPreviousBackupToUse *int `toml:"number_of_previous_backup_to_use"`
+ EnableLowLevelLogForRsync *bool `toml:"enable_low_level_log_rsync"`
+ EnableIntensiveLowLevelLogForRsync *bool `toml:"enable_intensive_low_level_log_rsync"`
+ // rsync --compress
+ RsyncCompressFileTransfer *bool `toml:"rsync_compress_file_transfer"`
+ // rsync --links
+ RsyncRecreateSymlinks *bool `toml:"rsync_recreate_symlinks"`
+ // rsync --perms
+ RsyncTransferSourcePermissions *bool `toml:"rsync_transfer_source_permissions"`
+ // rsync --group
+ RsyncTransferSourceGroup *bool `toml:"rsync_transfer_source_group"`
+ // rsync --owner
+ RsyncTransferSourceOwner *bool `toml:"rsync_transfer_source_owner"`
+ // rsync --devices
+ RsyncTransferDeviceFiles *bool `toml:"rsync_transfer_device_files"`
+ // rsync --specials
+ RsyncTransferSpecialFiles *bool `toml:"rsync_transfer_special_files"`
+
+ BackupNodes []BackupNode `toml:"backup_node"`
+}
+
+func NewConfig(filePath string) (*Config, error) {
+ var config Config
+ if _, err := toml.DecodeFile(filePath, &config); err != nil {
+ return nil, err
+ }
+ LocalLog.Debug(f("%+v", config))
+ return &config, nil
+}
+
+func (conf *Config) getRsyncParams(addExtraParams ...string) []string {
+ var params []string
+ if conf.RsyncCompressFileTransfer != nil && *conf.RsyncCompressFileTransfer {
+ params = append(params, "--compress")
+ }
+ if conf.RsyncTransferSourceOwner != nil && *conf.RsyncTransferSourceOwner {
+ params = append(params, "--owner")
+ }
+ if conf.RsyncTransferSourceGroup != nil && *conf.RsyncTransferSourceGroup {
+ params = append(params, "--group")
+ }
+ if conf.RsyncTransferSourcePermissions != nil && *conf.RsyncTransferSourcePermissions {
+ params = append(params, "--perms")
+ }
+ if conf.RsyncRecreateSymlinks != nil && *conf.RsyncRecreateSymlinks {
+ params = append(params, "--links")
+ }
+ if conf.RsyncTransferDeviceFiles != nil && *conf.RsyncTransferDeviceFiles {
+ params = append(params, "--devices")
+ }
+ if conf.RsyncTransferSpecialFiles != nil && *conf.RsyncTransferSpecialFiles {
+ params = append(params, "--specials")
+ }
+ params = append(params, addExtraParams...)
+ return params
+}
+
+func (conf *Config) usePreviousBackupEnabled() bool {
+ var usePreviousBackup bool = true
+ if conf.UsePreviousBackup != nil {
+ usePreviousBackup = *conf.UsePreviousBackup
+ }
+ return usePreviousBackup
+}
+
+func (conf *Config) numberOfPreviousBackupToUse() int {
+ var numberOfPreviousBackupToUse int = 1
+ if conf.NumberOfPreviousBackupToUse != nil {
+ numberOfPreviousBackupToUse = *conf.NumberOfPreviousBackupToUse
+ }
+ return numberOfPreviousBackupToUse
+}
+
+func (conf *Config) getRsyncSettings() *rsync.Logging {
+ logging := &rsync.Logging{}
+ if conf.EnableLowLevelLogForRsync != nil {
+ logging.EnableLog = *conf.EnableLowLevelLogForRsync
+ }
+ if conf.EnableIntensiveLowLevelLogForRsync != nil {
+ logging.EnableIntensiveLog = *conf.EnableIntensiveLowLevelLogForRsync
+ }
+ return logging
+}
+
+func (conf *Config) getBackupBlockSizeSettings() *backupBlockSizeSettings {
+ blockSize := &backupBlockSizeSettings{AutoManageBackupBlockSize: true, BackupBlockSize: 500}
+ if conf.AutoManageBackupBlockSize != nil {
+ blockSize.AutoManageBackupBlockSize = *conf.AutoManageBackupBlockSize
+ }
+ if conf.MaxBackupBlockSizeMb != nil {
+ blockSize.BackupBlockSize = uint64(*conf.MaxBackupBlockSizeMb * 1024 * 1024)
+ }
+ return blockSize
+}
+
+type sortConfig struct {
+ BackupNodes []BackupNode
+}
+
+func (s sortConfig) Len() int {
+ return len(s.BackupNodes)
+}
+
+func (s sortConfig) Less(i, j int) bool {
+ if s.BackupNodes[i].SourceRsync < s.BackupNodes[j].SourceRsync &&
+ s.BackupNodes[i].DestSubPath < s.BackupNodes[j].DestSubPath {
+ return true
+ } else {
+ return false
+ }
+}
+
+func (s sortConfig) Swap(i, j int) {
+ node := s.BackupNodes[i]
+ s.BackupNodes[i] = s.BackupNodes[j]
+ s.BackupNodes[j] = node
+}
diff --git a/backup/deduplication.go b/backup/deduplication.go
new file mode 100644
index 0000000..45aa7a2
--- /dev/null
+++ b/backup/deduplication.go
@@ -0,0 +1,257 @@
+package backup
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/gob"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "time"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/locale"
+)
+
+type NodeSignature struct {
+ SourceRsyncCipher string
+ DestSubPath string
+}
+
+func GetSignature(node BackupNode) NodeSignature {
+ sha := ChipherStr(normalizeRsync(node.SourceRsync))
+ signature := NodeSignature{SourceRsyncCipher: sha, DestSubPath: node.DestSubPath}
+ // lg.Debug(sha)
+ return signature
+}
+
+// ChipherStr encode str with SHA256.
+func ChipherStr(str string) string {
+ hasher := sha256.New()
+ var b bytes.Buffer
+ b.WriteString(str)
+ hasher.Write(b.Bytes())
+ sha := base64.URLEncoding.EncodeToString(hasher.Sum(nil))
+ return sha
+}
+
+// normalizeRsync remove excess divider in rsync path.
+func normalizeRsync(source string) string {
+ chars := []rune(source)
+ for i := len(chars) - 1; i >= 0; i-- {
+ if chars[i] != '/' {
+ newChars := chars[:i+1]
+ return string(newChars)
+ }
+ }
+ return ""
+}
+
+type NodeSignatures struct {
+ Signatures []NodeSignature
+}
+
+func GetNodeSignatures(config *Config) NodeSignatures {
+ signatures := make([]NodeSignature, len(config.BackupNodes))
+ for i, item := range config.BackupNodes {
+ signatures[i] = GetSignature(item)
+ }
+ s := NodeSignatures{Signatures: signatures}
+ return s
+}
+
+func (v NodeSignatures) FindFirstSignature(signature string) *NodeSignature {
+ for _, item := range v.Signatures {
+ if item.SourceRsyncCipher == signature {
+ return &item
+ }
+ }
+ return nil
+}
+
+// PrevBackup describe found previous backup, which contain same Rsync source.
+// Such previous backups used for Rsync utility deduplication, which
+// significantly decrease size and time for repeated backup sessions.
+type PrevBackup struct {
+ // Full path to signature file name
+ SignatureFileName string
+ Signature NodeSignature
+}
+
+func (v PrevBackup) GetDirPath() string {
+ backupPath := path.Join(path.Dir(v.SignatureFileName), v.Signature.DestSubPath)
+ return backupPath
+}
+
+// PrevBackups keeps list of previous backup found. See description of PrevBackup.
+type PrevBackups struct {
+ Backups []PrevBackup
+}
+
+func (v *PrevBackups) GetDirPaths() []string {
+ var paths []string
+ for _, b := range v.Backups {
+ paths = append(paths, b.GetDirPath())
+ }
+ return paths
+}
+
+func FindPrevBackupPathsByNodeSignatures(lg logger.PackageLog, destPath string,
+ signs NodeSignatures, lastN int) (*PrevBackups, error) {
+
+ items, err := ioutil.ReadDir(destPath)
+ if err != nil {
+ return nil, err
+ }
+ candidates := make(map[string][]struct {
+ time time.Time
+ backup PrevBackup
+ })
+
+ for _, item := range items {
+ if item.IsDir() {
+ fileName := filepath.Join(destPath, item.Name(), GetMetadataSignatureFileName())
+ stat, err := os.Stat(fileName)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ if os.IsPermission(err) {
+ lg.Notify(locale.T(MsgLogBackupStagePreviousBackupDiscoveryPermissionError,
+ struct{ Path string }{Path: item.Name()}))
+ } else {
+ lg.Notify(locale.T(MsgLogBackupStagePreviousBackupDiscoveryOtherError,
+ struct {
+ Path string
+ Error error
+ }{Path: item.Name(), Error: err}))
+ }
+ }
+ continue
+ }
+
+ file, err := os.Open(fileName)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ signs2, err := DecodeSignatures(scanner.Text())
+ if err != nil {
+ break
+ }
+ for _, item1 := range signs.Signatures {
+ if candidate := signs2.FindFirstSignature(item1.SourceRsyncCipher); candidate != nil {
+ backup := PrevBackup{SignatureFileName: fileName, Signature: *candidate}
+ candidates[item1.SourceRsyncCipher] = append(candidates[item1.SourceRsyncCipher], struct {
+ time time.Time
+ backup PrevBackup
+ }{time: stat.ModTime(), backup: backup})
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ candidates2 := make(map[string][]struct {
+ time time.Time
+ backup PrevBackup
+ })
+ for k, v := range candidates {
+ sorted := FilesSortedByDate{Files: v}
+ sort.Sort(sorted)
+ candidates2[k] = sorted.Files
+ }
+
+ var backups []PrevBackup
+ for i := 0; i < lastN; i++ {
+ for _, v := range candidates2 {
+ if i < len(v) {
+ backups = append(backups, v[i].backup)
+ }
+ }
+ }
+
+ backups2 := &PrevBackups{Backups: backups}
+ return backups2, nil
+}
+
+type FilesSortedByDate struct {
+ Files []struct {
+ time time.Time
+ backup PrevBackup
+ }
+}
+
+func (s FilesSortedByDate) Len() int {
+ return len(s.Files)
+}
+
+func (s FilesSortedByDate) Less(i, j int) bool {
+ return s.Files[i].time.After(s.Files[j].time)
+}
+
+func (s FilesSortedByDate) Swap(i, j int) {
+ node := s.Files[i]
+ s.Files[i] = s.Files[j]
+ s.Files[j] = node
+}
+
+func CreateMetadataSignatureFile(config *Config, destPath string) error {
+ signs := GetNodeSignatures(config)
+ err := os.MkdirAll(destPath, 0777)
+ if err != nil {
+ return err
+ }
+ destPath = filepath.Join(destPath, GetMetadataSignatureFileName())
+ file, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ v, err := EncodeSignatures(signs)
+ if err != nil {
+ return err
+ }
+ _, err = file.WriteString(v)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// EncodeSignatures encode NodeSignatures object to self-describing binary format.
+func EncodeSignatures(signs NodeSignatures) (string, error) {
+ b := bytes.Buffer{}
+ e := gob.NewEncoder(&b)
+ err := e.Encode(signs)
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(b.Bytes()), nil
+}
+
+// DecodeSignatures decode NodeSignatures object from self-describing binary format.
+func DecodeSignatures(str string) (*NodeSignatures, error) {
+ m := &NodeSignatures{}
+ by, err := base64.StdEncoding.DecodeString(str)
+ if err != nil {
+ return nil, err
+ }
+ b := bytes.Buffer{}
+ b.Write(by)
+ d := gob.NewDecoder(&b)
+ err = d.Decode(m)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
diff --git a/backup/heuristic.go b/backup/heuristic.go
new file mode 100644
index 0000000..ac05a1c
--- /dev/null
+++ b/backup/heuristic.go
@@ -0,0 +1,614 @@
+package backup
+
+import (
+ "context"
+ "math"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/rsync"
+)
+
+// =============================================================================================
+//
+// This is most scientific part of application :)
+//
+// Contains heuristic algorithm for searching of optimal backup RSYNC source
+// traverse path during backup process.
+//
+// Prerequisites:
+// - RSYNC source directory tree structure to backup. This structure downloaded
+// to temporary file space to build directory tree in memory.
+// - Backup block size. Define size of backup to do at once (at one call of RSYNC utility).
+// - RSYNC utility might backup in 2 modes:
+// * "full backup" when folder content backup recursively.
+// * "local content backup", when backup only flat content, which
+// include only files from folder, do not include any nested folders.
+//
+// Task:
+// Find optimal (or close to optimal) traverse path of directory tree to limit
+// number of calls to RSYNC. Limiting number of calls to RSYNC might significantly
+// decrease backup time in case when we have folders with a lot of child folders
+// (deep nested structure) with small content. In such cases we are trying to backup
+// such folders recursively, which might significantly speed up planing and backup stages.
+//
+// =============================================================================================
+
+const MaxUint = ^uint(0)
+const MaxInt = int(MaxUint >> 1)
+const MinInt = -MaxInt - 1
+
+func markMesuredAll(dir *core.Dir) {
+ dir.Metrics.Measured = true
+ for _, item := range dir.Childs {
+ markMesuredAll(item)
+ }
+}
+
+func findNonMeasuredIgnoreDir(dir *core.Dir) *core.Dir {
+ if !dir.Metrics.Measured && dir.Metrics.IgnoreToBackup {
+ return dir
+ }
+ for _, item := range dir.Childs {
+ if ignore := findNonMeasuredIgnoreDir(item); ignore != nil {
+ return ignore
+ }
+ }
+ return nil
+}
+
+func getNonMeasuredDir(dir *core.Dir) *core.Dir {
+ if ignore := findNonMeasuredIgnoreDir(dir); ignore != nil {
+ return ignore
+ }
+ if !dir.Metrics.Measured {
+ return dir
+ }
+ for _, item := range dir.Childs {
+ child := getNonMeasuredDir(item)
+ if child != nil {
+ return child
+ }
+ }
+ return nil
+}
+
+// measureLocalUpToRoot calculate "local size" metric for chain of parent folders
+// up to root, if not yet defined.
+func measureLocalUpToRoot(ctx context.Context, dir *core.Dir, retryCount *int, log *rsync.Logging) error {
+ item := dir
+ for {
+ item = item.Parent
+ if item == nil {
+ break
+ }
+ var err error
+ size := item.Metrics.Size
+ if size == nil {
+ size, err = rsync.ObtainDirLocalSize(ctx, item, retryCount, log)
+ if err != nil {
+ return err
+ }
+ }
+ item.Metrics.Measured = true
+ item.Metrics.BackupType = core.FBT_CONTENT
+ item.Metrics.Size = size
+ }
+ return nil
+}
+
+func findDownNonMeasuredDirByWeight(dir *core.Dir, weight int) *core.Dir {
+ if !dir.Metrics.Measured && dir.Metrics.ChildrenCount <= weight {
+ return dir
+ } else if !dir.Metrics.Measured && dir.Metrics.ChildrenCount > weight && len(dir.Childs) == 0 {
+ return dir
+ } else {
+ var found *core.Dir
+ minDiff := MaxInt
+ for _, item := range dir.Childs {
+ child := findDownNonMeasuredDirByWeight(item, weight)
+ if child != nil {
+ if int(math.Abs(float64(child.Metrics.ChildrenCount-weight))) < minDiff {
+ found = child
+ minDiff = int(math.Abs(float64(child.Metrics.ChildrenCount - weight)))
+ }
+ }
+ }
+ return found
+ }
+}
+
+func findUpNonMeasuredDirByWeight(dir *core.Dir, weight int) *core.Dir {
+ item := dir
+ parent := item.Parent
+ for {
+ if parent == nil || parent.Metrics.Measured {
+ //LocalLog.Debugf("From %v up to %v", dir.Paths.RsyncSourcePath, item.Paths.RsyncSourcePath)
+ return item
+ }
+ if parent.Metrics.ChildrenCount > weight {
+ //LocalLog.Debugf("From %v up to %v", dir.Paths.RsyncSourcePath, item.Paths.RsyncSourcePath)
+ return parent
+ }
+ item = parent
+ parent = item.Parent
+ }
+}
+
+func MeasureDir(ctx context.Context, dir *core.Dir, retryCount *int, log *rsync.Logging,
+ blockSize *backupBlockSizeSettings) (int, error) {
+
+ totalCount := 0
+ for {
+ found, count, err := searchDownOptimalDir(ctx, dir, retryCount, log, blockSize)
+ if err != nil {
+ return 0, err
+ }
+ totalCount += count
+ if found == nil {
+ break
+ }
+
+ if found.Metrics.IgnoreToBackup {
+ LocalLog.Debugf("Selected for skip (count=%v): %v", count, found.Paths.RsyncSourcePath)
+ found.Metrics.BackupType = core.FBT_SKIP
+ } else {
+ LocalLog.Debugf("Selected for full backup (count=%v): %v", count, found.Paths.RsyncSourcePath)
+ found.Metrics.BackupType = core.FBT_RECURSIVE
+ }
+
+ markMesuredAll(found)
+ err = measureLocalUpToRoot(ctx, found, retryCount, log)
+ if err != nil {
+ return 0, err
+ }
+ }
+ return totalCount, nil
+}
+
+func getFullSizesUpToRoot(dir *core.Dir) ([]uint64, []int) {
+ sizes := []uint64{}
+ depths := []int{}
+ item := dir
+ for {
+ if item.Metrics.FullSize != nil {
+ sizes = append([]uint64{item.Metrics.FullSize.GetByteCount()}, sizes...)
+ depths = append([]int{item.Metrics.Depth}, depths...)
+ }
+ item = item.Parent
+ if item == nil {
+ break
+ }
+ }
+ return sizes, depths
+}
+
+func getRoot(dir *core.Dir) *core.Dir {
+ root := dir
+ for {
+ if root.Parent != nil {
+ root = root.Parent
+ } else {
+ break
+ }
+ }
+ return root
+}
+
+// calcFullSizesWithRoot calc "full size" metric for current folder and root, if not defined yet.
+func calcFullSizesWithRoot(ctx context.Context, dir *core.Dir, retryCount *int, log *rsync.Logging) (int, error) {
+ count := 0
+ root := getRoot(dir)
+ if root.Metrics.FullSize == nil {
+ fullSize, err := rsync.ObtainDirFullSize(ctx, root, retryCount, log)
+ if err != nil {
+ return 0, err
+ }
+ root.Metrics.FullSize = fullSize
+ count++
+ }
+ if dir.Metrics.FullSize == nil {
+ fullSize, err := rsync.ObtainDirFullSize(ctx, dir, retryCount, log)
+ if err != nil {
+ return 0, err
+ }
+ dir.Metrics.FullSize = fullSize
+ count++
+ }
+ return count, nil
+}
+
+// findDownNonMeasuredDirByDepth find folder have not measured yet
+// from specific "depth".
+func findDownNonMeasuredDirByDepth(dir *core.Dir, depth int) *core.Dir {
+ if !dir.Metrics.Measured && dir.Metrics.Depth >= depth {
+ return dir
+ } else if !dir.Metrics.Measured && dir.Metrics.Depth < depth && len(dir.Childs) == 0 {
+ return dir
+ } else {
+ var found *core.Dir
+ maxWeight := MinInt
+ for _, item := range dir.Childs {
+ child := findDownNonMeasuredDirByDepth(item, depth)
+ if child != nil {
+ if child.Metrics.ChildrenCount > maxWeight {
+ found = child
+ maxWeight = child.Metrics.ChildrenCount
+ }
+ }
+ }
+ return found
+ }
+}
+
+// Round returns the nearest integer, rounding ties away from zero.
+func round(x float64) float64 {
+ t := math.Trunc(x)
+ if math.Abs(x-t) >= 0.5 {
+ return t + math.Copysign(1, x)
+ }
+ return t
+}
+
+// interpolLagrange implement prediction of next f(x), when set of fi(xi) provided.
+func interpolLagrange(sizes []uint64, depths []int, searchSize uint64) int {
+ result := 0.0
+ for i := 0; i < len(sizes); i++ {
+ term := float64(depths[i])
+ for j := 0; j < len(sizes); j++ {
+ if j != i {
+ term = term * float64(searchSize-sizes[j]) / float64(sizes[i]-sizes[j])
+ }
+ }
+ result += term
+ }
+ return int(round(result))
+}
+
+// interpolLinear implement prediction of next f(x), when dots f1(x1), f2(x2) provided.
+func interpolLinear(size1, size2 uint64, depth1, depth2 int, searchSize uint64) int {
+ depth3 := float64(depth1) + float64(searchSize-size1)*float64(depth2-depth1)/float64(size2-size1)
+ return int(round(depth3))
+}
+
+// selectChildByWeight choose child folder, where "weight" metric is maximized.
+func selectChildByWeight(dir *core.Dir) *core.Dir {
+ var found *core.Dir
+ maxWeight := MinInt
+ for _, item := range dir.Childs {
+ if maxWeight < item.Metrics.ChildrenCount {
+ found = item
+ maxWeight = item.Metrics.ChildrenCount
+ }
+ }
+ return found
+}
+
+// backupBlockSizeSettings provide default "backup block size at once"
+// taken from preference menu.
+type backupBlockSizeSettings struct {
+ AutoManageBackupBlockSize bool
+ BackupBlockSize uint64
+}
+
+// calcOptimalBackupBlockSize contains simple formula to
+// gives backup block size low/high limits obtained from
+// total backup size.
+func calcOptimalBackupBlockSize(dir *core.Dir) uint64 {
+ const splitTo = 50
+ root := getRoot(dir)
+ bs := root.Metrics.FullSize.GetByteCount() / splitTo
+ if bs > core.MegabytesToBytes(5000) {
+ bs = core.MegabytesToBytes(5000)
+ } else if bs < core.MegabytesToBytes(300) {
+ bs = core.MegabytesToBytes(300)
+ }
+ return bs
+}
+
+// searchDownOptimalDir is a main recurrent function to find optimal (or close to optimal)
+// traverse path to backup source minimizing number of RSYNC utility calls.
+func searchDownOptimalDir(ctx context.Context, dir *core.Dir, retryCount *int, log *rsync.Logging,
+ blockSize *backupBlockSizeSettings) (*core.Dir, int, error) {
+
+ LocalLog.Debugf("Start searching optimal folder from root %v",
+ dir.Paths.RsyncSourcePath)
+
+ found := getNonMeasuredDir(dir)
+
+ if found != nil {
+ LocalLog.Debugf("Get non-measured candidate %v to test",
+ found.Paths.RsyncSourcePath)
+ }
+
+ totalFullSizeCount := 0
+ if found != nil {
+ count, err := calcFullSizesWithRoot(ctx, found, retryCount, log)
+ if err != nil {
+ return nil, 0, err
+ }
+ totalFullSizeCount += count
+
+ if blockSize.AutoManageBackupBlockSize {
+ bs := calcOptimalBackupBlockSize(found)
+ if blockSize.BackupBlockSize != bs {
+ blockSize.BackupBlockSize = bs
+ }
+ }
+
+ if found.Metrics.IgnoreToBackup {
+ return found, totalFullSizeCount, nil
+ }
+
+ // Extract "full size" metrics from current folder up to root (with inversed order in output)
+ // to use it later for interpolation.
+ sizes, depths := getFullSizesUpToRoot(found)
+
+ // If from the start [found] directory get fit in size defined by blockSize.BackupBlockSize,
+ // then stop searching anymore and return first candidate found.
+ if sizes[0] <= blockSize.BackupBlockSize {
+ return found, totalFullSizeCount, nil
+ }
+
+ // Case of first iteration, when only root measured for "full size".
+ // We can't interpolate here, because for interpolation we need as minimum 2 points.
+ // So, we use other approach: we employ "bisection method" to select next
+ // candidate for optimal "block size".
+ if len(sizes) == 1 {
+ root := getRoot(found)
+ next := findDownNonMeasuredDirByWeight(found, root.Metrics.ChildrenCount/2)
+ if next == found {
+ return next, totalFullSizeCount, nil
+ } else {
+ count, err := calcFullSizesWithRoot(ctx, next, retryCount, log)
+ if err != nil {
+ return nil, 0, err
+ }
+ totalFullSizeCount += count
+
+ if next.Metrics.FullSize.GetByteCount() > blockSize.BackupBlockSize {
+ next, count, err = searchDownOptimalDir(ctx, next, retryCount, log, blockSize)
+ if err != nil {
+ return nil, 0, err
+ }
+ totalFullSizeCount += count
+ }
+ return next, totalFullSizeCount, nil
+ }
+ // In case, when we have more than 1 folder with "full size" metric,
+ // we can interpolate and predict to find next folder with appropriate "block size".
+ } else {
+ //depth := interpolLagrange(sizes, depths, blockSize.BackupBlockSize)
+ depth := interpolLinear(sizes[0], sizes[len(sizes)-1],
+ depths[0], depths[len(depths)-1], blockSize.BackupBlockSize)
+ LocalLog.Debugf("Found depth %v from [sizes=%v, depths=%v] for size %v",
+ depth, sizes, depths, blockSize.BackupBlockSize)
+
+ LocalLog.Debugf("Get dir by depth %v starting from %q", depth, found.Paths.RsyncSourcePath)
+
+ next := findDownNonMeasuredDirByDepth(found, depth)
+ count, err := calcFullSizesWithRoot(ctx, next, retryCount, log)
+ if err != nil {
+ return nil, 0, err
+ }
+ totalFullSizeCount += count
+ if next.Metrics.FullSize.GetByteCount() > blockSize.BackupBlockSize && len(next.Childs) > 0 {
+ next = selectChildByWeight(next)
+ next, count, err = searchDownOptimalDir(ctx, next, retryCount, log, blockSize)
+ if err != nil {
+ return nil, 0, err
+ }
+ totalFullSizeCount += count
+
+ }
+ return next, totalFullSizeCount, nil
+ }
+ }
+ return nil, 0, nil
+}
+
+/*
+func MeasureDir2(dir *core.Dir, ctx context.Context, retryCount *int, log *rsync.Logging,
+ mbSize uint64) (int, error) {
+ totalCount := 0
+ for {
+ found, count, err := searchDownOptimalDir2(dir, ctx, retryCount, log, mbSize)
+ if err != nil {
+ return 0, err
+ }
+ totalCount += count
+ if found == nil {
+ break
+ }
+ if found.Metrics.IgnoreToBackup {
+ LocalLog.Debugf("Selected for skip (count=%v): %v", count, found.Paths.RsyncSourcePath)
+ } else {
+ LocalLog.Debugf("Selected for full backup (count=%v): %v", count, found.Paths.RsyncSourcePath)
+ }
+ markMesuredAll(found)
+ err = measureLocalUpToRoot(found, ctx, retryCount, log)
+ if err != nil {
+ return 0, err
+ }
+ }
+ return totalCount, nil
+}
+
+func compareDirVs(dir *core.Dir, ctx context.Context, retryCount *int, log *rsync.Logging,
+ mbSize uint64) (cmp string, x, y int64, count int, err error) {
+
+ mbs := mbSize * 1024 * 1024
+ fullSize := dir.Metrics.FullSize
+ if fullSize == nil {
+ fullSize, err = obtainFullSize(dir, ctx, retryCount, log)
+ if err != nil {
+ return "", 0, 0, 0, err
+ }
+ count = 1
+ LocalLog.Debugf("Get %q full size (weight=%v): %v", dir.Paths.RsyncSourcePath,
+ dir.Metrics.FullCount, humanize.Bytes(fullSize.GetByteCount()))
+ dir.Metrics.FullSize = fullSize
+ }
+ if dir.Metrics.IgnoreToBackup ||
+ fullSize.GetByteCount() == mbs ||
+ fullSize.GetByteCount() > mbs && len(dir.Childs) == 0 {
+ return "=", int64(dir.Metrics.FullCount), int64(fullSize.GetByteCount()), count, nil
+ }
+ if fullSize.GetByteCount() > mbs {
+ return ">", int64(dir.Metrics.FullCount), int64(fullSize.GetByteCount()), count, nil
+ } else {
+ return "<", int64(dir.Metrics.FullCount), int64(fullSize.GetByteCount()), count, nil
+ }
+}
+
+func interpolLinear2(x1, x2, y1, y2, y3 int64) (x3 *int64) {
+ if y2-y1 == 0 {
+ return nil
+ } else {
+ var y float64
+ //if y3 > y1 {
+ y = float64(y3 - y1)
+ //} else {
+ //y = float64(y1 - y3)
+ //}
+ x31 := float64(x2-x1)*y/float64(y2-y1) + float64(x1)
+ x32 := int64(x31)
+ return &x32
+ }
+}
+
+type DirSizeSorted struct {
+ Dirs []*core.Dir
+}
+
+func (v *core.DirSizeSorted) Len() int {
+ return len(v.Dirs)
+}
+
+func (v *core.DirSizeSorted) Swap(i, j int) {
+ v.Dirs[i], v.Dirs[j] = v.Dirs[j], v.Dirs[i]
+}
+
+func (v *core.DirSizeSorted) Less(i, j int) bool {
+ if v.Dirs[i].Metrics.FullSize == v.Dirs[j].Metrics.FullSize {
+ return v.Dirs[i].Metrics.FullCount > v.Dirs[j].Metrics.FullCount
+ } else {
+ return v.Dirs[i].Metrics.FullSize.GetByteCount() >
+ v.Dirs[j].Metrics.FullSize.GetByteCount()
+ }
+}
+
+func (v *core.DirSizeSorted) Add(dir *core.Dir) {
+ v.Dirs = append(v.Dirs, dir)
+}
+
+func (v *core.DirSizeSorted) Clear() {
+ v.Dirs = nil
+}
+
+func (v *core.DirSizeSorted) Count() int {
+ return len(v.Dirs)
+}
+
+func searchDownOptimalDir2(dir *core.Dir, ctx context.Context, retryCount *int, log *rsync.Logging,
+ mbSize uint64) (*core.Dir, int, error) {
+
+ div := 2
+ var moveUp bool
+ //var prevMoveUp bool
+ count := 0
+ found := getNonMeasuredDir(dir)
+ first := true
+ //var lastX, lastY int64
+ //var x, y int64
+ if found != nil {
+ item := found
+ weight := found.Metrics.FullCount
+ //var prev *core.Dir
+ candidates := new(DirSizeSorted)
+ for {
+ //LocalLog.Debugf("Found non measured dir: %v", item.Paths.RsyncSourcePath)
+ var cmp string
+ var err error
+ var cnt int
+ //lastX, lastY = x, y
+ cmp, _, _, //, x, y,
+ cnt, err = compareDirVs(item, ctx, retryCount, log, mbSize)
+ if err != nil {
+ return nil, 0, err
+ }
+ count += cnt
+ if cmp == "=" || cmp == "<" && first {
+ if item.Metrics.IgnoreToBackup {
+ item.Metrics.BackupType = io.FBT_SKIP
+ } else {
+ item.Metrics.BackupType = io.FBT_RECURSIVE
+ }
+ return item, count, nil
+ } else if cmp == ">" {
+ LocalLog.Debugf("Move down from: %v", item.Paths.RsyncSourcePath)
+ //LocalLog.Debugf("Candidate count %v", candidates.Count())
+ if candidates.Count() > 0 {
+ sort.Sort(candidates)
+ selected := candidates.Dirs[0]
+ selected.Metrics.BackupType = io.FBT_RECURSIVE
+ return selected, count, nil
+ }
+ //prevMoveUp = moveUp
+ moveUp = false
+
+ if found.Metrics.FullCount/div > 0 {
+ weight -= found.Metrics.FullCount / div
+ } else {
+ weight--
+ }
+
+ //x := interpolLinear2(x, lastX, y, lastY, int64(mbSize*1024*1024))
+ //if x == nil {
+ // weight--
+ //} else {
+ // weight = int(*x)
+ //}
+
+ //LocalLog.Debugf("New weight=%v, interpol=%v", weight, newWeight)
+ //weight = int(newWeight)
+ } else if cmp == "<" {
+
+ candidates.Add(item)
+ //LocalLog.Debugf("Add candidate %v", item.Paths.RsyncSourcePath)
+ LocalLog.Debugf("Move up from: %v", item.Paths.RsyncSourcePath)
+ //prev = item
+ //prevMoveUp = moveUp
+ moveUp = true
+
+ if found.Metrics.FullCount/div > 0 {
+ weight += found.Metrics.FullCount / div
+ } else {
+ weight++
+ }
+
+ //x := interpolLinear2(x, lastX, y, lastY, int64(mbSize*1024*1024))
+ //if x == nil {
+ // weight++
+ //} else {
+ // weight = int(*x)
+ //}
+
+ //LocalLog.Debugf("New weight=%v, interpol=%v", weight, newWeight)
+ //weight = int(newWeight)
+ }
+ if moveUp {
+ item = findUpNonMeasuredDirByWeight(item, weight)
+ } else {
+ item = findDownNonMeasuredDirByWeight(found, weight)
+ }
+
+ if found.Metrics.FullCount/div > 0 {
+ div *= 2
+ }
+ //count++
+ first = false
+ }
+ }
+ return nil, count, nil
+}
+*/
diff --git a/backup/logfiles.go b/backup/logfiles.go
new file mode 100644
index 0000000..f710dfb
--- /dev/null
+++ b/backup/logfiles.go
@@ -0,0 +1,82 @@
+package backup
+
+import (
+ "io/ioutil"
+ "os"
+ "path"
+
+ shell "github.com/d2r2/go-shell"
+)
+
+type LogFiles struct {
+ rootPath string
+ logs map[string]*os.File
+}
+
+func NewLogFiles() *LogFiles {
+ v := &LogFiles{logs: make(map[string]*os.File)}
+ return v
+}
+
+func (v *LogFiles) GetAppendFile(suffixPath string) (*os.File, error) {
+ err := v.assignRootPathByDefault()
+ if err != nil {
+ return nil, err
+ }
+ file := v.logs[suffixPath]
+ if file == nil {
+ file, err = os.OpenFile(v.getFullPath(suffixPath), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return nil, err
+ }
+ v.logs[suffixPath] = file
+ }
+ return file, nil
+}
+
+func (v *LogFiles) getFullPath(suffixPath string) string {
+ return path.Join(v.rootPath, suffixPath)
+}
+
+func (v *LogFiles) Close() error {
+ for suffixPath, val := range v.logs {
+ if val != nil {
+ err := val.Close()
+ if err != nil {
+ return err
+ }
+ v.logs[suffixPath] = nil
+ }
+ }
+ return nil
+}
+
+func (v *LogFiles) ChangeRootPath(newRootPath string) error {
+ err := v.Close()
+ if err != nil {
+ return err
+ }
+ if _, err = os.Stat(v.rootPath); !os.IsNotExist(err) {
+ for suffixPath := range v.logs {
+ oldpath := v.getFullPath(suffixPath)
+ newpath := path.Join(newRootPath, suffixPath)
+ _, err = shell.CopyFile(oldpath, newpath)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ v.rootPath = newRootPath
+ return nil
+}
+
+func (v *LogFiles) assignRootPathByDefault() error {
+ if v.rootPath == "" {
+ dir, err := ioutil.TempDir("", "gorsync_logs_")
+ if err != nil {
+ return err
+ }
+ v.rootPath = dir
+ }
+ return nil
+}
diff --git a/backup/messagekeys.go b/backup/messagekeys.go
new file mode 100644
index 0000000..29b1bff
--- /dev/null
+++ b/backup/messagekeys.go
@@ -0,0 +1,66 @@
+package backup
+
+const (
+ MsgRsyncInfo = "RsyncInfo"
+ MsgGolangInfo = "GolangInfo"
+
+ MsgFolderBackupTypeSkipDescription = "FolderBackupTypeSkipDescription"
+ MsgFolderBackupTypeRecursiveDescription = "FolderBackupTypeRecursiveDescription"
+ MsgFolderBackupTypeContentDescription = "FolderBackupTypeContentDescription"
+
+ MsgLogPlanStageStarting = "LogPlanStageStarting"
+ MsgLogPlanStageStartTime = "LogPlanStageStartTime"
+ MsgLogPlanStageEndTime = "LogPlanStageEndTime"
+ MsgLogPlanStartIterateViaNSources = "LogPlanStartIterateViaNSources"
+ MsgLogPlanStageInquirySource = "LogPlanStageInquirySource"
+ MsgLogPlanStageSourceFolderCountInfo = "LogPlanStageSourceFolderCountInfo"
+ MsgLogPlanStageSourceSkipFolderCountInfo = "LogPlanStageSourceSkipFolderCountInfo"
+ MsgLogPlanStageSourceTotalSizeInfo = "LogPlanStageSourceTotalSizeInfo"
+ MsgLogPlanStageUseTemporaryFolder = "LogPlanStageUseTemporaryFolder"
+ MsgLogPlanStageBuildFolderError = "LogPlanStageBuildFolderError"
+
+ MsgLogBackupStageStarting = "LogBackupStageStarting"
+ MsgLogBackupStageStartTime = "LogBackupStageStartTime"
+ MsgLogBackupStageEndTime = "LogBackupStageEndTime"
+ MsgLogBackupStageBackupToDestination = "LogBackupStageBackupToDestination"
+ MsgLogBackupStagePreviousBackupDiscoveryPermissionError = "LogBackupStagePreviousBackupDiscoveryPermissionError"
+ MsgLogBackupStagePreviousBackupDiscoveryOtherError = "LogBackupStagePreviousBackupDiscoveryOtherError"
+ MsgLogBackupStagePreviousBackupFoundAndWillBeUsed = "LogBackupStagePreviousBackupFoundAndWillBeUsed"
+ MsgLogBackupStagePreviousBackupFoundButDisabled = "LogBackupStagePreviousBackupFoundButDisabled"
+ MsgLogBackupStagePreviousBackupNotFound = "LogBackupStagePreviousBackupNotFound"
+ MsgLogBackupStageStartToBackupFromSource = "LogBackupStageStartToBackupFromSource"
+ MsgLogBackupStageRenameDestination = "LogBackupStageRenameDestination"
+ MsgLogBackupStageFailedToCreateFolder = "LogBackupStageFailedToCreateFolder"
+ MsgLogBackupDetectedTotalBackupSizeGetChanged = "LogBackupDetectedTotalBackupSizeGetChanged"
+ MsgLogBackupStageProgressBackupSuccess = "LogBackupStageProgressBackupSuccess"
+ MsgLogBackupStageProgressBackupError = "LogBackupStageProgressBackupError"
+ MsgLogBackupStageProgressSkipBackupError = "LogBackupStageProgressSkipBackupError"
+ MsgLogBackupStageCriticalError = "LogBackupStageCriticalError"
+ MsgLogBackupStageDiscoveringPreviousBackups = "LogBackupStageDiscoveringPreviousBackups"
+ MsgLogBackupStageRecoveredFromError = "LogBackupStageRecoveredFromError"
+ MsgLogBackupStageSaveRsyncExtraLogTo = "LogBackupStageSaveRsyncExtraLogTo"
+ MsgLogBackupStageSaveLogTo = "LogBackupStageSaveLogTo"
+ MsgLogBackupStageExitMessage = "LogBackupStageExitMessage"
+
+ MsgLogStatisticsSummaryCaption = "LogStatisticsSummaryCaption"
+ MsgLogStatisticsEnvironmentCaption = "LogStatisticsEnvironmentCaption"
+ MsgLogStatisticsResultsCaption = "LogStatisticsResultsCaption"
+ MsgLogStatisticsStatusCaption = "LogStatisticsStatusCaption"
+ MsgLogStatisticsStatusSuccessfullyCompleted = "LogStatisticsStatusSuccessfullyCompleted"
+ MsgLogStatisticsStatusCompletedWithErrors = "LogStatisticsStatusCompletedWithErrors"
+ MsgLogStatisticsPlanStageCaption = "LogStatisticsPlanStageCaption"
+ MsgLogStatisticsPlanStageSourceToBackup = "LogStatisticsPlanStageSourceToBackup"
+ MsgLogStatisticsPlanStageTotalSize = "LogStatisticsPlanStageTotalSize"
+ MsgLogStatisticsPlanStageFolderCount = "LogStatisticsPlanStageFolderCount"
+ MsgLogStatisticsPlanStageFolderSkipCount = "LogStatisticsPlanStageFolderSkipCount"
+ MsgLogStatisticsPlanStageTimeTaken = "LogStatisticsPlanStageTimeTaken"
+ MsgLogStatisticsBackupStageCaption = "LogStatisticsBackupStageCaption"
+ MsgLogStatisticsBackupStageDestinationPath = "LogStatisticsBackupStageDestinationPath"
+ MsgLogStatisticsBackupStagePreviousBackupFound = "LogStatisticsBackupStagePreviousBackupFound"
+ MsgLogStatisticsBackupStagePreviousBackupFoundButDisabled = "LogStatisticsBackupStagePreviousBackupFoundButDisabled"
+ MsgLogStatisticsBackupStageNoValidPreviousBackupFound = "LogStatisticsBackupStageNoValidPreviousBackupFound"
+ MsgLogStatisticsBackupStageTotalSize = "LogStatisticsBackupStageTotalSize"
+ MsgLogStatisticsBackupStageSkippedSize = "LogStatisticsBackupStageSkippedSize"
+ MsgLogStatisticsBackupStageFailedToBackupSize = "LogStatisticsBackupStageFailedToBackupSize"
+ MsgLogStatisticsBackupStageTimeTaken = "LogStatisticsBackupStageTimeTaken"
+)
diff --git a/backup/process.go b/backup/process.go
new file mode 100644
index 0000000..ab70db2
--- /dev/null
+++ b/backup/process.go
@@ -0,0 +1,526 @@
+package backup
+
+import (
+ "context"
+ "errors"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+)
+
+var (
+ DoubleSplitLine string = strings.Repeat("=", 100)
+ SingleSplitLine string = strings.Repeat("-", 100)
+)
+
+func BuildBackupPlan(ctx context.Context, lg logger.PackageLog, config *Config,
+ notifier Notifier) (*BackupPlan, *Progress, error) {
+
+ progress := &Progress{Context: ctx, Notifier: notifier}
+
+ progress.LogFiles = NewLogFiles()
+
+ // create main log file
+ log := core.NewProxyLog(lg, "backup", 6, "2006-01-02T15:04:05",
+ func(line string) error {
+ writer, err := progress.LogFiles.GetAppendFile(GetLogFileName())
+ if err != nil {
+ return err
+ }
+ // ignore error
+ io.WriteString(writer, line)
+ return nil
+ }, logger.InfoLevel)
+ progress.Log = log
+
+ // create specific RSYNC log file
+ rsyncLog := config.getRsyncSettings()
+ if rsyncLog.EnableLog {
+ log = core.NewProxyLog(nil, "rsync", 5, "2006-01-02T15:04:05",
+ func(line string) error {
+ writer, err := progress.LogFiles.GetAppendFile(GetRsyncLogFileName())
+ if err != nil {
+ return err
+ }
+ // ignore error
+ io.WriteString(writer, line)
+ return nil
+ }, logger.InfoLevel)
+ rsyncLog.Log = log
+ progress.RsyncLog = rsyncLog
+ }
+
+ progress.StartPlanStage()
+
+ progress.Log.Info(DoubleSplitLine)
+ progress.Log.Info(locale.T(MsgLogPlanStageStarting, nil))
+ progress.Log.Info(locale.T(MsgLogPlanStageStartTime,
+ struct{ Time string }{Time: progress.StartPlanTime.Format("2006 Jan 2 15:04:05")}))
+
+ list := []BackupNodePlan{}
+ var totalBackupSize core.FolderSize
+ progress.Log.Info(locale.TP(MsgLogPlanStartIterateViaNSources,
+ struct{ SourceCount int }{SourceCount: len(config.BackupNodes)},
+ len(config.BackupNodes)))
+
+ for i, node := range config.BackupNodes {
+ progress.Log.Info(SingleSplitLine)
+ err := progress.EventPlanStage_NodeStructureStartInquiry(i, node.SourceRsync)
+ if err != nil {
+ progress.Log.Error(err)
+ return nil, nil, err
+ }
+
+ dr, backupSize, err := estimateNode(ctx, node, progress, config)
+ if err != nil {
+ progress.Log.Error(err)
+ return nil, nil, err
+ }
+ if backupSize != nil {
+ totalBackupSize += *backupSize
+ }
+
+ err = progress.EventPlanStage_NodeStructureDoneInquiry(i, node.SourceRsync, dr)
+ if err != nil {
+ progress.Log.Error(err)
+ return nil, nil, err
+ }
+
+ plan := BackupNodePlan{BackupNode: node, RootDir: dr}
+ list = append(list, plan)
+ }
+ progress.Log.Info(SingleSplitLine)
+ progress.FinishPlanStage()
+ // progress.Log.Debugf("Plan: %+v", list)
+ progress.Log.Info(locale.T(MsgLogPlanStageEndTime,
+ struct{ Time string }{Time: progress.EndPlanTime.Format("2006 Jan 2 15:04:05")}))
+ backup := &BackupPlan{Config: config, Nodes: list, BackupSize: totalBackupSize}
+ //progress.Log.Debugf("BackupPlan: %+v", backup)
+ return backup, progress, nil
+}
+
+func estimateNode(ctx context.Context, node BackupNode, progress *Progress, config *Config) (*core.Dir, *core.FolderSize, error) {
+ tempDir, err := ioutil.TempDir("", "backup_dir_tree_")
+ if err != nil {
+ return nil, nil, err
+ }
+ defer os.RemoveAll(tempDir)
+
+ progress.Log.Info(locale.T(MsgLogPlanStageUseTemporaryFolder,
+ struct{ Path string }{Path: tempDir}))
+
+ paths := core.SrcDstPath{
+ RsyncSourcePath: core.RsyncPathJoin(node.SourceRsync, ""),
+ DestPath: filepath.Join(tempDir, node.DestSubPath),
+ }
+
+ err = os.MkdirAll(paths.DestPath, 0777)
+ if err != nil {
+ err = errors.New(f("%s: %v", locale.T(MsgLogPlanStageUseTemporaryFolder,
+ struct{ Path string }{Path: tempDir}), err))
+ return nil, nil, err
+ }
+
+ // RSYNC settings to copy only folder's structure and some specific files
+ options := rsync.NewOptions(rsync.WithDefaultParams("--recursive")).
+ AddParams(f("--include=%s", "*"+"/")).
+ AddParams(f("--include=%s", config.SigFileIgnoreBackup)).
+ AddParams(f("--exclude=%s", "*")).
+ SetRetryCount(config.RsyncRetryCount)
+ sessionErr, _, _ := rsync.RunRsyncWithRetry(ctx, options, progress.RsyncLog, nil, paths)
+ if sessionErr != nil {
+ return nil, nil, sessionErr
+ }
+ dir, err := core.BuildDirTree(paths, config.SigFileIgnoreBackup)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ progress.Log.Debug("---------------------------------")
+ progress.Log.Debug("Start heuristic search")
+ progress.Log.Debug("---------------------------------")
+
+ blockSize := config.getBackupBlockSizeSettings()
+ count, err := MeasureDir(ctx, dir, config.RsyncRetryCount, progress.RsyncLog, blockSize)
+ if err != nil {
+ return nil, nil, err
+ }
+ progress.Log.Debugf("Total \"full size\" cycle factor %v, full backup %v, content backup %v", count,
+ core.GetReadableSize(dir.GetFullBackupSize()),
+ core.GetReadableSize(dir.GetContentBackupSize()))
+ progress.Log.Debug("---------------------------------")
+ progress.Log.Debug("End heuristic search")
+ progress.Log.Debug("---------------------------------")
+ backupSize2 := dir.GetTotalSize()
+
+ return dir, &backupSize2, nil
+}
+
+func (this *BackupPlan) RunBackup(progress *Progress, destPath string, errorHook rsync.ErrorHook) error {
+
+ // Execute backup stage
+ err := runBackup(this, progress, destPath, errorHook)
+ if err != nil {
+ progress.Log.Error(locale.T(MsgLogBackupStageCriticalError,
+ struct{ Error error }{Error: err}))
+ }
+
+ // Next lines should be executed even if backup failed and err variable is not empty,
+ // to store log files to backup folder.
+
+ if progress.RsyncLog != nil {
+ // _ = progress.WriteRsyncLog(progress.RsyncLogBuffer)
+ rsyncLogFileName := path.Join(progress.GetBackupFullPath(progress.BackupFolder), GetRsyncLogFileName())
+ progress.Log.Info(locale.T(MsgLogBackupStageSaveRsyncExtraLogTo,
+ struct{ Path string }{Path: rsyncLogFileName}))
+ }
+
+ logFileName := path.Join(progress.GetBackupFullPath(progress.BackupFolder), GetLogFileName())
+ progress.Log.Info(locale.T(MsgLogBackupStageSaveLogTo,
+ struct{ Path string }{Path: logFileName}))
+ // _ = progress.WriteLog(progress.LogBuffer)
+
+ progress.SayGoodbye(progress.Log)
+
+ return err
+}
+
+func runBackup(plan *BackupPlan, progress *Progress, destPath string, errorHook rsync.ErrorHook) error {
+
+ //var backupType io.BackupType = io.BT_DIFF
+
+ progress.TotalProgress = &core.SizeProgress{}
+ progress.Progress = &core.SizeProgress{}
+ progress.StartBackupStage()
+
+ progress.Log.Info(DoubleSplitLine)
+ progress.Log.Info(locale.T(MsgLogBackupStageStarting, nil))
+ progress.Log.Info(locale.T(MsgLogBackupStageStartTime,
+ struct{ Time string }{Time: progress.StartBackupTime.Format("2006 Jan 2 15:04:05")}))
+
+ err := createDirAll(destPath)
+ if err != nil {
+ return err
+ }
+ progress.SetRootDestination(destPath)
+
+ backupFolder := GetBackupFolderName(true, &progress.StartBackupTime)
+ path := progress.GetBackupFullPath(backupFolder)
+ err = createDirAll(path)
+ if err != nil {
+ return err
+ }
+ err = progress.SetBackupFolder(backupFolder)
+ if err != nil {
+ return err
+ }
+ destPath2 := progress.GetBackupFullPath(progress.BackupFolder)
+ progress.Log.Info(locale.T(MsgLogBackupStageBackupToDestination,
+ struct{ Path string }{Path: destPath2}))
+
+ progress.Log.Info(locale.T(MsgLogBackupStageDiscoveringPreviousBackups, nil))
+ prevBackups, err := FindPrevBackupPathsByNodeSignatures(progress.Log, destPath,
+ GetNodeSignatures(plan.Config), plan.Config.numberOfPreviousBackupToUse())
+ if err != nil {
+ return err
+ }
+ LocalLog.Debugf("End searching for previous backups")
+ progress.PrevBackupsUsed(prevBackups)
+ if len(prevBackups.Backups) > 0 && plan.Config.usePreviousBackupEnabled() {
+ paths, err := core.GetRelativePaths(destPath, prevBackups.GetDirPaths())
+ if err != nil {
+ return err
+ }
+ progress.Log.Info(locale.T(MsgLogBackupStagePreviousBackupFoundAndWillBeUsed,
+ struct{ Path string }{Path: destPath}))
+ for _, path := range paths {
+ progress.Log.Info(string(TAB_RUNE) + path)
+ }
+ } else if len(prevBackups.Backups) > 0 && !plan.Config.usePreviousBackupEnabled() {
+ paths, err := core.GetRelativePaths(destPath, prevBackups.GetDirPaths())
+ if err != nil {
+ return err
+ }
+ progress.Log.Info(locale.T(MsgLogBackupStagePreviousBackupFoundButDisabled,
+ struct{ Path string }{Path: destPath}))
+ for _, path := range paths {
+ progress.Log.Info(string(TAB_RUNE) + path)
+ }
+ } else {
+ progress.Log.Notify(locale.T(MsgLogBackupStagePreviousBackupNotFound, nil))
+ }
+
+ for i, node := range plan.Nodes {
+ progress.Log.Info(SingleSplitLine)
+ progress.Log.Info(locale.T(MsgLogBackupStageStartToBackupFromSource,
+ struct {
+ SeqID int
+ RsyncSource string
+ }{SeqID: i + 1, RsyncSource: node.BackupNode.SourceRsync}))
+
+ err := runBackupNode(plan, node, plan.Config.SigFileIgnoreBackup,
+ plan.Config.RsyncRetryCount, plan.Config.MaxBackupBlockSizeMb,
+ progress, destPath2, errorHook, prevBackups)
+ if err != nil {
+ return err
+ }
+ }
+ progress.Log.Info(SingleSplitLine)
+ newBackupFolder := GetBackupFolderName(false, &progress.StartBackupTime)
+ destPath3 := progress.GetBackupFullPath(newBackupFolder)
+ err = os.Rename(destPath2, destPath3)
+ if err != nil {
+ return err
+ }
+ err = progress.SetBackupFolder(newBackupFolder)
+ if err != nil {
+ return err
+ }
+
+ LocalLog.Debugf("BACKUP FINAL: total progress %+v", progress.TotalProgress)
+ LocalLog.Debugf("BACKUP FINAL: left to backup %+v", progress.LeftToBackup(plan))
+
+ progress.Log.Info(locale.T(MsgLogBackupStageRenameDestination,
+ struct{ Path string }{Path: destPath3}))
+
+ err = CreateMetadataSignatureFile(plan.Config, destPath3)
+ if err != nil {
+ return err
+ }
+
+ progress.FinishBackupStage()
+ progress.Log.Info(locale.T(MsgLogBackupStageEndTime,
+ struct{ Time string }{Time: progress.EndBackupTime.Format("2006 Jan 2 15:04:05")}))
+
+ err = progress.PrintTotalStatistics(progress.Log, plan)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func runBackupNode(plan *BackupPlan, nodePlan BackupNodePlan, ignoreBackupSigFileName string,
+ retryCount *int, rsyncMaxBlockSizeMb *int, progress *Progress, destRootPath string,
+ errorHook rsync.ErrorHook, prevBackups *PrevBackups) error {
+
+ paths := core.SrcDstPath{
+ RsyncSourcePath: core.RsyncPathJoin(nodePlan.BackupNode.SourceRsync, ""),
+ DestPath: filepath.Join(destRootPath, nodePlan.BackupNode.DestSubPath),
+ }
+
+ progress.Progress = &core.SizeProgress{}
+ err := backupDir(nodePlan.RootDir, ignoreBackupSigFileName, retryCount, rsyncMaxBlockSizeMb,
+ plan, progress, paths, errorHook, prevBackups.GetDirPaths())
+ return err
+}
+
+// func suppressRsyncError(err *rsync.ErrorSpec) *rsync.ErrorSpec {
+// if err != nil {
+// if err.ErrorCode == 23 {
+// return nil
+// } else {
+// return err
+// }
+// }
+// return nil
+// }
+
+// func IsCriticalError(err *rsync.ErrorSpec) bool {
+// if err != nil {
+// if err.Error == rsync.ErrRsyncProcessTerminated {
+// return true
+// }
+// }
+// return false
+// }
+
+func formatError(sessionErr error, skipped bool, rootDest string,
+ paths core.SrcDstPath, dirSize core.FolderSize) (string, error) {
+
+ destPath, err := core.GetRelativePath(rootDest, paths.DestPath)
+ if err != nil {
+ return "", err
+ }
+
+ if skipped {
+ str := locale.T(MsgLogBackupStageProgressSkipBackupError,
+ struct {
+ Error error
+ Size, RsyncSource, FolderPath string
+ }{
+ Error: sessionErr, Size: core.GetReadableSize(dirSize),
+ RsyncSource: paths.RsyncSourcePath, FolderPath: destPath})
+ return str, nil
+ }
+ str := locale.T(MsgLogBackupStageProgressBackupError,
+ struct {
+ Error error
+ Size, RsyncSource, FolderPath string
+ }{
+ Error: sessionErr, Size: core.GetReadableSize(dirSize),
+ RsyncSource: paths.RsyncSourcePath, FolderPath: destPath})
+ return str, nil
+}
+
+func reportProgress(sessionErr, retryErr error, size core.FolderSize,
+ plan *BackupPlan, progress *Progress, paths core.SrcDstPath,
+ backupType core.FolderBackupType, skipped bool) error {
+
+ if retryErr != nil {
+ progress.Log.Info(locale.T(MsgLogBackupStageRecoveredFromError,
+ struct{ Error error }{Error: retryErr}))
+ }
+
+ if sessionErr != nil {
+ str, err := formatError(sessionErr, skipped,
+ progress.RootDest, paths, size)
+ if err != nil {
+ return err
+ }
+ progress.Log.Warn(str)
+ err = progress.EventBackupStage_FolderDoneBackup(paths, backupType, plan,
+ core.NewProgressFailed(size), sessionErr)
+ if err != nil {
+ return err
+ }
+ } else {
+ var sizeProgress core.SizeProgress
+ if skipped {
+ sizeProgress = core.NewProgressSkipped(size)
+ } else {
+ sizeProgress = core.NewProgressCompleted(size)
+ }
+ err := progress.EventBackupStage_FolderDoneBackup(paths, backupType, plan,
+ sizeProgress, nil)
+ if err != nil {
+ return err
+ }
+ }
+ LocalLog.Debugf("TotalProgress = %v, Progress = %v", progress.TotalProgress, progress.Progress)
+ //LocalLog.Debugf("BACKUP: skipped size: %v", size)
+ return nil
+}
+
+func backupDir(dir *core.Dir, ignoreBackupSigFileName string,
+ retryCount *int, rsyncMaxBlockSizeMb *int,
+ plan *BackupPlan, progress *Progress,
+ paths core.SrcDstPath, errorHook rsync.ErrorHook,
+ prevBackupPaths []string) error {
+
+ var err error
+ var backupType core.FolderBackupType
+
+ err = createDirAll(paths.DestPath)
+ if err != nil {
+ return err
+ }
+
+ if dir.Metrics.BackupType == core.FBT_SKIP {
+ backupType = core.FBT_SKIP
+ err = progress.EventBackupStage_FolderStartBackup(paths, backupType, plan)
+ if err != nil {
+ return err
+ }
+ // run backup in "skip mode"
+ options := rsync.NewOptions(rsync.WithDefaultParams("--times")).
+ AddParams("--delete", "--dirs").
+ AddParams(f("--include=%s", ignoreBackupSigFileName), "--exclude=*").
+ SetRetryCount(retryCount).
+ SetErrorHook(errorHook).
+ // minimum size for empty signature file
+ SetPredictedSize(core.NewFolderSize(1000))
+
+ sessionErr, retryErr, criticalErr := rsync.RunRsyncWithRetry(progress.Context,
+ options, progress.RsyncLog, nil, paths)
+ if criticalErr != nil {
+ return criticalErr
+ }
+
+ err = reportProgress(sessionErr, retryErr, *dir.Metrics.FullSize, plan, progress, paths, backupType, true)
+ if err != nil {
+ return err
+ }
+ } else if dir.Metrics.BackupType == core.FBT_RECURSIVE {
+ backupType = core.FBT_RECURSIVE
+ err = progress.EventBackupStage_FolderStartBackup(paths, backupType, plan)
+ if err != nil {
+ return err
+ }
+ // run full backup including content with recursion
+ options := rsync.NewOptions(rsync.WithDefaultParams("--times")).
+ AddParams("--delete", "--recursive").
+ SetRetryCount(retryCount).
+ SetErrorHook(errorHook).
+ SetPredictedSize(*dir.Metrics.FullSize)
+
+ if plan.Config.usePreviousBackupEnabled() {
+ //options = append(options, "--fuzzy", "--fuzzy")
+ for _, path := range prevBackupPaths {
+ options.AddParams(f("--link-dest=%s", path))
+ }
+ }
+
+ sessionErr, retryErr, criticalErr := rsync.RunRsyncWithRetry(progress.Context,
+ options, progress.RsyncLog, nil, paths)
+ if criticalErr != nil {
+ return criticalErr
+ }
+
+ err = reportProgress(sessionErr, retryErr, *dir.Metrics.FullSize, plan, progress, paths, backupType, false)
+ if err != nil {
+ return err
+ }
+ } else if dir.Metrics.BackupType == core.FBT_CONTENT {
+ backupType = core.FBT_CONTENT
+ err = progress.EventBackupStage_FolderStartBackup(paths, backupType, plan)
+ if err != nil {
+ return err
+ }
+ // run backup only folder content without recursion (flat mode)
+ options := rsync.NewOptions(rsync.WithDefaultParams("--times")).
+ AddParams("--delete", "--dirs").
+ SetRetryCount(retryCount).
+ SetErrorHook(errorHook).
+ SetPredictedSize(*dir.Metrics.Size)
+
+ if plan.Config.usePreviousBackupEnabled() {
+ //options = append(options, "--fuzzy", "--fuzzy")
+ for _, path := range prevBackupPaths {
+ options.AddParams(f("--link-dest=%s", path))
+ }
+ }
+
+ sessionErr, retryErr, criticalErr := rsync.RunRsyncWithRetry(progress.Context,
+ options, progress.RsyncLog, nil, paths)
+ if criticalErr != nil {
+ return criticalErr
+ }
+
+ err = reportProgress(sessionErr, retryErr, *dir.Metrics.Size, plan, progress, paths, backupType, false)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range dir.Childs {
+ prevBackupPaths2 := append([]string(nil), prevBackupPaths...)
+ for i, path := range prevBackupPaths2 {
+ prevBackupPaths2[i] = filepath.Join(path, item.Name)
+ }
+ err = backupDir(item, ignoreBackupSigFileName, retryCount, rsyncMaxBlockSizeMb,
+ plan, progress, paths.Join(item.Name), errorHook, prevBackupPaths2)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/backup/progress.go b/backup/progress.go
new file mode 100644
index 0000000..7ef4168
--- /dev/null
+++ b/backup/progress.go
@@ -0,0 +1,389 @@
+package backup
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "time"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+)
+
+type Progress struct {
+ Context context.Context
+ LogFiles *LogFiles
+ Log logger.PackageLog
+ // LogBuffer bytes.Buffer
+ RsyncLog *rsync.Logging
+ // RsyncLogBuffer bytes.Buffer
+ Progress *core.SizeProgress
+ TotalProgress *core.SizeProgress
+ // GlobalErrorCount int
+ Notifier Notifier
+
+ StartPlanTime time.Time
+ EndPlanTime time.Time
+ StartBackupTime time.Time
+ EndBackupTime time.Time
+ PrevBackups *PrevBackups
+
+ RootDest string
+ BackupFolder string
+
+ // Notify only once
+ SizeChangedNotified bool
+}
+
+func (this *Progress) StartPlanStage() {
+ this.StartPlanTime = time.Now()
+}
+
+func (this *Progress) FinishPlanStage() {
+ this.EndPlanTime = time.Now()
+}
+
+func (this *Progress) StartBackupStage() {
+ this.StartBackupTime = time.Now()
+}
+
+func (this *Progress) FinishBackupStage() {
+ this.EndBackupTime = time.Now()
+}
+
+func (this *Progress) GetTotalTimeTaken() time.Duration {
+ var timeTaken time.Duration
+ now := time.Now()
+ if this.EndPlanTime.After(this.StartPlanTime) {
+ timeTaken += this.EndPlanTime.Sub(this.StartPlanTime)
+ } else if !this.StartPlanTime.IsZero() {
+ timeTaken += now.Sub(this.StartPlanTime)
+ }
+ if this.EndBackupTime.After(this.StartBackupTime) {
+ timeTaken += this.EndBackupTime.Sub(this.StartBackupTime)
+ } else if !this.StartBackupTime.IsZero() {
+ timeTaken += now.Sub(this.StartBackupTime)
+ }
+ return timeTaken
+}
+
+func (this *Progress) CalcTimePassedAndETA(plan *BackupPlan) (time.Duration, *time.Duration) {
+ timePassed := time.Now().Sub(this.StartBackupTime)
+ if this.SizeBackedUp() > 0 {
+ totalTime := float32(timePassed) * float32(plan.BackupSize) /
+ float32(this.SizeBackedUp())
+ eta := time.Duration(totalTime) - timePassed
+ // lg.Debugf("Left to backup: %v", this.LeftToBackup())
+ // lg.Debugf("Total time: %v", totalTime)
+ // lg.Debugf("Time pass: %v", timePassed)
+ // lg.Debugf("ETA: %v", time.Duration(totalTime)-timePassed)
+ return timePassed, &eta
+ }
+ return timePassed, nil
+}
+
+func (this *Progress) PrevBackupsUsed(prevBackups *PrevBackups) {
+ this.PrevBackups = prevBackups
+}
+
+func (this *Progress) PrintTotalStatistics(lg logger.PackageLog, plan *BackupPlan) error {
+ lines, err := this.GetTotalStatistics(plan)
+ if err != nil {
+ lg.Error(err)
+ return err
+ }
+ for _, line := range lines {
+ lg.Info(line)
+ }
+ return nil
+}
+
+func (this *Progress) SayGoodbye(lg logger.PackageLog) {
+ lg.Info(locale.T(MsgLogBackupStageExitMessage, nil))
+}
+
+func (this *Progress) SizeBackedUp() core.FolderSize {
+ return this.TotalProgress.GetTotal()
+}
+
+func (this *Progress) LeftToBackup(plan *BackupPlan) core.FolderSize {
+ var left core.FolderSize
+ // small protection in case when original backup size get changed
+ if plan.BackupSize >= this.SizeBackedUp() {
+ left = plan.BackupSize - this.SizeBackedUp()
+ } else {
+ if !this.SizeChangedNotified {
+ this.Log.Notify(locale.T(MsgLogBackupDetectedTotalBackupSizeGetChanged, nil))
+ this.SizeChangedNotified = true
+ }
+ }
+ return left
+}
+
+func (this *Progress) SetRootDestination(rootDestPath string) {
+ this.RootDest = rootDestPath
+}
+
+func (this *Progress) SetBackupFolder(backupFolder string) error {
+ this.BackupFolder = backupFolder
+ path := this.GetBackupFullPath(backupFolder)
+ return this.LogFiles.ChangeRootPath(path)
+}
+
+func (this *Progress) GetBackupFullPath(backupFolder string) string {
+ backupFullPath := filepath.Join(this.RootDest, backupFolder)
+ return backupFullPath
+}
+
+func (this *Progress) EventPlanStage_NodeStructureStartInquiry(sourceId int,
+ sourceRsync string) error {
+
+ this.Log.Info(locale.T(MsgLogPlanStageInquirySource,
+ struct {
+ SourceID int
+ Path string
+ }{SourceID: sourceId + 1, Path: sourceRsync}))
+
+ if this.Notifier != nil {
+ err := this.Notifier.NotifyPlanStage_NodeStructureStartInquiry(sourceId, sourceRsync)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (this *Progress) EventPlanStage_NodeStructureDoneInquiry(sourceId int,
+ sourceRsync string, dir *core.Dir) error {
+
+ folderCount := dir.GetFoldersCount()
+ skipFolderCount := dir.GetFoldersIgnoreCount()
+ this.Log.Infof("%s, %s, %s",
+ locale.TP(MsgLogPlanStageSourceFolderCountInfo,
+ struct{ FolderCount int }{FolderCount: folderCount}, folderCount),
+ locale.TP(MsgLogPlanStageSourceSkipFolderCountInfo,
+ struct{ SkipFolderCount int }{SkipFolderCount: skipFolderCount}, skipFolderCount),
+ locale.T(MsgLogPlanStageSourceTotalSizeInfo,
+ struct {
+ TotalSize string
+ }{TotalSize: core.GetReadableSize(dir.GetTotalSize())}))
+
+ if this.Notifier != nil {
+ err := this.Notifier.NotifyPlanStage_NodeStructureDoneInquiry(sourceId,
+ sourceRsync, dir)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+/*
+func (this *Progress) EventPlanStage_FolderStartInquiry(path core.SrcDstPath,
+ plan *BackupPlan) error {
+ return nil
+}
+
+func (this *Progress) EventPlanStage_FolderDoneInquiry(path core.SrcDstPath,
+ plan *BackupPlan, folderSize io.FolderSize, skipFlag bool) error {
+ return nil
+}
+*/
+
+func (this *Progress) EventBackupStage_FolderStartBackup(paths core.SrcDstPath,
+ backupType core.FolderBackupType, plan *BackupPlan) error {
+
+ backupFolder := this.GetBackupFullPath(this.BackupFolder)
+ path, err := core.GetRelativePath(backupFolder, paths.DestPath)
+ if err != nil {
+ return err
+ }
+
+ timePassed, eta := this.CalcTimePassedAndETA(plan)
+ leftToBackup := this.LeftToBackup(plan)
+
+ etaStr := "*"
+ if eta != nil {
+ sections := 2
+ etaStr = core.FormatDurationToDaysHoursMinsSecs(*eta, true, §ions)
+ }
+
+ msg := locale.T(MsgLogBackupStageProgressBackupSuccess,
+ struct{ SizeLeft, TimeLeft, BackupAction, FolderPath string }{
+ SizeLeft: core.GetReadableSize(leftToBackup), TimeLeft: etaStr,
+ BackupAction: GetBackupTypeDescription(backupType),
+ FolderPath: path})
+
+ if backupType == core.FBT_SKIP {
+ this.Log.Notify(msg)
+ } else {
+ this.Log.Info(msg)
+ }
+
+ if this.Notifier != nil {
+ err := this.Notifier.NotifyBackupStage_FolderStartBackup(backupFolder,
+ paths, backupType, leftToBackup, timePassed, eta)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (this *Progress) EventBackupStage_FolderDoneBackup(paths core.SrcDstPath,
+ backupType core.FolderBackupType, plan *BackupPlan,
+ sizeDone core.SizeProgress, sessionErr error) error {
+
+ this.Progress.Add(sizeDone)
+ this.TotalProgress.Add(sizeDone)
+
+ timePassed, eta := this.CalcTimePassedAndETA(plan)
+ leftToBackup := this.LeftToBackup(plan)
+
+ if this.Notifier != nil {
+ backupFolder := this.GetBackupFullPath(this.BackupFolder)
+ err := this.Notifier.NotifyBackupStage_FolderDoneBackup(backupFolder,
+ paths, backupType, leftToBackup, sizeDone, timePassed, eta, sessionErr)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (this *Progress) GetTotalStatistics(plan *BackupPlan) ([]string, error) {
+ sections := 2
+ var b bytes.Buffer
+ wli := writeLineIndent
+ wli(&b, 0, DoubleSplitLine)
+ wli(&b, 0, locale.T(MsgLogStatisticsSummaryCaption, nil))
+ wli(&b, 1, locale.T(MsgLogStatisticsEnvironmentCaption, nil))
+ wli(&b, 2, f("%s %s", core.GetAppFullTitle(), core.GetAppVersion()))
+ version, protocol, err := rsync.GetRsyncVersion()
+ if err != nil {
+ return nil, err
+ }
+ wli(&b, 2, locale.T(MsgRsyncInfo, struct{ RSYNCDetectedVer, RSYNCDetectedProtocol string }{
+ RSYNCDetectedVer: version, RSYNCDetectedProtocol: protocol}))
+ wli(&b, 2, locale.T(MsgGolangInfo, struct{ GolangVersion, AppArchitecture string }{
+ GolangVersion: core.GetGolangVersion(),
+ AppArchitecture: core.GetAppArchitecture()}))
+ wli(&b, 1, locale.T(MsgLogStatisticsResultsCaption, nil))
+ wli(&b, 2, locale.T(MsgLogStatisticsStatusCaption, nil))
+ if this.TotalProgress.Failed != nil {
+ wli(&b, 3, locale.T(MsgLogStatisticsStatusCompletedWithErrors, nil))
+ } else {
+ wli(&b, 3, locale.T(MsgLogStatisticsStatusSuccessfullyCompleted, nil))
+ }
+ wli(&b, 2, locale.T(MsgLogStatisticsPlanStageCaption, nil))
+ for i, node := range plan.Nodes {
+ wli(&b, 3, locale.T(MsgLogStatisticsPlanStageSourceToBackup,
+ struct {
+ SeqID int
+ RsyncSource string
+ }{
+ SeqID: i + 1, RsyncSource: node.BackupNode.SourceRsync}))
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsPlanStageTotalSize, struct{ TotalSize string }{
+ TotalSize: core.GetReadableSize(plan.BackupSize)}))
+ var foldersCount int
+ for _, node := range plan.Nodes {
+ foldersCount += node.RootDir.GetFoldersCount()
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsPlanStageFolderCount, struct{ FolderCount int }{
+ FolderCount: foldersCount}))
+ var foldersIgnoreCount int
+ for _, node := range plan.Nodes {
+ foldersIgnoreCount += node.RootDir.GetFoldersIgnoreCount()
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsPlanStageFolderSkipCount, struct{ FolderCount int }{
+ FolderCount: foldersIgnoreCount}))
+ timeTaken := this.EndPlanTime.Sub(this.StartPlanTime)
+ wli(&b, 3, locale.T(MsgLogStatisticsPlanStageTimeTaken, struct{ TimeTaken string }{
+ TimeTaken: core.FormatDurationToDaysHoursMinsSecs(timeTaken, true, §ions)}))
+ wli(&b, 2, locale.T(MsgLogStatisticsBackupStageCaption, nil))
+ backupFolder := this.GetBackupFullPath(this.BackupFolder)
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageDestinationPath, struct{ Path string }{
+ Path: backupFolder}))
+
+ if len(this.PrevBackups.Backups) > 0 && plan.Config.usePreviousBackupEnabled() {
+ paths, err := core.GetRelativePaths(this.RootDest, this.PrevBackups.GetDirPaths())
+ if err != nil {
+ return nil, err
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStagePreviousBackupFound, struct{ Path string }{
+ Path: this.RootDest}))
+ for _, path := range paths {
+ wli(&b, 4, path)
+ }
+ } else if len(this.PrevBackups.Backups) > 0 && !plan.Config.usePreviousBackupEnabled() {
+ paths, err := core.GetRelativePaths(this.RootDest, this.PrevBackups.GetDirPaths())
+ if err != nil {
+ return nil, err
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStagePreviousBackupFoundButDisabled, struct{ Path string }{
+ Path: this.RootDest}))
+ for _, path := range paths {
+ wli(&b, 4, path)
+ }
+ } else {
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageNoValidPreviousBackupFound, nil))
+ }
+
+ var size core.FolderSize
+ if this.TotalProgress.Completed != nil {
+ size = *this.TotalProgress.Completed
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageTotalSize, struct{ TotalSize string }{
+ TotalSize: core.GetReadableSize(size)}))
+ size = 0
+ if this.TotalProgress.Skipped != nil {
+ size = *this.TotalProgress.Skipped
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageSkippedSize, struct{ SkippedSize string }{
+ SkippedSize: core.GetReadableSize(size)}))
+ size = 0
+ if this.TotalProgress.Failed != nil {
+ size = *this.TotalProgress.Failed
+ }
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageFailedToBackupSize, struct{ FailedToBackupSize string }{
+ FailedToBackupSize: core.GetReadableSize(size)}))
+ timeTaken = this.EndBackupTime.Sub(this.StartBackupTime)
+ wli(&b, 3, locale.T(MsgLogStatisticsBackupStageTimeTaken, struct{ TimeTaken string }{
+ TimeTaken: core.FormatDurationToDaysHoursMinsSecs(timeTaken, true, §ions)}))
+ wli(&b, 0, DoubleSplitLine)
+ return splitToLines(&b)
+}
+
+func (this *Progress) Close() error {
+ if this.LogFiles != nil {
+ return this.LogFiles.Close()
+ }
+ return nil
+}
+
+// func (this *Progress) WriteLog(log bytes.Buffer) error {
+// backupFolder := this.GetBackupFullPath()
+// fileName := GetLogFileName()
+// err := writeLog(log, backupFolder, fileName)
+// if err != nil {
+// this.Log.Warn(f("Error writing file %q: %v", fileName, err))
+// }
+// return err
+// }
+
+// func (this *Progress) WriteRsyncLog(log bytes.Buffer) error {
+// backupFolder := this.GetBackupFullPath()
+// fileName := GetRsyncLogFileName()
+// err := writeLog(log, backupFolder, fileName)
+// if err != nil {
+// this.Log.Warn(f("Error writing file %q: %v", fileName, err))
+// }
+// return err
+// }
diff --git a/backup/utils.go b/backup/utils.go
new file mode 100644
index 0000000..2338301
--- /dev/null
+++ b/backup/utils.go
@@ -0,0 +1,130 @@
+package backup
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+)
+
+const (
+ TAB_RUNE = '\t'
+)
+
+func createDirAll(path string) error {
+ err := os.MkdirAll(path, 0777)
+ if err != nil {
+ err = errors.New(locale.T(MsgLogBackupStageFailedToCreateFolder,
+ struct {
+ Path string
+ Error error
+ }{Path: path, Error: err}))
+ return err
+ }
+ return nil
+}
+
+// func writeLog(log bytes.Buffer, destPath, fileName string) error {
+// err := os.MkdirAll(destPath, 0777)
+// if err != nil {
+// return err
+// }
+// destPath = filepath.Join(destPath, fileName)
+// file, err := os.Create(destPath)
+// if err != nil {
+// return err
+// }
+// defer file.Close()
+// _, err = file.Write(log.Bytes())
+// return err
+// }
+
+func splitToLines(buf *bytes.Buffer) ([]string, error) {
+ var lines []string
+ scanner := bufio.NewScanner(buf)
+ for scanner.Scan() {
+ lines = append(lines, scanner.Text())
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return lines, nil
+}
+
+func writeLineIndent(buf *bytes.Buffer, tabNumber int, text string) {
+ for i := 0; i < tabNumber; i++ {
+ buf.WriteRune(TAB_RUNE)
+ }
+ buf.WriteString(fmt.Sprintln(text))
+}
+
+func GetBackupTypeDescription(backupType core.FolderBackupType) string {
+ var backupStr string
+ switch backupType {
+ case core.FBT_SKIP:
+ backupStr = locale.T(MsgFolderBackupTypeSkipDescription, nil)
+ case core.FBT_RECURSIVE:
+ backupStr = locale.T(MsgFolderBackupTypeRecursiveDescription, nil)
+ case core.FBT_CONTENT:
+ backupStr = locale.T(MsgFolderBackupTypeContentDescription, nil)
+ }
+ return backupStr
+}
+
+// func TranslateRsyncError(err error) error {
+// if err2, ok := err.(*rsync.RsyncError); ok {
+// // translate RsyncError to local language
+// err = errors.New(locale.T(MsgRsyncCallFailedError,
+// struct {
+// Description string
+// ExitCode int
+// }{Description: err2.Description, ExitCode: err2.ExitCode}))
+// }
+// return err
+// }
+
+// GetBackupFolderName return new folder name for ongoing backup process.
+func GetBackupFolderName( /*backupType io.BackupType,*/
+ incomplete bool, date *time.Time) string {
+
+ var prefixPath string = "~rsync_backup"
+ /*
+ if backupType == io.BT_FULL {
+ // prefixPath = "~rsync_backup_full"
+ prefixPath = "~rsync_backup"
+ } else if backupType == io.BT_DIFF {
+ // prefixPath = "~rsync_backup_snap"
+ prefixPath = "~rsync_backup"
+ }
+ */
+ if incomplete {
+ prefixPath += "_(incomplete)"
+ }
+ var t time.Time = time.Now()
+ if date != nil {
+ t = *date
+ }
+ prefixPath += t.Format("~20060102-150405~")
+ return prefixPath
+}
+
+// GetMetadataSignatureFileName return the name of specific file
+// which describe all sources used in backup process.
+func GetMetadataSignatureFileName() string {
+ return "~backup_nodes~.signatures"
+}
+
+// GetLogFileName return the name of general backup process log.
+func GetLogFileName() string {
+ return "~backup_log~.log"
+}
+
+// GetRsyncLogFileName return the name of specific low-level Rsync utility log.
+func GetRsyncLogFileName() string {
+ return "~rsync_log~.log"
+}
diff --git a/builds/archlinux_pkgbuild/gorsync-git/.SRCINFO b/builds/archlinux_pkgbuild/gorsync-git/.SRCINFO
new file mode 100644
index 0000000..3788ca8
--- /dev/null
+++ b/builds/archlinux_pkgbuild/gorsync-git/.SRCINFO
@@ -0,0 +1,20 @@
+pkgbase = gorsync-git
+ pkgdesc = GTK+ frontend for RSYNC console utility.
+ pkgver = 0.3
+ pkgrel = 1
+ url = https://github.com/d2r2/go-rsync
+ install = gorsync-git.install
+ arch = x86_64
+ arch = i686
+ license = GPL3
+ makedepends = git
+ makedepends = go
+ depends = rsync
+ depends = glib2
+ depends = gtk3
+ provides = gorsync
+ source = go-rsync::git+https://github.com/d2r2/go-rsync.git
+ md5sums = SKIP
+
+pkgname = gorsync-git
+
diff --git a/builds/archlinux_pkgbuild/gorsync-git/.gitignore b/builds/archlinux_pkgbuild/gorsync-git/.gitignore
new file mode 100644
index 0000000..1a6df5f
--- /dev/null
+++ b/builds/archlinux_pkgbuild/gorsync-git/.gitignore
@@ -0,0 +1,17 @@
+### https://raw.github.com/github/gitignore/fa441f903154d8159ef71db23d4816d802450fef/ArchLinuxPackages.gitignore
+
+*.tar
+*.tar.*
+*.jar
+*.exe
+*.msi
+*.zip
+*.tgz
+*.log
+*.log.*
+*.sig
+
+pkg/
+src/
+go-rsync/
+
diff --git a/builds/archlinux_pkgbuild/gorsync-git/PKGBUILD b/builds/archlinux_pkgbuild/gorsync-git/PKGBUILD
new file mode 100644
index 0000000..51ab2e8
--- /dev/null
+++ b/builds/archlinux_pkgbuild/gorsync-git/PKGBUILD
@@ -0,0 +1,75 @@
+# Maintainer: Denis Dyakov
+
+pkgname=gorsync-git
+_pkgname=go-rsync
+pkgver=0.3.1
+epoch=
+pkgrel=1
+pkgdesc="Best GTK+ client frontend for RSYNC console utility."
+arch=('x86_64' 'i686')
+url="https://github.com/d2r2/go-rsync"
+license=('GPL3')
+makedepends=('git' 'go')
+depends=('rsync' 'glib2' 'gtk3' 'libnotify')
+provides=('gorsync')
+install="${pkgname}.install"
+source=("${_pkgname}"::'git+https://github.com/d2r2/go-rsync.git')
+md5sums=('SKIP')
+
+# Get the tag of the commit to use
+# Separated out to allow for `makepkg -e` not running prepare()
+_get_tag() {
+ _tag=$(git tag --list | grep '^v' | grep -v alpha | tail -n1)
+ echo "Selected git tag: $_tag" >&2 # To STDERR as called from pkgver()
+}
+
+prepare() {
+ cd "${srcdir}/${_pkgname}"
+ _get_tag
+ git reset --hard "${_tag}"
+}
+
+pkgver() {
+ cd "${srcdir}/${_pkgname}"
+ [[ -z ${_tag-} ]] && _get_tag
+ # Example: v2.1.0-beta-3 -> 2.1.0.beta.r3
+ # Version specification: https://github.com/robert7/nixnote2/issues/28
+ # echo "$_tag" | sed -E 's/^v//;s/-?([0-9]+)$/.r\1/;s/-/./'
+ echo "${_tag}" | sed 's/^v//;s/-/./g'
+}
+
+
+#pkgver() {
+# cd "${srcdir}/${_pkgname}"
+# git describe --tags --long | sed 's/^v//;s/-/./g'
+#}
+
+build() {
+ rm -rf "${srcdir}/.go/src"
+ mkdir -p "${srcdir}/.go/src/github.com/d2r2"
+ # export GOPATH="${srcdir}/.go"
+ mv "${srcdir}/${_pkgname}" "${srcdir}/.go/src/github.com/d2r2/"
+ cd "${srcdir}/.go/src/github.com/d2r2/${_pkgname}/"
+ # download and build main package and all dependencies
+ # retrying n times, due to issue with go get functionality
+ n=0
+ until [ $n -ge 7 ]
+ do
+ GOPATH="${srcdir}/.go" go get -v all && \
+ GOPATH="${srcdir}/.go" ./gorsync_build.sh --buildtype Release && \
+ break # substitute your command here
+ n=$[$n+1]
+ sleep 1
+ done
+}
+
+package() {
+ _binname="gorsync"
+ # echo "Working dir $(pwd)"
+ cd "${srcdir}/.go/src/github.com/d2r2/${_pkgname}"
+ install -Dm755 "${_binname}" "${pkgdir}/usr/bin/${_binname}"
+ install -Dm644 "builds/fpm_packages/gorsync.desktop" "$pkgdir/usr/share/applications/gorsync.desktop"
+ install -Dm644 "ui/gtkui/gsettings/org.d2r2.gorsync.gschema.xml" "$pkgdir/gsettings/org.d2r2.gorsync.gschema.xml"
+ # install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/${_pkgname}/LICENSE"
+}
+
diff --git a/builds/archlinux_pkgbuild/gorsync-git/gorsync-git.install b/builds/archlinux_pkgbuild/gorsync-git/gorsync-git.install
new file mode 100644
index 0000000..92a840b
--- /dev/null
+++ b/builds/archlinux_pkgbuild/gorsync-git/gorsync-git.install
@@ -0,0 +1,71 @@
+post_install() {
+ :
+#!/usr/bin/env sh
+
+#if [ -z "$1" ]; then
+ export PREFIX=/usr
+#else
+# export PREFIX=$1
+#fi
+
+if [ "$PREFIX" = "/usr" ] && [ "$(id -u)" != "0" ]; then
+ # Make sure only root can run our script
+ echo "This script must be run as root" 1>&2
+ exit 1
+fi
+
+# Check availability of required commands
+# COMMANDS="install glib-compile-schemas glib-compile-resources msgfmt desktop-file-validate gtk-update-icon-cache"
+COMMANDS="install glib-compile-schemas glib-compile-resources msgfmt desktop-file-validate gtk-update-icon-cache"
+# if [ "$PREFIX" = '/usr' ] || [ "$PREFIX" = "/usr/local" ]; then
+# COMMANDS="$COMMANDS xdg-desktop-menu"
+# fi
+# PACKAGES="coreutils glib2 glib2 gettext desktop-file-utils gtk-update-icon-cache xdg-utils"
+PACKAGES="coreutils glib2 glib2 gettext desktop-file-utils gtk-update-icon-cache xdg-utils"
+i=0
+for COMMAND in $COMMANDS; do
+ type $COMMAND >/dev/null 2>&1 || {
+ j=0
+ for PACKAGE in $PACKAGES; do
+ if [ $i = $j ]; then
+ break
+ fi
+ j=$(( $j + 1 ))
+ done
+ echo "Your system is missing command $COMMAND, please install $PACKAGE"
+ exit 1
+ }
+ i=$(( $i + 1 ))
+done
+
+echo "Installing gsettings schema to prefix ${PREFIX}"
+
+# Copy and compile schema
+echo "Copying and compiling schema..."
+install -d ${PREFIX}/share/glib-2.0/schemas
+install -m 644 gsettings/org.d2r2.gorsync.gschema.xml ${PREFIX}/share/glib-2.0/schemas/
+glib-compile-schemas ${PREFIX}/share/glib-2.0/schemas/
+
+
+}
+pre_remove() {
+ :
+#!/usr/bin/env sh
+
+#if [ -z "$1" ]; then
+ export PREFIX=/usr
+ # Make sure only root can run our script
+ if [ "$(id -u)" != "0" ]; then
+ echo "This script must be run as root" 1>&2
+ exit 1
+ fi
+#else
+# export PREFIX=$1
+#fi
+
+echo "Uninstalling gsettings schema from prefix ${PREFIX}"
+
+rm ${PREFIX}/share/glib-2.0/schemas/org.d2r2.gorsync.gschema.xml
+glib-compile-schemas ${PREFIX}/share/glib-2.0/schemas/
+
+}
diff --git a/builds/archlinux_pkgbuild/prepare_aur_pkgbuild.sh b/builds/archlinux_pkgbuild/prepare_aur_pkgbuild.sh
new file mode 100755
index 0000000..5bcfba6
--- /dev/null
+++ b/builds/archlinux_pkgbuild/prepare_aur_pkgbuild.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+# Read manuals to understand how to build aur package in Arch Linux:
+# https://wiki.archlinux.org/index.php/Arch_User_Repository
+# https://wiki.archlinux.org/index.php/Arch_package_guidelines
+# https://wiki.archlinux.org/index.php/Creating_packages
+# https://wiki.archlinux.org/index.php/PKGBUILD
+# https://wiki.archlinux.org/index.php/Makepkg
+# Examples aur packages with go sources:
+# https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=gometalinter-git
+# https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=vim-go
+# https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=gotags-git
+
+TEMP_DIR=$(mktemp -d)
+SAVE_DIR="${PWD}"
+
+git clone ssh://aur@aur.archlinux.org/gorsync-git.git "${TEMP_DIR}"
+#cp -R ./gorsync-git $TEMP_DIR
+cp ./gorsync-git/PKGBUILD "${TEMP_DIR}/"
+cp ./gorsync-git/gorsync-git.install "${TEMP_DIR}/"
+cd "${TEMP_DIR}"
+makepkg --printsrcinfo > .SRCINFO
+echo "go to ${TEMP_DIR} and run makepkg..."
diff --git a/builds/fpm_packages/create_distrib_packages_with_fpm.sh b/builds/fpm_packages/create_distrib_packages_with_fpm.sh
new file mode 100755
index 0000000..6645017
--- /dev/null
+++ b/builds/fpm_packages/create_distrib_packages_with_fpm.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+DESTDIR=/tmp/build_app
+TEMPDIR=/tmp/build_app2
+ITERATION='1'
+APP_NAME='gorsync'
+rm -R $DESTDIR >/dev/null 2>&1
+mkdir -p $DESTDIR/usr/bin
+mkdir -p $DESTDIR/usr/share/applications
+rm -R $TEMPDIR >/dev/null 2>&1
+mkdir -p $TEMPDIR
+
+SAVE_DIR="${PWD}"
+cd ../..
+PARENT_DIR="${PWD}"
+VERSION=`head -1 ./version`
+./gorsync_build.sh --buildtype Release
+
+if [ $? -eq 0 ]; then
+ echo "App successfully compiled."
+ cd "$SAVE_DIR"
+ cp "$PARENT_DIR/$APP_NAME" "$DESTDIR/usr/bin"
+
+ #mkdir -p $DESTDIR/etc/systemd/system
+ #cp ./rpc_server.service $DESTDIR/etc/systemd/system
+
+ cp "$PARENT_DIR/ui/gtkui/gs_schema_install.sh" "$TEMPDIR"
+ cp "$PARENT_DIR/ui/gtkui/gs_schema_uninstall.sh" "$TEMPDIR"
+ cp -R "$PARENT_DIR/ui/gtkui/gsettings" "$DESTDIR"
+ cp ./gorsync.desktop "$DESTDIR/usr/share/applications"
+
+ mkdir -p ./packages && cd ./packages
+
+ packages=( "pacman" "deb" "rpm" )
+ dependencies=( \
+ # for Archlinux
+ "--depends rsync --depends glib2 --depends gtk3 --depends libnotify" \
+ # for Debian, Ubuntu
+ "--depends rsync --depends libglib2.0-dev --depends libgtk-3-dev --depends libnotify-dev" \
+ # for Redhat, Centos
+ "--depends rsync --depends glib2-devel --depends gtk3 --depends libnotify-devel")
+
+ for ((i=0; i<${#packages[@]};++i))
+ do
+ fpm -s dir -f \
+ -t ${packages[i]} \
+ -C $DESTDIR \
+ --name $APP_NAME \
+ --version $VERSION \
+ --iteration $ITERATION \
+ --description "Gorsync Backup" \
+ ${dependencies[i]} \
+ --after-install=$TEMPDIR/gs_schema_install.sh \
+ --before-remove=$TEMPDIR/gs_schema_uninstall.sh
+ # --config-files /etc
+ done
+ echo "done."
+else
+ echo "FAIL"
+fi
+
diff --git a/builds/fpm_packages/gorsync.desktop b/builds/fpm_packages/gorsync.desktop
new file mode 100644
index 0000000..475b7e1
--- /dev/null
+++ b/builds/fpm_packages/gorsync.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Name=Gorsync Backup
+Comment=Easy-to-use backup app based on Rsync console utility
+Exec=gorsync
+Icon=media-tape-symbolic
+Type=Application
+Encoding=UTF-8
+Terminal=false
+Categories=GNOME;GTK;Utility;System;
+Keywords=backup;rsync;
diff --git a/core/abstract.go b/core/abstract.go
new file mode 100644
index 0000000..d2c3402
--- /dev/null
+++ b/core/abstract.go
@@ -0,0 +1,121 @@
+package core
+
+type FolderBackupType int
+
+const (
+ FBT_UNKNOWN FolderBackupType = iota
+ FBT_SKIP
+ FBT_RECURSIVE
+ FBT_CONTENT
+)
+
+func (v FolderBackupType) String() string {
+ var backupStr string
+ switch v {
+ case FBT_SKIP:
+ backupStr = "skip"
+ case FBT_RECURSIVE:
+ backupStr = "full folder content"
+ case FBT_CONTENT:
+ backupStr = "folder files"
+ }
+ return backupStr
+}
+
+type FolderSize int64
+
+func NewFolderSize(size int64) FolderSize {
+ v := FolderSize(size)
+ return v
+}
+
+func (v FolderSize) GetByteCount() uint64 {
+ return uint64(v)
+}
+
+// func (v FolderSize) GetReadableSize() string {
+// str := humanize.Bytes(v.GetByteCount())
+// return str
+// }
+
+func (v FolderSize) Add(value FolderSize) FolderSize {
+ a := v + value
+ return a
+}
+
+func (v FolderSize) AddSizeProgress(value SizeProgress) FolderSize {
+ var totalDone FolderSize
+ if value.Completed != nil {
+ totalDone += *value.Completed
+ }
+ if value.Failed != nil {
+ totalDone += *value.Failed
+ }
+ if value.Skipped != nil {
+ totalDone += *value.Skipped
+ }
+ a := v + totalDone
+ return a
+}
+
+type SizeProgress struct {
+ Completed *FolderSize
+ Skipped *FolderSize
+ Failed *FolderSize
+}
+
+func NewProgressCompleted(size FolderSize) SizeProgress {
+ this := SizeProgress{Completed: &size}
+ return this
+}
+
+func NewProgressSkipped(size FolderSize) SizeProgress {
+ this := SizeProgress{Skipped: &size}
+ return this
+}
+
+func NewProgressFailed(size FolderSize) SizeProgress {
+ this := SizeProgress{Failed: &size}
+ return this
+}
+
+func (this *SizeProgress) Add(size SizeProgress) {
+ if size.Completed != nil {
+ if this.Completed == nil {
+ this.Completed = size.Completed
+ } else {
+ done := *this.Completed + *size.Completed
+ this.Completed = &done
+ }
+ }
+ if size.Skipped != nil {
+ if this.Skipped == nil {
+ this.Skipped = size.Skipped
+ } else {
+ done := *this.Skipped + *size.Skipped
+ this.Skipped = &done
+ }
+ }
+ if size.Failed != nil {
+ if this.Failed == nil {
+ this.Failed = size.Failed
+ } else {
+ done := *this.Failed + *size.Failed
+ this.Failed = &done
+ }
+ }
+}
+
+func (this *SizeProgress) GetTotal() FolderSize {
+ var total FolderSize
+ if this.Completed != nil {
+ total += *this.Completed
+ }
+ if this.Skipped != nil {
+ total += *this.Skipped
+ }
+ if this.Failed != nil {
+ total += *this.Failed
+ }
+ return total
+}
diff --git a/core/common.go b/core/common.go
new file mode 100644
index 0000000..4e063c4
--- /dev/null
+++ b/core/common.go
@@ -0,0 +1,15 @@
+package core
+
+import (
+ "fmt"
+
+ "github.com/d2r2/go-logger"
+)
+
+var lg = logger.NewPackageLogger("io",
+ // logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+var e = fmt.Errorf
+var f = fmt.Sprintf
diff --git a/core/dir.go b/core/dir.go
new file mode 100644
index 0000000..1bd8572
--- /dev/null
+++ b/core/dir.go
@@ -0,0 +1,220 @@
+package core
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+)
+
+// DirMetrics keeps metrics defined in 1st pass of folders tree.
+// Metrics used lately in heuristic algorithm to find optimal folder tree traverse.
+type DirMetrics struct {
+ // Define depth from root folder. Root folder has Depth = 0.
+ Depth int
+ // Total count of all child folders.
+ ChildrenCount int
+ // "Size" metric defines summary size of all local files,
+ // do not include any child folders.
+ Size *FolderSize
+ // "Full size" metric, which include all files and
+ // child folders with their content.
+ FullSize *FolderSize
+ // Flag which means, that folder contain special file
+ // which serves as tag to do not backup this folder.
+ IgnoreToBackup bool
+ // Flag which means, that this folder already marked
+ // as "measured" in traverse path search.
+ Measured bool
+ // Type of backup for current folder defined
+ // as a result of traverse path search.
+ BackupType FolderBackupType
+}
+
+// Dir is a "tree data structure" to describe folder's tree
+// received from the source in 1st pass of backup process to measure
+// counts/sizes and to predict time necessary for backup process (ETA).
+type Dir struct {
+ Paths SrcDstPath
+ Name string
+ Parent *Dir
+ Childs []*Dir
+ Metrics DirMetrics
+}
+
+func BuildDirTree(paths SrcDstPath, ignoreBackupFileSigName string) (*Dir, error) {
+ info, err := os.Stat(paths.DestPath)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ // does not translate this message, since it is very unlikely
+ return nil, fmt.Errorf("path %q should be a folder", paths.DestPath)
+ }
+ root := &Dir{Name: info.Name(), Paths: paths, Metrics: DirMetrics{Depth: 0}}
+ _, err = createOffsprings(root, paths, ignoreBackupFileSigName, 1)
+ if err != nil {
+ return nil, err
+ }
+ return root, nil
+}
+
+func (v *Dir) GetTotalSize() FolderSize {
+ // use nested call since it's recursive
+ return getTotalSize(v)
+}
+
+func (v *Dir) GetIgnoreSize() FolderSize {
+ // use nested call since it's recursive
+ return getIgnoreSize(v)
+}
+
+func (v *Dir) GetFullBackupSize() FolderSize {
+ return getFullBackupSize(v)
+}
+
+func (v *Dir) GetContentBackupSize() FolderSize {
+ return getContentBackupSize(v)
+}
+
+func (v *Dir) GetFoldersCount() int {
+ // use nested call since it's recursive
+ return getFoldersCount(v)
+}
+
+func (v *Dir) GetFoldersIgnoreCount() int {
+ // use nested call since it's recursive
+ return getFoldersIgnoreCount(v)
+}
+
+func containsMeasuredDir(dir *Dir) bool {
+ if dir.Metrics.Measured {
+ return true
+ }
+ for _, item := range dir.Childs {
+ if containsMeasuredDir(item) {
+ return true
+ }
+ }
+ return false
+}
+
+func containsNonMeasuredDir(dir *Dir) bool {
+ if !dir.Metrics.Measured {
+ return true
+ }
+ for _, item := range dir.Childs {
+ if containsNonMeasuredDir(item) {
+ return true
+ }
+ }
+ return false
+}
+
+func getTotalSize(dir *Dir) FolderSize {
+ var size FolderSize
+ if dir.Metrics.BackupType == FBT_CONTENT {
+ size = *dir.Metrics.Size
+ } else if dir.Metrics.BackupType == FBT_RECURSIVE {
+ size = *dir.Metrics.FullSize
+ } else if dir.Metrics.BackupType == FBT_SKIP {
+ size = *dir.Metrics.FullSize
+ }
+ for _, item := range dir.Childs {
+ size += getTotalSize(item)
+ }
+ return size
+}
+
+func getFullBackupSize(dir *Dir) FolderSize {
+ var size FolderSize
+ if dir.Metrics.BackupType == FBT_RECURSIVE {
+ size = *dir.Metrics.FullSize
+ }
+ for _, item := range dir.Childs {
+ size += getFullBackupSize(item)
+ }
+ return size
+}
+
+func getContentBackupSize(dir *Dir) FolderSize {
+ var size FolderSize
+ if dir.Metrics.BackupType == FBT_CONTENT {
+ size = *dir.Metrics.Size
+ }
+ for _, item := range dir.Childs {
+ size += getContentBackupSize(item)
+ }
+ return size
+}
+
+func getIgnoreSize(dir *Dir) FolderSize {
+ var size FolderSize
+ if dir.Metrics.BackupType == FBT_SKIP {
+ size = *dir.Metrics.FullSize
+ }
+ for _, item := range dir.Childs {
+ size += getIgnoreSize(item)
+ }
+ return size
+}
+
+func getFoldersIgnoreCount(dir *Dir) int {
+ count := 0
+ if dir.Metrics.BackupType == FBT_SKIP {
+ count++
+ }
+ for _, item := range dir.Childs {
+ count += getFoldersIgnoreCount(item)
+ }
+ return count
+}
+
+func getFoldersCount(dir *Dir) int {
+ count := len(dir.Childs)
+ for _, item := range dir.Childs {
+ count += getFoldersCount(item)
+ }
+ return count
+}
+
+func createOffsprings(parent *Dir, paths SrcDstPath,
+ sigFileIgnoreBackup string, depth int) (int, error) {
+
+ // lg.Debug(f("Iterate path: %q", path))
+ items, err := ioutil.ReadDir(paths.DestPath)
+ if err != nil {
+ return 0, err
+ }
+ if sigFileIgnoreBackupFound(items, sigFileIgnoreBackup) {
+ parent.Metrics.IgnoreToBackup = true
+ parent.Metrics.ChildrenCount = 1
+ return 1, nil
+ }
+ totalCount := 1
+ for _, item := range items {
+ if item.IsDir() {
+ name := item.Name()
+ paths2 := paths.Join(name)
+ dir := &Dir{Parent: parent, Name: name, Paths: paths2,
+ Metrics: DirMetrics{Depth: depth}}
+ count, err := createOffsprings(dir, paths2,
+ sigFileIgnoreBackup, depth+1)
+ if err != nil {
+ return 0, err
+ }
+ parent.Childs = append(parent.Childs, dir)
+ totalCount += count
+ }
+ }
+ parent.Metrics.ChildrenCount = totalCount
+ return totalCount, nil
+}
+
+func sigFileIgnoreBackupFound(items []os.FileInfo, sigFileIgnoreBackup string) bool {
+ for _, item := range items {
+ if !item.IsDir() && item.Name() == sigFileIgnoreBackup {
+ return true
+ }
+ }
+ return false
+}
diff --git a/core/format.go b/core/format.go
new file mode 100644
index 0000000..8c97ed5
--- /dev/null
+++ b/core/format.go
@@ -0,0 +1,179 @@
+package core
+
+import (
+ "bytes"
+ "math"
+ "time"
+
+ "github.com/d2r2/go-rsync/locale"
+)
+
+// FormatDurationToDaysHoursMinsSecs print time span
+// in the format "x1 day(s) x2 hour(s) x3 minute(s) x4 second(s)".
+// Understand plural cases for right spellings. Might be limited to number of sections.
+func FormatDurationToDaysHoursMinsSecs(dur time.Duration, short bool, sections *int) string {
+ var buf bytes.Buffer
+ var totalHrs float64 = dur.Hours()
+ days := totalHrs / 24
+ count := 0
+ if days >= 1 {
+ count++
+ var a int
+ if sections == nil || *sections > count {
+ a = int(days)
+ } else {
+ a = int(Round(days))
+ }
+ if short {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgDaysShort, nil, a)))
+ } else {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgDaysLong, nil, a)))
+ }
+ }
+ hours := totalHrs - float64(int(days)*24)
+ if (hours >= 1 || count > 0) && (sections == nil || *sections > count) {
+ if count > 0 {
+ buf.WriteString(" ")
+ }
+ count++
+ var a int
+ if sections == nil || *sections > count {
+ a = int(hours)
+ } else {
+ a = int(Round(hours))
+ }
+ if short {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgHoursShort, nil, a)))
+ } else {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgHoursLong, nil, a)))
+ }
+ }
+ var totalSecsLeft float64 = (dur - time.Duration(days)*24*time.Hour -
+ time.Duration(hours)*time.Hour).Seconds()
+ minutes := totalSecsLeft / 60
+ if (minutes > 1 || count > 0) && (sections == nil || *sections > count) {
+ if count > 0 {
+ buf.WriteString(" ")
+ }
+ count++
+ var a int
+ if sections == nil || *sections > count {
+ a = int(minutes)
+ } else {
+ a = int(Round(minutes))
+ }
+ if short {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgMinutesShort, nil, a)))
+ } else {
+ buf.WriteString(f("%d %s", a, locale.TP(MsgMinutesLong, nil, a)))
+ }
+ }
+ seconds := int(totalSecsLeft - float64(int(minutes)*60))
+ if (seconds > 0 || count == 0) && (sections == nil || *sections > count) {
+ if count > 0 {
+ buf.WriteString(" ")
+ }
+ if short {
+ buf.WriteString(f("%d %s", seconds, locale.TP(MsgSecondsShort, nil, seconds)))
+ } else {
+ buf.WriteString(f("%d %s", seconds, locale.TP(MsgSecondsLong, nil, seconds)))
+ }
+ }
+ return buf.String()
+}
+
+func pluralFloatToInt(val float64) int {
+ if val == 1 {
+ return 1
+ } else if val < 1 {
+ return 0
+ } else if val < 2 {
+ return 2
+ } else {
+ return int(Round(math.Floor(val)))
+ }
+}
+
+const (
+ kB = 1000
+ MB = 1000 * 1000
+ GB = 1000 * 1000 * 1000
+ TB = 1000 * 1000 * 1000 * 1000
+ PB = 1000 * 1000 * 1000 * 1000 * 1000
+ EB = 1000 * 1000 * 1000 * 1000 * 1000 * 1000
+)
+
+func FormatSize(byteCount uint64, short bool) string {
+ if byteCount > EB {
+ a := float64(byteCount) / EB
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgExaBytesShort, nil, pluralFloatToInt(a)))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgExaBytesLong, nil, pluralFloatToInt(a)))
+ }
+ } else if byteCount > PB {
+ a := float64(byteCount) / PB
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgPetaBytesShort, nil, pluralFloatToInt(a)))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgPetaBytesLong, nil, pluralFloatToInt(a)))
+ }
+ } else if byteCount > TB {
+ a := float64(byteCount) / TB
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgTeraBytesShort, nil, pluralFloatToInt(a)))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgTeraBytesLong, nil, pluralFloatToInt(a)))
+ }
+ } else if byteCount > GB {
+ a := float64(byteCount) / GB
+ if short {
+ return f("%.1f %s", a,
+ locale.TP(MsgGigaBytesShort, nil, pluralFloatToInt(a)))
+ } else {
+ return f("%.1f %s", a,
+ locale.TP(MsgGigaBytesLong, nil, pluralFloatToInt(a)))
+ }
+ } else if byteCount > MB {
+ a := int(Round(float64(byteCount) / MB))
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgMegaBytesShort, nil, a))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgMegaBytesLong, nil, a))
+ }
+ } else if byteCount > kB {
+ a := int(Round(float64(byteCount) / kB))
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgKiloBytesShort, nil, a))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgKiloBytesLong, nil, a))
+ }
+ } else {
+ a := int(byteCount)
+ if short {
+ return f("%v %s", a,
+ locale.TP(MsgBytesShort, nil, a))
+ } else {
+ return f("%v %s", a,
+ locale.TP(MsgBytesLong, nil, a))
+ }
+ }
+}
+
+func GetReadableSize(size FolderSize) string {
+ return FormatSize(size.GetByteCount(), true)
+}
+
+func MegabytesToBytes(size uint64) uint64 {
+ return size * MB
+}
diff --git a/core/info.go b/core/info.go
new file mode 100644
index 0000000..d41ba29
--- /dev/null
+++ b/core/info.go
@@ -0,0 +1,82 @@
+package core
+
+import (
+ "fmt"
+ "runtime"
+ "strconv"
+ "time"
+
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/davecgh/go-spew/spew"
+)
+
+var (
+ APP_ID = "org.d2r2.gorsync"
+ SETTINGS_ID = APP_ID + ".Settings"
+ SETTINGS_PROFILE_ID = SETTINGS_ID + ".Profile"
+ SETTINGS_PROFILE_PATH = "/org/d2r2/gorsync/profiles/%s/"
+ SETTINGS_SOURCE_ID = SETTINGS_PROFILE_ID + ".Source"
+ SETTINGS_SOURCE_PATH = "/org/d2r2/gorsync/profiles/%s/sources/%s/"
+)
+
+// contain version+buildnum
+// initialized with option:
+// -ldflags "-X main.version `head -1 version` -X main.buildnum `date -u +%Y%m%d%H%M%S`"
+var (
+ _buildnum string
+ _version string
+)
+
+func SetVersion(version string) {
+ _version = version
+}
+
+func SetBuildNum(buildnum string) {
+ _buildnum = buildnum
+}
+
+// Pass in parameter datetime
+// from bash expression `date -u +%y%m%d%H%M%S`.
+func generateBuildNum() string {
+ if _, err := strconv.Atoi(_buildnum); err == nil && len(_buildnum) == 14 {
+ year, _ := strconv.Atoi(_buildnum[0:4])
+ month, _ := strconv.Atoi(_buildnum[4:6])
+ day, _ := strconv.Atoi(_buildnum[6:8])
+ hour, _ := strconv.Atoi(_buildnum[8:10])
+ min, _ := strconv.Atoi(_buildnum[10:12])
+ sec, _ := strconv.Atoi(_buildnum[12:])
+ tm := time.Date(year, time.Month(month), day, hour, min, sec, 0, time.Local)
+ tm2 := time.Date(2010, time.January, 1, 0, 0, 0, 0, time.Local)
+ return fmt.Sprintf("%d", (tm.Unix()-tm2.Unix())/30)
+ }
+ return _buildnum
+}
+
+func GetAppVersion() string {
+ return spew.Sprintf("v%s", _version)
+}
+
+func GetAppArchitecture() string {
+ return runtime.GOARCH
+}
+
+func GetGolangVersion() string {
+ return runtime.Version()
+}
+
+func GetAppTitle() string {
+ return "Gorsync Backup"
+}
+
+func GetAppExtraTitle() string {
+ return locale.T(MsgAppTitleExtra, nil)
+}
+
+func GetAppFullTitle() string {
+ appTitle := GetAppTitle()
+ appTitleExtra := GetAppExtraTitle()
+ if appTitleExtra != "" {
+ appTitle += " " + appTitleExtra
+ }
+ return appTitle
+}
diff --git a/core/messagekeys.go b/core/messagekeys.go
new file mode 100644
index 0000000..165fe05
--- /dev/null
+++ b/core/messagekeys.go
@@ -0,0 +1,29 @@
+package core
+
+const (
+ MsgAppTitleExtra = "AppTitleExtra"
+
+ MsgDaysLong = "DaysLong"
+ MsgDaysShort = "DaysShort"
+ MsgHoursLong = "HoursLong"
+ MsgHoursShort = "HoursShort"
+ MsgMinutesLong = "MinutesLong"
+ MsgMinutesShort = "MinutesShort"
+ MsgSecondsLong = "SecondsLong"
+ MsgSecondsShort = "SecondsShort"
+
+ MsgBytesLong = "BytesLong"
+ MsgBytesShort = "BytesShort"
+ MsgKiloBytesLong = "KiloBytesLong"
+ MsgKiloBytesShort = "KiloBytesShort"
+ MsgMegaBytesLong = "MegaBytesLong"
+ MsgMegaBytesShort = "MegaBytesShort"
+ MsgGigaBytesLong = "GigaBytesLong"
+ MsgGigaBytesShort = "GigaBytesShort"
+ MsgTeraBytesLong = "TeraBytesLong"
+ MsgTeraBytesShort = "TeraBytesShort"
+ MsgPetaBytesLong = "PetaBytesLong"
+ MsgPetaBytesShort = "PetaBytesShort"
+ MsgExaBytesLong = "ExaBytesLong"
+ MsgExaBytesShort = "ExaBytesShort"
+)
diff --git a/core/path.go b/core/path.go
new file mode 100644
index 0000000..222462e
--- /dev/null
+++ b/core/path.go
@@ -0,0 +1,61 @@
+package core
+
+import (
+ "bytes"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+// SrcDstPath link to each other RSYNC source URL
+// with destination extra path added to backup folder.
+type SrcDstPath struct {
+ RsyncSourcePath string
+ DestPath string
+}
+
+// Join fork SrcDstPath with new variant, where
+// new "folder" amended to the end of the path.
+func (v SrcDstPath) Join(item string) SrcDstPath {
+ newPathTwin := SrcDstPath{
+ RsyncSourcePath: RsyncPathJoin(v.RsyncSourcePath, item),
+ DestPath: filepath.Join(v.DestPath, item)}
+ return newPathTwin
+}
+
+// RsyncPathJoin used to join path elements in RSYNC url.
+func RsyncPathJoin(elements ...string) string {
+ // use standard URL separator
+ const separator = '/'
+ var buf bytes.Buffer
+ for _, item := range elements {
+ buf.WriteString(item)
+ if buf.Bytes()[buf.Len()-1] != separator {
+ buf.WriteByte(separator)
+ }
+ }
+ return buf.String()
+}
+
+// GetRelativePath cut off root prefix from destPath (if found).
+func GetRelativePath(rootDest, destPath string) (string, error) {
+ rel, err := filepath.Rel(rootDest, destPath)
+ if err != nil {
+ return "", err
+ }
+ rel = "." + strings.Trim((path.Join(" ", rel, " ")), " ")
+ return rel, nil
+}
+
+// GetRelativePaths cut off root prefix from multiple paths (if found).
+func GetRelativePaths(rootDest string, paths []string) ([]string, error) {
+ var newPaths []string
+ for _, p := range paths {
+ np, err := GetRelativePath(rootDest, p)
+ if err != nil {
+ return nil, err
+ }
+ newPaths = append(newPaths, np)
+ }
+ return newPaths, nil
+}
diff --git a/core/proxylog.go b/core/proxylog.go
new file mode 100644
index 0000000..4c1cc0e
--- /dev/null
+++ b/core/proxylog.go
@@ -0,0 +1,140 @@
+package core
+
+import (
+ "fmt"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/davecgh/go-spew/spew"
+)
+
+type WriteLine func(line string) error
+
+type ProxyLog struct {
+ log logger.PackageLog
+ packageName string
+ packageLen int
+ timeFormat string
+
+ customWriteLine WriteLine
+ customLogLevel logger.LogLevel
+}
+
+// Static cast to verify that type implement interface
+var _ logger.PackageLog = &ProxyLog{}
+
+func NewProxyLog(child logger.PackageLog, packageName string, packageLen int,
+ timeFormat string, writeLine WriteLine, customLogLevel logger.LogLevel) *ProxyLog {
+
+ v := &ProxyLog{log: child, packageName: packageName, packageLen: packageLen,
+ timeFormat: timeFormat, customLogLevel: customLogLevel,
+ customWriteLine: writeLine}
+ return v
+}
+
+/*
+func (v *ProxyLog) FormatMessage(options logger.FormatOptions, level logger.LogLevel,
+ msg string, colored bool) string {
+
+ return v.log.FormatMessage(options, level, msg, colored)
+}
+*/
+
+func (v *ProxyLog) getFormat() logger.FormatOptions {
+ options := logger.FormatOptions{TimeFormat: v.timeFormat,
+ LevelLength: logger.LevelShort, PackageLength: v.packageLen}
+ return options
+}
+
+func (v *ProxyLog) Printf(level logger.LogLevel, format string, args ...interface{}) {
+ if v.log != nil {
+ v.log.Printf(level, format, args...)
+ }
+ if v.customWriteLine != nil && level <= v.customLogLevel {
+ msg := spew.Sprintf(format, args...)
+ packageName := v.packageName
+ out := logger.FormatMessage(v.getFormat(), level, packageName, msg, false)
+ err := v.customWriteLine(out + fmt.Sprintln())
+ if err != nil {
+ v.log.Fatal(err)
+ }
+ }
+}
+
+func (v *ProxyLog) Print(level logger.LogLevel, args ...interface{}) {
+ if v.log != nil {
+ v.log.Print(level, args...)
+ }
+ if v.customWriteLine != nil && level <= v.customLogLevel {
+ msg := fmt.Sprint(args...)
+ packageName := v.packageName
+ out := logger.FormatMessage(v.getFormat(), level, packageName, msg, false)
+ err := v.customWriteLine(out + fmt.Sprintln())
+ if err != nil {
+ v.log.Fatal(err)
+ }
+ }
+}
+
+func (v *ProxyLog) Debugf(format string, args ...interface{}) {
+ v.Printf(logger.DebugLevel, format, args...)
+}
+
+func (v *ProxyLog) Debug(args ...interface{}) {
+ v.Print(logger.DebugLevel, args...)
+}
+
+func (v *ProxyLog) Infof(format string, args ...interface{}) {
+ v.Printf(logger.InfoLevel, format, args...)
+}
+
+func (v *ProxyLog) Info(args ...interface{}) {
+ v.Print(logger.InfoLevel, args...)
+}
+
+func (v *ProxyLog) Notifyf(format string, args ...interface{}) {
+ v.Printf(logger.NotifyLevel, format, args...)
+}
+
+func (v *ProxyLog) Notify(args ...interface{}) {
+ v.Print(logger.NotifyLevel, args...)
+}
+
+func (v *ProxyLog) Warningf(format string, args ...interface{}) {
+ v.Printf(logger.WarnLevel, format, args...)
+}
+
+func (v *ProxyLog) Warnf(format string, args ...interface{}) {
+ v.Printf(logger.WarnLevel, format, args...)
+}
+
+func (v *ProxyLog) Warning(args ...interface{}) {
+ v.Print(logger.WarnLevel, args...)
+}
+
+func (v *ProxyLog) Warn(args ...interface{}) {
+ v.Print(logger.WarnLevel, args...)
+}
+
+func (v *ProxyLog) Errorf(format string, args ...interface{}) {
+ v.Printf(logger.ErrorLevel, format, args...)
+}
+
+func (v *ProxyLog) Error(args ...interface{}) {
+ v.Print(logger.ErrorLevel, args...)
+}
+
+func (v *ProxyLog) Panicf(format string, args ...interface{}) {
+ v.Printf(logger.PanicLevel, format, args...)
+}
+
+func (v *ProxyLog) Panic(args ...interface{}) {
+ v.Print(logger.PanicLevel, args...)
+}
+
+func (v *ProxyLog) Fatalf(format string, args ...interface{}) {
+ v.Printf(logger.FatalLevel, format, args...)
+}
+
+func (v *ProxyLog) Fatal(args ...interface{}) {
+ v.Print(logger.FatalLevel, args...)
+}
diff --git a/core/utils.go b/core/utils.go
new file mode 100644
index 0000000..a532938
--- /dev/null
+++ b/core/utils.go
@@ -0,0 +1,52 @@
+package core
+
+import (
+ "math"
+ "regexp"
+ "strings"
+
+ shell "github.com/d2r2/go-shell"
+)
+
+// Round returns the nearest integer, rounding ties away from zero.
+func Round(x float64) float64 {
+ t := math.Trunc(x)
+ if math.Abs(x-t) >= 0.5 {
+ return t + math.Copysign(1, x)
+ }
+ return t
+}
+
+func SplitByEOL(text string) []string {
+ return strings.Split(strings.Replace(text, "\r\n", "\n", 0), "\n")
+}
+
+// func IsKillPending(ctx context.Context) bool {
+// select {
+// case <-ctx.Done():
+// return true
+// default:
+// return false
+// }
+// }
+
+func RunExecutableWithExtraVars(path string, env []string, args ...string) (error, int) {
+ app := shell.NewApp(path, args...)
+ app.AddEnvironments(env)
+ ec := app.Run(nil, nil)
+ return ec.Error, ec.ExitCode
+}
+
+func FindStringSubmatchIndexes(re *regexp.Regexp, s string) map[string][2]int {
+ captures := make(map[string][2]int)
+ ind := re.FindStringSubmatchIndex(s)
+ names := re.SubexpNames()
+ for i, name := range names {
+ if name != "" && i < len(ind)/2 {
+ if ind[i*2] != -1 && ind[i*2+1] != -1 {
+ captures[name] = [2]int{ind[i*2], ind[i*2+1]}
+ }
+ }
+ }
+ return captures
+}
diff --git a/data/assets/active.en.toml b/data/assets/active.en.toml
new file mode 100644
index 0000000..87efc57
--- /dev/null
+++ b/data/assets/active.en.toml
@@ -0,0 +1,248 @@
+AboutDlgDoNotShowCaption = "Do not show this information at application startup"
+AppWindowAboutMenuCaption = "About"
+AppWindowBackupProgressCompleted = "Successfully completed!"
+AppWindowBackupProgressCompletedWithErrors = "Completed with errors!"
+AppWindowBackupProgressETASuffix = "ETA"
+AppWindowBackupProgressFailed = "Failed!"
+AppWindowBackupProgressInquiringSourceDescription = "source: \"{{.RsyncSource}}\""
+AppWindowBackupProgressInquiringSourceID = "Inquiring source #{{.SourceID}}..."
+AppWindowBackupProgressSizeCompletedSuffix = "done"
+AppWindowBackupProgressSizeLeftSuffix = "left to backup"
+AppWindowBackupProgressStartMessage = "Start backup process..."
+AppWindowBackupProgressTerminated = "Terminated!"
+AppWindowBackupProgressTimePassedSuffix = "passed"
+AppWindowCannotStartBackupProcessTitle = "Can't start backup process"
+AppWindowDestPathCaption = "Destination root path"
+AppWindowDestPathHint = "Destination path for backup. You can alter default path taken from profile preferences."
+AppWindowDestPathIsEmptyError1 = "Path is not defined. Select path manually or assign default path to backup profile in preference menu."
+AppWindowDestPathIsEmptyError2 = "Destination path is empty. First select destination folder for backup."
+AppWindowDestPathIsNotExistAdvise = "Perhaps, you need to attach destination USB-disk or flash drive, and re-select backup profile again."
+AppWindowDestPathIsNotExistError = "Folder \"{{.FolderPath}}\" does not exist or permission denied."
+AppWindowDestPathIsValidStatusPart1 = "Path"
+AppWindowDestPathIsValidStatusPart2 = "is valid"
+AppWindowInquiringProfileStatus = "Inquiring backup profile \"{{.ProfileName}}\"... Please wait, this may take some time."
+AppWindowNoneProfileEntry = ""
+AppWindowOverallProgressCaption = "Overall progress"
+AppWindowPreferencesHint = "Show preferences"
+AppWindowPreferencesMenuCaption = "Preferences"
+AppWindowProfileBackupPlanInfoDirectoryCount = "Directory count:"
+AppWindowProfileBackupPlanInfoSkipSize = "Skip size:"
+AppWindowProfileBackupPlanInfoSourceCount = "RSYNC sources:"
+AppWindowProfileBackupPlanInfoTotalSize = "Total size:"
+AppWindowProfileCaption = "Select backup profile"
+AppWindowProfileHint = "Choose profile defined in preference menu from the drop down list to start backup process. Profile contains all specific settings including RSYNC sources and destination location to backup."
+AppWindowProgressStatusCaption = "Progress status"
+AppWindowQuitMenuCaption = "Quit application"
+AppWindowRsyncUtilityDlgNotFoundError = "Test RSYNC call get failed.\nInstall RSYNC and restart application."
+AppWindowRsyncUtilityDlgTitle = "RSYNC utility not found"
+AppWindowRunBackupHint = "Run backup process"
+AppWindowSessionLogCaption = "Session log"
+AppWindowStopBackupHint = "Terminate backup process"
+AppWindowTerminateBackupProcessDlgQuestion = "Press YES to interrupt backup procedure."
+AppWindowTerminateBackupProcessDlgTitle = "Terminate backup processes?"
+GeneralHintDescriptionCaption = "Description:"
+GeneralHintStatusCaption = "Status:"
+HelloWorld = "Hello world !!!"
+LogBackupDetectedTotalBackupSizeGetChanged = "Detected, that originally established total backup size get changed."
+LogBackupStageBackupToDestination = "Backup to destination path: \"{{.Path}}\""
+LogBackupStageEndTime = "End time: {{.Time}}"
+LogBackupStageExitMessage = "Goodbye..."
+LogBackupStageFailedToCreateFolder = "failed to create folder \"{{.Path}}\""
+LogBackupStageLogSaved = "This log saved to: \"{{.Path}}\""
+LogBackupStagePreviousBackupFoundAndWillBeUsed = "Previous backups found and will be used at root path \"{{.Path}}\":"
+LogBackupStagePreviousBackupFoundButDisabled = "Previous backups found (but disabled to use) at root path \"{{.Path}}\":"
+LogBackupStagePreviousBackupNotFound = "There is no valid previous backup found (neither time acceleration nor reduction in size are expected)"
+LogBackupStageProgressBackupError = "{{.Error}} to backup {{.Size}} to {{.FolderPath}}"
+LogBackupStageProgressBackupSuccess = "{{.SizeLeft}}, {{.TimeLeft}} left, {{.BackupAction}}: {{.FolderPath}}"
+LogBackupStageProgressSkipBackupError = "{{.Error}} to skip backup {{.Size}} to {{.FolderPath}}"
+LogBackupStageRenameDestination = "Rename destination path to: \"{{.Path}}\""
+LogBackupStageRsyncExtraLogSaved = "RSYNC extra log saved to: \"{{.Path}}\""
+LogBackupStageStartTime = "Start time: {{.Time}}"
+LogBackupStageStartToBackupFromSource = "Start to backup from source #{{.SeqID}}: {{.RsyncSource}}"
+LogBackupStageStarting = "Starting backup stage..."
+LogPlanStageEndTime = "End time: {{.Time}}"
+LogPlanStageFailedToCreateFolder = "failed to create folder \"{{.Path}}\""
+LogPlanStageInquirySource = "Inquiry information from source #{{.SourceID}} \"{{.Path}}\""
+LogPlanStageSourceInfo = "{{.FolderCount}} folders found, where {{.SkipCount}} will be skipped, with total size of {{.TotalSize}} to process"
+LogPlanStageStartTime = "Start time: {{.Time}}"
+LogPlanStageStarting = "Starting plan stage..."
+LogPlanStageUseTemporaryFolder = "Use temporary folder to get and analyze backup directory structure: \"{{.Path}}\""
+LogPlanStartIterateViaNSources = "Iterate via {{.SourceCount}} RSYNC sources to estimate folder structures and sizes..."
+LogStatisticsBackupStageCaption = "Backup stage:"
+LogStatisticsBackupStageDestinationPath = "Destination path: \"{{.Path}}\""
+LogStatisticsBackupStageFailedToBackupSize = "Failed to backup size: {{.FailedToBackupSize}}"
+LogStatisticsBackupStageNoValidPreviousBackupFound = "There is no valid previous backup found"
+LogStatisticsBackupStagePreviousBackupFound = "Previous backups found at root path \"{{.Path}}\":"
+LogStatisticsBackupStagePreviousBackupFoundButDisabled = "Previous backups found (but NOT USED: disabled in settings) at root path \"{{.Path}}\":"
+LogStatisticsBackupStageSkippedSize = "Skipped size: {{.SkippedSize}}"
+LogStatisticsBackupStageTimeTaken = "Time taken: {{.TimeTaken}}"
+LogStatisticsBackupStageTotalSize = "Successfully backed up size: {{.TotalSize}}"
+LogStatisticsEnvironmentCaption = "Environment:"
+LogStatisticsPlanStageCaption = "Plan stage:"
+LogStatisticsPlanStageFolderCount = "Folder's count total: {{.FolderCount}}"
+LogStatisticsPlanStageFolderSkipCount = "Folder's skip count total: {{.FolderCount}}"
+LogStatisticsPlanStageSourceToBackup = "Source #{{.SeqID}} to backup: {{.RsyncSource}}"
+LogStatisticsPlanStageTimeTaken = "Time taken: {{.TimeTaken}}"
+LogStatisticsPlanStageTotalSize = "Total size to backup: {{.TotalSize}}"
+LogStatisticsResultsCaption = "Results:"
+LogStatisticsSummaryCaption = "Summary:"
+PrefDlgAddBackupBlockHint = "Add new RSYNC source/destination block."
+PrefDlgAddProfileHint = "Add backup profile"
+PrefDlgAdvancedTabName = "Advanced"
+PrefDlgAutoManageBackupBlockSizeCaption = "Manage automatically backup block size"
+PrefDlgAutoManageBackupBlockSizeHint = "Automatically determine optimal backup block size. Application will try to split backup process to pieces to improve progress response. Backup block size may affect to backup productivity."
+PrefDlgBackupBlockSizeCaption = "Block size (in MB) to backup at once"
+PrefDlgBackupBlockSizeHint = "Block size (in megabytes) to backup at once. Application will try to split backup process to pieces to improve progress responce. Backup block size may affect to backup productivity."
+PrefDlgDefaultDestPathCaption = "Default destination path"
+PrefDlgDefaultDestPathHint = "Path to the default destination location where your backup data will be stored."
+PrefDlgDefaultLanguageEntry = ""
+PrefDlgDeleteBackupBlockCaption = "Remove"
+PrefDlgDeleteBackupBlockHint = "Delete current RSYNC source/destination block."
+PrefDlgDeleteProfileDialogText = " \nPress YES to delete profile."
+PrefDlgDeleteProfileDialogTitle = "Delete selected profile?"
+PrefDlgDeleteProfileHint = "Delete backup profile"
+PrefDlgDestinationSubpathCaption = "Destination subpath"
+PrefDlgDestinationSubpathExpressionError = "Invalid file path expression"
+PrefDlgDestinationSubpathHint = "File system extra path to add to the backup destination root folder, where data taken from this specific RSYNC source will be stored. Remember that in case of multiple RSYNC sources, destination subpath should be differ from each other in current profile scope."
+PrefDlgDestinationSubpathNotUniqueError = "Each destination subpath should be unique for backup profile"
+PrefDlgDestinationSubpathNotValidatedHint = "Not verified (disabled)"
+PrefDlgDoNotShowAtAppStartupCaption = "Do not show about information dialog\nat application startup"
+PrefDlgDoNotShowAtAppStartupHint = "Do not show at startup about dialog with general information about application."
+PrefDlgEnableBackupBlockCaption = "Active"
+PrefDlgEnableBackupBlockHint = "Include/exclude current block from backup process. You can use this option for temporary disable backup block, if you are not going purge this source forever."
+PrefDlgGeneralTabName = "General"
+PrefDlgLanguageCaption = "Language (application restart required)"
+PrefDlgLanguageHint = "User interface language. Could be automatically selected, either specified explicitly."
+PrefDlgNumberOfPreviousBackupToUseCaption = "Number of previous backup\nto use for \"deduplication\""
+PrefDlgNumberOfPreviousBackupToUseHint = "Number of previous backups used for deduplication more than 1, would increase chances for file deduplication."
+PrefDlgPreferencesDialogCaption = "Preferences"
+PrefDlgProfileConfigIssuesDetectedWarning = "Profile configuration issues detected. Check profile settings."
+PrefDlgProfileNameCaption = "Profile name"
+PrefDlgProfileNameEmptyWarning = "Empty profile name is not allowed. Please, correct the name"
+PrefDlgProfileNameExistsWarning = "Profile with name \"{{.ProfileName}}\" already exists. Please, correct the name"
+PrefDlgProfileNameHint = "Public profile name."
+PrefDlgProfileTabName = "Profile ({{.ProfileName}})"
+PrefDlgRsyncCompressFileTransferCaption = "Compress file transfer"
+PrefDlgRsyncCompressFileTransferHint = "See RSYNC --compress option.\nWith this option, rsync compresses the file data as it is sent to the destination machine, which reduces the amount of data being transmitted -- something that is useful over a slow connection."
+PrefDlgRsyncIntensiveLowLevelLogCaption = "RSYNC utility intensive low level log"
+PrefDlgRsyncIntensiveLowLevelLogHint = "Enable intensive low level log of RSYNC utility calls (include STDOUT output)."
+PrefDlgRsyncLowLevelLogCaption = "RSYNC utility low level log"
+PrefDlgRsyncLowLevelLogHint = "Enable low level logging of RSYNC utility calls."
+PrefDlgRsyncRecreateSymlinksCaption = "Recreate symlinks"
+PrefDlgRsyncRecreateSymlinksHint = "See RSYNC --links option.\nWhen symlinks are encountered, recreate the symlink on the destination."
+PrefDlgRsyncRetryCountCaption = "RSYNC utility retry count"
+PrefDlgRsyncRetryCountHint = "Number of retry attempts until RSYNC get failed. Each separate call to RSYNC will be retried corresponding number of times in case of failure."
+PrefDlgRsyncTransferDeviceFilesCaption = "Transfer device files"
+PrefDlgRsyncTransferDeviceFilesHint = "See RSYNC --devices option.\nThis option causes rsync to transfer character and block device files to the remote system to recreate these devices. This option has no effect if the receiving rsync is not run as the super-user."
+PrefDlgRsyncTransferSourceGroupCaption = "Transfer source group"
+PrefDlgRsyncTransferSourceGroupHint = "See RSYNC --group option.\nThis option causes rsync to preserve the group of the destination file - the same as the source file. If the receiving program is not running as the super-user (or if --no-super was specified), only groups that the invoking user on the receiving side is a member of will be preserved."
+PrefDlgRsyncTransferSourceOwnerCaption = "Transfer source owner"
+PrefDlgRsyncTransferSourceOwnerHint = "See RSYNC --owner option.\nThis option causes rsync to preserve the owner of the destination file - the same as the source file, but only if the receiving rsync is being run as the super-user."
+PrefDlgRsyncTransferSourcePermissionsCaption = "Transfer source permissions"
+PrefDlgRsyncTransferSourcePermissionsHint = "See RSYNC --perms option.\nThis option causes the receiving rsync to set the destination permissions to be the same as the source permissions."
+PrefDlgRsyncTransferSpecialFilesCaption = "Transfer special files"
+PrefDlgRsyncTransferSpecialFilesHint = "See RSYNC --specials option.\nThis option causes rsync to transfer special files such as named sockets and fifos."
+PrefDlgSessionLogControlFontSizeCaption = "Session log control font size"
+PrefDlgSessionLogControlFontSizeHint = "Define session log widget font size for ease of data reading."
+PrefDlgSkipFolderBackupFileSignatureCaption = "Skip folder backup file signature"
+PrefDlgSkipFolderBackupFileSignatureHint = "Once file with this name found in the folder, folder content (including all subfolders) is not backed up. If you'd like to skip backup of some folders, just put empty file with this name in there."
+PrefDlgSourceRsyncPathCaption = "Source RSYNC path"
+PrefDlgSourceRsyncPathDescriptionHint = "RSYNC source URL, which should start with \"rsync://\" prefix."
+PrefDlgSourceRsyncPathEmptyError = "RSYNC source path can not be empty"
+PrefDlgSourceRsyncPathNotValidatedHint = "Not verified (disabled)"
+PrefDlgSourceRsyncPathRetryHint = "Press to validate RSYNC source."
+PrefDlgSourceRsyncValidatingHint = "Validating... This may take some time"
+PrefDlgUsePreviousBackupForDedupCaption = "Use previous backup for\n\"deduplication\""
+PrefDlgUsePreviousBackupForDedupHint = "Each backup session is trying to find previous backup. Once previous backup found, it will be passed to RSYNC via --link-dest option, to decrease size of data send back and forth (thus significantly speed up following backup processes). It's highly recommended to do not switch off this option."
+SchemaConfigDlgNoSchemaFoundError = "No GLIB settings schema is found.\nInstall xml schema and re-run application."
+SchemaConfigDlgSchemaDoesNotFoundError = "GTK+ schema \"{{.SettingsID}}\" does not found.\nInstall xml schema and re-run application."
+SchemaConfigDlgSchemaErrorAdvise = "Find and execute script \"{{.ScriptName}}\" from application sources to resolve issue."
+SchemaConfigDlgTitle = "Schema settings configuration error"
+
+[AboutDlgAppAuthorsBlock]
+description = "Application authors block"
+other = "Written by Denis Dyakov \n"
+
+[AboutDlgAppCopyright]
+description = "Application copyright line"
+other = "Copyright {{.AppCreationYears}} {{.AppCopyrightAuthor}}"
+
+[AboutDlgAppDescriptionSection]
+description = "AboutDialog application section text"
+other = "Best GTK+ frontend for brilliant RSYNC console utility.\nEasy-to-use backup app with innovative approach.\n"
+
+[AboutDlgAppFeaturesAndBenefitsSection]
+description = "AboutDialog Features and Benefits section text"
+other = "- Multiple backup profiles can be created. Moreover, each profile\ncan be configured to get data from multiple RSYNC sources.\n- 2-pass backup session approach to estimated backup size\nin 1st pass. Display estimated time of completion in 2nd pass.\n- Demonstrate \"deduplication\" on modern file systems, once previous\nbackup found. Works if backup destination is Ext3/Ext4/NTFS\nfile system (employ file system hardlink feature). Wouldn't work\nif backup destination is FAT family, for instance.\n- Improved GOTK3+ library (GTK+ golang bindings) used for GUI.\n"
+
+[AboutDlgAppFeaturesAndBenefitsTitle]
+description = "AboutDialog Features and Benefits section title"
+other = "Features and benefits:"
+
+[AboutDlgFollowMyGithubProjectTitle]
+description = "AboutDialog my github project title"
+other = "Follow my golang projects on GitHub:"
+
+[AboutDlgReleasedUnderLicense]
+description = "License application released under"
+other = "Released under GNU {{.LicenseName}}."
+
+[AppEnvironmentTitle]
+description = "AboutDialog Environment section title"
+other = "Environment:"
+
+[AppTitleExtra]
+description = "Minor application description which might be translated"
+other = "(RSYNC frontend)"
+
+[FolderBackupTypeContentDescription]
+description = "Backup flat folder content and only files in it"
+other = "backup folder files"
+
+[FolderBackupTypeRecursiveDescription]
+description = "Backup folder content recursively"
+other = "backup folder content"
+
+[FolderBackupTypeSkipDescription]
+description = "Skip folder backup"
+other = "skip folder"
+
+[GLIBInfo]
+description = "GLIB versions info"
+other = "GLIB compiled version {{.GLIBCompiledVer}}, detected version {{.GLIBDetectedVer}}"
+
+[GTKInfo]
+description = "GTK+ versions info"
+other = "GTK+ compiled version {{.GTKCompiledVer}}, detected version {{.GTKDetectedVer}}"
+
+[GolangInfo]
+description = "Golang version and application architecture"
+other = "Compiled with {{.GolangVersion}} {{.AppArchitecture}}"
+
+[PrefDlgAdvancedBackupSettingsSection]
+description = "Options section title"
+other = "Backup settings"
+
+[PrefDlgAdvancedRsyncDedupSettingsSection]
+description = "Options section title"
+other = "RSYNC deduplication settings"
+
+[PrefDlgAdvancedRsyncFileTransferOptionsSection]
+description = "Options section title"
+other = "RSYNC file transfer options"
+
+[PrefDlgAdvansedRsyncSettingsSection]
+description = "Options section title"
+other = "RSYNC settings"
+
+[PrefDlgGeneralBackupSettingsSection]
+description = "Options section title"
+other = "Backup settings"
+
+[PrefDlgGeneralUserInterfaceOptionsSecion]
+description = "Options section title"
+other = "User interface options"
+
+[RsyncInfo]
+description = "RSYNC version/protocol info"
+other = "RSYNC detected version/protocol {{.RSYNCDetectedVer}}/{{.RSYNCDetectedProtocol}}"
diff --git a/data/assets/ajax-loader-gears_32x32.gif b/data/assets/ajax-loader-gears_32x32.gif
new file mode 100644
index 0000000..a7647b5
Binary files /dev/null and b/data/assets/ajax-loader-gears_32x32.gif differ
diff --git a/data/assets/emblem-important-red.gif b/data/assets/emblem-important-red.gif
new file mode 100644
index 0000000..debf3b3
Binary files /dev/null and b/data/assets/emblem-important-red.gif differ
diff --git a/data/assets/loading_20181001_64x64.gif b/data/assets/loading_20181001_64x64.gif
new file mode 100644
index 0000000..a86646f
Binary files /dev/null and b/data/assets/loading_20181001_64x64.gif differ
diff --git a/data/assets/translate.en.toml b/data/assets/translate.en.toml
new file mode 100644
index 0000000..4b86e35
--- /dev/null
+++ b/data/assets/translate.en.toml
@@ -0,0 +1,890 @@
+[HelloWorld]
+other = "Hello world !!!"
+
+
+#----------------------------------------------------
+# General translations throughout the application
+#----------------------------------------------------
+
+[AppEnvironmentTitle]
+description = "AboutDialog environment section title"
+other = "Environment:"
+
+[AppTitleExtra]
+description = "Minor application description"
+other = "(RSYNC frontend)"
+
+[GLIBInfo]
+description = "GLIB versions info"
+other = "GLIB compiled version {{.GLIBCompiledVer}}, detected version {{.GLIBDetectedVer}}"
+
+[GTKInfo]
+description = "GTK+ versions info"
+other = "GTK+ compiled version {{.GTKCompiledVer}}, detected version {{.GTKDetectedVer}}"
+
+[RsyncInfo]
+description = "RSYNC version/protocol info"
+other = "RSYNC detected version/protocol {{.RSYNCDetectedVer}}/{{.RSYNCDetectedProtocol}}"
+
+[GolangInfo]
+description = "Golang version and application architecture"
+other = "Compiled with {{.GolangVersion}} {{.AppArchitecture}}"
+
+[DialogYesButton]
+other = "YES"
+
+[DialogNoButton]
+other = "NO"
+
+
+#----------------------------------------------------
+# About dialog translations
+#----------------------------------------------------
+
+[AboutDlgAppFeaturesAndBenefitsTitle]
+description = "AboutDialog Features and Benefits section title"
+other = "Features and benefits:"
+
+[AboutDlgAppFeaturesAndBenefitsSection]
+description = "AboutDialog Features and Benefits section text"
+other = """
+- Multiple backup profiles can be created. Moreover, each profile
+can be configured to get data from multiple RSYNC sources.
+- 2-pass backup session approach to estimate optimal backup
+strategy and backup size in 1st pass. Display estimated
+time of completion in 2nd pass.
+- Demonstrate \"deduplication\" with repeated backup sessions
+on modern file systems. Works if backup destination is
+Ext3/Ext4/NTFS file system (employ file system hardlink
+feature). Wouldn't work if backup destination is
+FAT family, for instance.
+- Improved GOTK3+ library (GTK+ golang bindings) used for GUI.
+"""
+
+[AboutDlgAppDescriptionSection]
+description = "AboutDialog application section text"
+other = """
+Best GTK+ frontend for brilliant RSYNC console utility.
+Easy-to-use backup app with innovative approach.
+"""
+
+[AboutDlgReleasedUnderLicense]
+description = "License application released under"
+other = "Released under {{.LicenseName}}."
+
+[AboutDlgFollowMyGithubProjectTitle]
+description = "AboutDialog my github project title"
+other = "Follow my golang projects on GitHub:"
+
+[AboutDlgAppCopyright]
+description = "Application copyright line"
+other = "Copyright {{.AppCreationYears}} {{.AppCopyrightAuthor}}"
+
+[AboutDlgAppAuthorsBlock]
+description = "Application authors block"
+other = """
+Written by Denis Dyakov
+"""
+
+[AboutDlgDoNotShowCaption]
+other = "Do not show this information at application startup"
+
+
+#----------------------------------------------------
+# Preference dialog translations
+#----------------------------------------------------
+
+[PrefDlgGeneralUserInterfaceOptionsSecion]
+description = "Options section title"
+other = "User interface options"
+
+[PrefDlgGeneralBackupSettingsSection]
+description = "Options section title"
+other = "Backup settings"
+
+[PrefDlgAdvancedRsyncDedupSettingsSection]
+description = "Options section title"
+other = "RSYNC deduplication settings"
+
+[PrefDlgAdvansedRsyncSettingsSection]
+description = "Options section title"
+other = "RSYNC settings"
+
+[PrefDlgAdvancedBackupSettingsSection]
+description = "Options section title"
+other = "Backup settings"
+
+[PrefDlgAdvancedRsyncFileTransferOptionsSection]
+description = "Options section title"
+other = "RSYNC file transfer options"
+
+[PrefDlgDoNotShowAtAppStartupCaption]
+other = "Do not show about information dialog\nat application startup"
+
+[PrefDlgDoNotShowAtAppStartupHint]
+other = "Do not show at startup about dialog with general information about application."
+
+[PrefDlgSessionLogControlFontSizeCaption]
+other = "Session log widget font size"
+
+[PrefDlgSessionLogControlFontSizeHint]
+other = "Define session log widget font size for ease of data reading."
+
+[PrefDlgSourcesCaption]
+other = "Sources"
+
+[PrefDlgSourceRsyncPathCaption]
+other = "Source RSYNC path"
+
+[PrefDlgSourceRsyncPathRetryHint]
+other = "Press to validate RSYNC source."
+
+[PrefDlgSourceRsyncPathDescriptionHint]
+other = "RSYNC source URL, which should start with \"rsync://\" prefix."
+
+[PrefDlgSourceRsyncPathNotValidatedHint]
+other = "Not verified (disabled)"
+
+[PrefDlgSourceRsyncPathEmptyError]
+other = "RSYNC source path can not be empty"
+
+[PrefDlgSourceRsyncValidatingHint]
+other = "Validating... This may take some time"
+
+[PrefDlgDestinationSubpathCaption]
+other = "Destination subpath"
+
+[PrefDlgDestinationSubpathHint]
+other = "File system extra path to add to the backup destination root folder, where data taken from this specific RSYNC source will be stored. Remember that in case of multiple RSYNC sources, destination subpath should be differ from each other in current profile scope."
+
+[PrefDlgDestinationSubpathNotValidatedHint]
+other = "Not verified (disabled)"
+
+[PrefDlgDestinationSubpathExpressionError]
+other = "Invalid file path expression. Perhaps path string contains some restricted characters, like <>:\"|?*, or empty folder names"
+
+[PrefDlgDestinationSubpathNotUniqueError]
+other = "Each destination subpath should be unique for backup profile"
+
+[PrefDlgEnableBackupBlockCaption]
+other = "Active"
+
+[PrefDlgEnableBackupBlockHint]
+other = "Include/exclude current block from backup process. You can use this option for temporary disable backup block, if you are not going purge this source forever."
+
+[PrefDlgDeleteBackupBlockCaption]
+other = "Remove"
+
+[PrefDlgDeleteBackupBlockHint]
+other = "Delete current RSYNC source/destination block"
+
+[PrefDlgProfileNameCaption]
+other = "Profile name"
+
+[PrefDlgProfileNameHint]
+other = "Public profile name."
+
+[PrefDlgProfileNameExistsWarning]
+other = "Profile with name \"{{.ProfileName}}\" already exists. Please, correct the name"
+
+[PrefDlgProfileNameEmptyWarning]
+other = "Empty profile name is not allowed. Please, correct the name"
+
+[PrefDlgDefaultDestPathCaption]
+other = "Default destination path"
+
+[PrefDlgDefaultDestPathHint]
+other = "Path to the default destination location where your backup data will be stored."
+
+[PrefDlgSkipFolderBackupFileSignatureCaption]
+other = "Skip folder backup file signature"
+
+[PrefDlgSkipFolderBackupFileSignatureHint]
+other = "Once file with this name found in the folder, folder content (including all subfolders) is not backed up. If you'd like to skip backup of some folders, just put empty file with this name in there."
+
+[PrefDlgPerformDesktopNotificationCaption]
+other = "Show desktop notification on backup completion"
+
+[PrefDlgPerformDesktopNotificationHint]
+other = "Display message in desktop tray location about backup completion."
+
+[PrefDlgRunNotificationScriptCaption]
+other = "Run notification script on backup completion"
+
+[PrefDlgRunNotificationScriptHint]
+other = """Run script \"/etc/gorsync/notification.sh\" (if exists) on backup completion.
+Next environment variables are passed to the script:
+- BACKUP_STATUS: overall backup status. Might accept values 'terminated', 'failed', 'done', 'done_with_errors'.
+- SIZE_BACKEDUP_MB: total size of data successfully backed up in megabytes.
+- SIZE_FAILED_MB: size failed to backup due to errors in megabytes.
+- SIZE_SKIPPED_MB: size skipped to backup in megabytes.
+- TIME_TAKEN_SEC: time taken for whole backup process in seconds."""
+
+[PrefDlgAutoManageBackupBlockSizeCaption]
+other = "Manage automatically backup block size"
+
+[PrefDlgAutoManageBackupBlockSizeHint]
+other = "Automatically determine optimal backup block size. Application is trying to split backup process to pieces to improve progress response. Backup block size may affect to backup productivity."
+
+[PrefDlgBackupBlockSizeCaption]
+other = "Block size (in MB) to backup at once"
+
+[PrefDlgBackupBlockSizeHint]
+other = "Block size (in megabytes) to backup at once. Application is trying to split backup process to pieces to improve progress response. Backup block size may affect to backup productivity."
+
+[PrefDlgRsyncRetryCountCaption]
+other = "RSYNC utility retry count"
+
+[PrefDlgRsyncRetryCountHint]
+other = "Number of retry attempts until RSYNC get failed. Each separate call to RSYNC will be retried corresponding number of times in case of failure."
+
+[PrefDlgRsyncLowLevelLogCaption]
+other = "RSYNC utility low level log"
+
+[PrefDlgRsyncLowLevelLogHint]
+other = "Enable low level logging of RSYNC utility calls, saving results in separate log file."
+
+[PrefDlgRsyncIntensiveLowLevelLogCaption]
+other = "RSYNC utility intensive low level log"
+
+[PrefDlgRsyncIntensiveLowLevelLogHint]
+other = "Enable intensive low level log of RSYNC utility calls (include STDOUT output)."
+
+[PrefDlgUsePreviousBackupForDedupCaption]
+other = "Use previous backup for\"deduplication\""
+
+[PrefDlgUsePreviousBackupForDedupHint]
+other = "Each backup session is trying to find previous backup. Once previous backup found, it will be passed to RSYNC via --link-dest option, to decrease size of data send back and forth (thus significantly speed up following backup processes). It's highly recommended to do not switch off this option."
+
+[PrefDlgNumberOfPreviousBackupToUseCaption]
+other = "Number of previous backup sessions to use\nfor \"deduplication\""
+
+[PrefDlgNumberOfPreviousBackupToUseHint]
+other = "Number of previous backup sessions used for deduplication greater than 1, would increase chances for file deduplication occurrence."
+
+[PrefDlgRsyncCompressFileTransferCaption]
+other = "Compress file transfer"
+
+[PrefDlgRsyncCompressFileTransferHint]
+other = "See RSYNC --compress option.\nWith this option, RSYNC compresses the file data as it is sent to the destination machine, which reduces the amount of data being transmitted - something that is useful over a slow connection."
+
+[PrefDlgRsyncTransferSourcePermissionsCaption]
+other = "Transfer source permissions"
+
+[PrefDlgRsyncTransferSourcePermissionsHint]
+other = "See RSYNC --perms option.\nThis option causes the receiving RSYNC to set the destination permissions to be the same as the source permissions."
+
+[PrefDlgRsyncTransferSourceOwnerCaption]
+other = "Transfer source owner"
+
+[PrefDlgRsyncTransferSourceOwnerHint]
+other = "See RSYNC --owner option.\nThis option causes RSYNC to preserve the owner of the destination file - the same as the source file, but only if the receiving RSYNC is being run as the super-user."
+
+[PrefDlgRsyncTransferSourceGroupCaption]
+other = "Transfer source group"
+
+[PrefDlgRsyncTransferSourceGroupHint]
+other = "See RSYNC --group option.\nThis option causes RSYNC to preserve the group of the destination file - the same as the source file. If the receiving program is not running as the super-user (or if --no-super was specified), only groups that the invoking user on the receiving side is a member of will be preserved."
+
+[PrefDlgRsyncRecreateSymlinksCaption]
+other = "Recreate symlinks"
+
+[PrefDlgRsyncRecreateSymlinksHint]
+other = "See RSYNC --links option.\nWhen symlinks are encountered, recreate the symlink on the destination."
+
+[PrefDlgRsyncTransferDeviceFilesCaption]
+other = "Transfer device files"
+
+[PrefDlgRsyncTransferDeviceFilesHint]
+other = "See RSYNC --devices option.\nThis option causes RSYNC to transfer character and block device files to the remote system to recreate these devices. This option has no effect if the receiving RSYNC is not run as the super-user."
+
+[PrefDlgRsyncTransferSpecialFilesCaption]
+other = "Transfer special files"
+
+[PrefDlgRsyncTransferSpecialFilesHint]
+other = "See RSYNC --specials option.\nThis option causes RSYNC to transfer special files such as named sockets and fifos."
+
+[PrefDlgLanguageCaption]
+decsription = ""
+other = "Language (application restart required)"
+
+[PrefDlgLanguageHint]
+decsription = ""
+other = "User interface language. Could be automatically selected (depending on system settings), either specified explicitly."
+
+[PrefDlgDefaultLanguageEntry]
+decsription = "Combo Box entry to specify language select by default"
+other = ""
+
+[PrefDlgAddBackupBlockHint]
+other = "Add new RSYNC source/destination block"
+
+[PrefDlgProfileConfigIssuesDetectedWarning]
+other = "Profile configuration issues detected. Check profile settings."
+
+[PrefDlgPreferencesDialogCaption]
+other = "Preferences"
+
+[PrefDlgGeneralProfileTabName]
+other = "Backup profile"
+
+[PrefDlgProfileTabName]
+other = "Profile ({{.ProfileName}})"
+
+[PrefDlgGeneralTabName]
+other = "General"
+
+[PrefDlgAdvancedTabName]
+other = "Advanced"
+
+[PrefDlgAddProfileHint]
+other = "Add backup profile"
+
+[PrefDlgDeleteProfileHint]
+other = "Delete backup profile"
+
+[PrefDlgDeleteProfileDialogTitle]
+other = "Delete selected profile?"
+
+[PrefDlgDeleteProfileDialogText]
+other = """Press {{.YesButton}} to delete profile.
+"""
+
+[SchemaConfigDlgTitle]
+other = "Schema settings configuration error"
+
+[SchemaConfigDlgNoSchemaFoundError]
+other = """No GLIB settings schema is found.
+Install xml schema and re-run application."""
+
+[SchemaConfigDlgSchemaDoesNotFoundError]
+other = """GLIB schema \"{{.SettingsID}}\" does not found.
+Install xml schema and re-run application."""
+
+[SchemaConfigDlgSchemaErrorAdvise]
+other = "Find and execute script \"{{.ScriptName}}\" from application sources to resolve the issue."
+
+
+#----------------------------------------------------
+# Application window translations
+#----------------------------------------------------
+
+[AppWindowAboutMenuCaption]
+other = "About"
+
+[AppWindowPreferencesMenuCaption]
+other = "Preferences"
+
+[AppWindowPreferencesHint]
+other = "Show preferences"
+
+[AppWindowQuitMenuCaption]
+other = "Quit application"
+
+[AppWindowRunBackupHint]
+other = "Run backup process"
+
+[AppWindowStopBackupHint]
+other = "Terminate backup process"
+
+[AppWindowProfileCaption]
+other = "Select backup profile"
+
+[AppWindowProfileHint]
+other = "Choose profile defined in preference menu from the drop down list to start backup process. Profile contains all specific settings including RSYNC sources and destination location to backup."
+
+[AppWindowProfileBackupPlanInfoSourceCount]
+other = "RSYNC sources:"
+
+[AppWindowProfileBackupPlanInfoTotalSize]
+other = "Total size:"
+
+[AppWindowProfileBackupPlanInfoSkipSize]
+other = "Skip size:"
+
+[AppWindowProfileBackupPlanInfoDirectoryCount]
+other = "Directory count:"
+
+[AppWindowInquiringProfileStatus]
+other = "Inquiring backup profile \"{{.ProfileName}}\"... Please wait, this may take some time."
+
+[AppWindowNoneProfileEntry]
+other = ""
+
+[AppWindowDestPathCaption]
+other = "Destination root path"
+
+[AppWindowDestPathHint]
+other = "Destination path for backup. You can alter default path taken from profile preferences."
+
+[AppWindowDestPathIsValidStatusPart1]
+other = "Path"
+
+[AppWindowDestPathIsValidStatusPart2]
+other = "is valid"
+
+[AppWindowDestPathIsEmptyError1]
+other = "Path is not defined. Select path manually or assign default path to backup profile in preference menu."
+
+[AppWindowDestPathIsEmptyError2]
+other = "Destination path is empty. First select destination folder for backup."
+
+[AppWindowDestPathIsNotExistError]
+other = "Folder \"{{.FolderPath}}\" does not exist or permission denied."
+
+[AppWindowDestPathIsNotExistAdvise]
+other = "Perhaps, you need to attach destination USB-disk or flash drive, and re-select backup profile again."
+
+[AppWindowBackupProgressStartMessage]
+other = "Start backup process..."
+
+[AppWindowBackupProgressInquiringSourceID]
+other = "Inquiring source #{{.SourceID}}..."
+
+[AppWindowBackupProgressInquiringSourceDescription]
+other = "source: \"{{.RsyncSource}}\""
+
+[AppWindowBackupProgressTimePassedSuffix]
+other = "passed"
+
+[AppWindowBackupProgressETASuffix]
+other = "ETA"
+
+[AppWindowBackupProgressSizeCompletedSuffix]
+other = "done"
+
+[AppWindowBackupProgressSizeLeftToProcessSuffix]
+other = "left to process"
+
+[AppWindowBackupProgressCompleted]
+other = "Successfully completed!"
+
+[AppWindowBackupProgressCompletedWithErrors]
+other = "Completed with errors!"
+
+[AppWindowBackupProgressTerminated]
+other = "Terminated!"
+
+[AppWindowBackupProgressFailed]
+other = "Failed!"
+
+[AppWindowOverallProgressCaption]
+other = "Overall progress"
+
+[AppWindowProgressStatusCaption]
+other = "Progress status"
+
+[AppWindowSessionLogCaption]
+other = "Session log"
+
+[AppWindowCannotStartBackupProcessTitle]
+other = "Can't start backup process"
+
+[AppWindowTerminateBackupDlgTitle]
+other = "Terminate backup process?"
+
+[AppWindowTerminateBackupDlgText]
+other = """
+Press {{.TerminateButton}} to interrupt backup procedure.
+Press {{.EscapeKey}} key either {{.ContinueButton}} to continue.
+"""
+
+[AppWindowTerminateBackupDlgTerminateButton]
+other = "TERMINATE"
+
+[AppWindowTerminateBackupDlgContinueButton]
+other = "CONTINUE"
+
+[AppWindowOutOfSpaceDlgTitle]
+other = "Out of disk space detected"
+
+[AppWindowOutOfSpaceDlgText1]
+other = """
+Destination path \"{{.Path}}\" is out of space: {{.FreeSpace}} lefts."""
+
+[AppWindowOutOfSpaceDlgText2]
+other = """
+Free up disk space and press {{.RetryButton}} for new retry attempt.
+Press {{.EscapeKey}} key either {{.IgnoreButton}} to skip new retry attempt, but continue backup process.
+Press {{.TerminateButton}} to interrupt backup process."""
+
+[AppWindowOutOfSpaceDlgIgnoreButton]
+other = "IGNORE"
+
+[AppWindowOutOfSpaceDlgRetryButton]
+other = "RETRY"
+
+[AppWindowOutOfSpaceDlgTerminateButton]
+other = "TERMINATE"
+
+[AppWindowRsyncUtilityDlgTitle]
+other = "RSYNC utility not found"
+
+[AppWindowRsyncUtilityDlgNotFoundError]
+other = """Test RSYNC call get failed.
+Install RSYNC and restart application."""
+
+[AppWindowShowNotificationError]
+other = "Can't show desktop notification: {{.Error}}"
+
+[AppWindowRunNotificationScriptError]
+other = "Can't run notification script: {{.Error}}"
+
+[AppWindowNotificationScriptExecutableError]
+other = "Can't run notification script \"{{.ScriptPath}}\" because it isn't executable"
+
+[AppWindowGetExecutableScriptInfoError]
+other = "Can't get information about notification script: {{.Error}}"
+
+[GeneralHintStatusCaption]
+other = "Status:"
+
+[GeneralHintDescriptionCaption]
+other = "Description:"
+
+
+#----------------------------------------------------
+# Log translations
+#----------------------------------------------------
+
+[LogPlanStageStarting]
+other = "Starting plan stage..."
+
+[LogPlanStageStartTime]
+other = "Start time: {{.Time}}"
+
+[LogPlanStageEndTime]
+other = "End time: {{.Time}}"
+
+[LogPlanStartIterateViaNSources]
+one = "Iterate via {{.SourceCount}} RSYNC source to estimate folder structures and sizes..."
+other = "Iterate via {{.SourceCount}} RSYNC sources to estimate folder structures and sizes..."
+
+[LogPlanStageInquirySource]
+other = "Inquiry information from source #{{.SourceID}} \"{{.Path}}\""
+
+[LogPlanStageSourceFolderCountInfo]
+one = "{{.FolderCount}} folder found"
+other = "{{.FolderCount}} folders found"
+
+[LogPlanStageSourceSkipFolderCountInfo]
+one = "where {{.SkipFolderCount}} folder will be skipped"
+other = "where {{.SkipFolderCount}} folders will be skipped"
+
+[LogPlanStageSourceTotalSizeInfo]
+other = "with total size of {{.TotalSize}} to process"
+
+[LogPlanStageUseTemporaryFolder]
+other = "Use temporary folder to get and analyze backup directory structure: \"{{.Path}}\""
+
+[LogBackupStageStarting]
+other = "Starting backup stage..."
+
+[LogBackupStageStartTime]
+other = "Start time: {{.Time}}"
+
+[LogBackupStageEndTime]
+other = "End time: {{.Time}}"
+
+[LogBackupStageBackupToDestination]
+other = "Backup data to destination path: \"{{.Path}}\""
+
+[LogBackupStagePreviousBackupDiscoveryPermissionError]
+other = "Error reading folder \"{{.Path}}\": permission denied"
+
+[LogBackupStagePreviousBackupDiscoveryOtherError]
+other = "Error reading folder \"{{.Path}}\": {{.Error}}"
+
+[LogBackupStagePreviousBackupFoundAndWillBeUsed]
+other = "Previous backups found (and will be used) at root path \"{{.Path}}\":"
+
+[LogBackupStagePreviousBackupFoundButDisabled]
+other = "Previous backups found (but disabled to use) at root path \"{{.Path}}\":"
+
+[LogBackupStagePreviousBackupNotFound]
+other = "There is no valid previous backup found (neither time acceleration nor reduction in size are expected)"
+
+[LogBackupStageStartToBackupFromSource]
+other = "Start to backup from source #{{.SeqID}}: {{.RsyncSource}}"
+
+[LogBackupStageRenameDestination]
+other = "Rename destination path to: \"{{.Path}}\""
+
+[LogBackupStageFailedToCreateFolder]
+other = "failed to create folder \"{{.Path}}\": {{.Error}}"
+
+[LogBackupDetectedTotalBackupSizeGetChanged]
+other = "Detected, that originally established total backup size get changed."
+
+[LogBackupStageProgressBackupSuccess]
+other = "{{.SizeLeft}}, {{.TimeLeft}} left, {{.BackupAction}}: {{.FolderPath}}"
+
+[LogBackupStageProgressBackupError]
+other = "{{.Error}} to backup {{.Size}} to {{.FolderPath}}"
+
+[LogBackupStageProgressSkipBackupError]
+other = "{{.Error}} to skip backup {{.Size}} to {{.FolderPath}}"
+
+[LogBackupStageCriticalError]
+other = "Critical issue: {{.Error}}"
+
+[LogBackupStageOutOfSpaceWarning]
+other = "Destination path is out of space, only {{.SizeLeft}} left"
+
+[LogBackupStageDiscoveringPreviousBackups]
+other = "Discovering previous backup sessions..."
+
+[LogBackupStageRecoveredFromError]
+other = "Recovered from \"{{.Error}}\""
+
+[LogBackupStageSaveRsyncExtraLogTo]
+other = "RSYNC extra log saved to: \"{{.Path}}\""
+
+[LogBackupStageSaveLogTo]
+other = "Log saved to: \"{{.Path}}\""
+
+[LogBackupStageExitMessage]
+other = "Goodbye..."
+
+[LogStatisticsSummaryCaption]
+other = "Summary:"
+
+[LogStatisticsEnvironmentCaption]
+other = "Environment:"
+
+[LogStatisticsResultsCaption]
+other = "Results:"
+
+[LogStatisticsStatusCaption]
+other = "Status:"
+
+[LogStatisticsStatusSuccessfullyCompleted]
+other = "Successfully completed"
+
+[LogStatisticsStatusCompletedWithErrors]
+other = "Completed with errors"
+
+[LogStatisticsPlanStageCaption]
+other = "Plan stage:"
+
+[LogStatisticsPlanStageSourceToBackup]
+other = "Source #{{.SeqID}} to backup: {{.RsyncSource}}"
+
+[LogStatisticsPlanStageTotalSize]
+other = "Total size to backup: {{.TotalSize}}"
+
+[LogStatisticsPlanStageFolderCount]
+other = "Folder's count total: {{.FolderCount}}"
+
+[LogStatisticsPlanStageFolderSkipCount]
+other = "Folder's skip occurrence count: {{.FolderCount}}"
+
+[LogStatisticsPlanStageTimeTaken]
+other = "Time taken: {{.TimeTaken}}"
+
+[LogStatisticsBackupStageCaption]
+other = "Backup stage:"
+
+[LogStatisticsBackupStageDestinationPath]
+other = "Destination path: \"{{.Path}}\""
+
+[LogStatisticsBackupStagePreviousBackupFound]
+other = "Previous backups found at root path \"{{.Path}}\":"
+
+[LogStatisticsBackupStagePreviousBackupFoundButDisabled]
+other = "Previous backups found (but NOT USED: disabled in settings) at root path \"{{.Path}}\":"
+
+[LogStatisticsBackupStageNoValidPreviousBackupFound]
+other = "There is no valid previous backup found"
+
+[LogStatisticsBackupStageTotalSize]
+other = "Successfully backed up size: {{.TotalSize}}"
+
+[LogStatisticsBackupStageSkippedSize]
+other = "Skipped size: {{.SkippedSize}}"
+
+[LogStatisticsBackupStageFailedToBackupSize]
+other = "Failed to backup size: {{.FailedToBackupSize}}"
+
+[LogStatisticsBackupStageTimeTaken]
+other = "Time taken: {{.TimeTaken}}"
+
+
+#----------------------------------------------------
+# Backup type translations
+#----------------------------------------------------
+
+[FolderBackupTypeSkipDescription]
+description = "Skip folder backup"
+other = "skip folder"
+
+[FolderBackupTypeRecursiveDescription]
+description = "Backup folder content recursively"
+other = "backup folder content"
+
+[FolderBackupTypeContentDescription]
+description = "Backup flat folder content and only files in it"
+other = "backup folder files"
+
+
+#----------------------------------------------------
+# Desktop notification translations
+#----------------------------------------------------
+
+[DesktopNotificationBackupSuccessfullyCompleted]
+other = "Backup \"{{.ProfileName}}\" successfully completed"
+
+[DesktopNotificationBackupCompletedWithErrors]
+other = "Backup \"{{.ProfileName}}\" completed with errors"
+
+[DesktopNotificationBackupTerminated]
+other = "Backup \"{{.ProfileName}}\" terminated"
+
+[DesktopNotificationBackupFailed]
+other = "Backup \"{{.ProfileName}}\" failed"
+
+[DesktopNotificationTotalSize]
+other = "Backed up: {{.TotalSize}}."
+
+[DesktopNotificationSkippedSize]
+other = "Skipped: {{.SkippedSize}}."
+
+[DesktopNotificationFailedToBackupSize]
+other = "Failed: {{.FailedToBackupSize}}."
+
+[DesktopNotificationTimeTaken]
+other = "Time taken: {{.TimeTaken}}."
+
+
+#----------------------------------------------------
+# RSYNC translations
+#----------------------------------------------------
+
+[RsyncCallFailedError]
+other = "RSYNC call failed ({{.Description}}, code {{.ExitCode}})"
+
+[RsyncProcessTerminatedError]
+other = "RSYNC process terminated"
+
+[RsyncCannotFindFolderSizeOutputError]
+other = "can't find folder size in RSYNC output"
+
+[RsyncCannotParseFolderSizeOutputError]
+other = "can't parse folder size from RSYNC output \"{{.Text}}\""
+
+
+#----------------------------------------------------
+# Values translations
+#----------------------------------------------------
+
+[DaysLong]
+description = "Plural case"
+one = "day"
+other = "days"
+
+[DaysShort]
+description = "Plural case"
+one = "day"
+other = "days"
+
+[HoursLong]
+description = "Plural case"
+one = "hour"
+other = "hours"
+
+[HoursShort]
+description = "Plural case"
+one = "hr"
+other = "hr"
+
+[MinutesLong]
+description = "Plural case"
+one = "minute"
+other = "minutes"
+
+[MinutesShort]
+description = "Plural case"
+one = "min"
+other = "min"
+
+[SecondsLong]
+description = "Plural case"
+one = "second"
+other = "seconds"
+
+[SecondsShort]
+description = "Plural case"
+one = "sec"
+other = "sec"
+
+
+[BytesLong]
+description = "Plural case"
+one = "byte"
+other = "bytes"
+
+[BytesShort]
+description = "Plural case"
+one = "B"
+other = "B"
+
+[KiloBytesLong]
+description = "Plural case"
+one = "kilobyte"
+other = "kilobytes"
+
+[KiloBytesShort]
+description = "Plural case"
+one = "kB"
+other = "kB"
+
+[MegaBytesLong]
+description = "Plural case"
+one = "megabyte"
+other = "megabytes"
+
+[MegaBytesShort]
+description = "Plural case"
+one = "MB"
+other = "MB"
+
+[GigaBytesLong]
+description = "Plural case"
+one = "gigabyte"
+other = "gigabytes"
+
+[GigaBytesShort]
+description = "Plural case"
+one = "GB"
+other = "GB"
+
+[TeraBytesLong]
+description = "Plural case"
+one = "terabyte"
+other = "terabytes"
+
+[TeraBytesShort]
+description = "Plural case"
+one = "TB"
+other = "TB"
+
+[PetaBytesLong]
+description = "Plural case"
+one = "petabyte"
+other = "petabytes"
+
+[PetaBytesShort]
+description = "Plural case"
+one = "PB"
+other = "PB"
+
+[ExaBytesLong]
+description = "Plural case"
+one = "exabyte"
+other = "exabytes"
+
+[ExaBytesShort]
+description = "Plural case"
+one = "EB"
+other = "EB"
+
diff --git a/data/assets/translate.ru.toml b/data/assets/translate.ru.toml
new file mode 100644
index 0000000..8e8d6d8
--- /dev/null
+++ b/data/assets/translate.ru.toml
@@ -0,0 +1,924 @@
+[HelloWorld]
+other = "Привет Мир!!!"
+
+
+#----------------------------------------------------
+# General translations throughout the application
+#----------------------------------------------------
+
+[AppEnvironmentTitle]
+description = "AboutDialog environment section title"
+other = "Окружение:"
+
+[AppTitleExtra]
+description = "Minor application description"
+other = "(оболочка для RSYNC)"
+
+[GLIBInfo]
+description = "GLIB versions info"
+other = "GLIB скомпилирована версия {{.GLIBCompiledVer}}, обнаружена версия {{.GLIBDetectedVer}}"
+
+[GTKInfo]
+description = "GTK+ versions info"
+other = "GTK+ скомпилирована версия {{.GTKCompiledVer}}, обнаружена версия {{.GTKDetectedVer}}"
+
+[RsyncInfo]
+description = "RSYNC version/protocol info"
+other = "RSYNC обнаружена версия/протокол {{.RSYNCDetectedVer}}/{{.RSYNCDetectedProtocol}}"
+
+[GolangInfo]
+description = "Golang version and application architecture"
+other = "Скомпилировано с {{.GolangVersion}} {{.AppArchitecture}}"
+
+[DialogYesButton]
+other = "ДА"
+
+[DialogNoButton]
+other = "НЕТ"
+
+
+#----------------------------------------------------
+# About dialog translations
+#----------------------------------------------------
+
+[AboutDlgAppFeaturesAndBenefitsTitle]
+description = "AboutDialog Features and Benefits section title"
+other = "Особенности и преимущества:"
+
+[AboutDlgAppFeaturesAndBenefitsSection]
+description = "AboutDialog Features and Benefits section text"
+other = """
+- Позволяет создать множество профилей резервного копирования.
+При этом каждый профиль может быть настроен для получения
+данных из нескольких источников RSYNC.
+- 2-x проходной подход в резервном копировании для выбора стратегии и
+расчета размера резервной копии в 1-м проходе. Расчетное время
+завершения отображается во время 2-го прохода.
+- При повторном резервном копировании сокращает размер резервной
+копии и время работы за счет фукнции "дедупликация"
+работающей на современных файловых системах. Успешно
+работает на файловых системах Ext3/Ext4/NTFS (через
+использование опции "жесткая ссылка"). Не будет работать,
+в частности, если файловая система из семейства FAT.
+- Используется улучшенная библиотека GOTK3+ (библиотека привязки
+к GTK+) для графического интерейса приложения.
+"""
+
+[AboutDlgAppDescriptionSection]
+description = "AboutDialog application section text"
+other = """
+Лучшая GTK+ оболочка для гениальной утилиты RSYNC.
+Простое в использовании приложение резервного
+копирования с инновационным подходом.
+"""
+
+[AboutDlgReleasedUnderLicense]
+description = "License application released under"
+other = "Выпущено под лицензией {{.LicenseName}}."
+
+[AboutDlgFollowMyGithubProjectTitle]
+description = "AboutDialog my github project title"
+other = "Вы можете найти мои golang проекты на GitHub:"
+
+[AboutDlgAppCopyright]
+description = "Application copyright line"
+other = "Copyright {{.AppCreationYears}} {{.AppCopyrightAuthor}}"
+
+[AboutDlgAppAuthorsBlock]
+description = "Application authors block"
+other = """
+Автор Денис Дьяков
+"""
+
+[AboutDlgDoNotShowCaption]
+other = "Не показывать это окно при запуске приложения"
+
+
+#----------------------------------------------------
+# Preference dialog translations
+#----------------------------------------------------
+
+[PrefDlgGeneralUserInterfaceOptionsSecion]
+description = "Options section title"
+other = "Настройки графического интерейса"
+
+[PrefDlgGeneralBackupSettingsSection]
+description = "Options section title"
+other = "Настройки резервного копирования"
+
+[PrefDlgAdvancedRsyncDedupSettingsSection]
+description = "Options section title"
+other = "Настройки \"дедупликации\""
+
+[PrefDlgAdvansedRsyncSettingsSection]
+description = "Options section title"
+other = "Настройки утилиты RSYNC"
+
+[PrefDlgAdvancedBackupSettingsSection]
+description = "Options section title"
+other = "Настройки резервного копирования"
+
+[PrefDlgAdvancedRsyncFileTransferOptionsSection]
+description = "Options section title"
+other = "Настройки переноса данных утилиты RSYNC"
+
+[PrefDlgDoNotShowAtAppStartupCaption]
+other = "Не показывать окно \"О приложении\"\nпри запуске приложения"
+
+[PrefDlgDoNotShowAtAppStartupHint]
+other = "Не показывать окно \"О приложении\" с общей информацией при запуске приложения."
+
+[PrefDlgSessionLogControlFontSizeCaption]
+other = "Размер шрифта для элемента \"Лог сессии\""
+
+[PrefDlgSessionLogControlFontSizeHint]
+other = "Задать размер шрифта для графического элемента \"Лог сессии\", чтобы облегчить восприятие данных."
+
+[PrefDlgSourcesCaption]
+other = "Источники"
+
+[PrefDlgSourceRsyncPathCaption]
+other = "Источник данных RSYNC"
+
+[PrefDlgSourceRsyncPathRetryHint]
+other = "Нажмите для проверки доступности источника данных RSYNC."
+
+[PrefDlgSourceRsyncPathDescriptionHint]
+other = "Укажите источник данных RSYNC, который должен начинаться с \"rsync://\"."
+
+[PrefDlgSourceRsyncPathNotValidatedHint]
+other = "Не верифицируется (отключен)"
+
+[PrefDlgSourceRsyncPathEmptyError]
+other = "Источник данных RSYNC не может быть представлен пустой строкой"
+
+[PrefDlgSourceRsyncValidatingHint]
+other = "Источник данных проверяется... Это может занять некоторое время"
+
+[PrefDlgDestinationSubpathCaption]
+other = "Место хранения (доп. путь)"
+
+[PrefDlgDestinationSubpathHint]
+other = "Дополнительный путь добавленный к основному пути в файловой системе, где будут храниться данные полученные из источника данных RSYNC. Помните, что в случае множественных источников RSYNC дополнительный путь для каждого источника должен отличаться от другого внутри одного профиля."
+
+[PrefDlgDestinationSubpathNotValidatedHint]
+other = "Не верифицируется (отключен)"
+
+[PrefDlgDestinationSubpathExpressionError]
+other = "Ошибка формата файлового пути. Возможно строка содержит некоторые запрещенные символы, вроде <>:\"|?*, или пустые имена папок"
+
+[PrefDlgDestinationSubpathNotUniqueError]
+other = "Дополнительный путь для каждого источника должен отличаться от другого внутри одного профиля"
+
+[PrefDlgEnableBackupBlockCaption]
+other = "Включен"
+
+[PrefDlgEnableBackupBlockHint]
+other = "Включать/исключать текущий блок из процесса резервного копирования. Вы можете использовать эту опцию для временного отключения процесса резервного копирования для текущего источника данных, если вы не собираетесь удалить его полностью."
+
+[PrefDlgDeleteBackupBlockCaption]
+other = "Удалить"
+
+[PrefDlgDeleteBackupBlockHint]
+other = "Удалить текущий блок источника данных RSYNC и места хранения"
+
+[PrefDlgProfileNameCaption]
+other = "Имя профиля"
+
+[PrefDlgProfileNameHint]
+other = "Публичное имя профиля."
+
+[PrefDlgProfileNameExistsWarning]
+other = "Профиль с именем \"{{.ProfileName}}\" уже существует. Пожалуйста скорректируйте имя"
+
+[PrefDlgProfileNameEmptyWarning]
+other = "Имя профиля не может быть пустым. Пожалуйста скорректируйте имя"
+
+[PrefDlgDefaultDestPathCaption]
+other = "Место хранения (по умолчанию)"
+
+[PrefDlgDefaultDestPathHint]
+other = "Путь в файловой системе используемый как место хранения данных, заданный по умолчанию."
+
+[PrefDlgSkipFolderBackupFileSignatureCaption]
+other = "Имя файла для исключения резервного\nкопирования директории"
+
+[PrefDlgSkipFolderBackupFileSignatureHint]
+other = "Если файл с таким именем найден в директории, то содержимое директории не копируется (включая все поддиректории). Если вы не хотите выполнять резервное копирование определенных директорий, просто создайте пустой файл с соответствующим именем в нужном месте."
+
+[PrefDlgPerformDesktopNotificationCaption]
+other = "Выводить уведомление о завершении работы"
+
+[PrefDlgPerformDesktopNotificationHint]
+other = "Показывать уведомление о завершении процесса резервного копирования."
+
+[PrefDlgRunNotificationScriptCaption]
+other = "Запускать сприпт-уведомление по завершению работы"
+
+[PrefDlgRunNotificationScriptHint]
+other = """Запускать скрипт-уведомление \"/etc/gorsync/notification.sh\" (если существует) по завершению процесса резервного копирования.
+Следующие переменные создаются и передаются в окружение скрипта:
+- BACKUP_STATUS: итоговый статус резервного копирования. Может принимать значения 'terminated', 'failed', 'done', 'done_with_errors'.
+- SIZE_BACKEDUP_MB: полный размер данных, которые были успешно скоипированы в мегабайтах.
+- SIZE_FAILED_MB: размер данных, которые не были скопированы по причине ошибок в мегабайтах.
+- SIZE_SKIPPED_MB: размер данных, которые были проигнорированы при резервном копировании в мегабайтах.
+- TIME_TAKEN_SEC: время, которое заняло резервное копирование в секундах."""
+
+[PrefDlgAutoManageBackupBlockSizeCaption]
+other = "Автоматический выбор размера блока рез. копирования"
+
+[PrefDlgAutoManageBackupBlockSizeHint]
+other = "Автоматически выбирать размер блока резервного копирования выполняемого за один раз. Приложение разделяет процесс резервного копирования на блоки, пытаясь улучшить интерактивность процесса. Размер блока резервного копирования может повлиять на производительность резервного копирования."
+
+[PrefDlgBackupBlockSizeCaption]
+other = "Размер блока рез. копирования (в МБайт)"
+
+[PrefDlgBackupBlockSizeHint]
+other = "Размер блока резервного копирования (в МБайт) выполяемого за один раз. Приложение разделяет процесс резервного копирования на блоки, пытаясь улучшить интерактивность процесса. Размер блока резервного копирования может повлиять на производительность резервного копирования."
+
+[PrefDlgRsyncRetryCountCaption]
+other = "Количество повторных попыток запуска утилиты RSYNC"
+
+[PrefDlgRsyncRetryCountHint]
+other = "Количество повторных попыток запуска утилиты RSYNC в случае возникновения ошибок. Каждый отдельный вызов утилиты RSYNC будет обеспечен соответствующим числом повторных попыток в случае возникновения проблем."
+
+[PrefDlgRsyncLowLevelLogCaption]
+other = "Логировать вызовы утилиты RSYNC"
+
+[PrefDlgRsyncLowLevelLogHint]
+other = "Логировать вызовы утилиты RSYNC, сохраняя результаты в отдельный лог-файл."
+
+[PrefDlgRsyncIntensiveLowLevelLogCaption]
+other = "Подробно логировать вызовы утилиты RSYNC"
+
+[PrefDlgRsyncIntensiveLowLevelLogHint]
+other = "Сохранять всю детальную информацию о вызове утилиты RSYNC (включая консольный вывод STDOUT)."
+
+[PrefDlgUsePreviousBackupForDedupCaption]
+other = "Использовать предыдущие сессии резервного\nкопирования для \"дедупликации\""
+
+[PrefDlgUsePreviousBackupForDedupHint]
+other = "Каждая сессия резервного копирования будет пытаться найти и использовать предыдущии сессии. Если предыдущая сессии резервного копирования найдена, приложение передает эту информацию в утилиту RSYNC через параметр --link-dest, чтобы уменшить размер передаваемых данных (дополнительно это может сильно ускорять процесс резервного копирования). Настоятельно рекомендуется не отключать эту опцию."
+
+[PrefDlgNumberOfPreviousBackupToUseCaption]
+other = "Количество предыдущих резервных сессий\nразрешенных к использованию для \"дедупликации\""
+
+[PrefDlgNumberOfPreviousBackupToUseHint]
+other = "Количество предыдущих резервных сессий используемых для \"дедупликации\" превышающих 1, повышает шансы для \"дедупликации\"."
+
+[PrefDlgRsyncCompressFileTransferCaption]
+other = "Компрессировать переносимые данные"
+
+[PrefDlgRsyncCompressFileTransferHint]
+other = "Смотрите описание опции --compress утилиты RSYNC.\nС помощью этой опции RSYNC сжимает данные файла, когда они отправляются на конечный компьютер, что уменьшает количество передаваемых данных - что особенно полезно при медленном соединении (но помните, что это может повышать нагрузку на процессор системы - источника данных)."
+
+[PrefDlgRsyncTransferSourcePermissionsCaption]
+other = "Сохранять права доступа к файлам"
+
+[PrefDlgRsyncTransferSourcePermissionsHint]
+other = "Смотрите описание опции --perms утилиты RSYNC.\nЭтот параметр позволяет задать права доступа к файлам в месте хранения таким же образом, каким они были в источнике данных."
+
+[PrefDlgRsyncTransferSourceOwnerCaption]
+other = "Сохранять владельцев файлов"
+
+[PrefDlgRsyncTransferSourceOwnerHint]
+other = "Смотрите описание опции --owner утилиты RSYNC.\nЭтот параметр позволяет задать владельцев файлов в месте хранения таким же образом, каким они были в источнике данных, но только в случае запуска приложения с правами суперпользователя."
+
+[PrefDlgRsyncTransferSourceGroupCaption]
+other = "Сохранять группы доступа к файлам"
+
+[PrefDlgRsyncTransferSourceGroupHint]
+other = "Смотрите описание опции --group утилиты RSYNC.\nЭтот параметр позволяет задать группы доступа файлов в месте хранения таким же образом, каким они были в источнике данных. Если приложение запущено не с правами суперпользователя, то могут быть использованы только те группы, которые доступны пользователю из под которого запущено приложению."
+
+[PrefDlgRsyncRecreateSymlinksCaption]
+other = "Создавать символьные ссылки"
+
+[PrefDlgRsyncRecreateSymlinksHint]
+other = "Смотрите описание опции --links утилиты RSYNC.\nВоссоздает символьные ссылки в месте хранения, если таковые найдены в источнике данных."
+
+[PrefDlgRsyncTransferDeviceFilesCaption]
+other = "Копировать файлы устройств"
+
+[PrefDlgRsyncTransferDeviceFilesHint]
+other = "Смотрите описание опции --devices утилиты RSYNC.\nЭтот параметр разрешает RSYNC передавать файлы блочных устройства в удаленную систему для воссоздания этих устройств. Эта опция не действует, если принимающий RSYNC не запущен с правами суперпользователя."
+
+[PrefDlgRsyncTransferSpecialFilesCaption]
+other = "Копировать специальные файлы"
+
+[PrefDlgRsyncTransferSpecialFilesHint]
+other = "Смотрите описание опции --specials утилиты RSYNC.\nЭтот параметр разрешает RSYNC передавать специальные файлы, такие как именованные каналы и сокеты."
+
+[PrefDlgLanguageCaption]
+decsription = ""
+other = "Язык (требуется перезапуск приложения)"
+
+[PrefDlgLanguageHint]
+decsription = ""
+other = "Язык интерфейса приложения. Может выбираться автоматически (в зависимости от системных настроек), либо указываться явно."
+
+[PrefDlgDefaultLanguageEntry]
+decsription = "Combo Box entry to specify language select by default"
+other = "<автоматически>"
+
+[PrefDlgAddBackupBlockHint]
+other = "Добавить новый источник данных RSYNC"
+
+[PrefDlgProfileConfigIssuesDetectedWarning]
+other = "Обнаружены проблемы с конфигурацией профиля. Проверьте настройки профиля."
+
+[PrefDlgPreferencesDialogCaption]
+other = "Настройки"
+
+[PrefDlgGeneralProfileTabName]
+other = "Профиль резервного копирования"
+
+[PrefDlgProfileTabName]
+other = "Профиль ({{.ProfileName}})"
+
+[PrefDlgGeneralTabName]
+other = "Общие"
+
+[PrefDlgAdvancedTabName]
+other = "Расширенные"
+
+[PrefDlgAddProfileHint]
+other = "Добавить профиль резервного копирования"
+
+[PrefDlgDeleteProfileHint]
+other = "Удалить профиль резервного копирования"
+
+[PrefDlgDeleteProfileDialogTitle]
+other = "Вы хотите удалить выбранный профиль?"
+
+[PrefDlgDeleteProfileDialogText]
+other = """Нажмите {{.YesButton}} для удаления профиля резервного копирования.
+"""
+
+[SchemaConfigDlgTitle]
+other = "Ошибка конфигурации schema settings"
+
+[SchemaConfigDlgNoSchemaFoundError]
+other = """Не обнаружено GLIB settings schema.
+Сделайте установку соответствующей xml-схемы и перезапустите приложение."""
+
+[SchemaConfigDlgSchemaDoesNotFoundError]
+other = """GLIB схема \"{{.SettingsID}}\" не обнаружена.
+Установите соответствующую xml-схему и перезапустите приложение."""
+
+[SchemaConfigDlgSchemaErrorAdvise]
+other = "Найдите и запустите сценарий \"{{.ScriptName}}\" взятый из исходных текстов приложения чтобы устранить проблему."
+
+
+#----------------------------------------------------
+# Application window translations
+#----------------------------------------------------
+
+[AppWindowAboutMenuCaption]
+other = "О приложении"
+
+[AppWindowPreferencesMenuCaption]
+other = "Настройки"
+
+[AppWindowPreferencesHint]
+other = "Показать настройки"
+
+[AppWindowQuitMenuCaption]
+other = "Выйти из приложения"
+
+[AppWindowRunBackupHint]
+other = "Запустить процесс резервного копирования"
+
+[AppWindowStopBackupHint]
+other = "Остановить процесс резервного копирования"
+
+[AppWindowProfileCaption]
+other = "Профиль резервного копирования"
+
+[AppWindowProfileHint]
+other = "Выберите профиль резервного копирования определенный в Настройках из выпадающего списка, прежде чем запустить процесс резервного копирования. Профиль содержит все необходимые настройки, включая источники резервного копирования RSYNC и места хранения данных."
+
+[AppWindowProfileBackupPlanInfoSourceCount]
+other = "Источников данных RSYNC:"
+
+[AppWindowProfileBackupPlanInfoTotalSize]
+other = "полный размер:"
+
+[AppWindowProfileBackupPlanInfoSkipSize]
+other = "размер пропуска:"
+
+[AppWindowProfileBackupPlanInfoDirectoryCount]
+other = "кол-во директорий:"
+
+[AppWindowInquiringProfileStatus]
+other = "Запрашиваем профиль \"{{.ProfileName}}\"... Пожалуйста ждите, это может занять некоторое время."
+
+[AppWindowNoneProfileEntry]
+other = "<не выбран>"
+
+[AppWindowDestPathCaption]
+other = "Место хранения данных"
+
+[AppWindowDestPathHint]
+other = "Место хранения данных полученных в процессе резервного копирования. Вы можете вручную изменить место хранения данных полученных из настроек профиля."
+
+[AppWindowDestPathIsValidStatusPart1]
+other = "Файловый путь"
+
+[AppWindowDestPathIsValidStatusPart2]
+other = "действителен"
+
+[AppWindowDestPathIsEmptyError1]
+other = "Файловый путь не задан. Установите файловый путь вручную, либо задайте его в профиле в меню Настройки."
+
+[AppWindowDestPathIsEmptyError2]
+other = "Файловый путь определяющий место хранения данных не задан. Укажите файловый путь для хранения данных резервного копирования."
+
+[AppWindowDestPathIsNotExistError]
+other = "Директория \"{{.FolderPath}}\" не существует, либо существуют проблемы с доступом к ней."
+
+[AppWindowDestPathIsNotExistAdvise]
+other = "Возможно, вам нужно подсоединить внешний USB-диск к компьютеру, и выбрать повторно нужный профиль."
+
+[AppWindowBackupProgressStartMessage]
+other = "Запуск процесса резервного копирования..."
+
+[AppWindowBackupProgressInquiringSourceID]
+other = "Запрос источника данных #{{.SourceID}}..."
+
+[AppWindowBackupProgressInquiringSourceDescription]
+other = "источник: \"{{.RsyncSource}}\""
+
+[AppWindowBackupProgressTimePassedSuffix]
+other = "прошло"
+
+[AppWindowBackupProgressETASuffix]
+other = "ожидается до окончания"
+
+[AppWindowBackupProgressSizeCompletedSuffix]
+other = "обработано"
+
+[AppWindowBackupProgressSizeLeftToProcessSuffix]
+other = "осталось обработать"
+
+[AppWindowBackupProgressCompleted]
+other = "Успешно завершено!"
+
+[AppWindowBackupProgressCompletedWithErrors]
+other = "Завершено с ошибками!"
+
+[AppWindowBackupProgressTerminated]
+other = "Прервано!"
+
+[AppWindowBackupProgressFailed]
+other = "Закончилось неудачей!"
+
+[AppWindowOverallProgressCaption]
+other = "Общий прогресс"
+
+[AppWindowProgressStatusCaption]
+other = "Статус прогресса"
+
+[AppWindowSessionLogCaption]
+other = "Лог сессии"
+
+[AppWindowCannotStartBackupProcessTitle]
+other = "Не возможно начать процесс резервного копирования"
+
+[AppWindowTerminateBackupDlgTitle]
+other = "Прервать процесс резервного копирования?"
+
+[AppWindowTerminateBackupDlgText]
+other = """
+Нажмите {{.TerminateButton}} для остановки процесса резервного копирования.
+Нажмите клавишу {{.EscapeKey}} либо {{.ContinueButton}} для продолжения.
+"""
+
+[AppWindowTerminateBackupDlgTerminateButton]
+other = "ПРЕРВАТЬ"
+
+[AppWindowTerminateBackupDlgContinueButton]
+other = "ПРОДОЛЖИТЬ"
+
+[AppWindowOutOfSpaceDlgTitle]
+other = "Обнаружена нехватка дискового пространства"
+
+[AppWindowOutOfSpaceDlgText1]
+other = """
+По файловому пути \"{{.Path}}\" закончилось место: осталось {{.FreeSpace}}."""
+
+[AppWindowOutOfSpaceDlgText2]
+other = """
+Освободите место и нажмите {{.RetryButton}} для новой попытки резервного копирования.
+Нажмите клавишу {{.EscapeKey}} либо {{.IgnoreButton}} чтобы не пытаться исправить ошибку, но продолжить резервное копирования.
+Нажмите {{.TerminateButton}} для остановки процесса резервного копирования."""
+
+[AppWindowOutOfSpaceDlgIgnoreButton]
+other = "ПРОИГНОРИРОВАТЬ"
+
+[AppWindowOutOfSpaceDlgRetryButton]
+other = "ПОВТОРИТЬ"
+
+[AppWindowOutOfSpaceDlgTerminateButton]
+other = "ПРЕРВАТЬ"
+
+[AppWindowRsyncUtilityDlgTitle]
+other = "Утилита RSYNC не обранужена"
+
+[AppWindowRsyncUtilityDlgNotFoundError]
+other = """Тестовый вызов утилиты RSYNC провален.
+Устанровите утилиту RSYNC и запустите приложение повторно."""
+
+[AppWindowShowNotificationError]
+other = "Невозможно показать уведомление о завершении: {{.Error}}"
+
+[AppWindowRunNotificationScriptError]
+other = "Невозможно запустить скрипт-уведомление: {{.Error}}"
+
+[AppWindowNotificationScriptExecutableError]
+other = "Невозможно запустить скрипт-уведомление \"{{.ScriptPath}}\" поскольку он не является исполняемым"
+
+[AppWindowGetExecutableScriptInfoError]
+other = "Невозможно получить информацию о скрипте-уведомлении: {{.Error}}"
+
+[GeneralHintStatusCaption]
+other = "Статус:"
+
+[GeneralHintDescriptionCaption]
+other = "Описание:"
+
+
+#----------------------------------------------------
+# Log translations
+#----------------------------------------------------
+
+[LogPlanStageStarting]
+other = "Запуск стадии планирования..."
+
+[LogPlanStageStartTime]
+other = "Время начала: {{.Time}}"
+
+[LogPlanStageEndTime]
+other = "Время окончания: {{.Time}}"
+
+[LogPlanStartIterateViaNSources]
+description = "Plural case"
+one = "Перебор {{.SourceCount}} источника данных RSYNC для определения структуры и объема данных..."
+few = "Перебор {{.SourceCount}} источников данных RSYNC для определения структуры и объема данных..."
+many = "Перебор {{.SourceCount}} источников данных RSYNC для определения структуры и объема данных..."
+
+[LogPlanStageInquirySource]
+other = "Запрос информации об источнике данных #{{.SourceID}} \"{{.Path}}\""
+
+[LogPlanStageSourceFolderCountInfo]
+description = "Plural case"
+one = "{{.FolderCount}} директория найдена"
+few = "{{.FolderCount}} директории найдено"
+many = "{{.FolderCount}} директорий найдено"
+
+[LogPlanStageSourceSkipFolderCountInfo]
+description = "Plural case"
+one = "где {{.SkipFolderCount}} директория будет пропущена"
+few = "где {{.SkipFolderCount}} директорий будет пропущено"
+many = "где {{.SkipFolderCount}} директорий будет пропущено"
+
+[LogPlanStageSourceTotalSizeInfo]
+other = "с полным размером {{.TotalSize}} для обработки"
+
+[LogPlanStageUseTemporaryFolder]
+other = "Используем временную директорию для получения и оценки структуры данных: \"{{.Path}}\""
+
+[LogBackupStageStarting]
+other = "Запуск стадии резервного копирования..."
+
+[LogBackupStageStartTime]
+other = "Время начала: {{.Time}}"
+
+[LogBackupStageEndTime]
+other = "Время окончания: {{.Time}}"
+
+[LogBackupStageBackupToDestination]
+other = "Копируем данные в директорию: \"{{.Path}}\""
+
+[LogBackupStagePreviousBackupDiscoveryPermissionError]
+other = "Ошибка чтения директории \"{{.Path}}\": нет доступа"
+
+[LogBackupStagePreviousBackupDiscoveryOtherError]
+other = "Ошибка чтения директории \"{{.Path}}\": {{.Error}}"
+
+[LogBackupStagePreviousBackupFoundAndWillBeUsed]
+other = "Предыдущая сессия резервного копирования обнаружена (и будет использована) в \"{{.Path}}\":"
+
+[LogBackupStagePreviousBackupFoundButDisabled]
+other = "Предыдущая сессия резервного копирования обнаружена (но отключена для использования) в \"{{.Path}}\":"
+
+[LogBackupStagePreviousBackupNotFound]
+other = "Не обнаружено предыдущих сессий резервного копирования (не ожидается ни ускорения в работе резервного копирования, ни экономии места)"
+
+[LogBackupStageStartToBackupFromSource]
+other = "Начало копирования данных из источника #{{.SeqID}}: {{.RsyncSource}}"
+
+[LogBackupStageRenameDestination]
+other = "Переименовываем директорию куда сохранены данные в: \"{{.Path}}\""
+
+[LogBackupStageFailedToCreateFolder]
+other = "ошибка при создании директории \"{{.Path}}\": {{.Error}}"
+
+[LogBackupDetectedTotalBackupSizeGetChanged]
+other = "Обнаружено, что изначально установленный размер данных резервного копирования изменился в процессе."
+
+[LogBackupStageProgressBackupSuccess]
+other = "{{.SizeLeft}}, {{.TimeLeft}} осталось, {{.BackupAction}}: {{.FolderPath}}"
+
+[LogBackupStageProgressBackupError]
+other = "{{.Error}} при копировании {{.Size}} в {{.FolderPath}}"
+
+[LogBackupStageProgressSkipBackupError]
+other = "{{.Error}} при пропуске копирования {{.Size}} в {{.FolderPath}}"
+
+[LogBackupStageCriticalError]
+other = "Критическая проблема: {{.Error}}"
+
+[LogBackupStageOutOfSpaceWarning]
+other = "Закончилось дисковое пространство в месте хранения данных, осталось {{.SizeLeft}}"
+
+[LogBackupStageDiscoveringPreviousBackups]
+other = "Поиск предыдущих сессий резервного копирования..."
+
+[LogBackupStageRecoveredFromError]
+other = "Устранена временная проблема \"{{.Error}}\""
+
+[LogBackupStageSaveRsyncExtraLogTo]
+other = "Дополнительный лог утилиты RSYNC сохранен в: \"{{.Path}}\""
+
+[LogBackupStageSaveLogTo]
+other = "Этот лог сохранен в: \"{{.Path}}\""
+
+[LogBackupStageExitMessage]
+description = "Let's put here lovely russian mem ;)"
+other = "Вы держитесь здесь, вам всего доброго, хорошего настроения и здоровья..."
+
+[LogStatisticsSummaryCaption]
+other = "Итог:"
+
+[LogStatisticsEnvironmentCaption]
+other = "Окружение:"
+
+[LogStatisticsResultsCaption]
+other = "Результаты:"
+
+[LogStatisticsStatusCaption]
+other = "Статус:"
+
+[LogStatisticsStatusSuccessfullyCompleted]
+other = "Успешно завершено"
+
+[LogStatisticsStatusCompletedWithErrors]
+other = "Завершено с ошибками"
+
+[LogStatisticsPlanStageCaption]
+other = "Стадия планирования:"
+
+[LogStatisticsPlanStageSourceToBackup]
+other = "Источник данных #{{.SeqID}} для копирования: {{.RsyncSource}}"
+
+[LogStatisticsPlanStageTotalSize]
+other = "Полный размер для копирования: {{.TotalSize}}"
+
+[LogStatisticsPlanStageFolderCount]
+other = "Полное количество директорий: {{.FolderCount}}"
+
+[LogStatisticsPlanStageFolderSkipCount]
+other = "Количество директорий для пропуска: {{.FolderCount}}"
+
+[LogStatisticsPlanStageTimeTaken]
+other = "Затрачено времени: {{.TimeTaken}}"
+
+[LogStatisticsBackupStageCaption]
+other = "Стадия резервного копирования:"
+
+[LogStatisticsBackupStageDestinationPath]
+other = "Файловый путь для хранения данных: \"{{.Path}}\""
+
+[LogStatisticsBackupStagePreviousBackupFound]
+other = "Предыдущая сессия резервного копирования найдена в \"{{.Path}}\":"
+
+[LogStatisticsBackupStagePreviousBackupFoundButDisabled]
+other = "Предыдущая сессия резервного копирования найдена (но отключена для использования) в \"{{.Path}}\":"
+
+[LogStatisticsBackupStageNoValidPreviousBackupFound]
+other = "Не обнаружено предыдущих сессий резервного копирования"
+
+[LogStatisticsBackupStageTotalSize]
+other = "Успешно скопировано: {{.TotalSize}}"
+
+[LogStatisticsBackupStageSkippedSize]
+other = "Пропущено: {{.SkippedSize}}"
+
+[LogStatisticsBackupStageFailedToBackupSize]
+other = "Не скопировано из-за ошибок: {{.FailedToBackupSize}}"
+
+[LogStatisticsBackupStageTimeTaken]
+other = "Затрачено времени: {{.TimeTaken}}"
+
+
+#----------------------------------------------------
+# Backup type translations
+#----------------------------------------------------
+
+[FolderBackupTypeSkipDescription]
+description = "Skip folder backup"
+other = "пропуск директории"
+
+[FolderBackupTypeRecursiveDescription]
+description = "Backup folder content recursively"
+other = "полное копирование"
+
+[FolderBackupTypeContentDescription]
+description = "Backup flat folder content (only files in it)"
+other = "копирование файлов"
+
+
+#----------------------------------------------------
+# Desktop notification translations
+#----------------------------------------------------
+
+[DesktopNotificationBackupSuccessfullyCompleted]
+other = "Рез. копирование \"{{.ProfileName}}\" успешно завершено"
+
+[DesktopNotificationBackupCompletedWithErrors]
+other = "Рез. копирование \"{{.ProfileName}}\" завершено с ошибками"
+
+[DesktopNotificationBackupTerminated]
+other = "Рез. копирование \"{{.ProfileName}}\" прервано"
+
+[DesktopNotificationBackupFailed]
+other = "Рез. копирование \"{{.ProfileName}}\" закончилось неудачей"
+
+[DesktopNotificationTotalSize]
+other = "Скопировано: {{.TotalSize}}."
+
+[DesktopNotificationSkippedSize]
+other = "Пропущено: {{.SkippedSize}}."
+
+[DesktopNotificationFailedToBackupSize]
+other = "Не скопировано: {{.FailedToBackupSize}}."
+
+[DesktopNotificationTimeTaken]
+other = "Заняло: {{.TimeTaken}}."
+
+
+#----------------------------------------------------
+# RSYNC translations
+#----------------------------------------------------
+
+[RsyncCallFailedError]
+other = "RSYNC завершился с ошибкой ({{.Description}}, код {{.ExitCode}})"
+
+[RsyncProcessTerminatedError]
+other = "работа приложения RSYNC принудительно завершена"
+
+[RsyncCannotFindFolderSizeOutputError]
+other = "невозможно обнаружить информацию о размере директории в выводе RSYNC"
+
+[RsyncCannotParseFolderSizeOutputError]
+other = "невозможно получить информацию о размере директории из вывода RSYNC \"{{.Text}}\""
+
+
+#----------------------------------------------------
+# Values translations
+#----------------------------------------------------
+
+[DaysLong]
+description = "Plural case"
+one = "день"
+few = "дня"
+many = "дней"
+
+[DaysShort]
+description = "Plural case"
+one = "день"
+few = "дня"
+many = "дней"
+
+[HoursLong]
+description = "Plural case"
+one = "час"
+few = "часа"
+many = "часов"
+
+[HoursShort]
+description = "Plural case"
+one = "ч"
+few = "ч"
+many = "ч"
+
+[MinutesLong]
+description = "Plural case"
+one = "минута"
+few = "минуты"
+many = "минут"
+
+[MinutesShort]
+description = "Plural case"
+one = "мин"
+few = "мин"
+many = "мин"
+
+[SecondsLong]
+description = "Plural case"
+one = "секунда"
+few = "секунды"
+many = "секунд"
+
+[SecondsShort]
+description = "Plural case"
+one = "сек"
+few = "сек"
+many = "сек"
+
+
+[BytesLong]
+description = "Plural case"
+one = "байт"
+few = "байта"
+many = "байтов"
+
+[BytesShort]
+description = "Plural case"
+one = "Б"
+few = "Б"
+many = "Б"
+
+[KiloBytesLong]
+description = "Plural case"
+one = "килобайт"
+few = "килобайта"
+many = "килобайтов"
+
+[KiloBytesShort]
+description = "Plural case"
+one = "кбайт"
+few = "кбайт"
+many = "кбайт"
+
+[MegaBytesLong]
+description = "Plural case"
+one = "мегабайт"
+few = "мегабайта"
+many = "мегабайтов"
+
+[MegaBytesShort]
+description = "Plural case"
+one = "Мбайт"
+few = "Мбайт"
+many = "Мбайт"
+
+[GigaBytesLong]
+description = "Plural case"
+one = "гигабайт"
+few = "гигабайта"
+many = "гигабайтов"
+
+[GigaBytesShort]
+description = "Plural case"
+one = "Гбайт"
+few = "Гбайт"
+many = "Гбайт"
+other = "Гбайт"
+
+[TeraBytesLong]
+description = "Plural case"
+one = "терабайт"
+few = "терабайта"
+many = "терабайта"
+
+[TeraBytesShort]
+description = "Plural case"
+one = "Тбайт"
+few = "Тбайт"
+many = "Тбайт"
+
+[PetaBytesLong]
+description = "Plural case"
+one = "петабайт"
+few = "петабайт"
+many = "петабайт"
+
+[PetaBytesShort]
+description = "Plural case"
+one = "Пбайт"
+few = "Пбайт"
+many = "Пбайт"
+
+[ExaBytesLong]
+description = "Plural case"
+one = "эксабайт"
+few = "эксабайт"
+many = "эксабайт"
+
+[ExaBytesShort]
+description = "Plural case"
+one = "Эбайт"
+few = "Эбайт"
+many = "Эбайт"
+
diff --git a/data/data.go b/data/data.go
new file mode 100644
index 0000000..5ad12c9
--- /dev/null
+++ b/data/data.go
@@ -0,0 +1,10 @@
+// +build !gorsync_rel
+
+package data
+
+import (
+ "net/http"
+)
+
+// Assets contains project assets.
+var Assets http.FileSystem = http.Dir("data/assets")
diff --git a/data/generate/generate.go b/data/generate/generate.go
new file mode 100644
index 0000000..6c4ea0d
--- /dev/null
+++ b/data/generate/generate.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "log"
+
+ "github.com/d2r2/go-rsync/data"
+ "github.com/shurcooL/vfsgen"
+)
+
+func main() {
+ err := vfsgen.Generate(data.Assets, vfsgen.Options{
+ PackageName: "data",
+ BuildTags: "gorsync_rel",
+ VariableName: "Assets",
+ })
+ if err != nil {
+ log.Fatalln(err)
+ }
+}
diff --git a/data/graphics/emblem-important-red.xcf b/data/graphics/emblem-important-red.xcf
new file mode 100644
index 0000000..c41fd0b
Binary files /dev/null and b/data/graphics/emblem-important-red.xcf differ
diff --git a/docs/gorsync_about_dialog.png b/docs/gorsync_about_dialog.png
new file mode 100644
index 0000000..6a8e71e
Binary files /dev/null and b/docs/gorsync_about_dialog.png differ
diff --git a/docs/gorsync_main_form.png b/docs/gorsync_main_form.png
new file mode 100644
index 0000000..238a721
Binary files /dev/null and b/docs/gorsync_main_form.png differ
diff --git a/docs/gorsync_preference_dialog.png b/docs/gorsync_preference_dialog.png
new file mode 100644
index 0000000..9c02d3c
Binary files /dev/null and b/docs/gorsync_preference_dialog.png differ
diff --git a/gorsync.go b/gorsync.go
new file mode 100644
index 0000000..b24716e
--- /dev/null
+++ b/gorsync.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/ui/gtkui"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/d2r2/gotk3/libnotify"
+)
+
+var lg = logger.NewPackageLogger("main",
+ //logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+// contain version+buildnum
+// initialized with option:
+// -ldflags "-X main.version `head -1 version` -X main.buildnum `date -u +%Y%m%d%H%M%S`"
+var (
+ buildnum string
+ version string
+)
+
+func main() {
+
+ lg.Debugf("Version=%v", version)
+ lg.Debugf("Build number=%v", buildnum)
+ core.SetVersion(version)
+ core.SetBuildNum(buildnum)
+
+ locale.SetLanguage("")
+ err := libnotify.Init(core.GetAppTitle())
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ gtk.Init(nil)
+ app, err := gtkui.CreateApp()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ app.Run([]string{})
+
+ libnotify.Uninit()
+
+}
diff --git a/gorsync_build.sh b/gorsync_build.sh
new file mode 100755
index 0000000..f8bd7d3
--- /dev/null
+++ b/gorsync_build.sh
@@ -0,0 +1,99 @@
+#!/bin/bash
+#
+# Example showing use of getopt detection and use of GNU enhanced getopt
+# to handle arguments containing whitespace.
+#
+# Written in 2004 by Hoylen Sue
+# Modified in 2018 by Denis Dyakov
+#
+# To the extent possible under law, the author(s) have dedicated all copyright and
+# related and neighboring rights to this software to the public domain worldwide.
+# This software is distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication along with this software.
+# If not, see .
+
+PROG=$(basename $0)
+VERSION=v0.1
+
+# Define default values, if parameters not specified
+RELEASE_TYPE="Release"
+DEV_TYPE="Development"
+
+# Remove this trap if you are doing your own error detection or don't care about errors
+trap "echo $PROG: error encountered: aborted; exit 3" ERR
+
+#----------------------------------------------------------------
+# Process command line arguments
+
+## Define options: trailing colon means has an argument (customize this: 1 of 3)
+
+SHORT_OPTS=b:h
+LONG_OPTS=buildtype:,version,help
+
+SHORT_HELP="Usage: ${PROG} [options] arguments
+Options:
+ -b Build type. Release type = [${RELEASE_TYPE}].
+ -h Show this help message."
+
+LONG_HELP="Usage: ${PROG} [options] arguments
+Options:
+ -b | --buildtype Build type. Release type = [${RELEASE_TYPE}].
+ -h | --help Show this help message.
+ --version Show version information."
+
+# Detect if GNU Enhanced getopt is available
+
+HAS_GNU_ENHANCED_GETOPT=
+if getopt -T >/dev/null; then :
+else
+ if [ $? -eq 4 ]; then
+ HAS_GNU_ENHANCED_GETOPT=yes
+ fi
+fi
+
+# Run getopt (runs getopt first in `if` so `trap ERR` does not interfere)
+
+if [ -n "$HAS_GNU_ENHANCED_GETOPT" ]; then
+ # Use GNU enhanced getopt
+ if ! getopt --name "$PROG" --long $LONG_OPTS --options $SHORT_OPTS -- "$@" >/dev/null; then
+ echo "$PROG: usage error (use -h or --help for help)" >&2
+ exit 2
+ fi
+ ARGS=`getopt --name "$PROG" --long $LONG_OPTS --options $SHORT_OPTS -- "$@"`
+else
+ # Use original getopt (no long option names, no whitespace, no sorting)
+ if ! getopt $SHORT_OPTS "$@" >/dev/null; then
+ echo "$PROG: usage error (use -h for help)" >&2
+ exit 2
+ fi
+ ARGS=`getopt $SHORT_OPTS "$@"`
+fi
+eval set -- $ARGS
+
+## Process parsed options (customize this: 2 of 3)
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -b | --buildtype) BUILDTYPE="$2"; shift;;
+ -v | --verbose) VERBOSE=yes;;
+ -h | --help) if [ -n "$HAS_GNU_ENHANCED_GETOPT" ]
+ then echo "$LONG_HELP";
+ else echo "$SHORT_HELP";
+ fi; exit 0;;
+ --version) echo "$PROG $VERSION"; exit 0;;
+ --) shift; break;; # end of options
+ esac
+ shift
+done
+
+
+if [ "$BUILDTYPE" == "$RELEASE_TYPE" ]; then
+ echo "Release in progress..."
+ go run data/generate/generate.go && mv ./assets_vfsdata.go ./data
+ go build -v -ldflags="-X main.version=`head -1 version` -X main.buildnum=`date -u +%Y%m%d%H%M%S`" -tags gorsync_rel gorsync.go
+else
+ echo "Dev in progress..."
+ go build -v -ldflags="-X main.version=`head -1 version` -X main.buildnum=`date -u +%Y%m%d%H%M%S`" gorsync.go
+fi
+
diff --git a/gorsync_run.sh b/gorsync_run.sh
new file mode 100755
index 0000000..1586980
--- /dev/null
+++ b/gorsync_run.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+./gorsync_build.sh $@
+[ $? -eq 0 ] && ./gorsync || exit $?
diff --git a/locale/common.go b/locale/common.go
new file mode 100644
index 0000000..6ff8c1d
--- /dev/null
+++ b/locale/common.go
@@ -0,0 +1,15 @@
+package locale
+
+import (
+ "fmt"
+
+ "github.com/d2r2/go-logger"
+)
+
+var lg = logger.NewPackageLogger("i18n",
+ // logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+var e = fmt.Errorf
+var f = fmt.Sprintf
diff --git a/locale/localization.go b/locale/localization.go
new file mode 100644
index 0000000..e67e595
--- /dev/null
+++ b/locale/localization.go
@@ -0,0 +1,73 @@
+package locale
+
+import (
+ "io/ioutil"
+ "os"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/d2r2/go-rsync/data"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "golang.org/x/text/language"
+)
+
+var Localizer *i18n.Localizer
+
+var T = func(messageID string, template interface{}) string {
+ // if Localizer isn't initialized, set up with system language
+ if Localizer == nil {
+ SetLanguage("")
+ }
+ // get localized message
+ msg := Localizer.MustLocalize(&i18n.LocalizeConfig{
+ MessageID: messageID,
+ TemplateData: template})
+ return msg
+}
+
+var TP = func(messageID string, template interface{}, pluralCount interface{}) string {
+ // if Localizer isn't initialized, set up with system language
+ if Localizer == nil {
+ SetLanguage("")
+ }
+ // get localized message
+ msg := Localizer.MustLocalize(&i18n.LocalizeConfig{
+ MessageID: messageID,
+ TemplateData: template,
+ PluralCount: pluralCount})
+ return msg
+}
+
+func mustParseMessageFile(bundle *i18n.Bundle, assetIconName string) {
+ file, err := data.Assets.Open(assetIconName)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ defer file.Close()
+
+ buf, err := ioutil.ReadAll(file)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ bundle.MustParseMessageFileBytes(buf, assetIconName)
+}
+
+func SetLanguage(lang string) {
+ bundle := &i18n.Bundle{DefaultLanguage: language.English}
+ bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
+ mustParseMessageFile(bundle, "translate.en.toml")
+ mustParseMessageFile(bundle, "translate.ru.toml")
+
+ if lang == "" {
+ lang = os.Getenv("LANG")
+ // remove UTF-8 suffix from language if found
+ if i := strings.Index(lang, ".UTF-8"); i != -1 {
+ lang = lang[:i]
+ }
+ }
+ //Localizer = i18n.NewLocalizer(bundle, "en-US")
+ Localizer = i18n.NewLocalizer(bundle, lang)
+ // Test translation
+ // fmt.Println(Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "HelloWorld"}))
+}
diff --git a/rsync/abstract.go b/rsync/abstract.go
new file mode 100644
index 0000000..fed6a2d
--- /dev/null
+++ b/rsync/abstract.go
@@ -0,0 +1,84 @@
+package rsync
+
+import (
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/core"
+)
+
+// // Keep application exit state including exit code
+// // with information about error if took place.
+// type ErrorSpec struct {
+// Error error
+// ErrorCode int
+// RetryLeft int
+// }
+
+// // GetError build error object from internal failure state.
+// func (v *ErrorSpec) GetError() error {
+// var err error
+// if v != nil {
+// if v.Error == ErrRsyncProcessTerminated {
+// err = v.Error
+// } else {
+// err = errors.New(f("RSYNC call failed (%s, code %d)",
+// v.Error, v.ErrorCode))
+// }
+// }
+// return err
+// }
+
+type Logging struct {
+ EnableLog bool
+ EnableIntensiveLog bool
+ Log logger.PackageLog
+}
+
+type Options struct {
+ RetryCount int
+ Params []string
+ ErrorHook ErrorHook
+ PredictedSize *core.FolderSize
+}
+
+func NewOptions(params []string) *Options {
+ options := &Options{Params: params}
+ return options
+}
+
+func (v *Options) AddParams(params ...string) *Options {
+ v.Params = append(v.Params, params...)
+ return v
+}
+
+func (v *Options) SetRetryCount(retryCount *int) *Options {
+ if retryCount != nil {
+ if *retryCount >= 0 {
+ if *retryCount < 6 {
+ v.RetryCount = *retryCount
+ } else {
+ v.RetryCount = 5
+ }
+ }
+ }
+ return v
+}
+
+func (v *Options) SetErrorHook(errorHook ErrorHook) *Options {
+ v.ErrorHook = errorHook
+ return v
+}
+
+func (v *Options) SetPredictedSize(predictedSize core.FolderSize) *Options {
+ v.PredictedSize = &predictedSize
+ return v
+}
+
+func WithDefaultParams(params ...string) []string {
+ defParams := []string{"--progress", "--verbose"}
+ params2 := append(defParams, params...)
+ return params2
+}
+
+func Params(params ...string) []string {
+ return params
+}
diff --git a/rsync/common.go b/rsync/common.go
new file mode 100644
index 0000000..9fa916b
--- /dev/null
+++ b/rsync/common.go
@@ -0,0 +1,15 @@
+package rsync
+
+import (
+ "fmt"
+
+ "github.com/d2r2/go-logger"
+)
+
+var lg = logger.NewPackageLogger("rsync",
+ //logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+var e = fmt.Errorf
+var f = fmt.Sprintf
diff --git a/rsync/errors.go b/rsync/errors.go
new file mode 100644
index 0000000..e5da5cc
--- /dev/null
+++ b/rsync/errors.go
@@ -0,0 +1,88 @@
+package rsync
+
+import (
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+)
+
+type RsyncProcessTerminatedError struct {
+}
+
+func (v *RsyncProcessTerminatedError) Error() string {
+ return locale.T(MsgRsyncProcessTerminatedError, nil)
+}
+
+func IsRsyncProcessTerminatedError(err error) bool {
+ if err != nil {
+ _, ok := err.(*RsyncProcessTerminatedError)
+ return ok
+ }
+ return false
+}
+
+type RsyncCallFailedError struct {
+ ExitCode int
+ Description string
+}
+
+func NewRsyncCallFailedError(exitCode int) *RsyncCallFailedError {
+ v := &RsyncCallFailedError{
+ ExitCode: exitCode,
+ Description: getRsyncExitCodeDesc(exitCode),
+ }
+ return v
+}
+
+func (v *RsyncCallFailedError) Error() string {
+ return locale.T(MsgRsyncCallFailedError,
+ struct {
+ Description string
+ ExitCode int
+ }{Description: v.Description, ExitCode: v.ExitCode})
+}
+
+func IsRsyncCallFailedError(err error) bool {
+ if err != nil {
+ _, ok := err.(*RsyncCallFailedError)
+ return ok
+ }
+ return false
+}
+
+// GetRsyncExitCodeDesc return rsync exit code descriptions
+// taken from here: http://wpkg.org/Rsync_exit_codes
+func getRsyncExitCodeDesc(exitCode int) string {
+ codes := map[int]string{
+ 0: "success",
+ 1: "syntax or usage error",
+ 2: "protocol incompatibility",
+ 3: "errors selecting input/output files, dirs",
+ 4: "requested action not supported: an attempt was made to manipulate " +
+ "64-bit files on a platform that cannot support them; or an option was " +
+ "specified that is supported by the client and not by the server",
+ 5: "error starting client-server protocol",
+ 6: "daemon unable to append to log-file",
+ 10: "error in socket I/O",
+ 11: "error in file I/O",
+ 12: "error in rsync protocol data stream",
+ 13: "errors with program diagnostics",
+ 14: "error in IPC code",
+ 20: "received SIGUSR1 or SIGINT",
+ 21: "some error returned by waitpid()",
+ 22: "error allocating core memory buffers",
+ 23: "partial transfer due to error",
+ 24: "partial transfer due to vanished source files",
+ 25: "the --max-delete limit stopped deletions",
+ 30: "timeout in data send/receive",
+ 35: "timeout waiting for daemon connection",
+ 255: "unexplained error",
+ }
+ if v, ok := codes[exitCode]; ok {
+ return v
+ } else {
+ return f("Unknown rsync exit code: %d", exitCode)
+ }
+}
+
+type ErrorHook func(err error, paths core.SrcDstPath, predictedSize *core.FolderSize,
+ repeated int, retryLeft int) (newRetryLeft int, criticalError error)
diff --git a/rsync/messagekeys.go b/rsync/messagekeys.go
new file mode 100644
index 0000000..f218bd5
--- /dev/null
+++ b/rsync/messagekeys.go
@@ -0,0 +1,8 @@
+package rsync
+
+const (
+ MsgRsyncCallFailedError = "RsyncCallFailedError"
+ MsgRsyncProcessTerminatedError = "RsyncProcessTerminatedError"
+ MsgRsyncCannotFindFolderSizeOutputError = "RsyncCannotFindFolderSizeOutputError"
+ MsgRsyncCannotParseFolderSizeOutputError = "RsyncCannotParseFolderSizeOutputError"
+)
diff --git a/rsync/rsync.go b/rsync/rsync.go
new file mode 100644
index 0000000..89609e2
--- /dev/null
+++ b/rsync/rsync.go
@@ -0,0 +1,166 @@
+package rsync
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-shell"
+)
+
+// RSYNC_CMD contains RSYNC console utility name to run.
+const RSYNC_CMD = "rsync"
+
+// RunRsyncWithRetry run RSYNC utility with retry attempts.
+func RunRsyncWithRetry(ctx context.Context, options *Options, log *Logging, stdOut *bytes.Buffer,
+ paths core.SrcDstPath) (sessionErr, retryErr, criticalErr error) {
+
+ retryCount := 0
+ if options != nil {
+ retryCount = options.RetryCount
+ }
+ index := 0
+ for {
+ err := runSystemRsync(ctx, options.Params, log, stdOut,
+ paths.RsyncSourcePath, paths.DestPath)
+
+ if err == nil {
+ return
+ } else if IsRsyncProcessTerminatedError(err) {
+ sessionErr = err
+ criticalErr = err
+ return
+ }
+
+ if err != nil {
+ retryErr = err
+ }
+
+ if options != nil && options.ErrorHook != nil {
+ var newRetryLeft int
+ newRetryLeft, criticalErr = options.ErrorHook(err, paths,
+ options.PredictedSize, index, retryCount)
+ if criticalErr != nil {
+ break
+ }
+ retryCount = newRetryLeft
+ }
+
+ retryCount--
+ if retryCount < 0 {
+ break
+ }
+ index++
+ }
+ if criticalErr == nil && retryErr != nil {
+ sessionErr = retryErr
+ retryErr = nil
+ }
+ return
+}
+
+// IsInstalled verify, that RSYNC application present in the system.
+func IsInstalled() error {
+ app := shell.NewApp(RSYNC_CMD)
+ return app.CheckIsInstalled()
+}
+
+// GetRsyncVersion run RSYNC to get version and protocol.
+func GetRsyncVersion() (version string, protocol string, err error) {
+ app := shell.NewApp(RSYNC_CMD, "--version")
+ var stdOut, stdErr bytes.Buffer
+ exitCode := app.Run(&stdOut, &stdErr)
+ if exitCode.Error != nil {
+ return "", "", exitCode.Error
+ }
+ scanner := bufio.NewScanner(&stdOut)
+ scanner.Split(bufio.ScanLines)
+
+ // Expression should parse a line: rsync version 3.1.3 protocol version 31
+ re := regexp.MustCompile(`version\s+(?P\d+\.\d+(\.\d+)?)(\s+protocol\s+version\s+(?P\d+))?`)
+ for scanner.Scan() {
+ line := scanner.Text()
+ m := core.FindStringSubmatchIndexes(re, line)
+ if len(m) > 0 {
+ grName := "version"
+ if _, ok := m[grName]; ok {
+ start := m[grName][0]
+ end := m[grName][1]
+ version = line[start:end]
+ }
+ grName = "protocol"
+ if _, ok := m[grName]; ok {
+ start := m[grName][0]
+ end := m[grName][1]
+ protocol = line[start:end]
+ }
+ break
+ }
+ }
+ return version, protocol, nil
+}
+
+// runSystemRsync run RSYNC utility.
+// Parameters:
+// - Save console output to stdOut variable.
+func runSystemRsync(ctx context.Context, params []string, log *Logging,
+ stdOut *bytes.Buffer, source, dest string) error {
+
+ var args []string
+ if params != nil {
+ args = params
+ }
+ args = append(args, source, dest)
+ stdOut2 := stdOut
+
+ var logBuf bytes.Buffer
+ logEnabled := false
+ if log != nil && log.EnableLog && log.Log != nil {
+ logEnabled = true
+ if stdOut2 == nil {
+ stdOut2 = bytes.NewBuffer(nil)
+ }
+ }
+
+ app := shell.NewApp(RSYNC_CMD, args...)
+ lg.Debugf("Args: %v", args)
+ waitCh, err := app.Start(stdOut2, nil)
+ if err != nil {
+ return err
+ }
+
+ select {
+ case <-ctx.Done():
+ lg.Debugf("Killing rsync: %v", args)
+ err := app.Kill()
+ if err != nil {
+ return err
+ }
+ return &RsyncProcessTerminatedError{}
+ case st := <-waitCh:
+ if logEnabled {
+ logBuf.WriteString(RSYNC_CMD)
+ if len(args) > 0 {
+ logBuf.WriteString(" ")
+ logBuf.WriteString(strings.Join(args, " "))
+ }
+ if log.EnableIntensiveLog {
+ logBuf.WriteString(fmt.Sprintln())
+ logBuf.WriteString(fmt.Sprintln(">>>>>>>>>>>>>>>> Stdout start >>>>>>>>>>>>>>>>"))
+ logBuf.WriteString(fmt.Sprintln(strings.TrimRight(stdOut2.String(), "\n")))
+ logBuf.WriteString(fmt.Sprint("<<<<<<<<<<<<<<<< Stdout end <<<<<<<<<<<<<<<<"))
+ }
+ log.Log.Info(logBuf.String())
+ }
+ if st.Error != nil {
+ return st.Error
+ } else if st.ExitCode != 0 {
+ return NewRsyncCallFailedError(st.ExitCode)
+ }
+ }
+ return nil
+}
diff --git a/rsync/utils.go b/rsync/utils.go
new file mode 100644
index 0000000..2c2d286
--- /dev/null
+++ b/rsync/utils.go
@@ -0,0 +1,100 @@
+package rsync
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io/ioutil"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+)
+
+func ObtainDirLocalSize(ctx context.Context, dir *core.Dir, retryCount *int, log *Logging) (*core.FolderSize, error) {
+ // rsync "dry run" to get total size of backup
+ var stdOut bytes.Buffer
+ options := NewOptions(WithDefaultParams("--dry-run", "--compress")).
+ AddParams("--dirs").
+ SetRetryCount(retryCount)
+ sessionErr, _, _ := RunRsyncWithRetry(ctx, options, log, &stdOut, dir.Paths)
+ if sessionErr != nil {
+ return nil, sessionErr
+ }
+ backupSize, err := extractBackupSize(&stdOut)
+ if err != nil {
+ return nil, err
+ }
+ if backupSize != nil {
+ lg.Debugf("Get rsync %q size: %v", dir.Paths.RsyncSourcePath,
+ core.GetReadableSize(*backupSize))
+ }
+ return backupSize, nil
+}
+
+func ObtainDirFullSize(ctx context.Context, dir *core.Dir, retryCount *int, log *Logging) (*core.FolderSize, error) {
+ // rsync "dry run" to get total size of backup
+ var stdOut bytes.Buffer
+ options := NewOptions(WithDefaultParams("--dry-run", "--compress")).
+ AddParams("--recursive", f("--include=%s", "*"+"/")).
+ SetRetryCount(retryCount)
+ sessionErr, _, _ := RunRsyncWithRetry(ctx, options, log, &stdOut, dir.Paths)
+ if sessionErr != nil {
+ return nil, sessionErr
+ }
+ backupSize, err := extractBackupSize(&stdOut)
+ if err != nil {
+ return nil, err
+ }
+ return backupSize, nil
+}
+
+// extractBackupSize parse and decode Rsync console output
+// to obtain folder content size.
+func extractBackupSize(stdOut *bytes.Buffer) (*core.FolderSize, error) {
+ // Parse the line: "total size is 2,227,810,354 speedup is 507,127.33 (DRY RUN)"
+ // to extract "total size" value.
+ re := regexp.MustCompile(`total\s+size\s+is\s+(?P((\d+)\,?)+)`)
+ str := stdOut.String()
+ m := core.FindStringSubmatchIndexes(re, str)
+ if a, ok := m["Number"]; ok {
+ str2 := strings.Replace(str[a[0]:a[1]], ",", "", -1)
+ // lg.Debugf("%v", str2)
+ i, err := strconv.ParseInt(str2, 10, 64)
+ if err != nil {
+ return nil, errors.New(locale.T(MsgRsyncCannotParseFolderSizeOutputError,
+ struct{ Text string }{Text: str2}))
+ }
+ i2 := core.FolderSize(i)
+ return &i2, nil
+ } else {
+ return nil, errors.New(locale.T(MsgRsyncCannotFindFolderSizeOutputError, nil))
+ }
+}
+
+func GetPathStatus(ctx context.Context, sourceRSync string, recursive bool) error {
+ tempDir, err := ioutil.TempDir("", "backup_dir_status_")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tempDir)
+
+ paths := core.SrcDstPath{
+ RsyncSourcePath: core.RsyncPathJoin(sourceRSync, ""),
+ DestPath: tempDir,
+ }
+ // rsync "dry run" to get total size of backup
+ var stdOut bytes.Buffer
+ options := NewOptions(WithDefaultParams("--include=*/", "--dry-run"))
+ if recursive {
+ options.AddParams("--recursive")
+ }
+ sessionErr, _, _ := RunRsyncWithRetry(ctx, options, nil, &stdOut, paths)
+ if sessionErr != nil {
+ return sessionErr
+ }
+ return nil
+}
diff --git a/sandbox/dev_scripts/mount_10G_quota_dir.sh b/sandbox/dev_scripts/mount_10G_quota_dir.sh
new file mode 100755
index 0000000..ecdb791
--- /dev/null
+++ b/sandbox/dev_scripts/mount_10G_quota_dir.sh
@@ -0,0 +1,9 @@
+#!/bin/env bash
+# Read this to create quota directory:
+# https://www.linuxquestions.org/questions/linux-server-73/directory-quota-601140/
+#
+# This script help to mount destination folder to test application for "out of disk space" cases
+# to imporeve recovery and error management.
+#
+sudo mount -o loop,rw,usrquota,grpquota /run/media/ddyakov/sda9/tmp2/limit_size.ext4 /home/ddyakov/Downloads/7777
+
diff --git a/sandbox/sandbox.tar.gz b/sandbox/sandbox.tar.gz
new file mode 100644
index 0000000..d225f9c
Binary files /dev/null and b/sandbox/sandbox.tar.gz differ
diff --git a/ui/gtkui/aboutdlg.go b/ui/gtkui/aboutdlg.go
new file mode 100644
index 0000000..782777f
--- /dev/null
+++ b/ui/gtkui/aboutdlg.go
@@ -0,0 +1,263 @@
+package gtkui
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/davecgh/go-spew/spew"
+)
+
+const (
+ APP_LICENSE = ` GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.`
+)
+
+func buildCommentBlock() (*bytes.Buffer, error) {
+ version, protocol, err := rsync.GetRsyncVersion()
+ if err != nil {
+ return nil, err
+ }
+
+ var buf bytes.Buffer
+ glibMajor, glibMinor, glibMicro := GetGlibVersion()
+ glibBuildVersion := glib.GetBuildVersion()
+ gtkMajor, gtkMinor, gtkMicro := GetGtkVersion()
+ gtkBuildVersion := gtk.GetBuildVersion()
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAboutDlgAppDescriptionSection, nil)))
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAppEnvironmentTitle, nil)))
+ buf.WriteString(fmt.Sprintln(fmt.Sprintf("%s.",
+ locale.T(MsgGLIBInfo, struct{ GLIBCompiledVer, GLIBDetectedVer string }{
+ GLIBCompiledVer: spew.Sprintf("%s", glibBuildVersion),
+ GLIBDetectedVer: spew.Sprintf("%d.%d.%d", glibMajor, glibMinor, glibMicro)}))))
+ buf.WriteString(fmt.Sprintln(fmt.Sprintf("%s.",
+ locale.T(MsgGTKInfo, struct{ GTKCompiledVer, GTKDetectedVer string }{
+ GTKCompiledVer: spew.Sprintf("%s", gtkBuildVersion),
+ GTKDetectedVer: spew.Sprintf("%d.%d.%d", gtkMajor, gtkMinor, gtkMicro)}))))
+ buf.WriteString(fmt.Sprintln(fmt.Sprintf("%s.",
+ locale.T(MsgRsyncInfo, struct{ RSYNCDetectedVer, RSYNCDetectedProtocol string }{
+ RSYNCDetectedVer: version, RSYNCDetectedProtocol: protocol}))))
+ buf.WriteString(fmt.Sprintln(fmt.Sprintf("%s.",
+ locale.T(MsgGolangInfo, struct{ GolangVersion, AppArchitecture string }{
+ GolangVersion: core.GetGolangVersion(),
+ AppArchitecture: core.GetAppArchitecture()}))))
+ buf.WriteString(fmt.Sprintln())
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAboutDlgAppFeaturesAndBenefitsTitle, nil)))
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAboutDlgAppFeaturesAndBenefitsSection, nil)))
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAboutDlgReleasedUnderLicense,
+ struct{ LicenseName string }{LicenseName: "GNU LGPL v3.0"})))
+ buf.WriteString(fmt.Sprintln())
+ buf.WriteString(fmt.Sprintln(locale.T(MsgAboutDlgFollowMyGithubProjectTitle, nil)))
+
+ return &buf, nil
+}
+
+func CreateAboutDialog(appSettings *glib.Settings) (*gtk.AboutDialog, error) {
+ dlg, err := gtk.AboutDialogNew()
+ if err != nil {
+ return nil, err
+ }
+
+ dlg.SetProgramName(core.GetAppFullTitle())
+ dlg.SetLogoIconName("media-tape-symbolic")
+ dlg.SetVersion(core.GetAppVersion())
+ dlg.SetCopyright(locale.T(MsgAboutDlgAppCopyright,
+ struct{ AppCreationYears, AppCopyrightAuthor string }{
+ AppCreationYears: "2017-2018",
+ AppCopyrightAuthor: "Denis Dyakov "}))
+ dlg.SetAuthors(core.SplitByEOL(locale.T(MsgAboutDlgAppAuthorsBlock, nil)))
+
+ dlg.SetLicense(APP_LICENSE)
+
+ bh := BindingHelperNew(appSettings)
+ // Show about dialog on application startup
+ cbAboutInfo, err := gtk.CheckButtonNewWithLabel(locale.T(MsgAboutDlgDoNotShowCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ bh.Bind(CFG_DONT_SHOW_ABOUT_ON_STARTUP, cbAboutInfo, "active", glib.SETTINGS_BIND_DEFAULT)
+
+ content, err := dlg.GetContentArea()
+ if err != nil {
+ return nil, err
+ }
+ content.Add(cbAboutInfo)
+ content.ShowAll()
+
+ buf, err := buildCommentBlock()
+ if err != nil {
+ return nil, err
+ }
+ dlg.SetComments(buf.String())
+
+ dlg.SetWebsite("https://github.com/d2r2?tab=repositories")
+
+ return dlg, nil
+}
diff --git a/ui/gtkui/app.go b/ui/gtkui/app.go
new file mode 100644
index 0000000..959cbcc
--- /dev/null
+++ b/ui/gtkui/app.go
@@ -0,0 +1,1373 @@
+package gtkui
+
+import (
+ "context"
+ "errors"
+ "os"
+ "syscall"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/backup"
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/data"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+ shell "github.com/d2r2/go-shell"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/davecgh/go-spew/spew"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+// createQuitAction creates regular exit app action.
+func createQuitAction(win *gtk.Window, backupSync *BackupSessionStatus, supplimentary *RunningContexts) (glib.IAction, error) {
+ act, err := glib.SimpleActionNew("QuitAction", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = act.Connect("activate", func(action *glib.SimpleAction, param *glib.Variant) {
+ name, state, err := GetActionNameAndState(action)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ lg.Debugf("%v action activated with current state %v and args %v",
+ name, state, param)
+
+ quit := true
+ if backupSync.IsRunning() {
+ quit, err = interruptBackupDialog(win)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ if quit {
+ application, err := win.GetApplication()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ if backupSync.IsRunning() {
+ backupSync.Stop()
+ }
+ supplimentary.CancelAll()
+ application.Quit()
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return act, nil
+}
+
+// createAboutAction creates "about dialog" action.
+func createAboutAction(win *gtk.Window, appSettings *glib.Settings) (glib.IAction, error) {
+ act, err := glib.SimpleActionNew("AboutAction", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = act.Connect("activate", func(action *glib.SimpleAction, param *glib.Variant) {
+ name, state, err := GetActionNameAndState(action)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ lg.Debugf("%v action activated with current state %v and args %v",
+ name, state, param)
+
+ dlg, err := CreateAboutDialog(appSettings)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ dlg.SetTransientFor(win)
+ dlg.SetModal(true)
+ dlg.ShowNow()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return act, nil
+}
+
+// createMenuModelForPopover construct menu for popover button
+func createMenuModelForPopover() (glib.IMenuModel, error) {
+ main, err := glib.MenuNew()
+ if err != nil {
+ return nil, err
+ }
+
+ var section *glib.Menu
+ //var item *glib.MenuItem
+
+ // New menu section (with buttons)
+ section, err = glib.MenuNew()
+ if err != nil {
+ return nil, err
+ }
+ section.Append(locale.T(MsgAppWindowAboutMenuCaption, nil), "win.AboutAction")
+ main.AppendSection("", section)
+
+ section, err = glib.MenuNew()
+ if err != nil {
+ return nil, err
+ }
+ section.Append(locale.T(MsgAppWindowPreferencesMenuCaption, nil), "win.PreferenceAction")
+ main.AppendSection("", section)
+
+ section, err = glib.MenuNew()
+ if err != nil {
+ return nil, err
+ }
+ section.Append(locale.T(MsgAppWindowQuitMenuCaption, nil), "win.QuitAction")
+ main.AppendSection("", section)
+
+ // main.AppendItem(section.MenuModel)
+
+ return main, nil
+}
+
+// createToolbar creates GTK Toolbar.
+func createToolbar() (*gtk.Toolbar, error) {
+ tbx, err := gtk.ToolbarNew()
+ if err != nil {
+ return nil, err
+ }
+ tbx.SetStyle(gtk.TOOLBAR_BOTH_HORIZ)
+
+ var tbtn *gtk.ToolButton
+ var tdvd *gtk.SeparatorToolItem
+ var img *gtk.Image
+
+ img, err = gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName("application-exit-symbolic", gtk.ICON_SIZE_BUTTON)
+
+ tbtn, err = gtk.ToolButtonNew(img, "")
+ if err != nil {
+ return nil, err
+ }
+ tbtn.SetActionName("win.QuitAction")
+ tbx.Add(tbtn)
+
+ tdvd, err = gtk.SeparatorToolItemNew()
+ if err != nil {
+ return nil, err
+ }
+ tbx.Add(tdvd)
+
+ img, err = gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName("preferences-other-symbolic", gtk.ICON_SIZE_BUTTON)
+
+ /*
+ file, err := data.Assets.Open("ajax-loader-gears_32x32.gif")
+ if err != nil {
+ return nil, err
+ }
+ b, err := ioutil.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+ b2, err := glib.BytesNew(b)
+ if err != nil {
+ return nil, err
+ }
+ ms, err := glib.MemoryInputStreamFromBytesNew(b2)
+ if err != nil {
+ return nil, err
+ }
+ pb, err := gdk.PixbufNewFromStream(&ms.InputStream, nil)
+ if err != nil {
+ return nil, err
+ }
+ img, err = gtk.ImageNewFromPixbuf(pb)
+ if err != nil {
+ return nil, err
+ }
+ */
+
+ tbtn, err = gtk.ToolButtonNew(img, "")
+ if err != nil {
+ return nil, err
+ }
+ tbtn.SetActionName("win.PreferenceAction")
+ tbx.Add(tbtn)
+
+ img, err = gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName("help-about-symbolic", gtk.ICON_SIZE_BUTTON)
+ tbtn, err = gtk.ToolButtonNew(img, "")
+ if err != nil {
+ return nil, err
+ }
+ tbtn.SetActionName("win.AboutAction")
+ tbx.Add(tbtn)
+
+ tdvd, err = gtk.SeparatorToolItemNew()
+ if err != nil {
+ return nil, err
+ }
+ tbx.Add(tdvd)
+
+ img, err = gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName("media-playback-start-symbolic", gtk.ICON_SIZE_BUTTON)
+ tbtn, err = gtk.ToolButtonNew(img, "")
+ if err != nil {
+ return nil, err
+ }
+ tbtn.SetActionName("win.RunBackupAction")
+ tbx.Add(tbtn)
+
+ img, err = gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName("media-playback-stop-symbolic", gtk.ICON_SIZE_BUTTON)
+ tbtn, err = gtk.ToolButtonNew(img, "")
+ if err != nil {
+ return nil, err
+ }
+ tbtn.SetActionName("win.StopBackupAction")
+ tbx.Add(tbtn)
+
+ return tbx, nil
+}
+
+// createPreferenceAction creates multi-page preference dialog
+// with save/restore functionality to/from the GLib GSettings object.
+// Action activation require to have GLib Setting Schema
+// preliminary installed, otherwise will not work raising error.
+// Installation bash script from app folder must be performed in advance.
+func createPreferenceAction(win *gtk.Window, profile *gtk.ComboBox) (glib.IAction, error) {
+ act, err := glib.SimpleActionNew("PreferenceAction", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = act.Connect("activate", func(action *glib.SimpleAction, param *glib.Variant) {
+ name, state, err := GetActionNameAndState(action)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ lg.Debugf("%v action activated with current state %v and args %v",
+ name, state, param)
+
+ app, err := win.GetApplication()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ extraMsg := locale.T(MsgSchemaConfigDlgSchemaErrorAdvise,
+ struct{ ScriptName string }{ScriptName: "gs_schema_install.sh"})
+ found, err := CheckSchemaSettingsIsInstalled(core.SETTINGS_ID, app, &extraMsg)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ if found {
+ var profileChanged bool
+ win, err := CreatePreferenceDialog(core.SETTINGS_ID, app, &profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ win.ShowAll()
+ win.Show()
+
+ _, err = win.Connect("destroy", func(window *gtk.ApplicationWindow) {
+ win.Close()
+ win.Destroy()
+ lg.Debug("Destroy window")
+
+ if profileChanged {
+ lst, err := getProfileList()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = UpdateNameValueCombo(profile, lst)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ profile.SetActiveID("")
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return act, nil
+}
+
+// enableAction finds GAction by name and enable/disable it.
+func enableAction(win *gtk.ApplicationWindow, actionName string, enable bool) error {
+ act := win.LookupAction(actionName)
+ if act != nil {
+ action, err := glib.SimpleActionFromAction(act)
+ if err != nil {
+ return err
+ }
+ action.SetEnabled(enable)
+ } else {
+ err := errors.New(spew.Sprintf("action %q does not found", actionName))
+ return err
+ }
+ return nil
+}
+
+type EmptySpaceRecover struct {
+ main *gtk.ApplicationWindow
+ backupLog logger.PackageLog
+}
+
+func (v *EmptySpaceRecover) ErrorHook(err error, paths core.SrcDstPath, predictedSize *core.FolderSize,
+ repeated int, retryLeft int) (newRetryLeft int, criticalError error) {
+
+ if rsync.IsRsyncCallFailedError(err) {
+ erro := err.(*rsync.RsyncCallFailedError)
+ freeSpace, err2 := shell.GetFreeSpace(paths.DestPath)
+ if err2 != nil {
+ return retryLeft, err2
+ }
+
+ // if space/1000/1000 < 10 {
+ var predSize uint64
+ if predictedSize != nil {
+ predSize = predictedSize.GetByteCount()
+ }
+
+ const MB = 1000 * 1000
+
+ lg.Debugf("Exit code = %d, error = %v, predicted size = %d MB, retry left = %d, space left: %d kB",
+ erro.ExitCode, erro, predSize/1000/1000, retryLeft, freeSpace/1000)
+
+ if (erro.ExitCode == 23 || erro.ExitCode == 11) &&
+ (predictedSize == nil && freeSpace < 1*MB || predictedSize.GetByteCount() > freeSpace) {
+
+ ch := make(chan OutOfSpaceResponse)
+ defer close(ch)
+
+ v.backupLog.Notifyf(locale.T(MsgLogBackupStageOutOfSpaceWarning,
+ struct{ SizeLeft string }{SizeLeft: core.FormatSize(freeSpace, true)}))
+
+ _, err2 = glib.IdleAdd(func() {
+
+ response, err2 := outOfSpaceDialog(&v.main.Window, paths, freeSpace)
+ if err2 != nil {
+ lg.Fatal(err2)
+ }
+ ch <- response
+
+ })
+ if err2 != nil {
+ lg.Fatal(err2)
+ }
+
+ response, _ := <-ch
+ if response == OutOfSpaceRetry {
+ // retry
+ if retryLeft == 0 {
+ retryLeft++
+ }
+ return retryLeft, nil
+ } else if response == OutOfSpaceTerminate {
+ // terminated backup process
+ return 0, err
+ } else {
+ // ignore this call and continue
+ return 0, nil
+ }
+ }
+ // }
+ }
+ return retryLeft, nil
+}
+
+func traceLongRunningContext(ctx *ContextPack) chan struct{} {
+ // Build actual signals list to control
+ signals := []os.Signal{os.Kill}
+ if shell.IsLinuxMacOSFreeBSD() {
+ signals = append(signals, syscall.SIGTERM, os.Interrupt)
+ }
+ done := make(chan struct{})
+ shell.CloseContextOnSignals(ctx.Cancel, done, signals...)
+ return done
+}
+
+func performFullBackup(backupSync *BackupSessionStatus, notifier *NotifierUI,
+ win *gtk.ApplicationWindow, config *backup.Config, destPath string) {
+
+ ctx := backupSync.Start()
+ done := traceLongRunningContext(ctx)
+ defer close(done)
+ defer backupSync.Done(ctx.Context)
+
+ backupLog := core.NewProxyLog(backup.LocalLog, "backup", 6, "15:04:05",
+ func(line string) error {
+ err := notifier.UpdateTextViewLog(line)
+ if err != nil {
+ return err
+ }
+ return nil
+ }, logger.InfoLevel,
+ )
+
+ plan, progress, err := backup.BuildBackupPlan(ctx.Context, backupLog, config, notifier)
+ if err == nil {
+ lg.Debugf("Backup node's dir trees: %+v", plan)
+
+ emptySpaceRecover := &EmptySpaceRecover{main: win, backupLog: backupLog}
+ err = plan.RunBackup(progress, destPath, emptySpaceRecover.ErrorHook)
+
+ notifier.ReportCompletion(1, err, progress, true)
+ progress.Close()
+ } else {
+ notifier.ReportCompletion(0, err, nil, true)
+ }
+}
+
+func setControlStateOnBackupStarted(win *gtk.ApplicationWindow,
+ selectFolder *gtk.FileChooserButton, profile *gtk.ComboBox) {
+
+ err := enableAction(win, "RunBackupAction", false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = enableAction(win, "PreferenceAction", false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = enableAction(win, "StopBackupAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ profile.SetSensitive(false)
+ selectFolder.SetSensitive(false)
+}
+
+func setControlStateOnBackupEnded(win *gtk.ApplicationWindow, selectFolder *gtk.FileChooserButton,
+ profile *gtk.ComboBox, notifier *NotifierUI) {
+
+ call := func() {
+ profile.SetSensitive(true)
+ selectFolder.SetSensitive(true)
+ err := enableAction(win, "StopBackupAction", false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = enableAction(win, "PreferenceAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = enableAction(win, "RunBackupAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ <-notifier.Done()
+ _, err := glib.IdleAdd(call)
+ if err != nil {
+ lg.Fatal(err)
+ }
+}
+
+// createRunBackupAction creates action - entry point for data backup process start.
+func createRunBackupAction(win *gtk.ApplicationWindow, gridUI *gtk.Grid,
+ destPath *string, selectFolder *gtk.FileChooserButton, profile *gtk.ComboBox,
+ backupSync *BackupSessionStatus) (glib.IAction, error) {
+
+ act, err := glib.SimpleActionNew("RunBackupAction", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ act.SetEnabled(false)
+ _, err = act.Connect("activate", func(action *glib.SimpleAction, param *glib.Variant) {
+ name, state, err := GetActionNameAndState(action)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ lg.Debugf("%v action activated with current state %v and args %v",
+ name, state, param)
+
+ backupID := profile.GetActiveID()
+ lg.Debugf("BackupID = %v", backupID)
+
+ if backupID != "" {
+ if ok, msg := isDestPathError(*destPath, true); ok {
+ title := locale.T(MsgAppWindowCannotStartBackupProcessTitle, nil)
+ var text string
+ if *destPath == "" {
+ text = locale.T(MsgAppWindowDestPathIsEmptyError2, nil)
+ } else {
+ text = msg
+ }
+ err = ErrorMessage(&win.Window, title, []*DialogParagraph{NewDialogParagraph(text)})
+ } else {
+ config, err := readBackupConfig(backupID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ // enable/disable corresponding UI elements
+ setControlStateOnBackupStarted(win, selectFolder, profile)
+
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ val, err := GetComboValue(profile, 0)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ profileName, err := val.GetString()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ notifier := NewNotifierUI(profileName, gridUI)
+ err = notifier.ClearProgressGrid()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ fontSize := appSettings.GetString(CFG_SESSION_LOG_WIDGET_FONT_SIZE)
+ err = notifier.CreateProgressControls(fontSize)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = notifier.UpdateBackupProgress(nil, locale.T(MsgAppWindowBackupProgressStartMessage, nil), false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ go func() {
+ // perform a full backup cycle in one closure
+ performFullBackup(backupSync, notifier, win, config, *destPath)
+ // enable/disable corresponding UI elements
+ setControlStateOnBackupEnded(win, selectFolder, profile, notifier)
+ }()
+ }
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return act, nil
+}
+
+// createStopBackupAction creates entry point for action which terminate live backup session.
+func createStopBackupAction(win *gtk.ApplicationWindow, grid *gtk.Grid,
+ selectFolder *gtk.FileChooserButton, profile *gtk.ComboBox,
+ backupSync *BackupSessionStatus) (glib.IAction, error) {
+
+ act, err := glib.SimpleActionNew("StopBackupAction", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ act.SetEnabled(false)
+ _, err = act.Connect("activate", func(action *glib.SimpleAction, param *glib.Variant) {
+ name, state, err := GetActionNameAndState(action)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ lg.Debugf("%v action activated with current state %v and args %v",
+ name, state, param)
+
+ err = enableAction(win, "StopBackupAction", false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ if ok, err := interruptBackupDialog(&win.Window); err != nil || ok {
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ backupSync.Stop()
+
+ profile.SetSensitive(true)
+ selectFolder.SetSensitive(true)
+ err = enableAction(win, "PreferenceAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = enableAction(win, "RunBackupAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ } else {
+ err = enableAction(win, "StopBackupAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return act, nil
+}
+
+// getProfileList reads from app configuration profile's identifiers and names
+// to use as a source for GtkComboBox widget.
+func getProfileList() ([]struct{ value, key string }, error) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ return nil, err
+ }
+ sarr := NewSettingsArray(appSettings, CFG_BACKUP_LIST)
+ lst := sarr.GetArrayIDs()
+ arr := []struct{ value, key string }{{locale.T(MsgAppWindowNoneProfileEntry, nil), ""}}
+ for _, item := range lst {
+ backupSettings, err := getBackupSettings(item, nil)
+ if err != nil {
+ return nil, err
+ }
+ name := backupSettings.GetString(CFG_PROFILE_NAME)
+ arr = append(arr, struct{ value, key string }{name, item})
+ }
+ return arr, nil
+}
+
+// readBackupConfig reads from app configuration Config object which
+// contains all settings necessary to run new backup session.
+func readBackupConfig(backupID string) (*backup.Config, error) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ return nil, err
+ }
+ backupSettings, err := getBackupSettings(backupID, nil)
+ if err != nil {
+ return nil, err
+ }
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ sourceIDs := sarr.GetArrayIDs()
+
+ cfg := &backup.Config{}
+
+ cfg.SigFileIgnoreBackup = appSettings.GetString(CFG_IGNORE_FILE_SIGNATURE)
+
+ autoManageBackupBLockSize := appSettings.GetBoolean(CFG_MANAGE_AUTO_BACKUP_BLOCK_SIZE)
+ cfg.AutoManageBackupBlockSize = &autoManageBackupBLockSize
+
+ maxBackupBlockSize := appSettings.GetInt(CFG_MAX_BACKUP_BLOCK_SIZE_MB)
+ cfg.MaxBackupBlockSizeMb = &maxBackupBlockSize
+
+ usePreviousBackup := appSettings.GetBoolean(CFG_ENABLE_USE_OF_PREVIOUS_BACKUP)
+ cfg.UsePreviousBackup = &usePreviousBackup
+
+ numberOfPreviousBackupToUse := appSettings.GetInt(CFG_NUMBER_OF_PREVIOUS_BACKUP_TO_USE)
+ cfg.NumberOfPreviousBackupToUse = &numberOfPreviousBackupToUse
+
+ enableLowLevelLog := appSettings.GetBoolean(CFG_ENABLE_LOW_LEVEL_LOG_OF_RSYNC)
+ cfg.EnableLowLevelLogForRsync = &enableLowLevelLog
+
+ enableIntensiveLowLevelLog := appSettings.GetBoolean(CFG_ENABLE_INTENSIVE_LOW_LEVEL_LOG_OF_RSYNC)
+ cfg.EnableIntensiveLowLevelLogForRsync = &enableIntensiveLowLevelLog
+
+ compressFileTransfer := appSettings.GetBoolean(CFG_RSYNC_COMPRESS_FILE_TRANSFER)
+ cfg.RsyncCompressFileTransfer = &compressFileTransfer
+
+ recreateSymlinks := appSettings.GetBoolean(CFG_RSYNC_RECREATE_SYMLINKS)
+ cfg.RsyncRecreateSymlinks = &recreateSymlinks
+
+ transferDeviceFiles := appSettings.GetBoolean(CFG_RSYNC_TRANSFER_DEVICE_FILES)
+ cfg.RsyncTransferDeviceFiles = &transferDeviceFiles
+
+ transferSpecialFiles := appSettings.GetBoolean(CFG_RSYNC_TRANSFER_SPECIAL_FILES)
+ cfg.RsyncTransferSpecialFiles = &transferSpecialFiles
+
+ transferSourceOwner := appSettings.GetBoolean(CFG_RSYNC_TRANSFER_SOURCE_OWNER)
+ cfg.RsyncTransferSourceOwner = &transferSourceOwner
+
+ transferSourceGroup := appSettings.GetBoolean(CFG_RSYNC_TRANSFER_SOURCE_GROUP)
+ cfg.RsyncTransferSourceGroup = &transferSourceGroup
+
+ transferSourcePermissions := appSettings.GetBoolean(CFG_RSYNC_TRANSFER_SOURCE_PERMISSIONS)
+ cfg.RsyncTransferSourcePermissions = &transferSourcePermissions
+
+ retry := appSettings.GetInt(CFG_RSYNC_RETRY_COUNT)
+ cfg.RsyncRetryCount = &retry
+
+ for _, sid := range sourceIDs {
+ sourceSettings, err := getBackupSourceSettings(backupID, sid, nil)
+ if err != nil {
+ return nil, err
+ }
+ if sourceSettings.GetBoolean(CFG_SOURCE_ENABLED) {
+ node := backup.BackupNode{}
+ node.SourceRsync = sourceSettings.GetString(CFG_SOURCE_RSYNC_SOURCE_PATH)
+ subpath := sourceSettings.GetString(CFG_SOURCE_DEST_SUBPATH)
+ node.DestSubPath = normalizeSubpath(subpath)
+ cfg.BackupNodes = append(cfg.BackupNodes, node)
+ }
+
+ }
+
+ return cfg, nil
+}
+
+// getPlanInfoMarkup formats backup process totals.
+func getPlanInfoMarkup(plan *backup.BackupPlan) *Markup {
+ var sourceCount int = len(plan.Nodes)
+ var totalSize core.FolderSize
+ var ignoreSize core.FolderSize
+ var dirCount int
+ for _, node := range plan.Nodes {
+ totalSize += node.RootDir.GetTotalSize()
+ ignoreSize += node.RootDir.GetIgnoreSize()
+ dirCount += node.RootDir.GetFoldersCount()
+ }
+ mp := NewMarkup(0, MARKUP_COLOR_CHARTREUSE, 0, nil, nil,
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowProfileBackupPlanInfoSourceCount, nil), " "),
+ NewMarkup( /*MARKUP_SIZE_LARGER*/ 0, 0, 0, sourceCount, nil),
+ NewMarkup(0, 0, 0, spew.Sprintf("; %s", locale.T(MsgAppWindowProfileBackupPlanInfoTotalSize, nil)), " "),
+ NewMarkup( /*MARKUP_SIZE_LARGER*/ 0, 0, 0, core.GetReadableSize(totalSize), nil),
+ NewMarkup(0, 0, 0, spew.Sprintf("; %s", locale.T(MsgAppWindowProfileBackupPlanInfoSkipSize, nil)), " "),
+ NewMarkup( /*MARKUP_SIZE_LARGER*/ 0, 0, 0, core.GetReadableSize(ignoreSize), nil),
+ NewMarkup(0, 0, 0, spew.Sprintf("; %s", locale.T(MsgAppWindowProfileBackupPlanInfoDirectoryCount, nil)), " "),
+ NewMarkup( /*MARKUP_SIZE_LARGER*/ 0, 0, 0, dirCount, nil),
+ )
+ /*
+ s := spew.Sprintf("RSYNC sources: %v; Total size: %v; Skip size: %v; Directory count: %v",
+ MarkupTag("big", sourceCount),
+ MarkupTag("big", hum.Bytes(totalSize.GetByteCount())),
+ MarkupTag("big", hum.Bytes(ignoreSize.GetByteCount())),
+ MarkupTag("big", dirCount))
+ */
+ return mp
+}
+
+func getSpaceBox() (*gtk.Box, error) {
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.SetSizeRequest(3, -1)
+ return box, nil
+}
+
+// createHeader creates GtkHeader widget filled with children controls.
+func createHeader(title, subtitle string, showCloseButton bool) (*gtk.HeaderBar, error) {
+ hdr, err := SetupHeader(title, subtitle, showCloseButton)
+ if err != nil {
+ return nil, err
+ }
+
+ menu, err := createMenuModelForPopover()
+ if err != nil {
+ return nil, err
+ }
+ menuBtn, err := SetupMenuButtonWithThemedImage("open-menu-symbolic")
+ if err != nil {
+ return nil, err
+ }
+ menuBtn.SetUsePopover(true)
+ menuBtn.SetMenuModel(menu)
+ hdr.PackEnd(menuBtn)
+
+ var box *gtk.Box
+
+ btn, err := SetupButtonWithThemedImage("preferences-other-symbolic")
+ //btn, err := SetupButtonWithAssetAnimationImage("ajax-loader-gears_32x32.gif")
+ if err != nil {
+ return nil, err
+ }
+ btn.SetActionName("win.PreferenceAction")
+ btn.SetTooltipText(locale.T(MsgAppWindowPreferencesHint, nil))
+ hdr.PackStart(btn)
+
+ box, err = getSpaceBox()
+ if err != nil {
+ return nil, err
+ }
+ hdr.PackStart(box)
+
+ div, err := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL)
+ if err != nil {
+ return nil, err
+ }
+ hdr.PackStart(div)
+
+ box, err = getSpaceBox()
+ if err != nil {
+ return nil, err
+ }
+ hdr.PackStart(box)
+
+ btn, err = SetupButtonWithThemedImage("media-playback-start-symbolic")
+ if err != nil {
+ return nil, err
+ }
+ btn.SetActionName("win.RunBackupAction")
+ btn.SetTooltipText(locale.T(MsgAppWindowRunBackupHint, nil))
+ hdr.PackStart(btn)
+
+ btn, err = SetupButtonWithThemedImage("media-playback-stop-symbolic")
+ if err != nil {
+ return nil, err
+ }
+ btn.SetActionName("win.StopBackupAction")
+ btn.SetTooltipText(locale.T(MsgAppWindowStopBackupHint, nil))
+ hdr.PackStart(btn)
+
+ box, err = getSpaceBox()
+ if err != nil {
+ return nil, err
+ }
+ hdr.PackStart(box)
+
+ return hdr, nil
+}
+
+func createBoxWithThemedIcon(themedIconName string) (*gtk.Box, error) {
+ img, err := gtk.ImageNew()
+ if err != nil {
+ return nil, err
+ }
+ img.SetFromIconName(themedIconName, gtk.ICON_SIZE_BUTTON)
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(img)
+ return box, nil
+}
+
+func createBoxWithAssetIcon(assetIconName string) (*gtk.Box, error) {
+ img, err := ImageFromAssetsNew(assetIconName, 16, 16)
+ if err != nil {
+ return nil, err
+ }
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(img)
+ return box, nil
+}
+
+func createBoxWithSpinner() (*gtk.Box, error) {
+ spinner, err := gtk.SpinnerNew()
+ if err != nil {
+ return nil, err
+ }
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(spinner)
+ spinner.Start()
+ return box, nil
+}
+
+func updateDestPathWidget(destWidget *gtk.FileChooserButton, destFolderBox *gtk.Box, destFolderStatusBox **gtk.Box) error {
+ destPath := destWidget.GetFilename()
+ if *destFolderStatusBox != nil {
+ (*destFolderStatusBox).Destroy()
+ *destFolderStatusBox = nil
+ }
+ DEST_PATH_DESCRIPTION := locale.T(MsgAppWindowDestPathHint, nil)
+ if ok, msg := isDestPathError(destPath, false); ok {
+ destWidget.SetFilename("")
+ markup := markupTooltip(NewMarkup(MARKUP_WEIGHT_BOLD, MARKUP_COLOR_ORANGE_RED, 0, msg, nil),
+ DEST_PATH_DESCRIPTION)
+ destWidget.SetTooltipMarkup(markup.String())
+ var err error
+ // *destFolderStatusBox, err = createBoxWithThemedIcon(STOCK_IMPORTANT_ICON)
+ *destFolderStatusBox, err = createBoxWithAssetIcon(ASSET_IMPORTANT_ICON)
+ if err != nil {
+ return err
+ }
+ destFolderBox.Add(*destFolderStatusBox)
+ destFolderBox.ShowAll()
+ } else {
+ markup := markupTooltip(NewMarkup(0, 0, 0, nil, nil,
+ NewMarkup(0, MARKUP_COLOR_CHARTREUSE, 0,
+ spew.Sprintf("%s ", locale.T(MsgAppWindowDestPathIsValidStatusPart1, nil)),
+ spew.Sprintf(" %s", locale.T(MsgAppWindowDestPathIsValidStatusPart2, nil)),
+ NewMarkup(0, MARKUP_COLOR_CHARTREUSE, 0, spew.Sprintf("%q", destPath), nil),
+ ),
+ ), DEST_PATH_DESCRIPTION)
+ destWidget.SetTooltipMarkup(markup.String())
+ }
+ return nil
+}
+
+func getProfileWidgetHint() string {
+ return locale.T(MsgAppWindowProfileHint, nil)
+}
+
+func performBackupPlanStage(ctx *ContextPack, supplimentary *RunningContexts, config *backup.Config,
+ cbProfile *gtk.ComboBox, cbProfileBox *gtk.Box, cbProfileStatusBox *gtk.Box) error {
+
+ supplimentary.AddContext(ctx)
+ done := traceLongRunningContext(ctx)
+ defer close(done)
+ defer supplimentary.RemoveContext(ctx.Context)
+
+ backupLog := core.NewProxyLog(backup.LocalLog, "backup",
+ 6, "15:04:05", nil, logger.InfoLevel)
+
+ plan, _, err2 := backup.BuildBackupPlan(ctx.Context, backupLog, config, nil)
+ if err2 == nil || !rsync.IsRsyncProcessTerminatedError(err2) {
+ _, err := glib.IdleAdd(func() {
+ if cbProfileStatusBox != nil {
+ cbProfileStatusBox.Destroy()
+ cbProfileStatusBox = nil
+ }
+ if err2 == nil {
+ lg.Debugf("%+v", plan)
+ markup := markupTooltip(getPlanInfoMarkup(plan), getProfileWidgetHint())
+ cbProfile.SetTooltipMarkup(markup.String())
+ } else {
+ msg := err2.Error()
+ var err error
+ cbProfileStatusBox, err = createBoxWithAssetIcon(ASSET_IMPORTANT_ICON)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ markup := markupTooltip(NewMarkup(MARKUP_WEIGHT_BOLD, MARKUP_COLOR_ORANGE_RED, 0,
+ msg, nil), getProfileWidgetHint())
+ cbProfile.SetTooltipMarkup(markup.String())
+ }
+ if cbProfileStatusBox != nil {
+ cbProfileBox.Add(cbProfileStatusBox)
+ cbProfileBox.ShowAll()
+ }
+ })
+ if err != nil {
+ return err
+ }
+ } else {
+ _, err := glib.IdleAdd(func() {
+ cbProfile.SetActiveID("")
+ })
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// createMainForm creates main form of application.
+// This method is a main entry point for all GUI activity construction and display.
+func createMainForm(parent context.Context, cancel func(),
+ app *gtk.Application, appSettings *glib.Settings) (*gtk.ApplicationWindow, error) {
+
+ backupSync := NewBackupSessionStatus(parent)
+ supplimentary := &RunningContexts{}
+
+ win, err := gtk.ApplicationWindowNew(app)
+ if err != nil {
+ return nil, err
+ }
+ win.SetDefaultSize(800, 150)
+
+ _, err = win.Connect("destroy", func(window *gtk.ApplicationWindow) {
+ application, err := window.GetApplication()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ if backupSync.IsRunning() {
+ backupSync.Stop()
+ }
+ supplimentary.CancelAll()
+ application.Quit()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = win.Connect("delete-event", func(window *gtk.ApplicationWindow) bool {
+ quit := true
+ if backupSync.IsRunning() {
+ quit, err = interruptBackupDialog(&window.Window)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+ return !quit
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var act glib.IAction
+ var div *gtk.Separator
+
+ act, err = createAboutAction(&win.Window, appSettings)
+ if err != nil {
+ return nil, err
+ }
+ win.AddAction(act)
+
+ hdr, err := createHeader(core.GetAppTitle(), core.GetAppExtraTitle(), true)
+ if err != nil {
+ return nil, err
+ }
+ win.SetTitlebar(hdr)
+
+ box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.SetVAlign(gtk.ALIGN_FILL)
+
+ box2, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(box2, 18)
+ box2.SetVExpand(true)
+ box2.SetVAlign(gtk.ALIGN_FILL)
+
+ grid, err := gtk.GridNew()
+ if err != nil {
+ return nil, err
+ }
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ row := 0
+
+ lbl, err := setupLabelJustifyRight(locale.T(MsgAppWindowProfileCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, 0, row, 1, 1)
+
+ cbProfileBox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ lst, err := getProfileList()
+ if err != nil {
+ return nil, err
+ }
+ cbProfile, err := CreateNameValueCombo(lst)
+ if err != nil {
+ return nil, err
+ }
+ cbProfile.SetTooltipText(getProfileWidgetHint())
+ cbProfile.SetActiveID("")
+ cbProfile.SetHExpand(true)
+ cbProfileBox.Add(cbProfile)
+ cbProfileBox.SetHExpand(true)
+ grid.Attach(cbProfileBox, 1, row, 1, 1)
+ row++
+
+ box2.Add(grid)
+
+ div, err = gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ box2.Add(div)
+
+ box.Add(box2)
+
+ box3, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ box3.SetVExpand(true)
+ box3.SetVAlign(gtk.ALIGN_FILL)
+ box3.SetSensitive(false)
+
+ box2.Add(box3)
+
+ grid, err = gtk.GridNew()
+ if err != nil {
+ return nil, err
+ }
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ row = 0
+ box3.Add(grid)
+
+ lbl, err = setupLabelJustifyRight(locale.T(MsgAppWindowDestPathCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, 0, row, 1, 1)
+ destFolderBox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ destFolder, err := gtk.FileChooserButtonNew("Select destination folder", gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
+ if err != nil {
+ return nil, err
+ }
+ DEST_PATH_DESCRIPTION := locale.T(MsgAppWindowDestPathHint, nil)
+ destFolder.SetTooltipText(DEST_PATH_DESCRIPTION)
+ destFolder.SetHExpand(true)
+ destFolder.SetHAlign(gtk.ALIGN_FILL)
+ destFolderBox.Add(destFolder)
+ destFolderBox.SetHExpand(true)
+
+ grid.Attach(destFolderBox, 1, row, 1, 1)
+ grid.ShowAll()
+ row++
+
+ var cbProfileStatusBox *gtk.Box
+ var destFolderStatusBox *gtk.Box
+ var lastDestPath string
+
+ _, err = destFolder.Connect("file-set", func(dest *gtk.FileChooserButton, lastDestPath *string) {
+ destPath := dest.GetFilename()
+ //destFolder.SetTooltipText(destPath)
+
+ if *lastDestPath != destPath {
+ err := updateDestPathWidget(dest, destFolderBox, &destFolderStatusBox)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ *lastDestPath = destPath
+ lg.Debugf("file-set: assign last dest path to %q", *lastDestPath)
+ }
+ }, &lastDestPath)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = cbProfile.Connect("changed", func(profile *gtk.ComboBox, lastDestPath *string) {
+ cbProfile.SetTooltipText(getProfileWidgetHint())
+ backupID := profile.GetActiveID()
+ if backupID != "" {
+ val, err := GetComboValue(profile, 0)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ profileName, err := val.GetString()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ backupSettings, err := getBackupSettings(backupID, nil)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ box3.SetSensitive(true)
+ destPath := backupSettings.GetString(CFG_PROFILE_DEST_ROOT_PATH)
+ *lastDestPath = destPath
+ lg.Debugf("changed: assign last dest path to %q", *lastDestPath)
+ destFolder.SetFilename(destPath)
+ err = updateDestPathWidget(destFolder, destFolderBox, &destFolderStatusBox)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ err = enableAction(win, "RunBackupAction", true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ if cbProfileStatusBox != nil {
+ cbProfileStatusBox.Destroy()
+ cbProfileStatusBox = nil
+ }
+ msg := locale.T(MsgAppWindowInquiringProfileStatus,
+ struct{ ProfileName string }{ProfileName: profileName})
+ markup := markupTooltip(NewMarkup(0, MARKUP_COLOR_SKY_BLUE, 0, msg, nil), getProfileWidgetHint())
+ cbProfile.SetTooltipMarkup(markup.String())
+ cbProfileStatusBox, err = createBoxWithSpinner()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ cbProfileBox.Add(cbProfileStatusBox)
+ cbProfileBox.ShowAll()
+
+ config, err := readBackupConfig(backupID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ supplimentary.CancelAll()
+
+ go func() {
+ ctx := ForkContext(parent)
+
+ // perform backup plan stage in one closure
+ err := performBackupPlanStage(ctx, supplimentary, config, cbProfile, cbProfileBox, cbProfileStatusBox)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }()
+
+ } else {
+ box3.SetSensitive(false)
+ err = enableAction(win, "RunBackupAction", false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ supplimentary.CancelAll()
+ if cbProfileStatusBox != nil {
+ cbProfileStatusBox.Destroy()
+ cbProfileStatusBox = nil
+ }
+ }
+
+ }, &lastDestPath)
+ if err != nil {
+ return nil, err
+ }
+
+ act, err = createPreferenceAction(&win.Window, cbProfile)
+ if err != nil {
+ return nil, err
+ }
+ win.AddAction(act)
+
+ div, err = gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ box3.Add(div)
+
+ grid3, err := gtk.GridNew()
+ if err != nil {
+ return nil, err
+ }
+ grid3.SetVExpand(true)
+ grid3.SetVAlign(gtk.ALIGN_FILL)
+ grid3.SetColumnSpacing(12)
+ grid3.SetRowSpacing(6)
+ //grid3.SetSensitive(false)
+ row = 0
+ box3.Add(grid3)
+
+ act, err = createQuitAction(&win.Window, backupSync, supplimentary)
+ if err != nil {
+ return nil, err
+ }
+ win.AddAction(act)
+
+ act, err = createRunBackupAction(win, grid3,
+ &lastDestPath, destFolder, cbProfile, backupSync)
+ if err != nil {
+ return nil, err
+ }
+ win.AddAction(act)
+
+ act, err = createStopBackupAction(win, grid3,
+ destFolder, cbProfile, backupSync)
+ if err != nil {
+ return nil, err
+ }
+ win.AddAction(act)
+
+ win.Add(box)
+
+ return win, nil
+}
+
+// CreateApp creates GtkApplication instance to run.
+func CreateApp() (*gtk.Application, error) {
+
+ file, err := data.Assets.Open("./ajax-loader-gears_32x32.gif")
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ app, err := gtk.ApplicationNew(core.APP_ID, glib.APPLICATION_FLAGS_NONE)
+ if err != nil {
+ return nil, err
+ }
+
+ extraMsg := locale.T(MsgSchemaConfigDlgSchemaErrorAdvise,
+ struct{ ScriptName string }{ScriptName: "gs_schema_install.sh"})
+ found, err := CheckSchemaSettingsIsInstalled(core.SETTINGS_ID, app, &extraMsg)
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ // exit app
+ return app, nil
+ }
+ err = rsync.IsInstalled()
+ if err != nil {
+ text := locale.T(MsgAppWindowRsyncUtilityDlgNotFoundError, nil)
+ err = ErrorMessage(nil, locale.T(MsgAppWindowRsyncUtilityDlgTitle, nil),
+ []*DialogParagraph{NewDialogParagraph(text)})
+ if err != nil {
+ return nil, err
+ }
+ return app, nil
+ }
+
+ lang, err := GetLanguagePreference()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ locale.SetLanguage(lang)
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ _, err = app.Application.Connect("activate", func(application *gtk.Application) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ win, err := createMainForm(ctx, cancel, application, appSettings)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ win.ShowAll()
+ //win.SetGravity(gdk.GDK_GRAVITY_CENTER)
+ //win.Move(gdk.ScreenWidth()/2, gdk.ScreenHeight()/2)
+ win.SetPosition(gtk.WIN_POS_CENTER_ON_PARENT)
+
+ // Run code, when app message queue becomes empty.
+ if !appSettings.GetBoolean(CFG_DONT_SHOW_ABOUT_ON_STARTUP) {
+ _, err := glib.IdleAdd(func() {
+ action := win.LookupAction("AboutAction")
+ if action != nil {
+ action.Activate(nil)
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ locale.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "HelloWorld"})
+
+ return app, nil
+}
+
+func GetLanguagePreference() (string, error) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ return "", err
+ }
+ lang := appSettings.GetString(CFG_UI_LANGUAGE)
+ return lang, nil
+}
diff --git a/ui/gtkui/common.go b/ui/gtkui/common.go
new file mode 100644
index 0000000..3922db9
--- /dev/null
+++ b/ui/gtkui/common.go
@@ -0,0 +1,15 @@
+package gtkui
+
+import (
+ "fmt"
+
+ "github.com/d2r2/go-logger"
+)
+
+var lg = logger.NewPackageLogger("gtkui",
+ // logger.DebugLevel,
+ logger.InfoLevel,
+)
+
+var e = fmt.Errorf
+var f = fmt.Sprintf
diff --git a/ui/gtkui/context.go b/ui/gtkui/context.go
new file mode 100644
index 0000000..8396e5f
--- /dev/null
+++ b/ui/gtkui/context.go
@@ -0,0 +1,129 @@
+package gtkui
+
+import (
+ "context"
+ "sync"
+)
+
+// ContextPack keeps context with it cancel function.
+type ContextPack struct {
+ Context context.Context
+ Cancel func()
+}
+
+// ForkContext create child context from parent.
+func ForkContext(parent context.Context) *ContextPack {
+ child, cancel := context.WithCancel(parent)
+ v := &ContextPack{Context: child, Cancel: cancel}
+ return v
+}
+
+// RunningContexts keeps all currently started services,
+// preliminary added to the list, which we would like
+// to control, tracking and managing their states.
+// All methods of RunningContexts type are thread-safe.
+type RunningContexts struct {
+ sync.RWMutex
+ running []*ContextPack
+}
+
+// AddContext add new service to track.
+func (v *RunningContexts) AddContext(pack *ContextPack) {
+ v.Lock()
+ defer v.Unlock()
+ v.running = append(v.running, pack)
+}
+
+func (v *RunningContexts) findIndex(ctx context.Context) int {
+ index := -1
+ for i, item := range v.running {
+ if item.Context == ctx {
+ index = i
+ break
+ }
+ }
+ return index
+}
+
+// RemoveContext remove service from the list.
+func (v *RunningContexts) RemoveContext(ctx context.Context) {
+ v.Lock()
+ defer v.Unlock()
+ index := v.findIndex(ctx)
+ if index != -1 {
+ v.running = append(v.running[:index], v.running[index+1:]...)
+ }
+}
+
+// CancelContext cancel service from the list.
+func (v *RunningContexts) CancelContext(ctx context.Context) {
+ v.Lock()
+ defer v.Unlock()
+ index := v.findIndex(ctx)
+ if index != -1 {
+ v.running[index].Cancel()
+ v.running = append(v.running[:index], v.running[:index+1]...)
+ }
+}
+
+// CancelAll cancel all services in the list.
+func (v *RunningContexts) CancelAll() {
+ v.Lock()
+ defer v.Unlock()
+ for _, item := range v.running {
+ item.Cancel()
+ }
+ v.running = []*ContextPack{}
+}
+
+// FindContext finds service by context object.
+func (v *RunningContexts) FindContext(ctx context.Context) *ContextPack {
+ v.RLock()
+ defer v.RUnlock()
+ index := v.findIndex(ctx)
+ if index != -1 {
+ return v.running[index]
+ }
+ return nil
+}
+
+// GetCount returns number of services in the list to control.
+func (v *RunningContexts) GetCount() int {
+ v.RLock()
+ defer v.RUnlock()
+ return len(v.running)
+}
+
+// BackupSessionStatus keeps contexts - live multi-thread processes,
+// which life cycle should be controlled.
+type BackupSessionStatus struct {
+ parent context.Context
+ running RunningContexts
+}
+
+func NewBackupSessionStatus(parent context.Context) *BackupSessionStatus {
+ v := &BackupSessionStatus{parent: parent}
+ return v
+}
+
+// Start forks new context for some thread.
+func (v *BackupSessionStatus) Start() *ContextPack {
+ pack := ForkContext(v.parent)
+ v.running.AddContext(pack)
+ return pack
+}
+
+// IsRunning checks if any threads are alive.
+func (v *BackupSessionStatus) IsRunning() bool {
+ return v.running.GetCount() > 0
+}
+
+// Stop terminates all live thread's contexts.
+func (v *BackupSessionStatus) Stop() {
+ v.running.CancelAll()
+}
+
+// Done removes context from the pool of controlled threads.
+func (v *BackupSessionStatus) Done(ctx context.Context) {
+ v.running.RemoveContext(ctx)
+}
diff --git a/ui/gtkui/dialogs.go b/ui/gtkui/dialogs.go
new file mode 100644
index 0000000..2c45a3c
--- /dev/null
+++ b/ui/gtkui/dialogs.go
@@ -0,0 +1,190 @@
+package gtkui
+
+import (
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/d2r2/gotk3/pango"
+)
+
+func schemaSettingsErrorDialog(parent *gtk.Window, text string, extraMsg *string) error {
+ //title := "Schema settings configuration error "
+ titleMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, locale.T(MsgSchemaConfigDlgTitle, nil), nil)))
+ paragraphs := []*DialogParagraph{NewDialogParagraph(text).
+ SetJustify(gtk.JUSTIFY_CENTER).SetHorizAlign(gtk.ALIGN_CENTER)}
+ if extraMsg != nil {
+ paragraphs = append(paragraphs, NewDialogParagraph(*extraMsg).
+ SetJustify(gtk.JUSTIFY_CENTER).SetHorizAlign(gtk.ALIGN_CENTER))
+ }
+
+ err := ErrorMessage(parent, titleMarkup.String(), paragraphs)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// interruptBackupDialog shows dialog and query for active process termination.
+func interruptBackupDialog(parent *gtk.Window) (bool, error) {
+
+ title := locale.T(MsgAppWindowTerminateBackupDlgTitle, nil)
+ titleMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, title, nil))
+ terminateButtonCaption := locale.T(MsgAppWindowTerminateBackupDlgTerminateButton, nil)
+ terminateButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, terminateButtonCaption, nil)
+ continueButtonCaption := locale.T(MsgAppWindowTerminateBackupDlgContinueButton, nil)
+ continueButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, continueButtonCaption, nil)
+ escapeKeyMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, "esc", nil))
+ text := locale.T(MsgAppWindowTerminateBackupDlgText,
+ struct{ TerminateButton, ContinueButton, EscapeKey string }{
+ TerminateButton: terminateButtonMarkup.String(),
+ ContinueButton: continueButtonMarkup.String(),
+ EscapeKey: escapeKeyMarkup.String()})
+ // textMarkup := NewMarkup(0, 0, 0, text, nil)
+
+ buttons := []DialogButton{
+ {terminateButtonCaption, gtk.RESPONSE_YES, false, func(btn *gtk.Button) error {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ // style.AddClass("suggested-action")
+ style.AddClass("destructive-action")
+ return nil
+ }},
+ {continueButtonCaption, gtk.RESPONSE_NO, true, func(btn *gtk.Button) error {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ style.AddClass("suggested-action")
+ // style.AddClass("destructive-action")
+ return nil
+ }},
+ }
+ response, err := RunMessageDialog(parent, titleMarkup.String(), "",
+ []*DialogParagraph{NewMarkupDialogParagraph(text)}, false, buttons, nil)
+ if err != nil {
+ return false, err
+ }
+ PrintDialogResponse(response)
+ return IsResponseYes(response), nil
+}
+
+type OutOfSpaceResponse int
+
+const (
+ OutOfSpaceRetry OutOfSpaceResponse = iota
+ OutOfSpaceIgnore
+ OutOfSpaceTerminate
+)
+
+func outOfSpaceDialog(parent *gtk.Window, paths core.SrcDstPath, freeSpace uint64) (OutOfSpaceResponse, error) {
+ title := locale.T(MsgAppWindowOutOfSpaceDlgTitle, nil)
+ titleMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, title, nil))
+ terminateButtonCaption := locale.T(MsgAppWindowOutOfSpaceDlgTerminateButton, nil)
+ terminateButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, terminateButtonCaption, nil)
+ ignoreButtonCaption := locale.T(MsgAppWindowOutOfSpaceDlgIgnoreButton, nil)
+ ignoreButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, ignoreButtonCaption, nil)
+ retryButtonCaption := locale.T(MsgAppWindowOutOfSpaceDlgRetryButton, nil)
+ retryButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, retryButtonCaption, nil)
+ escapeKeyMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, "esc", nil))
+ buttons := []DialogButton{
+ {retryButtonCaption, gtk.RESPONSE_YES, true, func(btn *gtk.Button) error {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ style.AddClass("suggested-action")
+ // style.AddClass("destructive-action")
+ return nil
+ }},
+ {ignoreButtonCaption, gtk.RESPONSE_CANCEL, !true, nil},
+ {terminateButtonCaption, gtk.RESPONSE_NO, !true, func(btn *gtk.Button) error {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ //style.AddClass("suggested-action")
+ style.AddClass("destructive-action")
+ return nil
+ }},
+ }
+ text := locale.T(MsgAppWindowOutOfSpaceDlgText1,
+ struct{ Path, FreeSpace string }{Path: paths.DestPath,
+ FreeSpace: core.FormatSize(freeSpace, true)})
+ paragraphs := []*DialogParagraph{NewDialogParagraph(text).SetEllipsize(pango.ELLIPSIZE_MIDDLE).SetMaxWidthChars(10)}
+ text = locale.T(MsgAppWindowOutOfSpaceDlgText2,
+ struct{ EscapeKey, RetryButton, IgnoreButton, TerminateButton string }{EscapeKey: escapeKeyMarkup.String(),
+ RetryButton: retryButtonMarkup.String(), IgnoreButton: ignoreButtonMarkup.String(),
+ TerminateButton: terminateButtonMarkup.String()})
+ paragraphs = append(paragraphs, NewMarkupDialogParagraph(text).SetHorizAlign(gtk.ALIGN_CENTER))
+
+ response, err2 := RunMessageDialog(parent, titleMarkup.String(), "", paragraphs, false, buttons, nil)
+ if err2 != nil {
+ return 0, err2
+ }
+ PrintDialogResponse(response)
+
+ if IsResponseYes(response) {
+ return OutOfSpaceRetry, nil
+ } else if IsResponseNo(response) {
+ return OutOfSpaceTerminate, nil
+ } else {
+ return OutOfSpaceIgnore, nil
+ }
+}
+
+func questionDialog(parent *gtk.Window, titleMarkup string, textMarkup string,
+ defaultNo bool, yesDestructive bool, noSuggested bool) (bool, error) {
+ yesButtonCaption := locale.T(MsgDialogYesButton, nil)
+ noButtonCaption := locale.T(MsgDialogNoButton, nil)
+ // escapeKeyMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ // NewMarkup(MARKUP_SIZE_LARGER, 0, 0, "esc", nil))
+ buttons := []DialogButton{
+ {yesButtonCaption, gtk.RESPONSE_YES, false, func(btn *gtk.Button) error {
+ if yesDestructive {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ // style.AddClass("suggested-action")
+ style.AddClass("destructive-action")
+ }
+ return nil
+ }},
+ {noButtonCaption, gtk.RESPONSE_NO, defaultNo, func(btn *gtk.Button) error {
+ if noSuggested {
+ style, err2 := btn.GetStyleContext()
+ if err2 != nil {
+ return err2
+ }
+ style.AddClass("suggested-action")
+ // style.AddClass("destructive-action")
+ }
+ return nil
+ }},
+ }
+ for {
+ response, err := RunMessageDialog(parent, titleMarkup, "",
+ []*DialogParagraph{NewMarkupDialogParagraph(textMarkup)}, false, buttons, nil)
+ if err != nil {
+ return false, err
+ }
+ if IsResponseDeleteEvent(response) {
+ if defaultNo {
+ return false, nil
+ }
+ } else if IsResponseYes(response) {
+ return true, nil
+ } else if IsResponseNo(response) {
+ return false, nil
+ }
+ }
+ return false, nil
+}
diff --git a/ui/gtkui/gs_schema_install.sh b/ui/gtkui/gs_schema_install.sh
new file mode 100755
index 0000000..394f07f
--- /dev/null
+++ b/ui/gtkui/gs_schema_install.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env sh
+
+#if [ -z "$1" ]; then
+ export PREFIX=/usr
+#else
+# export PREFIX=$1
+#fi
+
+if [ "$PREFIX" = "/usr" ] && [ "$(id -u)" != "0" ]; then
+ # Make sure only root can run our script
+ echo "This script must be run as root" 1>&2
+ exit 1
+fi
+
+# Check availability of required commands
+# COMMANDS="install glib-compile-schemas glib-compile-resources msgfmt desktop-file-validate gtk-update-icon-cache"
+COMMANDS="install glib-compile-schemas glib-compile-resources msgfmt desktop-file-validate gtk-update-icon-cache"
+# if [ "$PREFIX" = '/usr' ] || [ "$PREFIX" = "/usr/local" ]; then
+# COMMANDS="$COMMANDS xdg-desktop-menu"
+# fi
+# PACKAGES="coreutils glib2 glib2 gettext desktop-file-utils gtk-update-icon-cache xdg-utils"
+PACKAGES="coreutils glib2 glib2 gettext desktop-file-utils gtk-update-icon-cache xdg-utils"
+i=0
+for COMMAND in $COMMANDS; do
+ type $COMMAND >/dev/null 2>&1 || {
+ j=0
+ for PACKAGE in $PACKAGES; do
+ if [ $i = $j ]; then
+ break
+ fi
+ j=$(( $j + 1 ))
+ done
+ echo "Your system is missing command $COMMAND, please install $PACKAGE"
+ exit 1
+ }
+ i=$(( $i + 1 ))
+done
+
+echo "Installing gsettings schema to prefix ${PREFIX}"
+
+# Copy and compile schema
+echo "Copying and compiling schema..."
+install -d ${PREFIX}/share/glib-2.0/schemas
+install -m 644 gsettings/org.d2r2.gorsync.gschema.xml ${PREFIX}/share/glib-2.0/schemas/
+glib-compile-schemas ${PREFIX}/share/glib-2.0/schemas/
+
diff --git a/ui/gtkui/gs_schema_uninstall.sh b/ui/gtkui/gs_schema_uninstall.sh
new file mode 100755
index 0000000..3fa4793
--- /dev/null
+++ b/ui/gtkui/gs_schema_uninstall.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+
+#if [ -z "$1" ]; then
+ export PREFIX=/usr
+ # Make sure only root can run our script
+ if [ "$(id -u)" != "0" ]; then
+ echo "This script must be run as root" 1>&2
+ exit 1
+ fi
+#else
+# export PREFIX=$1
+#fi
+
+echo "Uninstalling gsettings schema from prefix ${PREFIX}"
+
+rm ${PREFIX}/share/glib-2.0/schemas/org.d2r2.gorsync.gschema.xml
+glib-compile-schemas ${PREFIX}/share/glib-2.0/schemas/
diff --git a/ui/gtkui/gsettings/org.d2r2.gorsync.gschema.xml b/ui/gtkui/gsettings/org.d2r2.gorsync.gschema.xml
new file mode 100644
index 0000000..1f48caa
--- /dev/null
+++ b/ui/gtkui/gsettings/org.d2r2.gorsync.gschema.xml
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+ '!!!__SKIPBACKUP__!!!'
+
+
+
+ true
+ Show desktop notification about backup procedure completion
+
+
+
+ false
+ Run special script located in /etc/gorsync/ to notify about backup completion
+
+
+
+ 2
+
+
+
+ false
+ Do not shows about dialog on application startup
+
+
+
+ '14px'
+ Session log window font size (in pixels)
+
+
+
+ ''
+ User interface language
+
+
+
+ true
+ Determine automatically default backup block size
+
+
+
+ 300
+ Maximum batch size to backup at once
+
+
+
+ true
+ Activate attempts for search and use of previous backups
+
+
+
+ 3
+ Specify number of previous backups used for deduplication
+
+
+
+ false
+ Enable RSYNC log level log
+
+
+
+ false
+ Enable RSYNC intensive log level log (include stdout output)
+
+
+
+ false
+ RSYNC --compress option. Look for RSYNC help for details
+
+
+
+ true
+ RSYNC --links option. Look for RSYNC help for details
+
+
+
+ true
+ RSYNC --perms option. Look for RSYNC help for details
+
+
+
+ true
+ RSYNC --group option. Look for RSYNC help for details
+
+
+
+ true
+ RSYNC --owner option. Look for RSYNC help for details
+
+
+
+ false
+ RSYNC --devices option. Look for RSYNC help for details
+
+
+
+ false
+ RSYNC --specials option. Look for RSYNC help for details
+
+
+
+ []
+
+
+
+
+
+
+
+
+ ''
+
+
+
+ ''
+
+
+
+ []
+
+
+
+
+
+
+
+ ''
+
+
+
+ ''
+
+
+
+ true
+
+
+
+
+
diff --git a/ui/gtkui/gtk_before_3_22.go b/ui/gtkui/gtk_before_3_22.go
new file mode 100644
index 0000000..2b0965a
--- /dev/null
+++ b/ui/gtkui/gtk_before_3_22.go
@@ -0,0 +1,10 @@
+//+build gtk_3_6 gtk_3_8 gtk_3_10 gtk_3_12 gtk_3_14 gtk_3_16 gtk_3_18 gtk_3_20
+
+package gtkui
+
+import "github.com/d2r2/gotk3/gtk"
+
+// SetScrolledWindowPropogatedHeight compiled for GTK+ before 3.22 does nothing.
+func SetScrolledWindowPropogatedHeight(sw *gtk.ScrolledWindow, propagate bool) {
+ // No call
+}
diff --git a/ui/gtkui/gtk_since_3_22.go b/ui/gtkui/gtk_since_3_22.go
new file mode 100644
index 0000000..7090a0b
--- /dev/null
+++ b/ui/gtkui/gtk_since_3_22.go
@@ -0,0 +1,10 @@
+// +build !gtk_3_6,!gtk_3_8,!gtk_3_10,!gtk_3_12,!gtk_3_14,!gtk_3_16,!gtk_3_18,!gtk_3_20
+
+package gtkui
+
+import "github.com/d2r2/gotk3/gtk"
+
+// SetScrolledWindowPropogatedHeight compiled for GTK+ since 3.22 call corresponding GtkScrolledWindow method.
+func SetScrolledWindowPropogatedHeight(sw *gtk.ScrolledWindow, propagate bool) {
+ sw.SetPropagateNaturalHeight(propagate)
+}
diff --git a/ui/gtkui/markup.go b/ui/gtkui/markup.go
new file mode 100644
index 0000000..6d94376
--- /dev/null
+++ b/ui/gtkui/markup.go
@@ -0,0 +1,560 @@
+package gtkui
+
+import (
+ "bytes"
+ "html"
+ "strings"
+
+ "github.com/davecgh/go-spew/spew"
+)
+
+// Markup keeps data to create and generate Pango Markup strings.
+// Simplify construct of all corresponding format tags to translate
+// calls to Pango Markup subsystem.
+type Markup struct {
+ font MarkupFont
+ foreground MarkupColor
+ background MarkupColor
+ left interface{}
+ right interface{}
+ middle []*Markup
+}
+
+// NewMarkup create new markup object. Allows to create nested markup tags.
+func NewMarkup(font MarkupFont, foreground MarkupColor, backround MarkupColor,
+ left, right interface{}, spans ...*Markup) *Markup {
+
+ /*
+ if left != nil {
+ if str, ok := left.(string); ok {
+ left = escapeString(str)
+ }
+ }
+ if right != nil {
+ if str, ok := right.(string); ok {
+ right = escapeString(str)
+ }
+ }
+ */
+
+ sp := &Markup{font: font, foreground: foreground, background: backround, left: left, right: right, middle: spans}
+ return sp
+}
+
+// String provide Stringer interface.
+func (v *Markup) String() string {
+ var buf bytes.Buffer
+ formatMarkup(v, &buf)
+ return buf.String()
+}
+
+// formatMarkup write Pango Markup string stored in bytes.Buffer object.
+func formatMarkup(span *Markup, buf *bytes.Buffer) {
+ buf.WriteString("")
+ if span.left != nil {
+ str := spew.Sprintf("%v", span.left)
+
+ if _, ok := span.left.(string); ok {
+ str = html.EscapeString(str)
+ }
+
+ buf.WriteString(str)
+ }
+ for _, item := range span.middle {
+ formatMarkup(item, buf)
+ }
+ if span.right != nil {
+ str := spew.Sprintf("%v", span.right)
+
+ if _, ok := span.right.(string); ok {
+ str = html.EscapeString(str)
+ }
+
+ buf.WriteString(str)
+ }
+ buf.WriteString(" ")
+}
+
+/*
+var markupEscaper = strings.NewReplacer(
+ `&`, "&",
+ `'`, "'", // "'" is shorter than "'" and apos was not in HTML until HTML5.
+ `"`, """, // """ is shorter than """.
+ `<`, "<",
+ `>`, ">",
+)
+
+// EscapeString escapes special characters like "<" to become "<". It
+// escapes only five such characters: <, >, &, ' and ".
+// UnescapeString(EscapeString(s)) == s always holds, but the converse isn't
+// always true.
+func escapeString(s string) string {
+ return markupEscaper.Replace(s)
+}
+*/
+
+// MarkupFont is a bitmask to generate Pango Markup font attributes.
+// Read: https://developer.gnome.org/pango/stable/PangoMarkupFormat.html
+type MarkupFont int64
+
+// Read: https://developer.gnome.org/pango/stable/PangoMarkupFormat.html
+const (
+ // Pango Font Size
+ MARKUP_SIZE_XX_SMALL MarkupFont = 1 << iota
+ MARKUP_SIZE_X_SMALL
+ MARKUP_SIZE_SMALL
+ MARKUP_SIZE_MEDIUM
+ MARKUP_SIZE_LARGE
+ MARKUP_SIZE_X_LARGE
+ MARKUP_SIZE_XX_LARGE
+ MARKUP_SIZE_SMALLER
+ MARKUP_SIZE_LARGER
+ // Pango Font Style
+ MARKUP_STYLE_NORMAL
+ MARKUP_STYLE_OBLIQUE
+ MARKUP_STYLE_ITALIC
+ // Pango Font Weight
+ MARKUP_WEIGHT_ULTRALIGHT
+ MARKUP_WEIGHT_LIGHT
+ MARKUP_WEIGHT_NORMAL
+ MARKUP_WEIGHT_BOLD
+ MARKUP_WEIGHT_ULTRABOLD
+ MARKUP_WEIGHT_HEAVY
+ // Pango Font Variant
+ MARKUP_VARIANT_NORMAL
+ MARKUP_VARIANT_SMALLCAPS
+ // Pango Font Stretch
+ MARKUP_STRETCH_ULTRACONDENSED
+ MARKUP_STRETCH_EXTRACONDENSED
+ MARKUP_STRETCH_CONDENSED
+ MARKUP_STRETCH_SEMICONDENSED
+ MARKUP_STRETCH_NORMAL
+ MARKUP_STRETCH_SEMIEXPANDED
+ MARKUP_STRETCH_EXPANDED
+ MARKUP_STRETCH_EXTRAEXPANDED
+ MARKUP_STRETCH_ULTRAEXPANDED
+)
+
+// String provide Stringer interface.
+func (v MarkupFont) String() string {
+ var buf bytes.Buffer
+ const sizeAttr = "font_size"
+ const styleAttr = "font_style"
+ const weightAttr = "font_weight"
+ const variantAttr = "font_variant"
+ const stretchAttr = "font_stretch"
+ const template = "%s='%s' "
+ // Pango Font Size
+ if v&MARKUP_SIZE_XX_SMALL != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "xx-small"))
+ }
+ if v&MARKUP_SIZE_X_SMALL != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "x-small"))
+ }
+ if v&MARKUP_SIZE_SMALL != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "small"))
+ }
+ if v&MARKUP_SIZE_MEDIUM != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "medium"))
+ }
+ if v&MARKUP_SIZE_LARGE != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "large"))
+ }
+ if v&MARKUP_SIZE_X_LARGE != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "x-large"))
+ }
+ if v&MARKUP_SIZE_XX_LARGE != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "xx-large"))
+ }
+ if v&MARKUP_SIZE_SMALLER != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "smaller"))
+ }
+ if v&MARKUP_SIZE_LARGER != 0 {
+ buf.WriteString(spew.Sprintf(template, sizeAttr, "larger"))
+ }
+ // Pango Font Style
+ if v&MARKUP_STYLE_NORMAL != 0 {
+ buf.WriteString(spew.Sprintf(template, styleAttr, "normal"))
+ }
+ if v&MARKUP_STYLE_OBLIQUE != 0 {
+ buf.WriteString(spew.Sprintf(template, styleAttr, "oblique"))
+ }
+ if v&MARKUP_STYLE_ITALIC != 0 {
+ buf.WriteString(spew.Sprintf(template, styleAttr, "italic"))
+ }
+ // Pango Font Weight
+ if v&MARKUP_WEIGHT_ULTRALIGHT != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "ultralight"))
+ }
+ if v&MARKUP_WEIGHT_LIGHT != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "light"))
+ }
+ if v&MARKUP_WEIGHT_NORMAL != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "normal"))
+ }
+ if v&MARKUP_WEIGHT_BOLD != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "bold"))
+ }
+ if v&MARKUP_WEIGHT_ULTRABOLD != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "ultrabold"))
+ }
+ if v&MARKUP_WEIGHT_HEAVY != 0 {
+ buf.WriteString(spew.Sprintf(template, weightAttr, "heavy"))
+ }
+ // Pango Font Variant
+ if v&MARKUP_VARIANT_NORMAL != 0 {
+ buf.WriteString(spew.Sprintf(template, variantAttr, "normal"))
+ }
+ if v&MARKUP_VARIANT_SMALLCAPS != 0 {
+ buf.WriteString(spew.Sprintf(template, variantAttr, "smallcaps"))
+ }
+ // Pango Font Stretch
+ if v&MARKUP_STRETCH_ULTRACONDENSED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "ultracondensed"))
+ }
+ if v&MARKUP_STRETCH_EXTRACONDENSED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "extracondensed"))
+ }
+ if v&MARKUP_STRETCH_CONDENSED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "condensed"))
+ }
+ if v&MARKUP_STRETCH_SEMICONDENSED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "semicondensed"))
+ }
+ if v&MARKUP_STRETCH_NORMAL != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "normal"))
+ }
+ if v&MARKUP_STRETCH_SEMIEXPANDED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "semiexpanded"))
+ }
+ if v&MARKUP_STRETCH_EXPANDED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "expanded"))
+ }
+ if v&MARKUP_STRETCH_EXTRAEXPANDED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "extraexpanded"))
+ }
+ if v&MARKUP_STRETCH_ULTRAEXPANDED != 0 {
+ buf.WriteString(spew.Sprintf(template, stretchAttr, "ultraexpanded"))
+ }
+
+ str := strings.TrimSpace(buf.String())
+ return str
+}
+
+// MarkupColor is a flag to generate Pango Markup color attributes.
+type MarkupColor int
+
+// Colors taken from: https://en.wikipedia.org/wiki/X11_color_names
+const (
+ MARKUP_COLOR_ALICE_BLUE MarkupColor = iota
+ MARKUP_COLOR_ANTIQUE_WHITE
+ MARKUP_COLOR_AQUE
+ MARKUP_COLOR_AQUAMARINE
+ MARKUP_COLOR_AZURE
+ MARKUP_COLOR_BEIGE
+ MARKUP_COLOR_BISQUE
+ MARKUP_COLOR_BLACK
+ MARKUP_COLOR_BLANCHED_ALMOND
+ MARKUP_COLOR_BLUE
+ MARKUP_COLOR_BLUE_VIOLET
+ MARKUP_COLOR_BROWN
+ MARKUP_COLOR_BURLYWOOD
+ MARKUP_COLOR_CADET_BLUE
+ MARKUP_COLOR_CHARTREUSE
+ MARKUP_COLOR_CHOCOLATE
+ MARKUP_COLOR_CORAL
+ MARKUP_COLOR_CORNFLOWER
+ MARKUP_COLOR_CORNSILK
+ MARKUP_COLOR_CRIMSON
+ MARKUP_COLOR_CYAN
+ MARKUP_COLOR_DARK_BLUE
+ MARKUP_COLOR_DARK_CYAN
+ MARKUP_COLOR_DARK_GOLDENROD
+ MARKUP_COLOR_DARK_GRAY
+ MARKUP_COLOR_DARK_GREEN
+ MARKUP_COLOR_DARK_KHAKI
+ MARKUP_COLOR_DARK_MAGENTA
+ MARKUP_COLOR_DARK_OLIVE_GREEN
+ MARKUP_COLOR_DARK_ORANGE
+ MARKUP_COLOR_DARK_ORCHID
+ MARKUP_COLOR_DARK_RED
+ MARKUP_COLOR_DARK_SALMON
+ MARKUP_COLOR_DARK_SEA_GREEN
+ MARKUP_COLOR_DARK_SLATE_BLUE
+ MARKUP_COLOR_DARK_SLATE_GRAY
+ MARKUP_COLOR_DARK_TURQUOISE
+ MARKUP_COLOR_DARK_VIOLET
+ MARKUP_COLOR_DEEP_PINK
+ MARKUP_COLOR_DEEP_SKY_BLUE
+ MARKUP_COLOR_DIM_GRAY
+ MARKUP_COLOR_DODGER_BLUE
+ MARKUP_COLOR_FIREBRICK
+ MARKUP_COLOR_FLORAL_WHITE
+ MARKUP_COLOR_FOREST_GREEN
+ MARKUP_COLOR_FUCHSIA
+ MARKUP_COLOR_GAINSBORO
+ MARKUP_COLOR_GHOST_WHITE
+ MARKUP_COLOR_GOLD
+ MARKUP_COLOR_GOLDENROD
+ MARKUP_COLOR_GRAY
+ MARKUP_COLOR_WEB_GRAY
+ MARKUP_COLOR_GREEN
+ MARKUP_COLOR_WEB_GREEN
+ MARKUP_COLOR_GREEN_YELLOW
+ MARKUP_COLOR_HONEYDEW
+ MARKUP_COLOR_HOT_PINK
+ MARKUP_COLOR_INDIAN_RED
+ MARKUP_COLOR_INDIGO
+ MARKUP_COLOR_IVORY
+ MARKUP_COLOR_KHAKI
+ MARKUP_COLOR_LAVENDER
+ MARKUP_COLOR_LAVENDER_BLUSH
+ MARKUP_COLOR_LAWN_GREEN
+ MARKUP_COLOR_LEMON_CHIFFON
+ MARKUP_COLOR_LIGHT_BLUE
+ MARKUP_COLOR_LIGHT_CORAL
+ MARKUP_COLOR_LIGHT_CYAN
+ MARKUP_COLOR_LIGHT_GOLDENROD
+ MARKUP_COLOR_LIGHT_GRAY
+ MARKUP_COLOR_LIGHT_GREEN
+ MARKUP_COLOR_LIGHT_PINK
+ MARKUP_COLOR_LIGHT_SALMON
+ MARKUP_COLOR_LIGHT_SEA_GREEN
+ MARKUP_COLOR_LIGHT_SKY_BLUE
+ MARKUP_COLOR_LIGHT_SLATE_GRAY
+ MARKUP_COLOR_LIGHT_STEEL_BLUE
+ MARKUP_COLOR_LIGHT_YELLOW
+ MARKUP_COLOR_LIME
+ MARKUP_COLOR_LIME_GREEN
+ MARKUP_COLOR_LINEN
+ MARKUP_COLOR_MAGENTA
+ MARKUP_COLOR_MAROON
+ MARKUP_COLOR_WEB_MAROON
+ MARKUP_COLOR_MEDIUM_AQUAMARINE
+ MARKUP_COLOR_MEDIUM_BLUE
+ MARKUP_COLOR_MEDIUM_ORCHID
+ MARKUP_COLOR_MEDIUM_PURPLE
+ MARKUP_COLOR_MEDIUM_SEA_GREEN
+ MARKUP_COLOR_MEDIUM_SLATE_BLUE
+ MARKUP_COLOR_MEDIUM_SPRING_GREEN
+ MARKUP_COLOR_MEDIUM_TURQUOISE
+ MARKUP_COLOR_MEDIUM_VIOLET_RED
+ MARKUP_COLOR_MIDNIGHT_BLUE
+ MARKUP_COLOR_MINT_CREAM
+ MARKUP_COLOR_MISTY_ROSE
+ MARKUP_COLOR_MOCCASIN
+ MARKUP_COLOR_NAVAJO_WHITE
+ MARKUP_COLOR_NAVY_BLUE
+ MARKUP_COLOR_OLD_LACE
+ MARKUP_COLOR_OLIVE
+ MARKUP_COLOR_OLIVE_DRAB
+ MARKUP_COLOR_ORANGE
+ MARKUP_COLOR_ORANGE_RED
+ MARKUP_COLOR_ORCHID
+ MARKUP_COLOR_PALE_GOLDENROD
+ MARKUP_COLOR_PALE_GREEN
+ MARKUP_COLOR_PALE_TURQUOISE
+ MARKUP_COLOR_PALE_VIOLET_RED
+ MARKUP_COLOR_PAPAYA_WHIP
+ MARKUP_COLOR_PEACH_PUFF
+ MARKUP_COLOR_PERU
+ MARKUP_COLOR_PINK
+ MARKUP_COLOR_PLUM
+ MARKUP_COLOR_POWDER_BLUE
+ MARKUP_COLOR_PURPLE
+ MARKUP_COLOR_WEB_PURPLE
+ MARKUP_COLOR_REBECCA_PURPLE
+ MARKUP_COLOR_RED
+ MARKUP_COLOR_ROSY_BROWN
+ MARKUP_COLOR_ROYAL_BLUE
+ MARKUP_COLOR_SADDLE_BROWN
+ MARKUP_COLOR_SALMON
+ MARKUP_COLOR_SANDY_BROWN
+ MARKUP_COLOR_SEA_GREEN
+ MARKUP_COLOR_SEASHELL
+ MARKUP_COLOR_SIENNA
+ MARKUP_COLOR_SILVER
+ MARKUP_COLOR_SKY_BLUE
+ MARKUP_COLOR_SLATE_BLUE
+ MARKUP_COLOR_SLATE_GRAY
+ MARKUP_COLOR_SNOW
+ MARKUP_COLOR_SPRING_GREEN
+ MARKUP_COLOR_STEEL_BLUE
+ MARKUP_COLOR_TAN
+ MARKUP_COLOR_TEAL
+ MARKUP_COLOR_THISTLE
+ MARKUP_COLOR_TOMATO
+ MARKUP_COLOR_TURQUOISE
+ MARKUP_COLOR_VIOLET
+ MARKUP_COLOR_WHEAT
+ MARKUP_COLOR_WHITE
+ MARKUP_COLOR_WHITE_SMOKE
+ MARKUP_COLOR_YELLOW
+ MARKUP_COLOR_YELLOW_GREEN
+)
+
+// String provide Stringer interface.
+func (v MarkupColor) String() string {
+ var m = map[MarkupColor]string{
+ MARKUP_COLOR_ALICE_BLUE: "Alice Blue",
+ MARKUP_COLOR_ANTIQUE_WHITE: "Antique White",
+ MARKUP_COLOR_AQUE: "Aqua",
+ MARKUP_COLOR_AQUAMARINE: "Aquamarine",
+ MARKUP_COLOR_AZURE: "Azure",
+ MARKUP_COLOR_BEIGE: "Beige",
+ MARKUP_COLOR_BISQUE: "Bisque",
+ MARKUP_COLOR_BLACK: "Black",
+ MARKUP_COLOR_BLANCHED_ALMOND: "Blanched Almond",
+ MARKUP_COLOR_BLUE: "Blue",
+ MARKUP_COLOR_BLUE_VIOLET: "Blue Violet",
+ MARKUP_COLOR_BROWN: "Brown",
+ MARKUP_COLOR_BURLYWOOD: "Burlywood",
+ MARKUP_COLOR_CADET_BLUE: "Cadet Blue",
+ MARKUP_COLOR_CHARTREUSE: "Chartreuse",
+ MARKUP_COLOR_CHOCOLATE: "Chocolate",
+ MARKUP_COLOR_CORAL: "Coral",
+ MARKUP_COLOR_CORNFLOWER: "Cornflower",
+ MARKUP_COLOR_CORNSILK: "Cornsilk",
+ MARKUP_COLOR_CRIMSON: "Crimson",
+ MARKUP_COLOR_CYAN: "Cyan",
+ MARKUP_COLOR_DARK_BLUE: "Dark Blue",
+ MARKUP_COLOR_DARK_CYAN: "Dark Cyan",
+ MARKUP_COLOR_DARK_GOLDENROD: "Dark Goldenrod",
+ MARKUP_COLOR_DARK_GRAY: "Dark Gray",
+ MARKUP_COLOR_DARK_GREEN: "Dark Green",
+ MARKUP_COLOR_DARK_KHAKI: "Dark Khaki",
+ MARKUP_COLOR_DARK_MAGENTA: "Dark Magenta",
+ MARKUP_COLOR_DARK_OLIVE_GREEN: "Dark Olive Green",
+ MARKUP_COLOR_DARK_ORANGE: "Dark Orange",
+ MARKUP_COLOR_DARK_ORCHID: "Dark Orchid",
+ MARKUP_COLOR_DARK_RED: "Dark Red",
+ MARKUP_COLOR_DARK_SALMON: "Dark Salmon",
+ MARKUP_COLOR_DARK_SEA_GREEN: "Dark Sea Green",
+ MARKUP_COLOR_DARK_SLATE_BLUE: "Dark Slate Blue",
+ MARKUP_COLOR_DARK_SLATE_GRAY: "Dark Slate Gray",
+ MARKUP_COLOR_DARK_TURQUOISE: "Dark Turquoise",
+ MARKUP_COLOR_DARK_VIOLET: "Dark Violet",
+ MARKUP_COLOR_DEEP_PINK: "Deep Pink",
+ MARKUP_COLOR_DEEP_SKY_BLUE: "Deep Sky Blue",
+ MARKUP_COLOR_DIM_GRAY: "Dim Gray",
+ MARKUP_COLOR_DODGER_BLUE: "Dodger Blue",
+ MARKUP_COLOR_FIREBRICK: "Firebrick",
+ MARKUP_COLOR_FLORAL_WHITE: "Floral White",
+ MARKUP_COLOR_FOREST_GREEN: "Forest Green",
+ MARKUP_COLOR_FUCHSIA: "Fuchsia",
+ MARKUP_COLOR_GAINSBORO: "Gainsboro",
+ MARKUP_COLOR_GHOST_WHITE: "Ghost White",
+ MARKUP_COLOR_GOLD: "Gold",
+ MARKUP_COLOR_GOLDENROD: "Goldenrod",
+ MARKUP_COLOR_GRAY: "Gray",
+ MARKUP_COLOR_WEB_GRAY: "Web Gray",
+ MARKUP_COLOR_GREEN: "Green",
+ MARKUP_COLOR_WEB_GREEN: "Web Green",
+ MARKUP_COLOR_GREEN_YELLOW: "Green Yellow",
+ MARKUP_COLOR_HONEYDEW: "Honeydew",
+ MARKUP_COLOR_HOT_PINK: "Hot Pink",
+ MARKUP_COLOR_INDIAN_RED: "Indian Red",
+ MARKUP_COLOR_INDIGO: "Indigo",
+ MARKUP_COLOR_IVORY: "Ivory",
+ MARKUP_COLOR_KHAKI: "Khaki",
+ MARKUP_COLOR_LAVENDER: "Lavender",
+ MARKUP_COLOR_LAVENDER_BLUSH: "Lavender Blush",
+ MARKUP_COLOR_LAWN_GREEN: "Lawn Green",
+ MARKUP_COLOR_LEMON_CHIFFON: "Lemon Chiffon",
+ MARKUP_COLOR_LIGHT_BLUE: "Light Blue",
+ MARKUP_COLOR_LIGHT_CORAL: "Light Coral",
+ MARKUP_COLOR_LIGHT_CYAN: "Light Cyan",
+ MARKUP_COLOR_LIGHT_GOLDENROD: "Light Goldenrod",
+ MARKUP_COLOR_LIGHT_GRAY: "Light Gray",
+ MARKUP_COLOR_LIGHT_GREEN: "Light Green",
+ MARKUP_COLOR_LIGHT_PINK: "Light Pink",
+ MARKUP_COLOR_LIGHT_SALMON: "Light Salmon",
+ MARKUP_COLOR_LIGHT_SEA_GREEN: "Light Sea Green",
+ MARKUP_COLOR_LIGHT_SKY_BLUE: "Light Sky Blue",
+ MARKUP_COLOR_LIGHT_SLATE_GRAY: "Light Slate Gray",
+ MARKUP_COLOR_LIGHT_STEEL_BLUE: "Light Steel Blue",
+ MARKUP_COLOR_LIGHT_YELLOW: "Light Yellow",
+ MARKUP_COLOR_LIME: "Lime",
+ MARKUP_COLOR_LIME_GREEN: "Lime Green",
+ MARKUP_COLOR_LINEN: "Linen",
+ MARKUP_COLOR_MAGENTA: "Magenta",
+ MARKUP_COLOR_MAROON: "Maroon",
+ MARKUP_COLOR_WEB_MAROON: "Web Maroon",
+ MARKUP_COLOR_MEDIUM_AQUAMARINE: "Medium Aquamarine",
+ MARKUP_COLOR_MEDIUM_BLUE: "Medium Blue",
+ MARKUP_COLOR_MEDIUM_ORCHID: "Medium Orchid",
+ MARKUP_COLOR_MEDIUM_PURPLE: "Medium Purple",
+ MARKUP_COLOR_MEDIUM_SEA_GREEN: "Medium Sea Green",
+ MARKUP_COLOR_MEDIUM_SLATE_BLUE: "Medium Slate Blue",
+ MARKUP_COLOR_MEDIUM_SPRING_GREEN: "Medium Spring Green",
+ MARKUP_COLOR_MEDIUM_TURQUOISE: "Medium Turquoise",
+ MARKUP_COLOR_MEDIUM_VIOLET_RED: "Medium Violet Red",
+ MARKUP_COLOR_MIDNIGHT_BLUE: "Midnight Blue",
+ MARKUP_COLOR_MINT_CREAM: "Mint Cream",
+ MARKUP_COLOR_MISTY_ROSE: "Misty Rose",
+ MARKUP_COLOR_MOCCASIN: "Moccasin",
+ MARKUP_COLOR_NAVAJO_WHITE: "Navajo White",
+ MARKUP_COLOR_NAVY_BLUE: "Navy Blue",
+ MARKUP_COLOR_OLD_LACE: "Old Lace",
+ MARKUP_COLOR_OLIVE: "Olive",
+ MARKUP_COLOR_OLIVE_DRAB: "Olive Drab",
+ MARKUP_COLOR_ORANGE: "Orange",
+ MARKUP_COLOR_ORANGE_RED: "Orange Red",
+ MARKUP_COLOR_ORCHID: "Orchid",
+ MARKUP_COLOR_PALE_GOLDENROD: "Pale Goldenrod",
+ MARKUP_COLOR_PALE_GREEN: "Pale Green",
+ MARKUP_COLOR_PALE_TURQUOISE: "Pale Turquoise",
+ MARKUP_COLOR_PALE_VIOLET_RED: "Pale Violet Red",
+ MARKUP_COLOR_PAPAYA_WHIP: "Papaya Whip",
+ MARKUP_COLOR_PEACH_PUFF: "Peach Puff",
+ MARKUP_COLOR_PERU: "Peru",
+ MARKUP_COLOR_PINK: "Pink",
+ MARKUP_COLOR_PLUM: "Plum",
+ MARKUP_COLOR_POWDER_BLUE: "Powder Blue",
+ MARKUP_COLOR_PURPLE: "Purple",
+ MARKUP_COLOR_WEB_PURPLE: "Web Purple",
+ MARKUP_COLOR_REBECCA_PURPLE: "Rebecca Purple",
+ MARKUP_COLOR_RED: "Red",
+ MARKUP_COLOR_ROSY_BROWN: "Rosy Brown",
+ MARKUP_COLOR_ROYAL_BLUE: "Royal Blue",
+ MARKUP_COLOR_SADDLE_BROWN: "Saddle Brown",
+ MARKUP_COLOR_SALMON: "Salmon",
+ MARKUP_COLOR_SANDY_BROWN: "Sandy Brown",
+ MARKUP_COLOR_SEA_GREEN: "Sea Green",
+ MARKUP_COLOR_SEASHELL: "Seashell",
+ MARKUP_COLOR_SIENNA: "Sienna",
+ MARKUP_COLOR_SILVER: "Silver",
+ MARKUP_COLOR_SKY_BLUE: "Sky Blue",
+ MARKUP_COLOR_SLATE_BLUE: "Slate Blue",
+ MARKUP_COLOR_SLATE_GRAY: "Slate Gray",
+ MARKUP_COLOR_SNOW: "Snow",
+ MARKUP_COLOR_SPRING_GREEN: "Spring Green",
+ MARKUP_COLOR_STEEL_BLUE: "Steel Blue",
+ MARKUP_COLOR_TAN: "Tan",
+ MARKUP_COLOR_TEAL: "Teal",
+ MARKUP_COLOR_THISTLE: "Thistle",
+ MARKUP_COLOR_TOMATO: "Tomato",
+ MARKUP_COLOR_TURQUOISE: "Turquoise",
+ MARKUP_COLOR_VIOLET: "Violet",
+ MARKUP_COLOR_WHEAT: "Wheat",
+ MARKUP_COLOR_WHITE: "White",
+ MARKUP_COLOR_WHITE_SMOKE: "White Smoke",
+ MARKUP_COLOR_YELLOW: "Yellow",
+ MARKUP_COLOR_YELLOW_GREEN: "Yellow Green",
+ }
+ const template = "'%s'"
+ if val, ok := m[v]; ok {
+ return spew.Sprintf(template, val)
+ }
+ return ""
+}
diff --git a/ui/gtkui/messagekeys.go b/ui/gtkui/messagekeys.go
new file mode 100644
index 0000000..a164868
--- /dev/null
+++ b/ui/gtkui/messagekeys.go
@@ -0,0 +1,209 @@
+package gtkui
+
+const (
+ MsgAppEnvironmentTitle = "AppEnvironmentTitle"
+ MsgGLIBInfo = "GLIBInfo"
+ MsgGTKInfo = "GTKInfo"
+ MsgRsyncInfo = "RsyncInfo"
+ MsgGolangInfo = "GolangInfo"
+ MsgDialogYesButton = "DialogYesButton"
+ MsgDialogNoButton = "DialogNoButton"
+
+ MsgAboutDlgAppFeaturesAndBenefitsTitle = "AboutDlgAppFeaturesAndBenefitsTitle"
+ MsgAboutDlgAppFeaturesAndBenefitsSection = "AboutDlgAppFeaturesAndBenefitsSection"
+ MsgAboutDlgAppDescriptionSection = "AboutDlgAppDescriptionSection"
+ MsgAboutDlgReleasedUnderLicense = "AboutDlgReleasedUnderLicense"
+ MsgAboutDlgFollowMyGithubProjectTitle = "AboutDlgFollowMyGithubProjectTitle"
+ MsgAboutDlgAppCopyright = "AboutDlgAppCopyright"
+ MsgAboutDlgAppAuthorsBlock = "AboutDlgAppAuthorsBlock"
+ MsgAboutDlgDoNotShowCaption = "AboutDlgDoNotShowCaption"
+
+ MsgPrefDlgGeneralUserInterfaceOptionsSecion = "PrefDlgGeneralUserInterfaceOptionsSecion"
+ MsgPrefDlgGeneralBackupSettingsSection = "PrefDlgGeneralBackupSettingsSection"
+ MsgPrefDlgAdvancedRsyncDedupSettingsSection = "PrefDlgAdvancedRsyncDedupSettingsSection"
+ MsgPrefDlgAdvansedRsyncSettingsSection = "PrefDlgAdvansedRsyncSettingsSection"
+ MsgPrefDlgAdvancedBackupSettingsSection = "PrefDlgAdvancedBackupSettingsSection"
+ MsgPrefDlgAdvancedRsyncFileTransferOptionsSection = "PrefDlgAdvancedRsyncFileTransferOptionsSection"
+
+ MsgPrefDlgDoNotShowAtAppStartupCaption = "PrefDlgDoNotShowAtAppStartupCaption"
+ MsgPrefDlgDoNotShowAtAppStartupHint = "PrefDlgDoNotShowAtAppStartupHint"
+
+ MsgPrefDlgSessionLogControlFontSizeCaption = "PrefDlgSessionLogControlFontSizeCaption"
+ MsgPrefDlgSessionLogControlFontSizeHint = "PrefDlgSessionLogControlFontSizeHint"
+
+ MsgPrefDlgSourcesCaption = "PrefDlgSourcesCaption"
+ MsgPrefDlgSourceRsyncPathCaption = "PrefDlgSourceRsyncPathCaption"
+ MsgPrefDlgSourceRsyncPathRetryHint = "PrefDlgSourceRsyncPathRetryHint"
+ MsgPrefDlgSourceRsyncPathDescriptionHint = "PrefDlgSourceRsyncPathDescriptionHint"
+ MsgPrefDlgSourceRsyncPathNotValidatedHint = "PrefDlgSourceRsyncPathNotValidatedHint"
+ MsgPrefDlgSourceRsyncPathEmptyError = "PrefDlgSourceRsyncPathEmptyError"
+ MsgPrefDlgSourceRsyncValidatingHint = "PrefDlgSourceRsyncValidatingHint"
+
+ MsgPrefDlgDestinationSubpathCaption = "PrefDlgDestinationSubpathCaption"
+ MsgPrefDlgDestinationSubpathHint = "PrefDlgDestinationSubpathHint"
+ MsgPrefDlgDestinationSubpathNotValidatedHint = "PrefDlgDestinationSubpathNotValidatedHint"
+ MsgPrefDlgDestinationSubpathExpressionError = "PrefDlgDestinationSubpathExpressionError"
+ MsgPrefDlgDestinationSubpathNotUniqueError = "PrefDlgDestinationSubpathNotUniqueError"
+
+ MsgPrefDlgEnableBackupBlockCaption = "PrefDlgEnableBackupBlockCaption"
+ MsgPrefDlgEnableBackupBlockHint = "PrefDlgEnableBackupBlockHint"
+
+ MsgPrefDlgDeleteBackupBlockCaption = "PrefDlgDeleteBackupBlockCaption"
+ MsgPrefDlgDeleteBackupBlockHint = "PrefDlgDeleteBackupBlockHint"
+
+ MsgPrefDlgProfileNameCaption = "PrefDlgProfileNameCaption"
+ MsgPrefDlgProfileNameHint = "PrefDlgProfileNameHint"
+ MsgPrefDlgProfileNameExistsWarning = "PrefDlgProfileNameExistsWarning"
+ MsgPrefDlgProfileNameEmptyWarning = "PrefDlgProfileNameEmptyWarning"
+
+ MsgPrefDlgDefaultDestPathCaption = "PrefDlgDefaultDestPathCaption"
+ MsgPrefDlgDefaultDestPathHint = "PrefDlgDefaultDestPathHint"
+
+ MsgPrefDlgSkipFolderBackupFileSignatureCaption = "PrefDlgSkipFolderBackupFileSignatureCaption"
+ MsgPrefDlgSkipFolderBackupFileSignatureHint = "PrefDlgSkipFolderBackupFileSignatureHint"
+
+ MsgPrefDlgPerformDesktopNotificationCaption = "PrefDlgPerformDesktopNotificationCaption"
+ MsgPrefDlgPerformDesktopNotificationHint = "PrefDlgPerformDesktopNotificationHint"
+
+ MsgPrefDlgRunNotificationScriptCaption = "PrefDlgRunNotificationScriptCaption"
+ MsgPrefDlgRunNotificationScriptHint = "PrefDlgRunNotificationScriptHint"
+
+ MsgPrefDlgAutoManageBackupBlockSizeCaption = "PrefDlgAutoManageBackupBlockSizeCaption"
+ MsgPrefDlgAutoManageBackupBlockSizeHint = "PrefDlgAutoManageBackupBlockSizeHint"
+
+ MsgPrefDlgBackupBlockSizeCaption = "PrefDlgBackupBlockSizeCaption"
+ MsgPrefDlgBackupBlockSizeHint = "PrefDlgBackupBlockSizeHint"
+
+ MsgPrefDlgRsyncRetryCountCaption = "PrefDlgRsyncRetryCountCaption"
+ MsgPrefDlgRsyncRetryCountHint = "PrefDlgRsyncRetryCountHint"
+
+ MsgPrefDlgRsyncLowLevelLogCaption = "PrefDlgRsyncLowLevelLogCaption"
+ MsgPrefDlgRsyncLowLevelLogHint = "PrefDlgRsyncLowLevelLogHint"
+
+ MsgPrefDlgRsyncIntensiveLowLevelLogCaption = "PrefDlgRsyncIntensiveLowLevelLogCaption"
+ MsgPrefDlgRsyncIntensiveLowLevelLogHint = "PrefDlgRsyncIntensiveLowLevelLogHint"
+
+ MsgPrefDlgUsePreviousBackupForDedupCaption = "PrefDlgUsePreviousBackupForDedupCaption"
+ MsgPrefDlgUsePreviousBackupForDedupHint = "PrefDlgUsePreviousBackupForDedupHint"
+
+ MsgPrefDlgNumberOfPreviousBackupToUseCaption = "PrefDlgNumberOfPreviousBackupToUseCaption"
+ MsgPrefDlgNumberOfPreviousBackupToUseHint = "PrefDlgNumberOfPreviousBackupToUseHint"
+
+ MsgPrefDlgRsyncCompressFileTransferCaption = "PrefDlgRsyncCompressFileTransferCaption"
+ MsgPrefDlgRsyncCompressFileTransferHint = "PrefDlgRsyncCompressFileTransferHint"
+
+ MsgPrefDlgRsyncTransferSourcePermissionsCaption = "PrefDlgRsyncTransferSourcePermissionsCaption"
+ MsgPrefDlgRsyncTransferSourcePermissionsHint = "PrefDlgRsyncTransferSourcePermissionsHint"
+
+ MsgPrefDlgRsyncTransferSourceOwnerCaption = "PrefDlgRsyncTransferSourceOwnerCaption"
+ MsgPrefDlgRsyncTransferSourceOwnerHint = "PrefDlgRsyncTransferSourceOwnerHint"
+
+ MsgPrefDlgRsyncTransferSourceGroupCaption = "PrefDlgRsyncTransferSourceGroupCaption"
+ MsgPrefDlgRsyncTransferSourceGroupHint = "PrefDlgRsyncTransferSourceGroupHint"
+
+ MsgPrefDlgRsyncRecreateSymlinksCaption = "PrefDlgRsyncRecreateSymlinksCaption"
+ MsgPrefDlgRsyncRecreateSymlinksHint = "PrefDlgRsyncRecreateSymlinksHint"
+
+ MsgPrefDlgRsyncTransferDeviceFilesCaption = "PrefDlgRsyncTransferDeviceFilesCaption"
+ MsgPrefDlgRsyncTransferDeviceFilesHint = "PrefDlgRsyncTransferDeviceFilesHint"
+
+ MsgPrefDlgRsyncTransferSpecialFilesCaption = "PrefDlgRsyncTransferSpecialFilesCaption"
+ MsgPrefDlgRsyncTransferSpecialFilesHint = "PrefDlgRsyncTransferSpecialFilesHint"
+
+ MsgPrefDlgLanguageCaption = "PrefDlgLanguageCaption"
+ MsgPrefDlgLanguageHint = "PrefDlgLanguageHint"
+ MsgPrefDlgDefaultLanguageEntry = "PrefDlgDefaultLanguageEntry"
+ MsgPrefDlgAddBackupBlockHint = "PrefDlgAddBackupBlockHint"
+ MsgPrefDlgProfileConfigIssuesDetectedWarning = "PrefDlgProfileConfigIssuesDetectedWarning"
+ MsgPrefDlgPreferencesDialogCaption = "PrefDlgPreferencesDialogCaption"
+
+ MsgPrefDlgGeneralProfileTabName = "PrefDlgGeneralProfileTabName"
+ MsgPrefDlgProfileTabName = "PrefDlgProfileTabName"
+ MsgPrefDlgGeneralTabName = "PrefDlgGeneralTabName"
+ MsgPrefDlgAdvancedTabName = "PrefDlgAdvancedTabName"
+
+ MsgPrefDlgAddProfileHint = "PrefDlgAddProfileHint"
+ MsgPrefDlgDeleteProfileHint = "PrefDlgDeleteProfileHint"
+ MsgPrefDlgDeleteProfileDialogTitle = "PrefDlgDeleteProfileDialogTitle"
+ MsgPrefDlgDeleteProfileDialogText = "PrefDlgDeleteProfileDialogText"
+
+ MsgSchemaConfigDlgTitle = "SchemaConfigDlgTitle"
+ MsgSchemaConfigDlgNoSchemaFoundError = "SchemaConfigDlgNoSchemaFoundError"
+ MsgSchemaConfigDlgSchemaDoesNotFoundError = "SchemaConfigDlgSchemaDoesNotFoundError"
+ MsgSchemaConfigDlgSchemaErrorAdvise = "SchemaConfigDlgSchemaErrorAdvise"
+
+ MsgAppWindowAboutMenuCaption = "AppWindowAboutMenuCaption"
+ MsgAppWindowPreferencesMenuCaption = "AppWindowPreferencesMenuCaption"
+ MsgAppWindowPreferencesHint = "AppWindowPreferencesHint"
+ MsgAppWindowQuitMenuCaption = "AppWindowQuitMenuCaption"
+ MsgAppWindowRunBackupHint = "AppWindowRunBackupHint"
+ MsgAppWindowStopBackupHint = "AppWindowStopBackupHint"
+
+ MsgAppWindowProfileCaption = "AppWindowProfileCaption"
+ MsgAppWindowProfileHint = "AppWindowProfileHint"
+ MsgAppWindowProfileBackupPlanInfoSourceCount = "AppWindowProfileBackupPlanInfoSourceCount"
+ MsgAppWindowProfileBackupPlanInfoTotalSize = "AppWindowProfileBackupPlanInfoTotalSize"
+ MsgAppWindowProfileBackupPlanInfoSkipSize = "AppWindowProfileBackupPlanInfoSkipSize"
+ MsgAppWindowProfileBackupPlanInfoDirectoryCount = "AppWindowProfileBackupPlanInfoDirectoryCount"
+ MsgAppWindowInquiringProfileStatus = "AppWindowInquiringProfileStatus"
+ MsgAppWindowNoneProfileEntry = "AppWindowNoneProfileEntry"
+
+ MsgAppWindowDestPathCaption = "AppWindowDestPathCaption"
+ MsgAppWindowDestPathHint = "AppWindowDestPathHint"
+ MsgAppWindowDestPathIsValidStatusPart1 = "AppWindowDestPathIsValidStatusPart1"
+ MsgAppWindowDestPathIsValidStatusPart2 = "AppWindowDestPathIsValidStatusPart2"
+ MsgAppWindowDestPathIsEmptyError1 = "AppWindowDestPathIsEmptyError1"
+ MsgAppWindowDestPathIsEmptyError2 = "AppWindowDestPathIsEmptyError2"
+ MsgAppWindowDestPathIsNotExistError = "AppWindowDestPathIsNotExistError"
+ MsgAppWindowDestPathIsNotExistAdvise = "AppWindowDestPathIsNotExistAdvise"
+
+ MsgAppWindowBackupProgressStartMessage = "AppWindowBackupProgressStartMessage"
+ MsgAppWindowBackupProgressInquiringSourceID = "AppWindowBackupProgressInquiringSourceID"
+ MsgAppWindowBackupProgressInquiringSourceDescription = "AppWindowBackupProgressInquiringSourceDescription"
+ MsgAppWindowBackupProgressTimePassedSuffix = "AppWindowBackupProgressTimePassedSuffix"
+ MsgAppWindowBackupProgressETASuffix = "AppWindowBackupProgressETASuffix"
+ MsgAppWindowBackupProgressSizeCompletedSuffix = "AppWindowBackupProgressSizeCompletedSuffix"
+ MsgAppWindowBackupProgressSizeLeftToProcessSuffix = "AppWindowBackupProgressSizeLeftToProcessSuffix"
+ MsgAppWindowBackupProgressCompleted = "AppWindowBackupProgressCompleted"
+ MsgAppWindowBackupProgressCompletedWithErrors = "AppWindowBackupProgressCompletedWithErrors"
+ MsgAppWindowBackupProgressTerminated = "AppWindowBackupProgressTerminated"
+ MsgAppWindowBackupProgressFailed = "AppWindowBackupProgressFailed"
+ MsgAppWindowOverallProgressCaption = "AppWindowOverallProgressCaption"
+ MsgAppWindowProgressStatusCaption = "AppWindowProgressStatusCaption"
+ MsgAppWindowSessionLogCaption = "AppWindowSessionLogCaption"
+ MsgAppWindowCannotStartBackupProcessTitle = "AppWindowCannotStartBackupProcessTitle"
+
+ MsgAppWindowTerminateBackupDlgTitle = "AppWindowTerminateBackupDlgTitle"
+ MsgAppWindowTerminateBackupDlgText = "AppWindowTerminateBackupDlgText"
+ MsgAppWindowTerminateBackupDlgTerminateButton = "AppWindowTerminateBackupDlgTerminateButton"
+ MsgAppWindowTerminateBackupDlgContinueButton = "AppWindowTerminateBackupDlgContinueButton"
+
+ MsgAppWindowOutOfSpaceDlgTitle = "AppWindowOutOfSpaceDlgTitle"
+ MsgAppWindowOutOfSpaceDlgText1 = "AppWindowOutOfSpaceDlgText1"
+ MsgAppWindowOutOfSpaceDlgText2 = "AppWindowOutOfSpaceDlgText2"
+ MsgAppWindowOutOfSpaceDlgIgnoreButton = "AppWindowOutOfSpaceDlgIgnoreButton"
+ MsgAppWindowOutOfSpaceDlgRetryButton = "AppWindowOutOfSpaceDlgRetryButton"
+ MsgAppWindowOutOfSpaceDlgTerminateButton = "AppWindowOutOfSpaceDlgTerminateButton"
+
+ MsgAppWindowRsyncUtilityDlgTitle = "AppWindowRsyncUtilityDlgTitle"
+ MsgAppWindowRsyncUtilityDlgNotFoundError = "AppWindowRsyncUtilityDlgNotFoundError"
+
+ MsgAppWindowShowNotificationError = "AppWindowShowNotificationError"
+ MsgAppWindowRunNotificationScriptError = "AppWindowRunNotificationScriptError"
+ MsgAppWindowNotificationScriptExecutableError = "AppWindowNotificationScriptExecutableError"
+ MsgAppWindowGetExecutableScriptInfoError = "AppWindowGetExecutableScriptInfoError"
+
+ MsgLogBackupStageOutOfSpaceWarning = "LogBackupStageOutOfSpaceWarning"
+
+ MsgGeneralHintStatusCaption = "GeneralHintStatusCaption"
+ MsgGeneralHintDescriptionCaption = "GeneralHintDescriptionCaption"
+
+ MsgDesktopNotificationBackupSuccessfullyCompleted = "DesktopNotificationBackupSuccessfullyCompleted"
+ MsgDesktopNotificationBackupCompletedWithErrors = "DesktopNotificationBackupCompletedWithErrors"
+ MsgDesktopNotificationBackupTerminated = "DesktopNotificationBackupTerminated"
+ MsgDesktopNotificationBackupFailed = "DesktopNotificationBackupFailed"
+ MsgDesktopNotificationTotalSize = "DesktopNotificationTotalSize"
+ MsgDesktopNotificationSkippedSize = "DesktopNotificationSkippedSize"
+ MsgDesktopNotificationFailedToBackupSize = "DesktopNotificationFailedToBackupSize"
+ MsgDesktopNotificationTimeTaken = "DesktopNotificationTimeTaken"
+)
diff --git a/ui/gtkui/notifier.go b/ui/gtkui/notifier.go
new file mode 100644
index 0000000..c7ab480
--- /dev/null
+++ b/ui/gtkui/notifier.go
@@ -0,0 +1,817 @@
+package gtkui
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ logger "github.com/d2r2/go-logger"
+ "github.com/d2r2/go-rsync/backup"
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+ shell "github.com/d2r2/go-shell"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/d2r2/gotk3/libnotify"
+ "github.com/d2r2/gotk3/pango"
+ "github.com/davecgh/go-spew/spew"
+)
+
+// NotifierUI is a binding object, which connect
+// backup notification with GUI controls.
+type NotifierUI struct {
+ profileName string
+ gridUI *gtk.Grid
+ totalDone core.FolderSize
+ progress *float32
+ done chan struct{}
+ // GTK widgets
+ pbm *ProgressBarManage
+ statusLabel *gtk.Label
+ logTextView *gtk.TextView
+ logViewPort *gtk.Viewport
+}
+
+// Static cast to verify that struct implement specific interface.
+var _ backup.Notifier = &NotifierUI{}
+
+func NewNotifierUI(profileName string, gridUI *gtk.Grid) *NotifierUI {
+ v := &NotifierUI{profileName: profileName, gridUI: gridUI, done: make(chan struct{})}
+ return v
+}
+
+func (v *NotifierUI) Done() chan struct{} {
+ return v.done
+}
+
+func formatInqueryProgress(sourceId int, sourceRsync string) string {
+ mp := NewMarkup(0, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, locale.T(MsgAppWindowBackupProgressInquiringSourceID, struct{ SourceID int }{
+ SourceID: sourceId + 1}), spew.Sprintln()),
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowBackupProgressInquiringSourceDescription, struct{ RsyncSource string }{
+ RsyncSource: sourceRsync}), nil),
+ )
+ return mp.String()
+}
+
+// NotifyPlanStage_NodeStructureStartInquiry implements core.BackupNotifier interface method.
+func (v *NotifierUI) NotifyPlanStage_NodeStructureStartInquiry(sourceID int,
+ sourceRsync string) error {
+ msg := formatInqueryProgress(sourceID, sourceRsync)
+ err := v.UpdateBackupProgress(nil, msg, true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ return nil
+}
+
+// NotifyPlanStage_NodeStructureDoneInquiry implements core.BackupNotifier interface method.
+func (v *NotifierUI) NotifyPlanStage_NodeStructureDoneInquiry(sourceID int,
+ sourceRsync string, dir *core.Dir) error {
+ return nil
+}
+
+func formatBackupProgress(backupType core.FolderBackupType, totalDone, leftToBackup core.FolderSize,
+ timePassed time.Duration, eta *time.Duration, path string) string {
+
+ sections := 2
+ etaStr := "*"
+ if eta != nil {
+ etaStr = core.FormatDurationToDaysHoursMinsSecs(*eta, true, §ions)
+ }
+ passedStr := core.FormatDurationToDaysHoursMinsSecs(timePassed, true, §ions)
+ mp := NewMarkup(0, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, passedStr, " "),
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowBackupProgressTimePassedSuffix, nil), " | "),
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, etaStr, " "),
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowBackupProgressETASuffix, nil), "\n"),
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, core.GetReadableSize(totalDone), " "),
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowBackupProgressSizeCompletedSuffix, nil), " | "),
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, core.GetReadableSize(leftToBackup), " "),
+ NewMarkup(0, 0, 0, locale.T(MsgAppWindowBackupProgressSizeLeftToProcessSuffix, nil), "\n"),
+ NewMarkup(0, 0, 0, spew.Sprintf("%s: %q", backup.GetBackupTypeDescription(backupType), path),
+ nil),
+ )
+ /*
+ msg := spew.Sprintf("%s %s | %s %s\n%s %s | %s %s\n%s: %q",
+ MarkupTag("big", utils.FormatDurToDaysHoursMinsSecs(timePassed, §ions)), MarkupTag("span", "passed"),
+ MarkupTag("big", etaStr), MarkupTag("span", "ETA"),
+ MarkupTag("big", hum.Bytes(totalDone.GetByteCount())), MarkupTag("span", "done"),
+ MarkupTag("big", hum.Bytes(leftToBackup.GetByteCount())), MarkupTag("span", "left to backup"),
+ MarkupTag("span", backupStr), MarkupTag("span", path))
+ */
+ return mp.String()
+}
+
+// NotifyBackupStage_FolderStartBackup implements core.BackupNotifier interface method.
+func (v *NotifierUI) NotifyBackupStage_FolderStartBackup(rootDest string,
+ paths core.SrcDstPath, backupType core.FolderBackupType,
+ leftToBackup core.FolderSize,
+ timePassed time.Duration, eta *time.Duration) error {
+
+ path, err := core.GetRelativePath(rootDest, paths.DestPath)
+ if err != nil {
+ return err
+ }
+
+ msg := formatBackupProgress(backupType, v.totalDone, leftToBackup, timePassed, eta, path)
+
+ err = v.UpdateBackupProgress(v.progress, msg, true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ return err
+}
+
+// NotifyBackupStage_FolderDoneBackup implements core.BackupNotifier interface method.
+func (v *NotifierUI) NotifyBackupStage_FolderDoneBackup(rootDest string,
+ paths core.SrcDstPath, backupType core.FolderBackupType,
+ leftToBackup core.FolderSize, sizeDone core.SizeProgress,
+ timePassed time.Duration, eta *time.Duration,
+ sessionErr error) error {
+
+ path, err := core.GetRelativePath(rootDest, paths.DestPath)
+ if err != nil {
+ return err
+ }
+
+ v.totalDone = v.totalDone.AddSizeProgress(sizeDone)
+
+ msg := formatBackupProgress(backupType, v.totalDone, leftToBackup, timePassed, eta, path)
+
+ lg.Debugf("Total done: %v", v.totalDone)
+ lg.Debugf("Left to backup: %v", leftToBackup.GetByteCount())
+ progress := float32(float64(v.totalDone) / float64(v.totalDone+leftToBackup))
+ const minProgress = 0.002
+ if progress < minProgress {
+ progress = minProgress
+ }
+ v.progress = &progress
+
+ err = v.UpdateBackupProgress(v.progress, msg, true)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ return err
+}
+
+func (v *NotifierUI) ClearProgressGrid() error {
+ v.statusLabel = nil
+ if v.pbm != nil {
+ v.pbm.StopPulse()
+ v.pbm = nil
+ }
+ v.logTextView = nil
+ v.logViewPort = nil
+ lst := v.gridUI.GetChildren()
+ lst.Foreach(func(item interface{}) {
+ if wdg, ok := item.(*gtk.Widget); ok {
+ wdg.Destroy()
+ }
+ })
+ /*
+ for gtk.EventsPending() {
+ lg.Info("Pending events 2")
+ gtk.MainIteration()
+ }
+ */
+ return nil
+}
+
+func (v *NotifierUI) CreateProgressControls(sessionLogFontSize string) error {
+ row := 0
+ if v.pbm == nil {
+ lbl, err := gtk.LabelNew(locale.T(MsgAppWindowOverallProgressCaption, nil))
+ if err != nil {
+ return err
+ }
+ lbl.SetHAlign(gtk.ALIGN_START)
+ v.gridUI.Attach(lbl, 0, row, 1, 1)
+ progressBar, err := gtk.ProgressBarNew()
+ if err != nil {
+ return err
+ }
+ css := `
+/****************
+ * Progress bar *
+ ****************/
+progressbar progress, trough {
+ min-height: 20px;
+}
+
+progressbar progress {
+
+ background-image: linear-gradient(to top, @theme_bg_color, @progressbar_bg_color);
+
+
+ border-radius: 3px;
+ border-style: solid;
+
+ border-color: @progressbar_border;
+
+}
+
+/*
+progressbar progress {
+ background-image: linear-gradient(to top, @theme_bg_color, @theme_fg_color);
+
+ border-radius: 3px;
+ border-style: solid;
+ border-color: alpha(@progressbar_border, 0.01);
+}
+*/
+
+
+/*
+progressbar trough {
+ background-color: rgba(255, 255, 255, 255);
+}
+*/
+`
+ err = applyStyleCSS(&progressBar.Widget, css)
+ if err != nil {
+ return err
+ }
+ progressBar.SetHAlign(gtk.ALIGN_FILL)
+ progressBar.SetHExpand(true)
+ v.pbm = NewProgressBarManage(progressBar)
+ _, err = progressBar.Connect("destroy", func(pb *gtk.ProgressBar, pbm *ProgressBarManage) {
+ pbm.StopPulse()
+ }, v.pbm)
+ if err != nil {
+ return err
+ }
+
+ v.gridUI.Attach(progressBar, 1, row, 1, 1)
+ }
+ row++
+
+ if v.statusLabel == nil {
+ lbl, err := gtk.LabelNew(locale.T(MsgAppWindowProgressStatusCaption, nil))
+ if err != nil {
+ return err
+ }
+ lbl.SetHAlign(gtk.ALIGN_START)
+ lbl.SetVAlign(gtk.ALIGN_START)
+ v.gridUI.Attach(lbl, 0, row, 1, 1)
+ v.statusLabel, err = gtk.LabelNew("")
+ if err != nil {
+ return err
+ }
+ v.statusLabel.SetHAlign(gtk.ALIGN_START)
+ v.statusLabel.SetHExpand(true)
+ v.statusLabel.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
+ v.gridUI.Attach(v.statusLabel, 1, row, 1, 1)
+ }
+ row++
+
+ if v.logTextView == nil {
+ lbl, err := gtk.LabelNew(locale.T(MsgAppWindowSessionLogCaption, nil))
+ if err != nil {
+ return err
+ }
+ lbl.SetHAlign(gtk.ALIGN_START)
+ v.gridUI.Attach(lbl, 0, row, 2, 1)
+ row++
+ v.logTextView, err = gtk.TextViewNew()
+ if err != nil {
+ return err
+ }
+ buffer, err := v.logTextView.GetBuffer()
+ if err != nil {
+ return err
+ }
+ err = addColorTags(buffer)
+ if err != nil {
+ return err
+ }
+
+ css := `
+textview {
+ font: %s "Monospace";
+}
+ `
+ err = applyStyleCSS(&v.logTextView.Widget, spew.Sprintf(css, sessionLogFontSize))
+ if err != nil {
+ return err
+ }
+ v.logTextView.SetEditable(false)
+ v.logViewPort, err = gtk.ViewportNew(nil, nil)
+ if err != nil {
+ return err
+ }
+ sw, err := gtk.ScrolledWindowNew(nil, nil)
+ if err != nil {
+ return err
+ }
+ sw.SetSizeRequest(-1, 120)
+ sw.SetVAlign(gtk.ALIGN_FILL)
+ sw.SetVExpand(true)
+ sw.Add(v.logViewPort)
+ v.logViewPort.Add(v.logTextView)
+ v.gridUI.Attach(sw, 0, row, 2, 1)
+ }
+ row++
+
+ v.gridUI.ShowAll()
+ return nil
+}
+
+func (v *NotifierUI) ScrollView() error {
+ adj, err := v.logViewPort.GetVAdjustment()
+ if err != nil {
+ return err
+ }
+ adj.SetValue(adj.GetUpper())
+ //v.grid.QueueDraw()
+ //v.logViewPort.QueueDraw()
+ return nil
+}
+
+func addColorTags(buffer *gtk.TextBuffer) error {
+ table, err := buffer.GetTagTable()
+ if err != nil {
+ return err
+ }
+
+ tag, err := gtk.TextTagNew("BlueColor")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("foreground", "Dodger Blue")
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ tag, err = gtk.TextTagNew("RedColor")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("foreground", "Red")
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ tag, err = gtk.TextTagNew("AquaColor")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("foreground", "Aqua")
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ tag, err = gtk.TextTagNew("YellowColor")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("foreground", "Goldenrod")
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ tag, err = gtk.TextTagNew("OrangeRedColor")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("foreground", "Orange Red")
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ tag, err = gtk.TextTagNew("Path")
+ if err != nil {
+ return err
+ }
+ err = tag.SetProperty("underline", pango.UNDERLINE_SINGLE)
+ if err != nil {
+ return err
+ }
+ table.Add(tag)
+
+ return nil
+}
+
+// // GetSubpathRegexp verify that proposed file system path expression is valid.
+// // Understand path separator for different OS, taking path separator setting from runtime.
+// func getSubpathRegexp() (*regexp.Regexp, error) {
+// template := fmt.Sprintf(`"(?P(\%[1]c?([^\%[1]c]+\%[1]c?)*))"`, os.PathSeparator)
+// lg.Debugf("Subpath regex template: %s", template)
+// rexp := regexp.MustCompile(template)
+// return rexp, nil
+// }
+
+func getRuneIndex(line string, byteOffset int) int {
+ runeIndex := 0
+ var index int
+ // var runeValue rune
+ for index, _ = range line {
+ // lg.Infof("rune=%v, offset=%d", runeValue, index)
+ if index == byteOffset {
+ return runeIndex
+ }
+ runeIndex++
+ }
+ if index+1 == byteOffset {
+ return runeIndex
+ }
+ return -1
+}
+
+func lToU(level logger.LogLevel) string {
+ return strings.ToUpper(level.ShortStr())
+}
+
+func getLogEventsRegex(events []struct {
+ Level logger.LogLevel
+ TagName string
+}) *regexp.Regexp {
+
+ var buf bytes.Buffer
+ for i, event := range events {
+ buf.WriteString(lToU(event.Level))
+ if i < len(events)-1 {
+ buf.WriteString("|")
+ }
+ }
+ re := regexp.MustCompile(fmt.Sprintf(`\[.+\]\s+(?P(%s))`, buf.String()))
+ return re
+}
+
+func (v *NotifierUI) addLineToBuffer(buffer *gtk.TextBuffer, line string) {
+ end := buffer.GetEndIter()
+ endOffset := end.GetOffset()
+ buffer.Insert(end, line)
+
+ events := []struct {
+ Level logger.LogLevel
+ TagName string
+ }{
+ {logger.InfoLevel, "BlueColor"},
+ {logger.NotifyLevel, "YellowColor"},
+ {logger.WarnLevel, "OrangeRedColor"},
+ {logger.ErrorLevel, "RedColor"},
+ {logger.FatalLevel, "RedColor"},
+ {logger.PanicLevel, "RedColor"},
+ }
+ re := getLogEventsRegex(events)
+ m := core.FindStringSubmatchIndexes(re, line)
+ if a, ok := m["Event"]; ok {
+ value := line[a[0]:a[1]]
+ p1 := buffer.GetIterAtOffset(getRuneIndex(line, a[0]) + endOffset)
+ p2 := buffer.GetIterAtOffset(getRuneIndex(line, a[1]) + endOffset)
+ for _, event := range events {
+ if value == lToU(event.Level) {
+ buffer.ApplyTagByName(event.TagName, p1, p2)
+ }
+ }
+ }
+ /*
+ var err error
+ re, err = getSubpathRegexp()
+ if err != nil {
+ return err
+ }
+ m = core.FindStringSubmatchIndexes(re, line)
+ if b, ok := m["Path"]; ok {
+ link, err := gtk.LabelNew("/home/")
+ if err != nil {
+ return err
+ }
+ // SetAllMargins(link, 0)
+ css := `
+ * {
+ margin: 0;
+ padding: 0;
+ border-style: none;
+ border-radius: 0;
+ border-width: 0;
+ outline-style: none;
+ outline-offset: 0px;
+ }
+ `
+ // end := buffer.GetEndIter()
+ p1 := buffer.GetIterAtOffset(getRuneIndex(line, b[0]) + endOffset)
+ // p2 := buffer.GetIterAtOffset(getRuneIndex(line, b[1]) + endOffset)
+ anchor, err := buffer.CreateChildAnchor(p1)
+ if err != nil {
+ return err
+ }
+ v.logTextView.AddChildAtAnchor(link, anchor)
+ link.ShowAll()
+ applyStyleCSS(&link.Widget, css)
+ // buffer.ApplyTagByName("Path", p1, p2)
+ }
+ */
+}
+
+// UpdateTextViewLog add log line to the end of
+// Session Log control.
+func (v *NotifierUI) UpdateTextViewLog(line string) error {
+ call := func() {
+ //if v.logTextView != nil {
+ buffer, err := v.logTextView.GetBuffer()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ v.addLineToBuffer(buffer, line)
+
+ err = v.ScrollView()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ //}
+ }
+ _, err := glib.IdleAdd(call)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// UpdateBackupProgress updates visual progress of backup
+// with status and percent progresses.
+func (v *NotifierUI) UpdateBackupProgress(progress *float32,
+ progressStr string, fromAsync bool) error {
+
+ call := func() {
+ if progress == nil {
+ v.pbm.StartPulse()
+ } else {
+ prg := float64(*progress)
+ err := v.pbm.SetFraction(prg)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+ v.statusLabel.SetMarkup(progressStr)
+ }
+ if fromAsync {
+ _, err := glib.IdleAdd(call)
+ if err != nil {
+ return err
+ }
+ } else {
+ call()
+ }
+ return nil
+}
+
+type BackupCompletionType int
+
+const (
+ BackupFailed BackupCompletionType = iota
+ BackupTerminated
+ BackupSucessfullyCompleted
+ BackupCompletedWithErrors
+)
+
+func (v *NotifierUI) decodeBackupCompletionType(err error,
+ backupProgress *backup.Progress) BackupCompletionType {
+ if err != nil {
+ if rsync.IsRsyncProcessTerminatedError(err) {
+ return BackupTerminated
+ } else {
+ return BackupFailed
+ }
+ } else {
+ if backupProgress.TotalProgress.Failed != nil {
+ return BackupCompletedWithErrors
+ } else {
+ return BackupSucessfullyCompleted
+ }
+ }
+}
+
+// getDesktopNotificationSummaryAndBody prepares desktop notification subject and body text.
+func (v *NotifierUI) getDesktopNotificationSummaryAndBody(completionType BackupCompletionType,
+ backupProgress *backup.Progress) (string, string) {
+
+ var summary, body string
+ switch completionType {
+ case BackupSucessfullyCompleted:
+ summary = locale.T(
+ MsgDesktopNotificationBackupSuccessfullyCompleted,
+ struct{ ProfileName string }{ProfileName: v.profileName})
+ case BackupCompletedWithErrors:
+ summary = locale.T(
+ MsgDesktopNotificationBackupCompletedWithErrors,
+ struct{ ProfileName string }{ProfileName: v.profileName})
+ case BackupFailed:
+ summary = locale.T(
+ MsgDesktopNotificationBackupFailed,
+ struct{ ProfileName string }{ProfileName: v.profileName})
+ case BackupTerminated:
+ summary = locale.T(
+ MsgDesktopNotificationBackupTerminated,
+ struct{ ProfileName string }{ProfileName: v.profileName})
+ }
+
+ var buf bytes.Buffer
+ if completionType != BackupFailed && completionType != BackupTerminated &&
+ backupProgress != nil && backupProgress.TotalProgress != nil {
+
+ if backupProgress.TotalProgress.Completed != nil {
+ buf.WriteString(fmt.Sprintln(locale.T(MsgDesktopNotificationTotalSize,
+ struct{ TotalSize string }{TotalSize: core.GetReadableSize(
+ *backupProgress.TotalProgress.Completed)})))
+ }
+ if backupProgress.TotalProgress.Failed != nil {
+ buf.WriteString(fmt.Sprintln(locale.T(MsgDesktopNotificationFailedToBackupSize,
+ struct{ FailedToBackupSize string }{FailedToBackupSize: core.GetReadableSize(
+ *backupProgress.TotalProgress.Failed)})))
+ }
+ if backupProgress.TotalProgress.Skipped != nil {
+ buf.WriteString(fmt.Sprintln(locale.T(MsgDesktopNotificationSkippedSize,
+ struct{ SkippedSize string }{SkippedSize: core.GetReadableSize(
+ *backupProgress.TotalProgress.Skipped)})))
+ }
+ }
+ if backupProgress != nil {
+ timeTaken := backupProgress.GetTotalTimeTaken()
+ sections := 2
+ buf.WriteString(fmt.Sprintln(locale.T(MsgDesktopNotificationTimeTaken,
+ struct{ TimeTaken string }{TimeTaken: core.FormatDurationToDaysHoursMinsSecs(
+ timeTaken, true, §ions)})))
+ }
+ body = buf.String()
+
+ return summary, body
+}
+
+func (v *NotifierUI) checkDesktopNotificationEnabled() (bool, error) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ return false, err
+ }
+ enabled := appSettings.GetBoolean(CFG_PERFORM_DESKTOP_NOTIFICATION)
+ return enabled, nil
+}
+
+func (v *NotifierUI) sendDesktopNotification(completionType BackupCompletionType,
+ backupProgress *backup.Progress) error {
+
+ summary, body := v.getDesktopNotificationSummaryAndBody(completionType, backupProgress)
+ notif, err := libnotify.NotifyNotificationNew(summary, body, "")
+ if err != nil {
+ return err
+ }
+ err = notif.Show()
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (v *NotifierUI) checkNotificationScriptEnabled() (bool, error) {
+ appSettings, err := glib.SettingsNew(core.SETTINGS_ID)
+ if err != nil {
+ return false, err
+ }
+ enabled := appSettings.GetBoolean(CFG_RUN_NOTIFICATION_SCRIPT)
+ return enabled, nil
+}
+
+func buildEnvVars(completionType BackupCompletionType,
+ backupProgress *backup.Progress) []string {
+
+ var status string
+ switch completionType {
+ case BackupTerminated:
+ status = "terminated"
+ case BackupFailed:
+ status = "failed"
+ case BackupSucessfullyCompleted:
+ status = "done"
+ case BackupCompletedWithErrors:
+ status = "done_with_errors"
+ }
+
+ var vars []string
+ vars = append(vars, fmt.Sprintf("BACKUP_STATUS=%s", status))
+ if backupProgress != nil {
+ if backupProgress.TotalProgress.Completed != nil {
+ vars = append(vars, fmt.Sprintf("SIZE_BACKEDUP_MB=%d",
+ backupProgress.TotalProgress.Completed.GetByteCount()/1000/1000))
+ }
+ if backupProgress.TotalProgress.Failed != nil {
+ vars = append(vars, fmt.Sprintf("SIZE_FAILED_MB=%d",
+ backupProgress.TotalProgress.Failed.GetByteCount()/1000/1000))
+ }
+ if backupProgress.TotalProgress.Skipped != nil {
+ vars = append(vars, fmt.Sprintf("SIZE_SKIPPED_MB=%d",
+ backupProgress.TotalProgress.Skipped.GetByteCount()/1000/1000))
+ }
+ timeTaken := backupProgress.GetTotalTimeTaken()
+ if timeTaken != time.Duration(0) {
+ vars = append(vars, fmt.Sprintf("TIME_TAKEN_SEC=%d", int(timeTaken.Seconds())))
+ }
+ }
+ return vars
+}
+
+func (v *NotifierUI) runNotificationScript(completionType BackupCompletionType,
+ backupProgress *backup.Progress, scriptPath string) error {
+
+ // get default shell
+ shell := os.Getenv("SHELL")
+ // once not found fallback to bash
+ if shell == "" {
+ shell = "/usr/bin/bash"
+ }
+
+ err, _ := core.RunExecutableWithExtraVars(shell,
+ buildEnvVars(completionType, backupProgress), "/etc/gorsync/notification.sh")
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// reportCompletion updates process state and progress bar progress.
+func (v *NotifierUI) ReportCompletion(progress float32, err error,
+ backupProgress *backup.Progress, async bool) {
+
+ completionType := v.decodeBackupCompletionType(err, backupProgress)
+ var finalMsg string
+ switch completionType {
+ case BackupTerminated:
+ finalMsg = locale.T(MsgAppWindowBackupProgressTerminated, nil)
+ case BackupFailed:
+ finalMsg = locale.T(MsgAppWindowBackupProgressFailed, nil)
+ case BackupCompletedWithErrors:
+ finalMsg = locale.T(MsgAppWindowBackupProgressCompletedWithErrors, nil)
+ case BackupSucessfullyCompleted:
+ finalMsg = locale.T(MsgAppWindowBackupProgressCompleted, nil)
+ }
+
+ mp := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, finalMsg, nil)
+ err2 := v.UpdateBackupProgress(&progress, mp.String(), async)
+ if err2 != nil {
+ lg.Fatal(err2)
+ }
+
+ go func(completionType BackupCompletionType, backupProgress *backup.Progress) {
+ time.Sleep(time.Millisecond * 200)
+ _, err := glib.IdleAdd(func() {
+ err := v.ScrollView()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ enabled, err := v.checkDesktopNotificationEnabled()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ if enabled && completionType != BackupTerminated {
+ err = v.sendDesktopNotification(completionType, backupProgress)
+ if err != nil {
+ lg.Warn(locale.T(MsgAppWindowShowNotificationError,
+ struct{ Error error }{Error: err}))
+ }
+ }
+ scriptPath := "/etc/gorsync/notification.sh"
+ enabled, err = v.checkNotificationScriptEnabled()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ if enabled {
+ if stat, err := os.Stat(scriptPath); err == nil {
+ mode := stat.Mode()
+ // check script is executable for POSIX-kind OS
+ if !shell.IsLinuxMacOSFreeBSD() || mode&0111 != 0 {
+ err = v.runNotificationScript(completionType,
+ backupProgress, scriptPath)
+ if err != nil {
+ lg.Warn(locale.T(MsgAppWindowRunNotificationScriptError,
+ struct{ Error error }{Error: err}))
+ }
+ } else {
+ lg.Warn(locale.T(MsgAppWindowNotificationScriptExecutableError,
+ struct{ ScriptPath string }{ScriptPath: scriptPath}))
+ }
+ } else {
+ lg.Warn(locale.T(MsgAppWindowGetExecutableScriptInfoError,
+ struct{ Error error }{Error: err}))
+ }
+ }
+ // report about real completion via async method
+ close(v.done)
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }(completionType, backupProgress)
+
+}
diff --git a/ui/gtkui/prefdlg.go b/ui/gtkui/prefdlg.go
new file mode 100644
index 0000000..2f86bec
--- /dev/null
+++ b/ui/gtkui/prefdlg.go
@@ -0,0 +1,2164 @@
+package gtkui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/d2r2/go-rsync/core"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/go-rsync/rsync"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/davecgh/go-spew/spew"
+)
+
+const (
+ STOCK_WARNING_ICON = "dialog-warning-symbolic"
+ //STOCK_WARNING_ICON = "dialog-warning"
+ STOCK_OK_ICON = "emblem-ok-symbolic"
+ STOCK_QUESTION_ICON = "dialog-question-symbolic"
+ STOCK_SYNCHRONIZING_ICON = "emblem-synchronizing-symbolic"
+ STOCK_IMPORTANT_ICON = "emblem-important-symbolic"
+ ASSET_IMPORTANT_ICON = "emblem-important-red.gif"
+ STOCK_NETWORK_ERROR_ICON = "network-error-symbolic"
+)
+
+// return error describing issue with conversion from one type to another.
+func validatorConversionError(fromType, toType string) error {
+ msg := spew.Sprintf("Can't convert %[1]v to %[2]v", fromType, toType)
+ err := errors.New(msg)
+ return err
+}
+
+// setupLabelJustifyRight create GtkLabel with justification to the right by default.
+func setupLabelJustifyRight(caption string) (*gtk.Label, error) {
+ lbl, err := gtk.LabelNew(caption)
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetHAlign(gtk.ALIGN_END)
+ lbl.SetJustify(gtk.JUSTIFY_RIGHT)
+ return lbl, nil
+}
+
+const (
+ DesignIndentCol = 0
+ DesignFirstCol = 4
+ DesignSecondCol = 5
+ DesignTotalColCount = 6
+)
+
+// Create preference dialog with "General" page, where controls
+// being bound to GLib Setting object to save/restore functionality.
+func GeneralPreferencesNew(gsSettings *glib.Settings) (*gtk.Container, error) {
+ box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(box, 18)
+
+ bh := BindingHelperNew(gsSettings)
+
+ grid, err := gtk.GridNew()
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ row := 0
+
+ // ---------------------------------------------------------
+ // Interface options block
+ // ---------------------------------------------------------
+ markup := NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgGeneralUserInterfaceOptionsSecion, nil), "")
+ lbl, err := gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Option to show about dialog on application startup
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgDoNotShowAtAppStartupCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err := gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbAboutInfo, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbAboutInfo.SetActive(!cbAboutInfo.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbAboutInfo.SetTooltipText(locale.T(MsgPrefDlgDoNotShowAtAppStartupHint, nil))
+ cbAboutInfo.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_DONT_SHOW_ABOUT_ON_STARTUP, cbAboutInfo, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbAboutInfo, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Show desktop notification on backup completion
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgPerformDesktopNotificationCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err = gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbPerformBackupCompletionDesktopNotification, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbPerformBackupCompletionDesktopNotification.SetActive(!cbPerformBackupCompletionDesktopNotification.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbPerformBackupCompletionDesktopNotification.SetTooltipText(locale.T(MsgPrefDlgPerformDesktopNotificationHint, nil))
+ cbPerformBackupCompletionDesktopNotification.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_PERFORM_DESKTOP_NOTIFICATION, cbPerformBackupCompletionDesktopNotification,
+ "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbPerformBackupCompletionDesktopNotification, DesignSecondCol, row, 1, 1)
+ row++
+
+ // UI Language
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgLanguageCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ values := []struct{ value, key string }{
+ {locale.T(MsgPrefDlgDefaultLanguageEntry, nil), ""},
+ {"English", "en"},
+ {"Русский", "ru"},
+ }
+ cbUILanguage, err := CreateNameValueCombo(values)
+ if err != nil {
+ return nil, err
+ }
+ cbUILanguage.SetTooltipText(locale.T(MsgPrefDlgLanguageHint, nil))
+ bh.Bind(CFG_UI_LANGUAGE, cbUILanguage, "active-id", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbUILanguage, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Session log font size
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgSessionLogControlFontSizeCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ values = []struct{ value, key string }{
+ {"10 px", "10px"},
+ {"12 px", "12px"},
+ {"13 px", "13px"},
+ {"14 px", "14px"},
+ {"16 px", "16px"},
+ {"18 px", "18px"},
+ }
+ cbSessionLogFontSize, err := CreateNameValueCombo(values)
+ if err != nil {
+ return nil, err
+ }
+ cbSessionLogFontSize.SetTooltipText(locale.T(MsgPrefDlgSessionLogControlFontSizeHint, nil))
+ bh.Bind(CFG_SESSION_LOG_WIDGET_FONT_SIZE, cbSessionLogFontSize, "active-id", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbSessionLogFontSize, DesignSecondCol, row, 1, 1)
+ row++
+
+ sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(&sep.Widget, 6)
+ grid.Attach(sep, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // ---------------------------------------------------------
+ // Backup settings block
+ // ---------------------------------------------------------
+ markup = NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgGeneralBackupSettingsSection, nil), "")
+ lbl, err = gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Ignore file signature
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgSkipFolderBackupFileSignatureCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ edIgnoreFile, err := gtk.EntryNew()
+ if err != nil {
+ return nil, err
+ }
+ edIgnoreFile.SetHExpand(true)
+ edIgnoreFile.SetTooltipText(locale.T(MsgPrefDlgSkipFolderBackupFileSignatureHint, nil))
+ bh.Bind(CFG_IGNORE_FILE_SIGNATURE, edIgnoreFile, "text", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(edIgnoreFile, DesignSecondCol, row, 1, 1)
+ row++
+
+ /*
+ // ---------------------------------------------------------
+ // Debug section
+ // ---------------------------------------------------------
+ lbl, err = gtk.LabelNew(NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ "DEBUG SECTION", "").String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, 0, row, 2, 1)
+ row++
+
+ pb, err := gtk.ProgressBarNew()
+ if err != nil {
+ return nil, err
+ }
+ pb.SetPulseStep(0.1)
+ err = FixProgressBarCSSStyle(pb)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(pb, 0, row, 2, 1)
+ row++
+
+ btn, err := gtk.ButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ lbl, err = gtk.LabelNew("1")
+ if err != nil {
+ return nil, err
+ }
+ btn.Add(lbl)
+ btn.Connect("clicked", func(btn *gtk.Button) {
+ pb.Pulse()
+ })
+ grid.Attach(btn, 0, row, 1, 1)
+ row++
+
+ btn, err = gtk.ButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ lbl, err = gtk.LabelNew("2")
+ if err != nil {
+ return nil, err
+ }
+ btn.Add(lbl)
+ btn.Connect("clicked", func(btn *gtk.Button) {
+ pb.SetFraction(0.3)
+ })
+ grid.Attach(btn, 0, row, 1, 1)
+ row++
+ */
+
+ box.Add(grid)
+
+ _, err = box.Connect("destroy", func(b *gtk.Box) {
+ bh.Unbind()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &box.Container, nil
+}
+
+// GetSubpathRegexp verify that proposed file system path expression is valid.
+// Understand path separator for different OS, taking path separator setting from runtime.
+//
+// Use Microsoft Windows restriction list taken from here:
+// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
+//
+// Linux/Unix:
+// / (forward slash)
+//
+// Windows:
+// < (less than)
+// > (greater than)
+// : (colon - sometimes works, but is actually NTFS Alternate Data Streams)
+// " (double quote)
+// / (forward slash)
+// \ (backslash)
+// | (vertical bar or pipe)
+// ? (question mark)
+// * (asterisk)
+//
+func GetSubpathRegexp() (*regexp.Regexp, error) {
+ template := spew.Sprintf(`^\%[1]c?([^\<\>\:\"\|\?\*\%[1]c]+\%[1]c?)*$`, os.PathSeparator)
+ lg.Debugf("Subpath regex template: %s", template)
+ rexp, err := regexp.Compile(template)
+ if err != nil {
+ return nil, err
+ }
+ return rexp, nil
+}
+
+// RestartTimer restart timer with call fire after specific millisecond period.
+// Used as a trigger for validation events.
+func RestartTimer(timer *time.Timer, milliseconds time.Duration) {
+ timer.Stop()
+ timer.Reset(time.Millisecond * milliseconds)
+}
+
+func createBackupSourceBlock(profileID, sourceID string, gsSettings *glib.Settings,
+ prefRow *PreferenceRow, validator *UIValidator, profileChanged *bool) (gtk.IWidget, error) {
+ // frame, err := gtk.FrameNew("")
+ // if err != nil {
+ // return nil, err
+ // }
+ // frame.SetShadowType(gtk.SHADOW_ETCHED_OUT)
+
+ rsyncPathGroupName := spew.Sprintf("RsyncPath_%v_%v", profileID, sourceID)
+ destSubPathGroupName := spew.Sprintf("DestSubpath_%v", profileID)
+
+ box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(box, 18)
+
+ bh := BindingHelperNew(gsSettings)
+
+ grid, err := gtk.GridNew()
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ grid.SetHAlign(gtk.ALIGN_FILL)
+ row := 0
+
+ // Source rsync path
+ lbl, err := setupLabelJustifyRight(locale.T(MsgPrefDlgSourceRsyncPathCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ grid.Attach(lbl, 0, row, 1, 1)
+ edRsyncPath, err := gtk.EntryNew()
+ if err != nil {
+ return nil, err
+ }
+ edRsyncPath.SetHExpand(true)
+ //edRsyncPath.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_QUESTION_ICON)
+ edRsyncPath.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, locale.T(MsgPrefDlgSourceRsyncPathRetryHint, nil))
+
+ grid.Attach(edRsyncPath, 1, row, 1, 1)
+ row++
+
+ // Destination root path
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgDestinationSubpathCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ grid.Attach(lbl, 0, row, 1, 1)
+ edDestSubpath, err := gtk.EntryNew()
+ if err != nil {
+ return nil, err
+ }
+ //edDestSubpath.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_OK_ICON)
+ edDestSubpath.SetTooltipText(locale.T(MsgPrefDlgDestinationSubpathHint, nil))
+ grid.Attach(edDestSubpath, 1, row, 1, 1)
+ row++
+
+ // Enable/disable backup block
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgEnableBackupBlockCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ grid.Attach(lbl, 0, row, 1, 1)
+ swEnabled, err := gtk.SwitchNew()
+ if err != nil {
+ return nil, err
+ }
+ swEnabled.SetTooltipText(locale.T(MsgPrefDlgEnableBackupBlockHint, nil))
+ swEnabled.SetHAlign(gtk.ALIGN_START)
+ grid.Attach(swEnabled, 1, row, 1, 1)
+ row++
+
+ rsyncPathGroupIndex := validator.AddEntry(rsyncPathGroupName,
+ // 1st stage.
+ // Initialize data validation.
+ // Synchronized call: can update GTK widgets from here.
+ func(data *ValidatorData, group []*ValidatorData) error {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_SYNCHRONIZING_ICON)
+ RsyncSourcePathDescription := locale.T(MsgPrefDlgSourceRsyncPathDescriptionHint, nil)
+ markup := markupTooltip(NewMarkup(0, MARKUP_COLOR_SKY_BLUE, 0,
+ locale.T(MsgPrefDlgSourceRsyncValidatingHint, nil), nil), RsyncSourcePathDescription)
+ entry.SetTooltipMarkup(markup.String())
+ return nil
+ },
+ // 2nd stage.
+ // Execute validation.
+ // Asynchronous call: doesn't allowed to change GTK widgets from here (only read)!
+ func(ctx context.Context, data *ValidatorData, group []*ValidatorData) ([]interface{}, error) {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch, ok := data.Items[1].(*gtk.Switch)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+
+ var warning *string
+ if swtch.GetActive() {
+ rsyncURL, err := entry.GetText()
+ if err != nil {
+ return nil, err
+ }
+ rsyncURL = strings.TrimSpace(rsyncURL)
+ lg.Debugf("Validate rsync source: %q", rsyncURL)
+
+ if rsyncURL == "" {
+ msg := locale.T(MsgPrefDlgSourceRsyncPathEmptyError, nil)
+ warning = &msg
+ } else {
+ lg.Debugf("Start rsync utility to validate rsync source")
+ err := rsync.GetPathStatus(ctx, rsyncURL, false)
+ if err != nil {
+ lg.Debug(err)
+ if !rsync.IsRsyncProcessTerminatedError(err) {
+ msg := err.Error()
+ warning = &msg
+ }
+ }
+ }
+ }
+ return []interface{}{warning}, nil
+ },
+ // 3rd stage.
+ // Finalize data validation.
+ // Synchronized call: can update GTK widgets from here.
+ func(data *ValidatorData, results []interface{}) error {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch, ok := data.Items[1].(*gtk.Switch)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+ row, ok := data.Items[2].(*PreferenceRow)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[2]", "*PreferenceRow")
+ }
+ RsyncSourcePathDescription := locale.T(MsgPrefDlgSourceRsyncPathDescriptionHint, nil)
+ if swtch.GetActive() {
+ warning, ok := results[0].(*string)
+ if !ok {
+ return validatorConversionError("interface{}[0]", "*string")
+ }
+ if warning != nil {
+ err := SetEntryIconWithAssetImage(entry, gtk.ENTRY_ICON_SECONDARY, ASSET_IMPORTANT_ICON)
+ if err != nil {
+ return err
+ }
+ markup := markupTooltip(NewMarkup(MARKUP_WEIGHT_BOLD, MARKUP_COLOR_ORANGE_RED, 0, *warning, nil),
+ RsyncSourcePathDescription)
+ entry.SetTooltipMarkup(markup.String())
+ //entry.SetTooltipText(spew.Sprintf("Error: %s", *warning))
+ err = row.AddErrorStatus(entry.Native(), *warning)
+ if err != nil {
+ return err
+ }
+ } else {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_OK_ICON)
+ entry.SetTooltipText(RsyncSourcePathDescription)
+ //fgColor := "Cyan"
+ //fntWeight := "bold"
+ //markup := markupTooltip("Verified", &fgColor, &fntWeight, RsyncSourcePathDescription)
+ //entry.SetTooltipMarkup(markup)
+ entry.SetTooltipText(RsyncSourcePathDescription)
+ // entry.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, "Press to validate rsync source")
+ err := row.AddErrorStatus(entry.Native(), "")
+ if err != nil {
+ return err
+ }
+ }
+ } else {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "")
+ markup := markupTooltip(NewMarkup(0, 0 /*MARKUP_COLOR_LIGHT_GRAY*/, 0,
+ locale.T(MsgPrefDlgSourceRsyncPathNotValidatedHint, nil), nil), RsyncSourcePathDescription)
+ entry.SetTooltipMarkup(markup.String())
+ err := row.AddErrorStatus(entry.Native(), "")
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }, edRsyncPath, swEnabled, prefRow)
+
+ rsyncPathTimer := time.AfterFunc(time.Millisecond*1000, func() {
+ _, err := glib.IdleAdd(func() {
+ err := validator.Validate(rsyncPathGroupName)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ rsyncPathTimer.Stop()
+ _, err = edRsyncPath.Connect("changed", func(v *gtk.Entry) {
+ RestartTimer(rsyncPathTimer, 1000)
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, err = edRsyncPath.Connect("icon-press", func(v *gtk.Entry) {
+ RestartTimer(rsyncPathTimer, 50)
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, err = edRsyncPath.Connect("destroy", func(entry *gtk.Entry) {
+ lg.Debug("Destroy edRsyncPath")
+ err := prefRow.RemoveErrorStatus(entry.Native())
+ if err != nil {
+ lg.Fatal(err)
+ }
+ validator.RemoveEntry(rsyncPathGroupIndex)
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ bh.Bind(CFG_SOURCE_RSYNC_SOURCE_PATH, edRsyncPath, "text", glib.SETTINGS_BIND_DEFAULT)
+ text, err := edRsyncPath.GetText()
+ if err != nil {
+ return nil, err
+ }
+ charWidth := utf8.RuneCountInString(text)
+ if charWidth > 0 {
+ edRsyncPath.SetWidthChars(utf8.RuneCountInString(text))
+ }
+
+ rexp, err := GetSubpathRegexp()
+ if err != nil {
+ return nil, err
+ }
+ destSubPathGroupIndex := validator.AddEntry(destSubPathGroupName,
+ // 1st stage.
+ // Initialize data validation.
+ // Synchronized call: can update GTK widgets from here.
+ func(data *ValidatorData, group []*ValidatorData) error {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch, ok := data.Items[1].(*gtk.Switch)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+ if swtch.GetActive() {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_SYNCHRONIZING_ICON)
+ }
+ return nil
+ },
+ // 2nd stage.
+ // Execute validation.
+ // Asynchronous call: doesn't allowed to change GTK widgets from here (only read)!
+ func(ctx context.Context, data *ValidatorData, group []*ValidatorData) ([]interface{}, error) {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch, ok := data.Items[1].(*gtk.Switch)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+ destSubPath, err := entry.GetText()
+ if err != nil {
+ return nil, err
+ }
+ var warning *string
+ if swtch.GetActive() && !rexp.MatchString(destSubPath) {
+ msg := locale.T(MsgPrefDlgDestinationSubpathExpressionError, nil)
+ warning = &msg
+ } else {
+ foundCollision := false
+ lg.Debugf("DestSubPath validation group count = %v", len(group))
+ for _, item := range group {
+ entry2, ok := item.Items[0].(*gtk.Entry)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch2, ok := item.Items[1].(*gtk.Switch)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+ destSubPath2, err := entry2.GetText()
+ if err != nil {
+ return nil, err
+ }
+ if entry != entry2 && swtch.GetActive() && swtch2.GetActive() &&
+ normalizeSubpath(destSubPath) == normalizeSubpath(destSubPath2) {
+ foundCollision = true
+ break
+ }
+ }
+ lg.Debugf("DestSubPath collision found = %v", foundCollision)
+ if foundCollision {
+ msg := locale.T(MsgPrefDlgDestinationSubpathNotUniqueError, nil)
+ warning = &msg
+ }
+ }
+ return []interface{}{warning}, nil
+ },
+ // 3rd stage.
+ // Finalize data validation.
+ // Synchronized call: can update GTK widgets from here.
+ func(data *ValidatorData, results []interface{}) error {
+ var DestSubpathHint = locale.T(MsgPrefDlgDestinationSubpathHint, nil)
+
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ swtch, ok := data.Items[1].(*gtk.Switch)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[1]", "*gtk.Switch")
+ }
+ row, ok := data.Items[2].(*PreferenceRow)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[2]", "*PreferenceRow")
+ }
+ if swtch.GetActive() {
+ warning, ok := results[0].(*string)
+ if !ok {
+ return validatorConversionError("interface{}[0]", "*string")
+ }
+ if warning != nil {
+ err := SetEntryIconWithAssetImage(entry, gtk.ENTRY_ICON_SECONDARY, ASSET_IMPORTANT_ICON)
+ if err != nil {
+ return err
+ }
+ markup := markupTooltip(NewMarkup(MARKUP_WEIGHT_BOLD, MARKUP_COLOR_ORANGE_RED, 0, *warning, nil),
+ DestSubpathHint)
+ entry.SetTooltipMarkup(markup.String())
+ //entry.SetTooltipText(*warning)
+ err = row.AddErrorStatus(entry.Native(), *warning)
+ if err != nil {
+ return err
+ }
+ } else {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, STOCK_OK_ICON)
+ //fgcolor := "Royal Blue"
+ //fgColor := "Cyan"
+ //fntWeight := "bold"
+ //markup := markupTooltip("Verified", &fgColor, &fntWeight, DestSubpathHint)
+ //entry.SetTooltipMarkup(markup)
+ entry.SetTooltipText(DestSubpathHint)
+ err = row.AddErrorStatus(entry.Native(), "")
+ if err != nil {
+ return err
+ }
+ }
+ } else {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "")
+ markup := markupTooltip(NewMarkup(0, 0 /*MARKUP_COLOR_LIGHT_GRAY*/, 0,
+ locale.T(MsgPrefDlgDestinationSubpathNotValidatedHint, nil), nil), DestSubpathHint)
+ entry.SetTooltipMarkup(markup.String())
+ err := row.AddErrorStatus(entry.Native(), "")
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+ return nil
+ }, edDestSubpath, swEnabled, prefRow)
+ destSubpathTimer := time.AfterFunc(time.Millisecond*500, func() {
+ _, err := glib.IdleAdd(func() {
+ err := validator.Validate(destSubPathGroupName)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ destSubpathTimer.Stop()
+ _, err = edDestSubpath.Connect("changed", func(v *gtk.Entry) {
+ RestartTimer(destSubpathTimer, 500)
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, err = edDestSubpath.Connect("destroy", func(entry *gtk.Entry) {
+ lg.Debug("Destroy edDestSubpath")
+ err := prefRow.RemoveErrorStatus(entry.Native())
+ if err != nil {
+ lg.Fatal(err)
+ }
+ validator.RemoveEntry(destSubPathGroupIndex)
+ RestartTimer(destSubpathTimer, 50)
+ })
+ if err != nil {
+ return nil, err
+ }
+ bh.Bind(CFG_SOURCE_DEST_SUBPATH, edDestSubpath, "text", glib.SETTINGS_BIND_DEFAULT)
+
+ _, err = swEnabled.Connect("state-set", func(v *gtk.Switch) {
+ RestartTimer(rsyncPathTimer, 50)
+ RestartTimer(destSubpathTimer, 50)
+ })
+ if err != nil {
+ return nil, err
+ }
+ bh.Bind(CFG_SOURCE_ENABLED, swEnabled, "active", glib.SETTINGS_BIND_DEFAULT)
+
+ box.PackStart(grid, true, true, 0)
+ box.SetHExpand(true)
+ box.SetVExpand(true)
+
+ _, err = box.Connect("destroy", func(b *gtk.Box) {
+ lg.Debug("Destroy box")
+ bh.Unbind()
+ //validator.CancelValidate(rsyncPathGroupName)
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ RestartTimer(rsyncPathTimer, 50)
+ RestartTimer(destSubpathTimer, 50)
+
+ return box, nil
+}
+
+// getBackupSettings create GlibSettings object with change event
+// connected to specific indexed profile[profileID].
+func getBackupSettings(profileID string, profileChanged *bool) (*glib.Settings, error) {
+ path := fmt.Sprintf(core.SETTINGS_PROFILE_PATH, profileID)
+ gs, err := glib.SettingsNewWithPath(core.SETTINGS_PROFILE_ID, path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = gs.Connect("changed", func() {
+ if profileChanged != nil {
+ *profileChanged = true
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ return gs, nil
+}
+
+// getBackupSourceSettings create GlibSettings object with change event
+// connected to specific indexed source[profile[profileID], sourceID].
+func getBackupSourceSettings(profileID, sourceID string, profileChanged *bool) (*glib.Settings, error) {
+ path := fmt.Sprintf(core.SETTINGS_SOURCE_PATH, profileID, sourceID)
+ gs, err := glib.SettingsNewWithPath(core.SETTINGS_SOURCE_ID, path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = gs.Connect("changed", func() {
+ if profileChanged != nil {
+ *profileChanged = true
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ return gs, nil
+}
+
+func createBackupSourceBlock2(profileID, sourceID string, prefRow *PreferenceRow,
+ validator *UIValidator, profileChanged *bool) (*gtk.Container, error) {
+
+ backupSettings, err := getBackupSettings(profileID, profileChanged)
+ if err != nil {
+ return nil, err
+ }
+ sourceSettings, err := getBackupSourceSettings(profileID, sourceID, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ box2, err := createBackupSourceBlock(profileID, sourceID, sourceSettings, prefRow, validator, profileChanged)
+ if err != nil {
+ return nil, err
+ }
+
+ box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ box3, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ box3.Add(box2)
+ box.Add(box3)
+ btnDeleteSource, err := gtk.ButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ lbl, err := gtk.LabelNew(locale.T(MsgPrefDlgDeleteBackupBlockCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ btnDeleteSource.Add(lbl)
+ btnDeleteSource.SetVAlign(gtk.ALIGN_CENTER)
+ btnDeleteSource.SetTooltipText(locale.T(MsgPrefDlgDeleteBackupBlockHint, nil))
+ _, err = btnDeleteSource.Connect("clicked", func(btn *gtk.Button, box *gtk.Box) {
+ box.Destroy()
+
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ err = sarr.DeleteNode(sourceSettings, sourceID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }, box)
+ if err != nil {
+ return nil, err
+ }
+ // if sourceID == "0" {
+ // btnDeleteSource.SetSensitive(false)
+ // }
+ box3.PackStart(btnDeleteSource, false, false, 0)
+ box3.SetHExpand(true)
+ box3.SetVExpand(false)
+ sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(sep)
+
+ return &box.Container, nil
+}
+
+// Create preference dialog with "Sources" page, where controls
+// being bound to GLib Setting object to save/restore functionality.
+func BackupPreferencesNew(appSettings *glib.Settings, list *PreferenceRowList,
+ validator *UIValidator, profileID string, prefRow *PreferenceRow,
+ profileChanged *bool, initProfileName *string) (*gtk.Container, string, error) {
+
+ sw, err := gtk.ScrolledWindowNew(nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
+ //SetScrolledWindowPropogatedHeight(sw, true)
+
+ //box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ frame, err := gtk.FrameNew(locale.T(MsgPrefDlgSourcesCaption, nil))
+ if err != nil {
+ return nil, "", err
+ }
+ //frame.SetLabelAlign(0.01, 0.5)
+ box0, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, "", err
+ }
+ SetAllMargins(box0, 18)
+ frame.Add(box0)
+
+ box1, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, "", err
+ }
+ box0.Add(box1)
+
+ backupSettings, err := getBackupSettings(profileID, profileChanged)
+ if err != nil {
+ return nil, "", err
+ }
+
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ for _, srcID := range sarr.GetArrayIDs() {
+ box2, err := createBackupSourceBlock2(profileID, srcID, prefRow, validator, profileChanged)
+ if err != nil {
+ return nil, "", err
+ }
+ box1.Add(box2)
+ }
+
+ btnAddSource, err := SetupButtonWithThemedImage("list-add-symbolic")
+ if err != nil {
+ return nil, "", err
+ }
+ btnAddSource.SetTooltipText(locale.T(MsgPrefDlgAddBackupBlockHint, nil))
+ _, err = btnAddSource.Connect("clicked", func() {
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ sourceID, err := sarr.AddNode()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ box3, err := createBackupSourceBlock2(profileID, sourceID, prefRow, validator, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ box1.Add(box3)
+ box1.ShowAll()
+
+ destSubPathGroupName := spew.Sprintf("DestSubpath_%v", profileID)
+ err = validator.Validate(destSubPathGroupName)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ return nil, "", err
+ }
+
+ box0.Add(btnAddSource)
+
+ grid, err := gtk.GridNew()
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ grid.SetHAlign(gtk.ALIGN_FILL)
+ row := 0
+
+ var lbl *gtk.Label
+
+ appBH := BindingHelperNew(appSettings)
+ backupBH := BindingHelperNew(backupSettings)
+
+ // Profile name
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgProfileNameCaption, nil))
+ if err != nil {
+ return nil, "", err
+ }
+ grid.Attach(lbl, 0, row, 1, 1)
+ edProfileName, err := gtk.EntryNew()
+ if err != nil {
+ return nil, "", err
+ }
+ // edProfileName.SetTooltipText("Profile name")
+ edProfileName.SetHExpand(true)
+ edProfileName.SetHAlign(gtk.ALIGN_FILL)
+ profileGroupIndex := validator.AddEntry("ProfileName",
+ // 1st stage.
+ // Initialize data validation.
+ // Synchronized call: can update GTK widgets here.
+ func(data *ValidatorData, group []*ValidatorData) error {
+ return nil
+ },
+ // 2nd stage.
+ // Execute validation.
+ // Asynchronous call: doesn't allowed to change GTK widgets here (only read)!
+ func(ctx context.Context, data *ValidatorData, group []*ValidatorData) ([]interface{}, error) {
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ profileName, err := entry.GetText()
+ if err != nil {
+ return nil, err
+ }
+ var warning *string
+ if profileName == "" {
+ msg := locale.T(MsgPrefDlgProfileNameEmptyWarning, nil)
+ warning = &msg
+ } else {
+ foundCollision := false
+ for _, item := range group {
+ entry2, ok := item.Items[0].(*gtk.Entry)
+ if !ok {
+ return nil, validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ profileName2, err := entry2.GetText()
+ if err != nil {
+ return nil, err
+ }
+ if entry != entry2 && profileName == profileName2 {
+ foundCollision = true
+ break
+ }
+
+ }
+ if foundCollision {
+ msg := locale.T(MsgPrefDlgProfileNameExistsWarning,
+ struct{ ProfileName string }{ProfileName: profileName})
+ warning = &msg
+ }
+ }
+ return []interface{}{warning}, nil
+ },
+ // 3rd stage.
+ // Finalize data validation.
+ // Synchronized call: can update GTK widgets here.
+ func(data *ValidatorData, results []interface{}) error {
+ var ProfileNameHint = locale.T(MsgPrefDlgProfileNameHint, nil)
+ entry, ok := data.Items[0].(*gtk.Entry)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[0]", "*gtk.Entry")
+ }
+ row, ok := data.Items[1].(*PreferenceRow)
+ if !ok {
+ return validatorConversionError("ValidatorData.Items[1]", "*PreferenceRow")
+ }
+ warning, ok := results[0].(*string)
+ if !ok {
+ return validatorConversionError("interface{}[0]", "*string")
+ }
+ if warning != nil {
+ err := SetEntryIconWithAssetImage(entry, gtk.ENTRY_ICON_SECONDARY, ASSET_IMPORTANT_ICON)
+ if err != nil {
+ return err
+ }
+ markup := markupTooltip(NewMarkup(MARKUP_WEIGHT_BOLD, MARKUP_COLOR_ORANGE_RED, 0, *warning, nil),
+ ProfileNameHint)
+ entry.SetTooltipMarkup(markup.String())
+ err = row.AddErrorStatus(entry.Native(), *warning)
+ if err != nil {
+ return err
+ }
+ } else {
+ entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "")
+ entry.SetTooltipText(ProfileNameHint)
+ err = row.RemoveErrorStatus(entry.Native())
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }, edProfileName, prefRow)
+ backupBH.Bind(CFG_PROFILE_NAME, edProfileName, "text", glib.SETTINGS_BIND_DEFAULT)
+ timer := time.AfterFunc(time.Millisecond*500, func() {
+ _, err := glib.IdleAdd(func() {
+ name, err := edProfileName.GetText()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ prefRow.SetName(name)
+ err = validator.Validate("ProfileName")
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ _, err = edProfileName.Connect("changed", func(v *gtk.Entry, tmr *time.Timer) {
+ tmr.Stop()
+ tmr.Reset(time.Millisecond * 500)
+ }, timer)
+ if err != nil {
+ return nil, "", err
+ }
+ _, err = edProfileName.Connect("destroy", func(entry *gtk.Entry) {
+ validator.RemoveEntry(profileGroupIndex)
+ err = validator.Validate("ProfileName")
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ return nil, "", err
+ }
+
+ if initProfileName != nil {
+ edProfileName.SetText(*initProfileName)
+ }
+
+ grid.Attach(edProfileName, 1, row, 1, 1)
+ row++
+
+ // Destination root path
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgDefaultDestPathCaption, nil))
+ if err != nil {
+ return nil, "", err
+ }
+ grid.Attach(lbl, 0, row, 1, 1)
+ destFolder, err := gtk.FileChooserButtonNew("Select default destination folder", gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
+ if err != nil {
+ return nil, "", err
+ }
+ destFolder.SetTooltipText(locale.T(MsgPrefDlgDefaultDestPathHint, nil))
+ destFolder.SetHExpand(true)
+ destFolder.SetHAlign(gtk.ALIGN_FILL)
+ folder := backupSettings.GetString(CFG_PROFILE_DEST_ROOT_PATH)
+ if _, err := os.Stat(folder); !os.IsNotExist(err) {
+ // lg.Println(spew.Sprintf("File %q found", filename))
+ destFolder.SetFilename(folder)
+ }
+ _, err = destFolder.Connect("file-set", func(fcb *gtk.FileChooserButton) {
+ folder := fcb.GetFilename()
+ if _, err := os.Stat(folder); !os.IsNotExist(err) {
+ backupSettings.SetString(CFG_PROFILE_DEST_ROOT_PATH, folder)
+ }
+ })
+ if err != nil {
+ return nil, "", err
+ }
+ grid.Attach(destFolder, 1, row, 1, 1)
+ row++
+
+ box2, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, "", err
+ }
+ SetAllMargins(box2, 18)
+ box2.Add(grid)
+ box2.Add(frame)
+
+ vp, err := gtk.ViewportNew(nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ vp.Add(box2)
+
+ sw.Add(vp)
+ _, err = sw.Connect("destroy", func(b gtk.IWidget) {
+ appBH.Unbind()
+ backupBH.Unbind()
+ })
+ if err != nil {
+ return nil, "", err
+ }
+
+ /*
+ act, err := createAddNewBackupSourceAction(profileID, box, btnAddSource)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ actionMap.AddAction(act)
+ */
+
+ name := backupSettings.GetString(CFG_PROFILE_NAME)
+ return &sw.Container, name, nil
+}
+
+// AdvancedPreferencesNew create preference dialog with "Advanced" page, where controls
+// being bound to GLib Setting object to save/restore functionality.
+func AdvancedPreferencesNew(gsSettings *glib.Settings) (*gtk.Container, error) {
+ box, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(box, 18)
+
+ bh := BindingHelperNew(gsSettings)
+
+ grid, err := gtk.GridNew()
+ grid.SetColumnSpacing(12)
+ grid.SetRowSpacing(6)
+ row := 0
+
+ // ---------------------------------------------------------
+ // Backup settings block
+ // ---------------------------------------------------------
+ markup := NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgAdvancedBackupSettingsSection, nil), "")
+ lbl, err := gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Enable/disable automatic backup block size
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgAutoManageBackupBlockSizeCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err := gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbAutoManageBackupBlockSize, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbAutoManageBackupBlockSize.SetActive(!cbAutoManageBackupBlockSize.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbAutoManageBackupBlockSize.SetTooltipText(locale.T(MsgPrefDlgAutoManageBackupBlockSizeHint, nil))
+ cbAutoManageBackupBlockSize.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_MANAGE_AUTO_BACKUP_BLOCK_SIZE, cbAutoManageBackupBlockSize, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbAutoManageBackupBlockSize, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Backup block size
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgBackupBlockSizeCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ bh.Bind(CFG_MANAGE_AUTO_BACKUP_BLOCK_SIZE, lbl, "sensitive",
+ glib.SETTINGS_BIND_GET|glib.SETTINGS_BIND_INVERT_BOOLEAN)
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ sbBackupBlockSize, err := gtk.SpinButtonNewWithRange(50, 10000, 1)
+ if err != nil {
+ return nil, err
+ }
+ sbBackupBlockSize.SetTooltipText(locale.T(MsgPrefDlgBackupBlockSizeHint, nil))
+ sbBackupBlockSize.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_MAX_BACKUP_BLOCK_SIZE_MB, sbBackupBlockSize, "value", glib.SETTINGS_BIND_DEFAULT)
+ bh.Bind(CFG_MANAGE_AUTO_BACKUP_BLOCK_SIZE, sbBackupBlockSize, "sensitive",
+ glib.SETTINGS_BIND_GET|glib.SETTINGS_BIND_INVERT_BOOLEAN)
+ grid.Attach(sbBackupBlockSize, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Run notification script on backup completion
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgRunNotificationScriptCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err = gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbRunBackupCompletionNotificationScript, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbRunBackupCompletionNotificationScript.SetActive(!cbRunBackupCompletionNotificationScript.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbRunBackupCompletionNotificationScript.SetTooltipText(locale.T(MsgPrefDlgRunNotificationScriptHint, nil))
+ cbRunBackupCompletionNotificationScript.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RUN_NOTIFICATION_SCRIPT, cbRunBackupCompletionNotificationScript,
+ "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbRunBackupCompletionNotificationScript, DesignSecondCol, row, 1, 1)
+ row++
+
+ sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(&sep.Widget, 6)
+ grid.Attach(sep, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // ---------------------------------------------------------
+ // Rsync general block
+ // ---------------------------------------------------------
+ markup = NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgAdvansedRsyncSettingsSection, nil), "")
+ lbl, err = gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Rsync utility retry count
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgRsyncRetryCountCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ sbRetryCount, err := gtk.SpinButtonNewWithRange(0, 5, 1)
+ if err != nil {
+ return nil, err
+ }
+ sbRetryCount.SetTooltipText(locale.T(MsgPrefDlgRsyncRetryCountHint, nil))
+ //sbRetryCount.SetHExpand(false)
+ sbRetryCount.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_RETRY_COUNT, sbRetryCount, "value", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(sbRetryCount, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Enable/disable RSYNC low level log
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgRsyncLowLevelLogCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err = gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbLowLevelRsyncLog, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbLowLevelRsyncLog.SetActive(!cbLowLevelRsyncLog.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbLowLevelRsyncLog.SetTooltipText(locale.T(MsgPrefDlgRsyncLowLevelLogHint, nil))
+ cbLowLevelRsyncLog.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_ENABLE_LOW_LEVEL_LOG_OF_RSYNC, cbLowLevelRsyncLog, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbLowLevelRsyncLog, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Enable/disable RSYNC intensive low level log
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgRsyncIntensiveLowLevelLogCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err = gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ eb.Add(lbl)
+ bh.Bind(CFG_ENABLE_LOW_LEVEL_LOG_OF_RSYNC, eb, "sensitive", glib.SETTINGS_BIND_GET)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbIntensiveLowLevelRsyncLog, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbIntensiveLowLevelRsyncLog.SetActive(!cbIntensiveLowLevelRsyncLog.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ cbIntensiveLowLevelRsyncLog.SetTooltipText(locale.T(MsgPrefDlgRsyncIntensiveLowLevelLogHint, nil))
+ cbIntensiveLowLevelRsyncLog.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_ENABLE_INTENSIVE_LOW_LEVEL_LOG_OF_RSYNC, cbIntensiveLowLevelRsyncLog, "active", glib.SETTINGS_BIND_DEFAULT)
+ bh.Bind(CFG_ENABLE_LOW_LEVEL_LOG_OF_RSYNC, cbIntensiveLowLevelRsyncLog, "sensitive", glib.SETTINGS_BIND_GET)
+ grid.Attach(cbIntensiveLowLevelRsyncLog, DesignSecondCol, row, 1, 1)
+ row++
+
+ sep, err = gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(&sep.Widget, 6)
+ grid.Attach(sep, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // ---------------------------------------------------------
+ // Rsync deduplication block
+ // ---------------------------------------------------------
+ markup = NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgAdvancedRsyncDedupSettingsSection, nil), "")
+ lbl, err = gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Use previous backup if found
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgUsePreviousBackupForDedupCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ eb, err = gtk.EventBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ //eb.AddEvents(int(gdk.BUTTON_PRESS_MASK))
+ //eb.AddEvents(int(gdk.BUTTON_RELEASE_MASK))
+ //eb.AddEvents(int(gdk.EVENT_BUTTON_PRESS))
+ //eb.AddEvents(int(gdk.EVENT_BUTTON_RELEASE))
+ //eb.AddEvents(int(gdk.EVENT_DOUBLE_BUTTON_PRESS))
+ // eb.AddEvents(int(gdk.EVENT_DOUBLE_BUTTON_PRESS))
+ eb.Add(lbl)
+ grid.Attach(eb, DesignFirstCol, row, 1, 1)
+ cbPrevBackupUsage, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ _, err = eb.Connect("button-press-event", func() {
+ cbPrevBackupUsage.SetActive(!cbPrevBackupUsage.GetActive())
+ })
+ if err != nil {
+ return nil, err
+ }
+ //eb.Connect("button-release-event", func() {
+ // cbPrevBackupUsage.SetActive(!cbPrevBackupUsage.GetActive())
+ //})
+ //eb.Connect("toggled", func() {
+ // cbPrevBackupUsage.SetActive(!cbPrevBackupUsage.GetActive())
+ //})
+ cbPrevBackupUsage.SetTooltipText(locale.T(MsgPrefDlgUsePreviousBackupForDedupHint, nil))
+ cbPrevBackupUsage.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_ENABLE_USE_OF_PREVIOUS_BACKUP, cbPrevBackupUsage, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbPrevBackupUsage, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Number of previous backup to use
+ lbl, err = setupLabelJustifyRight(locale.T(MsgPrefDlgNumberOfPreviousBackupToUseCaption, nil))
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignFirstCol, row, 1, 1)
+ sbNumberOfPreviousBackupToUse, err := gtk.SpinButtonNewWithRange(1, 5, 1)
+ if err != nil {
+ return nil, err
+ }
+ sbNumberOfPreviousBackupToUse.SetTooltipText(locale.T(MsgPrefDlgNumberOfPreviousBackupToUseHint, nil))
+ sbNumberOfPreviousBackupToUse.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_NUMBER_OF_PREVIOUS_BACKUP_TO_USE, sbNumberOfPreviousBackupToUse, "value", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(sbNumberOfPreviousBackupToUse, DesignSecondCol, row, 1, 1)
+ row++
+
+ sep, err = gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(&sep.Widget, 6)
+ grid.Attach(sep, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // ---------------------------------------------------------
+ // Rsync file transfer options block
+ // ---------------------------------------------------------
+ markup = NewMarkup(MARKUP_WEIGHT_BOLD, 0, 0,
+ locale.T(MsgPrefDlgAdvancedRsyncFileTransferOptionsSection, nil), "")
+ lbl, err = gtk.LabelNew(markup.String())
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetUseMarkup(true)
+ lbl.SetHAlign(gtk.ALIGN_START)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, DesignIndentCol, row, DesignTotalColCount, 1)
+ row++
+
+ // Enable/disable RSYNC compress file transfer
+ cbCompressFileTransfer, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbCompressFileTransfer.SetLabel(locale.T(MsgPrefDlgRsyncCompressFileTransferCaption, nil))
+ cbCompressFileTransfer.SetTooltipText(locale.T(MsgPrefDlgRsyncCompressFileTransferHint, nil))
+ cbCompressFileTransfer.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_COMPRESS_FILE_TRANSFER, cbCompressFileTransfer, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbCompressFileTransfer, DesignFirstCol, row, 1, 1)
+
+ // Enable/disable RSYNC transfer source permissions
+ cbTransferSourcePermissions, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbTransferSourcePermissions.SetLabel(locale.T(MsgPrefDlgRsyncTransferSourcePermissionsCaption, nil))
+ cbTransferSourcePermissions.SetTooltipText(locale.T(MsgPrefDlgRsyncTransferSourcePermissionsHint, nil))
+ cbTransferSourcePermissions.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_TRANSFER_SOURCE_PERMISSIONS, cbTransferSourcePermissions, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbTransferSourcePermissions, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Enable/disable RSYNC transfer source owner
+ cbTransferSourceOwner, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbTransferSourceOwner.SetLabel(locale.T(MsgPrefDlgRsyncTransferSourceOwnerCaption, nil))
+ cbTransferSourceOwner.SetTooltipText(locale.T(MsgPrefDlgRsyncTransferSourceOwnerHint, nil))
+ cbTransferSourceOwner.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_TRANSFER_SOURCE_OWNER, cbTransferSourceOwner, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbTransferSourceOwner, DesignFirstCol, row, 1, 1)
+
+ // Enable/disable RSYNC transfer source group
+ cbTransferSourceGroup, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbTransferSourceGroup.SetLabel(locale.T(MsgPrefDlgRsyncTransferSourceGroupCaption, nil))
+ cbTransferSourceGroup.SetTooltipText(locale.T(MsgPrefDlgRsyncTransferSourceGroupHint, nil))
+ cbTransferSourceGroup.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_TRANSFER_SOURCE_GROUP, cbTransferSourceGroup, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbTransferSourceGroup, DesignSecondCol, row, 1, 1)
+ row++
+
+ // Enable/disable RSYNC symlinks recreation
+ cbRecreateSymlinks, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbRecreateSymlinks.SetLabel(locale.T(MsgPrefDlgRsyncRecreateSymlinksCaption, nil))
+ cbRecreateSymlinks.SetTooltipText(locale.T(MsgPrefDlgRsyncRecreateSymlinksHint, nil))
+ cbRecreateSymlinks.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_RECREATE_SYMLINKS, cbRecreateSymlinks, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbRecreateSymlinks, DesignFirstCol, row, 1, 1)
+ row++
+
+ // Enable/disable RSYNC transfer device files
+ cbTransferDeviceFiles, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbTransferDeviceFiles.SetLabel(locale.T(MsgPrefDlgRsyncTransferDeviceFilesCaption, nil))
+ cbTransferDeviceFiles.SetTooltipText(locale.T(MsgPrefDlgRsyncTransferDeviceFilesHint, nil))
+ cbTransferDeviceFiles.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_TRANSFER_DEVICE_FILES, cbTransferDeviceFiles, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbTransferDeviceFiles, DesignFirstCol, row, 1, 1)
+
+ // Enable/disable RSYNC transfer special files
+ cbTransferSpecialFiles, err := gtk.CheckButtonNew()
+ if err != nil {
+ return nil, err
+ }
+ cbTransferSpecialFiles.SetLabel(locale.T(MsgPrefDlgRsyncTransferSpecialFilesCaption, nil))
+ cbTransferSpecialFiles.SetTooltipText(locale.T(MsgPrefDlgRsyncTransferSpecialFilesHint, nil))
+ cbTransferSpecialFiles.SetHAlign(gtk.ALIGN_START)
+ bh.Bind(CFG_RSYNC_TRANSFER_SPECIAL_FILES, cbTransferSpecialFiles, "active", glib.SETTINGS_BIND_DEFAULT)
+ grid.Attach(cbTransferSpecialFiles, DesignSecondCol, row, 1, 1)
+ row++
+
+ box.Add(grid)
+
+ _, err = box.Connect("destroy", func(b *gtk.Box) {
+ bh.Unbind()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &box.Container, nil
+
+ //bh := BindingHelperNew(gsSettings)
+
+ _, err = box.Connect("destroy", func() {
+ //bh.Unbind()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &box.Container, nil
+}
+
+// PreferenceRow keeps here extra data for each page of multi-page preference dialog.
+type PreferenceRow struct {
+ sync.RWMutex
+ ID string
+ name string
+ Title string
+ Row *gtk.ListBoxRow
+ Container *gtk.Box
+ Label *gtk.Label
+ Icon *gtk.Image
+ Page *gtk.Container
+ Profile bool
+ Errors map[uintptr]string
+}
+
+// PreferenceRowNew instantiate new PreferenceRow object.
+func PreferenceRowNew(id, title string, page *gtk.Container,
+ profile bool) (*PreferenceRow, error) {
+
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(box, 6)
+ box.SetSpacing(6)
+
+ lbl, err := gtk.LabelNew("")
+ if err != nil {
+ return nil, err
+ }
+ lbl.SetHAlign(gtk.ALIGN_START)
+ box.PackStart(lbl, false, true, 0)
+
+ row, err := gtk.ListBoxRowNew()
+ if err != nil {
+ return nil, err
+ }
+ row.Add(box)
+
+ errors := make(map[uintptr]string)
+
+ pr := &PreferenceRow{ID: id, Title: title, Row: row,
+ Container: box, Label: lbl, Page: page,
+ Profile: profile, Errors: errors}
+
+ pr.SetName(title)
+
+ return pr, nil
+}
+
+// SetName set profile name as a template "Profile()"
+func (v *PreferenceRow) SetName(name string) {
+ v.Lock()
+ defer v.Unlock()
+
+ v.name = name
+ if v.Profile {
+ publicName := locale.T(MsgPrefDlgProfileTabName,
+ struct{ ProfileName string }{ProfileName: name})
+ v.Label.SetText(publicName)
+ } else {
+ v.Label.SetText(name)
+ }
+}
+
+// GetName get name.
+func (v *PreferenceRow) GetName() string {
+ v.RLock()
+ defer v.RUnlock()
+
+ return v.name
+}
+
+// setThemedIcon assign icon to the right side of the list box item.
+func (v *PreferenceRow) setThemedIcon(themedName string) error {
+ img, err := gtk.ImageNew()
+ if err != nil {
+ return err
+ }
+ img.SetFromIconName(themedName, gtk.ICON_SIZE_BUTTON)
+ v.clearIcon()
+ v.Icon = img
+ v.Container.PackEnd(img, false, false, 0)
+ v.Container.ShowAll()
+ return nil
+}
+
+// setAssetsIcon assign icon to the right side of the list box item.
+func (v *PreferenceRow) setAssetsIcon(assetName string) error {
+ img, err := ImageFromAssetsNew(assetName, 16, 16)
+ if err != nil {
+ return err
+ }
+ v.clearIcon()
+ v.Icon = img
+ v.Container.PackEnd(img, false, false, 0)
+ v.Container.ShowAll()
+ return nil
+}
+
+// clearIcon removes icon from the list box item.
+func (v *PreferenceRow) clearIcon() {
+ if v.Icon != nil {
+ v.Icon.Destroy()
+ v.Icon = nil
+ }
+}
+
+// setTooltipMarkup assign tooltip to the list box item.
+func (v *PreferenceRow) setTooltipMarkup(tooltip string) {
+ v.Row.SetTooltipMarkup(tooltip)
+}
+
+// updateErrorStatus clear or set error status to the list box item.
+func (v *PreferenceRow) updateErrorStatus() error {
+ found := false
+ for _, v := range v.Errors {
+ if v != "" {
+ lg.Debugf("PreferenceRow error %q", v)
+ found = true
+ break
+ }
+ }
+ // glib.IdleAdd(func() {
+ if found {
+ lg.Debug("Error found")
+ markup := NewMarkup(0, MARKUP_COLOR_ORANGE_RED, 0,
+ locale.T(MsgPrefDlgProfileConfigIssuesDetectedWarning, nil), nil)
+ v.setTooltipMarkup(markup.String())
+ err := v.setAssetsIcon(ASSET_IMPORTANT_ICON)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ } else {
+ lg.Debug("No errors found")
+ v.setTooltipMarkup("")
+ v.clearIcon()
+ }
+ // })
+ return nil
+}
+
+// AddErrorStatus add error status to the list box item.
+func (v *PreferenceRow) AddErrorStatus(sourceId uintptr, err string) error {
+ v.Lock()
+ defer v.Unlock()
+
+ v.Errors[sourceId] = err
+
+ err2 := v.updateErrorStatus()
+ if err2 != nil {
+ return err2
+ }
+ return nil
+}
+
+// RemoveErrorStatus removes error status from the list box item.
+func (v *PreferenceRow) RemoveErrorStatus(sourceId uintptr) error {
+ v.Lock()
+ defer v.Unlock()
+
+ delete(v.Errors, sourceId)
+
+ err2 := v.updateErrorStatus()
+ if err2 != nil {
+ return err2
+ }
+ return nil
+}
+
+// PreferenceRowList keeps a link between GtkListBoxRow
+// and specific PreferenceRow object.
+type PreferenceRowList struct {
+ m map[uintptr]*PreferenceRow
+ sorted []uintptr
+}
+
+func PreferenceRowListNew() *PreferenceRowList {
+ var m = make(map[uintptr]*PreferenceRow)
+ v := &PreferenceRowList{m: m}
+ return v
+}
+
+func (v *PreferenceRowList) Append(row *PreferenceRow) {
+ v.m[row.Row.Native()] = row
+ v.sorted = append(v.sorted, row.Row.Native())
+}
+
+func (v *PreferenceRowList) Delete(rowID uintptr) {
+ delete(v.m, rowID)
+ for ind, val := range v.sorted {
+ if val == rowID {
+ v.sorted = append(v.sorted[:ind], v.sorted[ind+1:]...)
+ break
+ }
+ }
+}
+
+func (v *PreferenceRowList) Get(rowID uintptr) *PreferenceRow {
+ return v.m[rowID]
+}
+
+func (v *PreferenceRowList) GetLastProfileListIndex() int {
+ lastIndex := -1
+ for _, rowID := range v.sorted {
+ if v.m[rowID].Profile && v.m[rowID].Row.GetIndex() > lastIndex {
+ lastIndex = v.m[rowID].Row.GetIndex()
+ }
+ }
+ return lastIndex
+}
+
+func (v *PreferenceRowList) GetProfileCount() int {
+ count := 0
+ for _, rowID := range v.sorted {
+ if v.m[rowID].Profile {
+ count++
+ }
+ }
+ return count
+}
+
+func (v *PreferenceRowList) GetProfiles() []*PreferenceRow {
+ var rows []*PreferenceRow
+ for _, rowID := range v.sorted {
+ if v.m[rowID].Profile {
+ rows = append(rows, v.m[rowID])
+ }
+ }
+ return rows
+}
+
+/*
+func findProfilesByNameTillCurrent(list []*PreferenceRow,
+ current *PreferenceRow, profileName string) []*PreferenceRow {
+
+ rows := []*PreferenceRow{}
+ for _, pr := range list {
+ if pr == current {
+ break
+ }
+ if pr.GetName() == profileName {
+ rows = append(rows, pr)
+ }
+ }
+ return rows
+}
+*/
+
+// addProfilePage build UI on the top of profile taken from GlibSettings.
+func addProfilePage(profileID string, initProfileName *string, appSettings *glib.Settings,
+ list *PreferenceRowList, validator *UIValidator, lbSide *gtk.ListBox, pages *gtk.Stack, selectNew bool,
+ profileChanged *bool) error {
+
+ prefRow, err := PreferenceRowNew(profileID,
+ locale.T(MsgPrefDlgGeneralProfileTabName, nil), nil, true)
+ if err != nil {
+ return err
+ }
+ page, profileName, err := BackupPreferencesNew(appSettings, list, validator,
+ profileID, prefRow, profileChanged, initProfileName)
+ if err != nil {
+ return err
+ }
+ prefRow.SetName(profileName)
+ prefRow.Page = page
+ pages.AddTitled(page, profileID, "Profile")
+ list.Append(prefRow)
+ index := list.GetLastProfileListIndex()
+ lbSide.Insert(prefRow.Row, index+1)
+ lbSide.ShowAll()
+ pages.ShowAll()
+ if selectNew {
+ lbSide.SelectRow(prefRow.Row)
+ }
+ return nil
+}
+
+// Create multi-page preference dialog
+// with save/restore functionality to/from the GLib Setting object.
+func CreatePreferenceDialog(settingsID string, app *gtk.Application, profileChanged *bool) (*gtk.ApplicationWindow, error) {
+ parentWin := app.GetActiveWindow()
+ win, err := gtk.ApplicationWindowNew(app)
+ if err != nil {
+ return nil, err
+ }
+
+ // Settings
+ win.SetTransientFor(parentWin)
+ win.SetDestroyWithParent(false)
+ win.SetShowMenubar(false)
+ appSettings, err := glib.SettingsNew(settingsID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create window header
+ hbMain, err := SetupHeader("", "", true)
+ if err != nil {
+ return nil, err
+ }
+ hbMain.SetHExpand(true)
+
+ hbSide, err := SetupHeader(locale.T(MsgPrefDlgPreferencesDialogCaption, nil), "", false)
+ if err != nil {
+ return nil, err
+ }
+ hbSide.SetHExpand(false)
+
+ bTitle, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ bTitle.Add(hbSide)
+ sTitle, err := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL)
+ bTitle.Add(sTitle)
+ bTitle.Add(hbMain)
+
+ win.SetTitlebar(bTitle)
+
+ var list = PreferenceRowListNew()
+ var validator = UIValidatorNew(context.Background())
+
+ _, err = win.Connect("destroy", func() {
+ validator.CancelAll()
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Create Stack and boxes
+ pages, err := gtk.StackNew()
+ if err != nil {
+ return nil, err
+ }
+ pages.SetHExpand(true)
+ pages.SetVExpand(true)
+
+ // Create ListBox
+ lbSide, err := gtk.ListBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ lbSide.SetCanFocus(true)
+ lbSide.SetSelectionMode(gtk.SELECTION_BROWSE)
+ lbSide.SetVExpand(true)
+
+ var pr *PreferenceRow
+
+ profileSettingsArray := NewSettingsArray(appSettings, CFG_BACKUP_LIST)
+ profileList := profileSettingsArray.GetArrayIDs()
+ if len(profileList) == 0 {
+ profileID, err := profileSettingsArray.AddNode()
+ if err != nil {
+ return nil, err
+ }
+ backupSettings, err := getBackupSettings(profileID, nil)
+ if err != nil {
+ return nil, err
+ }
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ _, err = sarr.AddNode()
+ if err != nil {
+ return nil, err
+ }
+ profileName := profileID
+ if i, err := strconv.Atoi(profileID); err == nil {
+ profileName = strconv.Itoa(i + 1)
+ }
+ err = addProfilePage(profileID, &profileName, appSettings, list,
+ validator, lbSide, pages, false, profileChanged)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ for _, profileID := range profileList {
+ err = addProfilePage(profileID, nil, appSettings, list,
+ validator, lbSide, pages, false, profileChanged)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ gp, err := GeneralPreferencesNew(appSettings)
+ if err != nil {
+ return nil, err
+ }
+ pages.AddTitled(gp, "General_ID", locale.T(MsgPrefDlgGeneralTabName, nil))
+ pr, err = PreferenceRowNew("General_ID", locale.T(MsgPrefDlgGeneralTabName, nil), gp, false)
+ if err != nil {
+ return nil, err
+ }
+ list.Append(pr)
+ lbSide.Add(pr.Row)
+
+ ap, err := AdvancedPreferencesNew(appSettings)
+ if err != nil {
+ return nil, err
+ }
+ pages.AddTitled(ap, "Advanced_ID", locale.T(MsgPrefDlgAdvancedTabName, nil))
+ pr, err = PreferenceRowNew("Advanced_ID", locale.T(MsgPrefDlgAdvancedTabName, nil), ap, false)
+ if err != nil {
+ return nil, err
+ }
+ list.Append(pr)
+ lbSide.Add(pr.Row)
+
+ sw, err := gtk.ScrolledWindowNew(nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ sw.Add(lbSide)
+ sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
+ sw.SetShadowType(gtk.SHADOW_NONE)
+ sw.SetSizeRequest(220, -1)
+
+ vp, err := gtk.ViewportNew(nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ vp.Add(sw)
+
+ bSide, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ bSide.Add(vp)
+
+ bButtons, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ SetAllMargins(bButtons, 6)
+ bSide.Add(bButtons)
+ btnAddProfile, err := SetupButtonWithThemedImage("list-add-symbolic")
+ if err != nil {
+ return nil, err
+ }
+ btnAddProfile.SetTooltipText(locale.T(MsgPrefDlgAddProfileHint, nil))
+ _, err = btnAddProfile.Connect("clicked", func() {
+ profileID, err := profileSettingsArray.AddNode()
+ if err != nil {
+ lg.Fatal(err)
+ }
+ backupSettings, err := getBackupSettings(profileID, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ _, err = sarr.AddNode()
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ profileName := profileID
+ if i, err := strconv.Atoi(profileID); err == nil {
+ profileName = strconv.Itoa(i + 1)
+ }
+ err = addProfilePage(profileID, &profileName, appSettings, list,
+ validator, lbSide, pages, true, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ if profileChanged != nil {
+ *profileChanged = true
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ bButtons.PackStart(btnAddProfile, false, false, 0)
+ btnDeleteProfile, err := SetupButtonWithThemedImage("list-remove-symbolic")
+ if err != nil {
+ return nil, err
+ }
+ btnDeleteProfile.SetTooltipText(locale.T(MsgPrefDlgDeleteProfileHint, nil))
+ _, err = btnDeleteProfile.Connect("clicked", func() {
+ title := locale.T(MsgPrefDlgDeleteProfileDialogTitle, nil)
+ titleMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, nil, nil,
+ NewMarkup(MARKUP_SIZE_LARGER, 0, 0, title, nil))
+ yesButtonCaption := locale.T(MsgDialogYesButton, nil)
+ yesButtonMarkup := NewMarkup(MARKUP_SIZE_LARGER, 0, 0, yesButtonCaption, nil)
+ textMarkup := locale.T(MsgPrefDlgDeleteProfileDialogText, struct{ YesButton string }{YesButton: yesButtonMarkup.String()})
+ responseYes, err := questionDialog(&win.Window, titleMarkup.String(), textMarkup, true, true, false)
+ // responseYes, err := QuestionDialog(&win.Window, title,
+ // []*DialogParagraph{NewDialogParagraph(text)}, false)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ if responseYes {
+ sr := lbSide.GetSelectedRow()
+ sri := sr.GetIndex()
+ pr := list.Get(sr.Native())
+ if pr.Profile {
+ profileID := pr.ID
+ backupSettings, err := getBackupSettings(profileID, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ sarr := NewSettingsArray(backupSettings, CFG_SOURCE_LIST)
+ ids := sarr.GetArrayIDs()
+ for _, sourceID := range ids {
+ sourceSettings, err := getBackupSourceSettings(profileID, sourceID, profileChanged)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ err = sarr.DeleteNode(sourceSettings, sourceID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+
+ err = profileSettingsArray.DeleteNode(backupSettings, profileID)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ nsr := lbSide.GetRowAtIndex(sri + 1)
+ lbSide.SelectRow(nsr)
+ pages.Remove(pr.Page)
+ list.Delete(sr.Native())
+ pr.Page.Destroy()
+ sr.Destroy()
+ if profileChanged != nil {
+ *profileChanged = true
+ }
+ }
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ bButtons.PackStart(btnDeleteProfile, false, false, 0)
+
+ _, err = lbSide.Connect("row-selected", func(lb *gtk.ListBox, row *gtk.ListBoxRow) {
+ lg.Debugf("Row at index %d selected", row.GetIndex())
+ pr := list.Get(row.Native())
+ //lg.Println(spew.Sprintf("%+v", r1))
+ pages.SetVisibleChildName(pr.ID)
+ hbMain.SetTitle(pr.Title)
+ btnDeleteProfile.SetSensitive(pr.Profile && list.GetProfileCount() > 1)
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(bSide)
+ div, err := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL)
+ if err != nil {
+ return nil, err
+ }
+ box.Add(div)
+ box.Add(pages)
+
+ win.Add(box)
+
+ sgSide, err := gtk.SizeGroupNew(gtk.SIZE_GROUP_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ sgSide.AddWidget(hbSide)
+ sgSide.AddWidget(bSide)
+
+ sgMain, err := gtk.SizeGroupNew(gtk.SIZE_GROUP_HORIZONTAL)
+ if err != nil {
+ return nil, err
+ }
+ sgMain.AddWidget(hbMain)
+ sgMain.AddWidget(pages)
+
+ // Set initial title
+ //hbMain.SetTitle("Global")
+
+ //win.SetDefaultSize(1000, -1)
+ // win.SetDefaultSize(500, -1)
+
+ return win, nil
+}
diff --git a/ui/gtkui/settings.go b/ui/gtkui/settings.go
new file mode 100644
index 0000000..93b40da
--- /dev/null
+++ b/ui/gtkui/settings.go
@@ -0,0 +1,31 @@
+package gtkui
+
+const (
+ CFG_IGNORE_FILE_SIGNATURE = "ignore-file-signature"
+ CFG_RSYNC_RETRY_COUNT = "rsync-retry-count"
+ CFG_MANAGE_AUTO_BACKUP_BLOCK_SIZE = "manage-automatically-backup-block-size"
+ CFG_MAX_BACKUP_BLOCK_SIZE_MB = "max-backup-block-size-mb"
+ CFG_ENABLE_USE_OF_PREVIOUS_BACKUP = "enable-use-of-previous-backup"
+ CFG_NUMBER_OF_PREVIOUS_BACKUP_TO_USE = "number-of-previous-backup-to-use"
+ CFG_ENABLE_LOW_LEVEL_LOG_OF_RSYNC = "enable-low-level-log-for-rsync"
+ CFG_ENABLE_INTENSIVE_LOW_LEVEL_LOG_OF_RSYNC = "enable-intensive-low-level-log-for-rsync"
+ CFG_RSYNC_COMPRESS_FILE_TRANSFER = "rsync-compress-file-transfer"
+ CFG_RSYNC_RECREATE_SYMLINKS = "rsync-recreate-symlinks"
+ CFG_RSYNC_TRANSFER_SOURCE_PERMISSIONS = "rsync-transfer-source-permissions"
+ CFG_RSYNC_TRANSFER_SOURCE_GROUP = "rsync-transfer-source-group"
+ CFG_RSYNC_TRANSFER_SOURCE_OWNER = "rsync-transfer-source-owner"
+ CFG_RSYNC_TRANSFER_DEVICE_FILES = "rsync-transfer-device-files"
+ CFG_RSYNC_TRANSFER_SPECIAL_FILES = "rsync-transfer-special-files"
+ CFG_BACKUP_LIST = "profile-list"
+ CFG_SOURCE_LIST = "source-list"
+ CFG_DONT_SHOW_ABOUT_ON_STARTUP = "dont-show-about-dialog-on-startup"
+ CFG_UI_LANGUAGE = "ui-language"
+ CFG_SESSION_LOG_WIDGET_FONT_SIZE = "session-log-widget-font-size"
+ CFG_PROFILE_NAME = "profile-name"
+ CFG_PROFILE_DEST_ROOT_PATH = "destination-root-path"
+ CFG_SOURCE_RSYNC_SOURCE_PATH = "rsync-source-path"
+ CFG_SOURCE_DEST_SUBPATH = "dest-subpath"
+ CFG_SOURCE_ENABLED = "source-dest-block-enabled"
+ CFG_PERFORM_DESKTOP_NOTIFICATION = "perform-backup-completion-desktop-notification"
+ CFG_RUN_NOTIFICATION_SCRIPT = "run-backup-completion-notification-script"
+)
diff --git a/ui/gtkui/uitools.go b/ui/gtkui/uitools.go
new file mode 100644
index 0000000..6f8f9aa
--- /dev/null
+++ b/ui/gtkui/uitools.go
@@ -0,0 +1,921 @@
+package gtkui
+
+import (
+ "errors"
+ "strconv"
+
+ "github.com/d2r2/gotk3/gdk"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/d2r2/gotk3/pango"
+ "github.com/davecgh/go-spew/spew"
+)
+
+// ========================================================================================
+// ************************* GTK GUI UTILITIES SECTION START ******************************
+// ========================================================================================
+// In real application copy this section to separate file as utilities functions to simplify
+// creation of GLIB/GTK+ components and widgets, including menus, dialog boxes, messages,
+// application settings and so on...
+
+// SetupHeader construct Header widget with standard initialization.
+func SetupHeader(title, subtitle string, showCloseButton bool) (*gtk.HeaderBar, error) {
+ hdr, err := gtk.HeaderBarNew()
+ if err != nil {
+ return nil, err
+ }
+ hdr.SetShowCloseButton(showCloseButton)
+ hdr.SetTitle(title)
+ if subtitle != "" {
+ hdr.SetSubtitle(subtitle)
+ }
+ return hdr, nil
+}
+
+// SetupMenuItemWithIcon construct MenuItem widget with icon image.
+func SetupMenuItemWithIcon(label, detailedAction string, icon *glib.Icon) (*glib.MenuItem, error) {
+ mi, err := glib.MenuItemNew(label, detailedAction)
+ if err != nil {
+ return nil, err
+ }
+ //mi.SetAttributeValue("verb-icon", iconNameVar)
+ mi.SetIcon(icon)
+ return mi, nil
+}
+
+// SetupMenuItemWithThemedIcon construct MenuItem widget with image
+// taken by iconName from themed icons image lib.
+func SetupMenuItemWithThemedIcon(label, detailedAction, iconName string) (*glib.MenuItem, error) {
+ iconNameVar, err := glib.VariantStringNew(iconName)
+ if err != nil {
+ return nil, err
+ }
+ mi, err := glib.MenuItemNew(label, detailedAction)
+ if err != nil {
+ return nil, err
+ }
+ mi.SetAttributeValue("verb-icon", iconNameVar)
+ return mi, nil
+}
+
+// SetupToolButton construct ToolButton widget with standart initialization.
+func SetupToolButton(themedIconName, label string) (*gtk.ToolButton, error) {
+ var btn *gtk.ToolButton
+ var img *gtk.Image
+ var err error
+ if themedIconName != "" {
+ img, err = gtk.ImageNewFromIconName(themedIconName, gtk.ICON_SIZE_BUTTON)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ btn, err = gtk.ToolButtonNew(img, label)
+ if err != nil {
+ return nil, err
+ }
+ return btn, nil
+}
+
+// SetupButtonWithThemedImage construct Button widget with image
+// taken by themedIconName from themed icons image lib.
+func SetupButtonWithThemedImage(themedIconName string) (*gtk.Button, error) {
+ img, err := gtk.ImageNewFromIconName(themedIconName, gtk.ICON_SIZE_BUTTON)
+ if err != nil {
+ return nil, err
+ }
+
+ btn, err := gtk.ButtonNew()
+ if err != nil {
+ return nil, err
+ }
+
+ btn.Add(img)
+
+ return btn, nil
+}
+
+func getPixbufFromBytes(bytes []byte) (*gdk.Pixbuf, error) {
+ b2, err := glib.BytesNew(bytes)
+ if err != nil {
+ return nil, err
+ }
+ ms, err := glib.MemoryInputStreamFromBytesNew(b2)
+ if err != nil {
+ return nil, err
+ }
+ pb, err := gdk.PixbufNewFromStream(&ms.InputStream, nil)
+ if err != nil {
+ return nil, err
+ }
+ return pb, nil
+}
+
+func getPixbufAnimationFromBytes(bytes []byte) (*gdk.PixbufAnimation, error) {
+ b2, err := glib.BytesNew(bytes)
+ if err != nil {
+ return nil, err
+ }
+ ms, err := glib.MemoryInputStreamFromBytesNew(b2)
+ if err != nil {
+ return nil, err
+ }
+ pba, err := gdk.PixbufAnimationNewFromStream(&ms.InputStream, nil)
+ if err != nil {
+ return nil, err
+ }
+ return pba, nil
+}
+
+// SetupMenuButtonWithThemedImage construct MenuButton widget with image
+// taken by themedIconName from themed icons image lib.
+func SetupMenuButtonWithThemedImage(themedIconName string) (*gtk.MenuButton, error) {
+ img, err := gtk.ImageNewFromIconName(themedIconName, gtk.ICON_SIZE_BUTTON)
+ if err != nil {
+ return nil, err
+ }
+
+ btn, err := gtk.MenuButtonNew()
+ if err != nil {
+ return nil, err
+ }
+
+ btn.Add(img)
+
+ return btn, nil
+}
+
+// AppendSectionAsHorzButtons used for Popover widget menu
+// as a hint to display items as a horizontal buttons.
+func AppendSectionAsHorzButtons(main, section *glib.Menu) error {
+ val1, err := glib.VariantStringNew("horizontal-buttons")
+ if err != nil {
+ return err
+ }
+ mi1, err := glib.MenuItemNew("", "")
+ if err != nil {
+ return err
+ }
+ mi1.SetSection(section)
+ mi1.SetAttributeValue("display-hint", val1)
+ main.AppendItem(mi1)
+ //section.AppendItem(mi1)
+ return nil
+}
+
+// DialogButton simplify Dialog window initialization.
+// Keep all necessary information about how attached
+// dialog button should look and act.
+type DialogButton struct {
+ Text string
+ Response gtk.ResponseType
+ Default bool
+ Customize func(button *gtk.Button) error
+}
+
+// GetActiveWindow find real active window in application running.
+func GetActiveWindow(win *gtk.Window) (*gtk.Window, error) {
+ app, err := win.GetApplication()
+ if err != nil {
+ return nil, err
+ }
+ return app.GetActiveWindow(), nil
+}
+
+// IsResponseYes gives true if dialog window
+// responded with gtk.RESPONSE_YES.
+func IsResponseYes(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_YES
+}
+
+// IsResponseNo gives true if dialog window
+// responded with gtk.RESPONSE_NO.
+func IsResponseNo(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_NO
+}
+
+// IsResponseNone gives true if dialog window
+// responded with gtk.RESPONSE_NONE.
+func IsResponseNone(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_NONE
+}
+
+// IsResponseOk gives true if dialog window
+// responded with gtk.RESPONSE_OK.
+func IsResponseOk(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_OK
+}
+
+// IsResponseCancel gives true if dialog window
+// responded with gtk.RESPONSE_CANCEL.
+func IsResponseCancel(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_CANCEL
+}
+
+// IsResponseReject gives true if dialog window
+// responded with gtk.RESPONSE_REJECT.
+func IsResponseReject(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_REJECT
+}
+
+// IsResponseClose gives true if dialog window
+// responded with gtk.RESPONSE_CLOSE.
+func IsResponseClose(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_CLOSE
+}
+
+// IsResponseDeleteEvent gives true if dialog window
+// responded with gtk.RESPONSE_DELETE_EVENT.
+func IsResponseDeleteEvent(response gtk.ResponseType) bool {
+ return response == gtk.RESPONSE_DELETE_EVENT
+}
+
+// PrintDialogResponse print and debug dialog responce.
+func PrintDialogResponse(response gtk.ResponseType) {
+ if IsResponseNo(response) {
+ lg.Debug("Dialog result = NO")
+ } else if IsResponseYes(response) {
+ lg.Debug("Dialog result = YES")
+ } else if IsResponseNone(response) {
+ lg.Debug("Dialog result = NONE")
+ } else if IsResponseOk(response) {
+ lg.Debug("Dialog result = OK")
+ } else if IsResponseReject(response) {
+ lg.Debug("Dialog result = REJECT")
+ } else if IsResponseCancel(response) {
+ lg.Debug("Dialog result = CANCEL")
+ } else if IsResponseClose(response) {
+ lg.Debug("Dialog result = CLOSE")
+ } else if IsResponseDeleteEvent(response) {
+ lg.Debug("Dialog result = DELETE_EVENT")
+ }
+}
+
+// DialogParagraph is an object which keep text paragraph added
+// to dialog window, complemented with all necessary format options.
+type DialogParagraph struct {
+ Text string
+ Markup bool
+ HorizAlign gtk.Align
+ Justify gtk.Justification
+ Ellipsize pango.EllipsizeMode
+ MaxWidthChars int
+}
+
+func NewDialogParagraph(text string) *DialogParagraph {
+ v := &DialogParagraph{Text: text, HorizAlign: gtk.Align(-1), Justify: gtk.Justification(-1),
+ Ellipsize: pango.EllipsizeMode(-1), MaxWidthChars: -1}
+ return v
+}
+
+func NewMarkupDialogParagraph(text string) *DialogParagraph {
+ v := &DialogParagraph{Text: text, Markup: true, HorizAlign: gtk.Align(-1), Justify: gtk.Justification(-1),
+ Ellipsize: pango.EllipsizeMode(-1), MaxWidthChars: -1}
+ return v
+}
+
+func (v *DialogParagraph) SetHorizAlign(align gtk.Align) *DialogParagraph {
+ v.HorizAlign = align
+ return v
+}
+
+func (v *DialogParagraph) SetJustify(justify gtk.Justification) *DialogParagraph {
+ v.Justify = justify
+ return v
+}
+
+func (v *DialogParagraph) SetEllipsize(ellipsize pango.EllipsizeMode) *DialogParagraph {
+ v.Ellipsize = ellipsize
+ return v
+}
+
+func (v *DialogParagraph) SetMaxWidthChars(maxWidthChars int) *DialogParagraph {
+ v.MaxWidthChars = maxWidthChars
+ return v
+}
+
+func (v *DialogParagraph) createLabel() (*gtk.Label, error) {
+ lbl, err := gtk.LabelNew("")
+ if err != nil {
+ return nil, err
+ }
+ if v.Markup {
+ lbl.SetMarkup(v.Text)
+ } else {
+ lbl.SetText(v.Text)
+ }
+ if v.HorizAlign != gtk.Align(-1) {
+ lbl.SetHAlign(v.HorizAlign)
+ }
+ if v.Justify != gtk.Justification(-1) {
+ lbl.SetJustify(v.Justify)
+ }
+ if v.Ellipsize != pango.EllipsizeMode(-1) {
+ lbl.SetEllipsize(v.Ellipsize)
+ }
+ if v.MaxWidthChars != -1 {
+ lbl.SetMaxWidthChars(v.MaxWidthChars)
+ }
+ return lbl, nil
+}
+
+func TextToDialogParagraphs(lines []string) []*DialogParagraph {
+ var msgs []*DialogParagraph
+ for _, line := range lines {
+ msgs = append(msgs, NewDialogParagraph(line))
+ }
+ return msgs
+}
+
+func TextToMarkupDialogParagraphs(lines []string) []*DialogParagraph {
+ var msgs []*DialogParagraph
+ for _, line := range lines {
+ msgs = append(msgs, NewMarkupDialogParagraph(line))
+ }
+ return msgs
+}
+
+// SetupMessageDialog construct MessageDialog widget with customized settings.
+func SetupMessageDialog(parent *gtk.Window, markupTitle string, secondaryMarkupTitle string,
+ paragraphs []*DialogParagraph, addButtons []DialogButton,
+ addExtraControls func(area *gtk.Box) error) (*gtk.MessageDialog, error) {
+
+ var active *gtk.Window
+ var err error
+
+ if parent != nil {
+ active, err = GetActiveWindow(parent)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ dlg, err := gtk.MessageDialogNew(active, /*gtk.DIALOG_MODAL|*/
+ gtk.DIALOG_USE_HEADER_BAR, gtk.MESSAGE_WARNING, gtk.BUTTONS_NONE, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if active != nil {
+ dlg.SetTransientFor(active)
+ }
+ dlg.SetMarkup(markupTitle)
+ if secondaryMarkupTitle != "" {
+ dlg.FormatSecondaryMarkup(secondaryMarkupTitle)
+ }
+
+ for _, button := range addButtons {
+ btn, err := dlg.AddButton(button.Text, button.Response)
+ if err != nil {
+ return nil, err
+ }
+
+ if button.Default {
+ dlg.SetDefaultResponse(button.Response)
+ }
+
+ if button.Customize != nil {
+ err := button.Customize(btn)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ grid, err := gtk.GridNew()
+ if err != nil {
+ return nil, err
+ }
+
+ grid.SetRowSpacing(6)
+ grid.SetHAlign(gtk.ALIGN_CENTER)
+
+ box, err := dlg.GetMessageArea()
+ if err != nil {
+ return nil, err
+ }
+ box.Add(grid)
+
+ col := 1
+ row := 0
+
+ // add empty line after title
+ paragraphs = append([]*DialogParagraph{NewDialogParagraph("")}, paragraphs...)
+
+ for _, paragraph := range paragraphs {
+ lbl, err := paragraph.createLabel()
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, col, row, 1, 1)
+ row++
+ }
+
+ if addExtraControls != nil {
+ box1, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(box1, col, row, 1, 1)
+
+ err = addExtraControls(box1)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ box.ShowAll()
+
+ return dlg, nil
+}
+
+// RunMessageDialog construct and run MessageDialog widget with customized settings.
+func RunMessageDialog(parent *gtk.Window, markupTitle string, secondaryMarkupTitle string,
+ paragraphs []*DialogParagraph, ignoreCloseBox bool, addButtons []DialogButton,
+ addExtraControls func(area *gtk.Box) error) (gtk.ResponseType, error) {
+
+ dlg, err := SetupMessageDialog(parent, markupTitle, secondaryMarkupTitle,
+ paragraphs, addButtons, addExtraControls)
+ if err != nil {
+ return 0, err
+ }
+ defer dlg.Destroy()
+
+ dlg.ShowAll()
+ res := dlg.Run()
+ for gtk.ResponseType(res) == gtk.RESPONSE_NONE || gtk.ResponseType(res) == gtk.RESPONSE_DELETE_EVENT && ignoreCloseBox {
+ res = dlg.Run()
+ }
+ return gtk.ResponseType(res), nil
+}
+
+// SetupDialog construct Dialog widget with customized settings.
+func SetupDialog(parent *gtk.Window, messageType gtk.MessageType, userHeaderbar bool,
+ title string, paragraphs []*DialogParagraph, addButtons []DialogButton,
+ addExtraControls func(area *gtk.Box) error) (*gtk.Dialog, error) {
+
+ var active *gtk.Window
+ var err error
+
+ if parent != nil {
+ active, err = GetActiveWindow(parent)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ flags := gtk.DIALOG_MODAL
+ if userHeaderbar {
+ flags |= gtk.DIALOG_USE_HEADER_BAR
+ }
+ dlg, err := gtk.DialogWithFlagsNew(title, active, flags)
+ if err != nil {
+ return nil, err
+ }
+
+ dlg.SetDefaultSize(100, 100)
+ dlg.SetTransientFor(active)
+ dlg.SetDeletable(false)
+
+ var img *gtk.Image
+ size := gtk.ICON_SIZE_DIALOG
+ if userHeaderbar {
+ size = gtk.ICON_SIZE_LARGE_TOOLBAR
+ }
+ var iconName string
+ switch messageType {
+ case gtk.MESSAGE_WARNING:
+ iconName = "dialog-warning"
+ case gtk.MESSAGE_ERROR:
+ iconName = "dialog-error"
+ case gtk.MESSAGE_INFO:
+ iconName = "dialog-information"
+ case gtk.MESSAGE_QUESTION:
+ iconName = "dialog-question"
+ }
+
+ if iconName != "" {
+ img, err = gtk.ImageNewFromIconName(iconName, size)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ grid, err := gtk.GridNew()
+ if err != nil {
+ return nil, err
+ }
+
+ grid.SetBorderWidth(10)
+ grid.SetColumnSpacing(10)
+ grid.SetRowSpacing(6)
+ grid.SetHAlign(gtk.ALIGN_CENTER)
+
+ box, err := dlg.GetContentArea()
+ if err != nil {
+ return nil, err
+ }
+ box.Add(grid)
+
+ if img != nil {
+ if userHeaderbar {
+ hdr, err := dlg.GetHeaderBar()
+ if err != nil {
+ return nil, err
+ }
+
+ hdr.PackStart(img)
+ } else {
+ grid.Attach(img, 0, 0, 1, 1)
+ }
+ }
+
+ for _, button := range addButtons {
+ btn, err := dlg.AddButton(button.Text, button.Response)
+ if err != nil {
+ return nil, err
+ }
+
+ if button.Default {
+ dlg.SetDefaultResponse(button.Response)
+ }
+
+ if button.Customize != nil {
+ err := button.Customize(btn)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ col := 1
+ row := 0
+
+ for _, paragraph := range paragraphs {
+ lbl, err := paragraph.createLabel()
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(lbl, col, row, 1, 1)
+ row++
+ }
+
+ if addExtraControls != nil {
+ box1, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6)
+ if err != nil {
+ return nil, err
+ }
+ grid.Attach(box1, col, row, 1, 1)
+
+ err = addExtraControls(box1)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ _, w := dlg.GetPreferredWidth()
+ _, h := dlg.GetPreferredHeight()
+ dlg.Resize(w, h)
+
+ return dlg, nil
+}
+
+// RunDialog construct and run Dialog widget with customized settings.
+func RunDialog(parent *gtk.Window, messageType gtk.MessageType, userHeaderbar bool,
+ title string, paragraphs []*DialogParagraph, ignoreCloseBox bool, addButtons []DialogButton,
+ addExtraControls func(area *gtk.Box) error) (gtk.ResponseType, error) {
+
+ dlg, err := SetupDialog(parent, messageType, userHeaderbar, title,
+ paragraphs, addButtons, addExtraControls)
+ if err != nil {
+ return 0, err
+ }
+ defer dlg.Destroy()
+
+ //dlg.ShowAll()
+ dlg.ShowAll()
+ res := dlg.Run()
+ for gtk.ResponseType(res) == gtk.RESPONSE_NONE || gtk.ResponseType(res) == gtk.RESPONSE_DELETE_EVENT && ignoreCloseBox {
+ res = dlg.Run()
+ }
+ return gtk.ResponseType(res), nil
+}
+
+func ErrorMessage(parent *gtk.Window, titleMarkup string, text []*DialogParagraph) error {
+ buttons := []DialogButton{
+ {"_OK", gtk.RESPONSE_OK, false, nil},
+ }
+ _, err := RunMessageDialog(parent, titleMarkup, "", text, false, buttons, nil)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func QuestionDialog(parent *gtk.Window, title string,
+ messages []*DialogParagraph, defaultYes bool) (bool, error) {
+
+ title2 := spew.Sprintf("%s", title)
+ buttons := []DialogButton{
+ {"_YES", gtk.RESPONSE_YES, defaultYes, nil},
+ {"_NO", gtk.RESPONSE_NO, !defaultYes, nil},
+ }
+ response, err := RunDialog(parent, gtk.MESSAGE_QUESTION, true, title2,
+ messages, false, buttons, nil)
+ if err != nil {
+ return false, err
+ }
+ PrintDialogResponse(response)
+ return IsResponseYes(response), nil
+}
+
+// GetActionNameAndState display status of action-with-state, which used in
+// menu-with-state behavior. Convenient for debug purpose.
+func GetActionNameAndState(act *glib.SimpleAction) (string, *glib.Variant, error) {
+ name, err := act.GetName()
+ if err != nil {
+ return "", nil, err
+ }
+ state := act.GetState()
+ return name, state, nil
+}
+
+// SetMargins set margins of a widget to the passed values,
+// replacing 4 calls with only one.
+func SetMargins(widget gtk.IWidget, left int, top int, right int, bottom int) {
+ w := widget.GetWidget()
+ w.SetMarginStart(left)
+ w.SetMarginTop(top)
+ w.SetMarginEnd(right)
+ w.SetMarginBottom(bottom)
+}
+
+// SetAllMargins set all margins of a widget to the same value.
+func SetAllMargins(widget gtk.IWidget, margin int) {
+ SetMargins(widget, margin, margin, margin, margin)
+}
+
+// AppendValues append multiple values to a row in a list store.
+func AppendValues(ls *gtk.ListStore, values ...interface{}) (*gtk.TreeIter, error) {
+ iter := ls.Append()
+ for i := 0; i < len(values); i++ {
+ err := ls.SetValue(iter, i, values[i])
+ if err != nil {
+ return nil, err
+ }
+ }
+ return iter, nil
+}
+
+// CreateNameValueCombo create a GtkComboBox that holds
+// a set of name/value pairs where the name is displayed.
+func CreateNameValueCombo(keyValues []struct{ value, key string }) (*gtk.ComboBox, error) {
+ ls, err := gtk.ListStoreNew(glib.TYPE_STRING, glib.TYPE_STRING)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, item := range keyValues {
+ _, err = AppendValues(ls, item.value, item.key)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ cb, err := gtk.ComboBoxNew()
+ if err != nil {
+ return nil, err
+ }
+ err = UpdateNameValueCombo(cb, keyValues)
+ if err != nil {
+ return nil, err
+ }
+ cb.SetFocusOnClick(false)
+ cb.SetIDColumn(1)
+ cell, err := gtk.CellRendererTextNew()
+ if err != nil {
+ return nil, err
+ }
+ cell.SetAlignment(0, 0)
+ cb.PackStart(cell, false)
+ cb.AddAttribute(cell, "text", 0)
+
+ return cb, nil
+}
+
+// UpdateNameValueCombo update GtkComboBox list of name/value pairs.
+func UpdateNameValueCombo(cb *gtk.ComboBox, keyValues []struct{ value, key string }) error {
+ ls, err := gtk.ListStoreNew(glib.TYPE_STRING, glib.TYPE_STRING)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range keyValues {
+ _, err = AppendValues(ls, item.value, item.key)
+ if err != nil {
+ return err
+ }
+ }
+
+ cb.SetModel(ls)
+ return nil
+}
+
+// GetComboValue return GtkComboBox selected value from specific column.
+func GetComboValue(cb *gtk.ComboBox, columnID int) (*glib.Value, error) {
+ ti, err := cb.GetActiveIter()
+ if err != nil {
+ return nil, err
+ }
+ tm, err := cb.GetModel()
+ if err != nil {
+ return nil, err
+ }
+ val, err := tm.GetValue(ti, 0)
+ if err != nil {
+ return nil, err
+ }
+ return val, nil
+}
+
+// GetGtkVersion return actually installed GTK+ version.
+func GetGtkVersion() (magor, minor, micro uint) {
+ magor = gtk.GetMajorVersion()
+ minor = gtk.GetMinorVersion()
+ micro = gtk.GetMicroVersion()
+ return
+}
+
+// GetGlibVersion return actually installed GLIB version.
+func GetGlibVersion() (magor, minor, micro uint) {
+ magor = glib.GetMajorVersion()
+ minor = glib.GetMinorVersion()
+ micro = glib.GetMicroVersion()
+ return
+}
+
+// GetSchema obtains glib.SettingsSchema from glib.Settings.
+func GetSchema(v *glib.Settings) (*glib.SettingsSchema, error) {
+ val, err := v.GetProperty("settings-schema")
+ if err != nil {
+ return nil, err
+ }
+ if schema, ok := val.(*glib.SettingsSchema); ok {
+ return schema, nil
+ } else {
+ return nil, errors.New("GLib settings-schema property is not convertible to SettingsSchema")
+ }
+}
+
+// FixProgressBarCSS eliminate issue with default GtkProgressBar control formating.
+func applyStyleCSS(widget *gtk.Widget, css string) error {
+ // provider, err := gtk.CssProviderNew()
+ provider, err := gtk.CssProviderNew()
+ if err != nil {
+ return err
+ }
+ err = provider.LoadFromData(css)
+ if err != nil {
+ return err
+ }
+ sc, err := widget.GetStyleContext()
+ if err != nil {
+ return err
+ }
+ sc.AddProvider(provider, gtk.STYLE_PROVIDER_PRIORITY_USER)
+ //sc.AddClass("osd")
+ return nil
+}
+
+// Binding cache link between Key string identifier and GLIB object property.
+// Code taken from https://github.com/gnunn1/tilix project.
+type Binding struct {
+ Key string
+ Object glib.IObject
+ Property string
+ Flags glib.SettingsBindFlags
+}
+
+// BindingHelper is a bookkeeping class that keeps track of objects which are
+// binded to a GSettings object so they can be unbinded later. it
+// also supports the concept of deferred bindings where a binding
+// can be added but is not actually attached to a Settings object
+// until one is set.
+type BindingHelper struct {
+ bindings []Binding
+ settings *glib.Settings
+}
+
+// BindingHelperNew creates new BindingHelper object.
+func BindingHelperNew(settings *glib.Settings) *BindingHelper {
+ bh := &BindingHelper{settings: settings}
+ return bh
+}
+
+// SetSettings will replace underlying GLIB Settings object to unbind
+// previously set bindings and re-bind to the new settings automatically.
+func (v *BindingHelper) SetSettings(value *glib.Settings) {
+ if value != v.settings {
+ if v.settings != nil {
+ v.Unbind()
+ }
+ v.settings = value
+ if v.settings != nil {
+ v.bindAll()
+ }
+ }
+}
+
+func (v *BindingHelper) bindAll() {
+ if v.settings != nil {
+ for _, b := range v.bindings {
+ v.settings.Bind(b.Key, b.Object, b.Property, b.Flags)
+ }
+ }
+}
+
+// addBind add a binding to the list
+func (v *BindingHelper) addBind(key string, object glib.IObject, property string, flags glib.SettingsBindFlags) {
+ v.bindings = append(v.bindings, Binding{key, object, property, flags})
+}
+
+// Bind add a binding to list and binds to Settings if it is set.
+func (v *BindingHelper) Bind(key string, object glib.IObject, property string, flags glib.SettingsBindFlags) {
+ v.addBind(key, object, property, flags)
+ if v.settings != nil {
+ v.settings.Bind(key, object, property, flags)
+ }
+}
+
+// Unbind all added binds from settings object.
+func (v *BindingHelper) Unbind() {
+ for _, b := range v.bindings {
+ v.settings.Unbind(b.Object, b.Property)
+ }
+}
+
+// Clear unbind all bindings and clears list of bindings.
+func (v *BindingHelper) Clear() {
+ v.Unbind()
+ v.bindings = nil
+}
+
+// SettingsArray is a way how to create multiple (indexed) GLib setting's group.
+// For instance, multiple backup profiles with identical
+// settings inside of each profile. Either each backup profile may
+// contain more than one data source for backup.
+type SettingsArray struct {
+ settings *glib.Settings
+ arrayID string
+}
+
+func NewSettingsArray(settings *glib.Settings, arrayID string) *SettingsArray {
+ v := &SettingsArray{settings: settings, arrayID: arrayID}
+ return v
+}
+
+func (v *SettingsArray) DeleteNode(childSettings *glib.Settings, nodeID string) error {
+ schema, err := GetSchema(childSettings)
+ if err != nil {
+ return err
+ }
+ keys := schema.ListKeys()
+ lg.Debug(spew.Sprintf("%+v", keys))
+ for _, key := range keys {
+ childSettings.Reset(key)
+ }
+
+ sources := v.settings.GetStrv(v.arrayID)
+ var newSources []string
+ for _, id := range sources {
+ if id != nodeID {
+ newSources = append(newSources, id)
+ }
+ }
+ v.settings.SetStrv(v.arrayID, newSources)
+ return nil
+}
+
+func (v *SettingsArray) AddNode() (nodeID string, err error) {
+ sources := v.settings.GetStrv(v.arrayID)
+ var ni int
+ if len(sources) > 0 {
+ ni, err = strconv.Atoi(sources[len(sources)-1])
+ if err != nil {
+ return "", err
+ }
+ ni++
+ }
+ //lg.Println(spew.Sprintf("New node id: %+v", ni))
+ sources = append(sources, strconv.Itoa(ni))
+ v.settings.SetStrv(v.arrayID, sources)
+ return sources[len(sources)-1], nil
+}
+
+func (v *SettingsArray) GetArrayIDs() []string {
+ sources := v.settings.GetStrv(v.arrayID)
+ return sources
+}
+
+// ========================================================================================
+// ************************* GTK GUI UTILITIES SECTION END ********************************
+// ========================================================================================
diff --git a/ui/gtkui/utils.go b/ui/gtkui/utils.go
new file mode 100644
index 0000000..3569de9
--- /dev/null
+++ b/ui/gtkui/utils.go
@@ -0,0 +1,272 @@
+package gtkui
+
+import (
+ "bytes"
+ "io/ioutil"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/d2r2/go-rsync/data"
+ "github.com/d2r2/go-rsync/locale"
+ "github.com/d2r2/gotk3/gdk"
+ "github.com/d2r2/gotk3/glib"
+ "github.com/d2r2/gotk3/gtk"
+ "github.com/davecgh/go-spew/spew"
+)
+
+func PixbufFromAssetsNew(assetIconName string) (*gdk.Pixbuf, error) {
+ file, err := data.Assets.Open(assetIconName)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ b, err := ioutil.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+
+ pb, err := getPixbufFromBytes(b)
+ if err != nil {
+ return nil, err
+ }
+ return pb, nil
+}
+
+func PixbufAnimationFromAssetsNew(assetIconName string) (*gdk.PixbufAnimation, error) {
+ file, err := data.Assets.Open(assetIconName)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ b, err := ioutil.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+
+ pb, err := getPixbufAnimationFromBytes(b)
+ if err != nil {
+ return nil, err
+ }
+ return pb, nil
+}
+
+func AnimationImageFromAssetsNew(assetIconName string) (*gtk.Image, error) {
+ pba, err := PixbufAnimationFromAssetsNew(assetIconName)
+ if err != nil {
+ return nil, err
+ }
+ img, err := gtk.ImageNewFromAnimation(pba)
+ if err != nil {
+ return nil, err
+ }
+ return img, nil
+}
+
+func ImageFromAssetsNew(assetIconName string, resizeToDestWidth, resizeToDestHeight int) (*gtk.Image, error) {
+ pb, err := PixbufFromAssetsNew(assetIconName)
+ if err != nil {
+ return nil, err
+ }
+ pb2 := pb
+ if resizeToDestWidth >= 0 && resizeToDestHeight >= 0 {
+ pb2, err = pb.ScaleSimple(resizeToDestWidth, resizeToDestHeight, gdk.INTERP_BILINEAR)
+ if err != nil {
+ return nil, err
+ }
+ }
+ img, err := gtk.ImageNewFromPixbuf(pb2)
+ if err != nil {
+ return nil, err
+ }
+ return img, nil
+}
+
+func SetEntryIconWithAssetImage(entry *gtk.Entry, iconPos gtk.EntryIconPosition, assetIconName string) error {
+ pb, err := PixbufFromAssetsNew(assetIconName)
+ if err != nil {
+ return err
+ }
+ entry.SetIconFromPixbuf(iconPos, pb)
+ return nil
+}
+
+func SetupButtonWithAssetAnimationImage(assetIconName string) (*gtk.Button, error) {
+ img, err := AnimationImageFromAssetsNew(assetIconName)
+ if err != nil {
+ return nil, err
+ }
+
+ btn, err := gtk.ButtonNew()
+ if err != nil {
+ return nil, err
+ }
+
+ btn.Add(img)
+
+ return btn, nil
+}
+
+// CheckSchemaSettingsIsInstalled verify, that GLib Setting's schema is installed, otherwise return false.
+func CheckSchemaSettingsIsInstalled(settingsID string, app *gtk.Application, extraMsg *string) (bool, error) {
+ parent := app.GetActiveWindow()
+ // Verify that GSettingsSchema is installed
+ schemaSource := glib.SettingsSchemaSourceGetDefault()
+ if schemaSource == nil {
+ //title := "Schema settings configuration error "
+ text := locale.T(MsgSchemaConfigDlgNoSchemaFoundError, nil)
+ err := schemaSettingsErrorDialog(parent, text, extraMsg)
+ if err != nil {
+ return false, err
+ }
+ return false, nil
+ }
+ schema := schemaSource.Lookup(settingsID, false)
+ if schema == nil {
+ //title := "Schema settings configuration error "
+ text := locale.T(MsgSchemaConfigDlgSchemaDoesNotFoundError, nil)
+ err := schemaSettingsErrorDialog(parent, text, extraMsg)
+ if err != nil {
+ return false, err
+ }
+ return false, nil
+ }
+ return true, nil
+}
+
+// ProgressBarManage simplify setting up GtkProgressBar to pulse either progress mode.
+type ProgressBarManage struct {
+ sync.Mutex
+ progressBar *gtk.ProgressBar
+ pulse *time.Ticker
+ stopPulse chan struct{}
+}
+
+func NewProgressBarManage(pb *gtk.ProgressBar) *ProgressBarManage {
+ p := &ProgressBarManage{progressBar: pb}
+ return p
+}
+
+func (v *ProgressBarManage) StartPulse() {
+ v.Lock()
+ defer v.Unlock()
+
+ if v.stopPulse == nil {
+ v.progressBar.SetPulseStep(0.5)
+ v.progressBar.Pulse()
+ //v.progressBar.Pulse()
+ v.stopPulse = make(chan struct{})
+ v.pulse = time.NewTicker(time.Millisecond * 2000)
+ go func(stopPulse chan struct{}) {
+ for {
+ select {
+ case <-v.pulse.C:
+ _, err := glib.IdleAdd(func() {
+ v.progressBar.Pulse()
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ case <-stopPulse:
+ v.Lock()
+ v.pulse.Stop()
+ v.Unlock()
+ _, err := glib.IdleAdd(func() {
+ v.progressBar.SetFraction(0)
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+ return
+ }
+ }
+ }(v.stopPulse)
+ }
+}
+
+func (v *ProgressBarManage) StopPulse() {
+ v.Lock()
+ defer v.Unlock()
+
+ if v.stopPulse != nil {
+ close(v.stopPulse)
+ v.stopPulse = nil
+ }
+}
+
+func (v *ProgressBarManage) SetFraction(value float64) error {
+ v.StopPulse()
+ v.Lock()
+ defer v.Unlock()
+
+ _, err := glib.IdleAdd(func() {
+ v.progressBar.SetFraction(value)
+ })
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func normalizeSubpath(subpath string) string {
+ subpath = strings.TrimSpace(subpath)
+ subpath1 := []rune(subpath)
+ if len(subpath1) > 0 && subpath1[0] == rune(os.PathSeparator) {
+ subpath1 = subpath1[1:]
+ }
+ if len(subpath1) > 0 && subpath1[len(subpath1)-1] == rune(os.PathSeparator) {
+ subpath1 = subpath1[:len(subpath1)-1]
+ }
+ subpath = string(subpath1)
+ return subpath
+}
+
+// markupTooltip create hint for GtkWidget to display description
+// with standard template: "Status: ... Description: ...".
+func markupTooltip(status *Markup, description string) *Markup {
+ var mp *Markup
+ if status != nil {
+ mp = NewMarkup(0, 0, 0, nil, nil,
+ NewMarkup(0, MARKUP_COLOR_LIGHT_GRAY, 0,
+ spew.Sprintf("%s ", locale.T(MsgGeneralHintStatusCaption, nil)), nil),
+ status,
+ NewMarkup(0, MARKUP_COLOR_LIGHT_GRAY, 0,
+ spew.Sprintf("\n%s ", locale.T(MsgGeneralHintDescriptionCaption, nil)), nil),
+ NewMarkup(0, 0, 0, description, nil),
+ )
+ } else {
+ mp = NewMarkup(0, 0, 0, description, nil)
+ }
+ return mp
+}
+
+// isDestPathError verify file system path availability status.
+// Returns error, if path isn't reachable.
+func isDestPathError(destPath string, formatMultiline bool) (bool, string) {
+ if destPath == "" {
+ msg := locale.T(MsgAppWindowDestPathIsEmptyError1, nil)
+ return true, msg
+ } else {
+ _, err := os.Stat(destPath)
+ if err != nil {
+ var msg string
+ if os.IsNotExist(err) {
+ var buf bytes.Buffer
+ buf.WriteString(locale.T(MsgAppWindowDestPathIsNotExistError,
+ struct{ FolderPath string }{FolderPath: destPath}))
+ if formatMultiline {
+ buf.WriteString(spew.Sprintln())
+ } else {
+ buf.WriteString(" ")
+ }
+ buf.WriteString(locale.T(MsgAppWindowDestPathIsNotExistAdvise, nil))
+ msg = buf.String()
+ } else {
+ msg = err.Error()
+ }
+ return true, msg
+ }
+ }
+ return false, ""
+}
diff --git a/ui/gtkui/validator.go b/ui/gtkui/validator.go
new file mode 100644
index 0000000..b5106c4
--- /dev/null
+++ b/ui/gtkui/validator.go
@@ -0,0 +1,315 @@
+package gtkui
+
+import (
+ "context"
+ "sync"
+
+ "github.com/d2r2/gotk3/glib"
+)
+
+// ValidatorData is an array of arbitrary data
+// used to pass to the validation process.
+type ValidatorData struct {
+ Items []interface{}
+}
+
+// ValidatorInit init validation process with next attributes:
+// - Synchronous call.
+// - Should take a limited time.
+// - Allowed to updated GTK widgets.
+type ValidatorInit func(data *ValidatorData, group []*ValidatorData) error
+
+// ValidatorRun run validation process with next characteristics:
+// - Asynchronous call.
+// - Can take long time to run.
+// - GTK widgets should not be updated here (read only allowed).
+type ValidatorRun func(ctx context.Context, data *ValidatorData,
+ group []*ValidatorData) ([]interface{}, error)
+
+// ValidatorEnd finalize validation process with next characteristics:
+// - Synchronous call.
+// - Should take a limited time.
+// - Allowed to updated GTK widgets.
+type ValidatorEnd func(data *ValidatorData, results []interface{}) error
+
+// ValidatorEntry stores validation data all together,
+// including 3-step validation process (init, run, finallize).
+type ValidatorEntry struct {
+ GroupName string
+ init ValidatorInit
+ run ValidatorRun
+ end ValidatorEnd
+ Data *ValidatorData
+}
+
+// GroupMap gives thread-safe dictionary,
+// which allow manipulations in asynchronous mode.
+type GroupMap struct {
+ sync.RWMutex
+ m map[string]*ContextPack
+}
+
+func GroupMapNew() *GroupMap {
+ v := &GroupMap{m: make(map[string]*ContextPack)}
+ return v
+}
+
+func (v *GroupMap) Add(groupName string, ctxPack *ContextPack) {
+ v.Lock()
+ defer v.Unlock()
+
+ v.m[groupName] = ctxPack
+}
+
+func (v *GroupMap) Remove(groupName string) {
+ v.Lock()
+ defer v.Unlock()
+
+ delete(v.m, groupName)
+}
+
+func (v *GroupMap) Get(groupName string) (*ContextPack, bool) {
+ v.RLock()
+ defer v.RUnlock()
+
+ ctxPack, ok := v.m[groupName]
+ return ctxPack, ok
+}
+
+// UIValidator simplify GTK UI validation process
+// mixing synchronized and asynchroniouse calls,
+// which all together does not freeze GTK UI,
+// providing beautifull GTK UI responce.
+// UIValidator is a thread-safe (except cases
+// when you need update GtkWidget components -
+// you must be careful in such circumstances).
+type UIValidator struct {
+ sync.Mutex
+ entries map[int]*ValidatorEntry
+ sorted []int
+ key int
+ parent context.Context
+ runningContexts RunningContexts
+ groupRunning *GroupMap
+}
+
+func UIValidatorNew(parent context.Context) *UIValidator {
+ entries := make(map[int]*ValidatorEntry)
+ groupRunning := GroupMapNew()
+ v := &UIValidator{entries: entries, parent: parent, groupRunning: groupRunning}
+ return v
+}
+
+func (v *UIValidator) AddEntry(groupName string, init ValidatorInit, run ValidatorRun, end ValidatorEnd,
+ data ...interface{}) int {
+
+ v.Lock()
+ defer v.Unlock()
+
+ vEntry := &ValidatorEntry{GroupName: groupName,
+ init: init, run: run, end: end, Data: &ValidatorData{data}}
+ key := v.key
+ v.entries[key] = vEntry
+ v.sorted = append(v.sorted, key)
+ v.key++
+ return key
+}
+
+func (v *UIValidator) RemoveEntry(key int) {
+ v.Lock()
+ defer v.Unlock()
+
+ if val, ok := v.entries[key]; ok {
+ v.cancelValidateIfRunning(val.GroupName)
+
+ lg.Debugf("Delete group %q with index %v", val.GroupName, key)
+ delete(v.entries, key)
+ }
+ for ind, k := range v.sorted {
+ if key == k {
+ v.sorted = append(v.sorted[:ind], v.sorted[ind+1:]...)
+ break
+ }
+ }
+}
+
+func (v *UIValidator) GetCount() int {
+ v.Lock()
+ defer v.Unlock()
+
+ return len(v.entries)
+}
+
+func (v *UIValidator) getGroupEntries(groupName string) []*ValidatorEntry {
+ var list []*ValidatorEntry
+ for _, key := range v.sorted {
+ if v.entries[key].GroupName == groupName {
+ list = append(list, v.entries[key])
+ }
+ }
+ return list
+}
+
+func (v *UIValidator) getGroupData(groupName string) []*ValidatorData {
+ var list []*ValidatorData
+ for _, key := range v.sorted {
+ if v.entries[key].GroupName == groupName {
+ list = append(list, v.entries[key].Data)
+ }
+ }
+ return list
+}
+
+// resultsOrError used to get results from
+// asynchonous context.
+type resultsOrError struct {
+ Entry *ValidatorEntry
+ Results []interface{}
+ Error error
+}
+
+// callEnd run 3rd validation step asynchronously,
+// but synchronize call with Gtk+ context via
+// glib.IdleAdd function.
+func (v *UIValidator) callEnd(r resultsOrError) {
+ _, err := glib.IdleAdd(func() {
+ err := r.Entry.end(r.Entry.Data, r.Results)
+ if err != nil {
+ lg.Fatal(err)
+ }
+ })
+ if err != nil {
+ lg.Fatal(err)
+ }
+}
+
+// callRun run 2nd validation step asynchronously.
+func (v *UIValidator) callRun(ctx context.Context, entry *ValidatorEntry,
+ dataList []*ValidatorData) ([]interface{}, error) {
+
+ return entry.run(ctx, entry.Data, dataList)
+}
+
+// callRun run 1st validation step synchronously.
+func (v *UIValidator) callInit(entry *ValidatorEntry, dataList []*ValidatorData) error {
+
+ return entry.init(entry.Data, dataList)
+}
+
+// runAsync run 2nd and 3rd validation process steps.
+func (v *UIValidator) runAsync(groupName string, entryList []*ValidatorEntry,
+ dataList []*ValidatorData) {
+
+ waitCh := make(chan resultsOrError)
+ done := make(chan struct{})
+
+ ctxPack := ForkContext(v.parent)
+ v.runningContexts.AddContext(ctxPack)
+ v.groupRunning.Add(groupName, ctxPack)
+
+ // run here 3rd validation step, with
+ go func() {
+ for {
+ select {
+ case r := <-waitCh:
+ terminated := false
+ select {
+ case <-ctxPack.Context.Done():
+ terminated = true
+ default:
+ }
+ if !terminated {
+ lg.Debugf("Read Validator results: %v", r)
+ err := r.Error
+ if r.Error == nil {
+ lg.Debugf("Call Validator End")
+ v.callEnd(r)
+ }
+ if err != nil {
+ lg.Fatal(err)
+ }
+ }
+ case <-done:
+ lg.Debugf("Complete group %q validation 2", groupName)
+ close(waitCh)
+ return
+ }
+ }
+ }()
+
+ // run here 2nd validation step
+ go func() {
+ for _, item := range entryList {
+ r := resultsOrError{Entry: item}
+ results, err := v.callRun(ctxPack.Context, item, dataList)
+ if err != nil {
+ r.Error = err
+ } else {
+ r.Results = results
+ }
+ select {
+ case waitCh <- r:
+ lg.Debugf("Send Validator results: %v", r)
+ case <-ctxPack.Context.Done():
+ break
+ }
+ }
+ lg.Debugf("Complete group %q validation 1", groupName)
+ close(done)
+ v.runningContexts.RemoveContext(ctxPack.Context)
+ v.groupRunning.Remove(groupName)
+ }()
+}
+
+// cancelValidateIfRunning checks if validation process
+// in progress and cancel it.
+func (v *UIValidator) cancelValidateIfRunning(groupName string) {
+ if ctxPack, ok := v.groupRunning.Get(groupName); ok {
+ lg.Debugf("Cancel group %q validation", groupName)
+ ctxPack.Cancel()
+ }
+}
+
+// Validate is main entry point to start validation process
+// for specific group.
+// Validate process trigger next strictly sequential steps:
+// 1) Call "init validatation" custom function in synchronous context.
+// So, it's safe to update Gtk+ widgets here.
+// 2) Call "run validation" custom function in asynchronous context.
+// You should never update Gtk+ widgets here (you can read widgets), but might run
+// long-term operations here (for instance run some external application).
+// 3) Call "finalize validation" custom function in asynchronous context.
+// Still you can update Gtk+ widgets here again, because this call synchonized
+// with Gtk+ context via glib.IdleAdd() function from GOTK+ library.
+func (v *UIValidator) Validate(groupName string) error {
+ v.Lock()
+ defer v.Unlock()
+
+ entryList := v.getGroupEntries(groupName)
+ dataList := v.getGroupData(groupName)
+ if len(entryList) > 0 {
+ v.cancelValidateIfRunning(groupName)
+
+ for _, item := range entryList {
+ // 1st step of validation process
+ err := v.callInit(item, dataList)
+ if err != nil {
+ return err
+ }
+ // 2nd and 3rd steps of validation process
+ v.runAsync(groupName, entryList, dataList)
+ }
+ }
+ return nil
+}
+
+func (v *UIValidator) CancelValidate(groupName string) {
+ v.Lock()
+ defer v.Unlock()
+
+ v.cancelValidateIfRunning(groupName)
+}
+
+func (v *UIValidator) CancelAll() {
+ v.runningContexts.CancelAll()
+}
diff --git a/version b/version
new file mode 100644
index 0000000..9e11b32
--- /dev/null
+++ b/version
@@ -0,0 +1 @@
+0.3.1