Applets/ClipboardHistory: Add persistent storage

Clipboard entries are now preserved upon reboot :^). Unfortunately, it
only supports data with the mimetype "text/".

This is done by writing all entries as a JSON object in a file located
in ~/.data.

Co-authored-by: Sagittarius-a <sagittarius-a@users.noreply.github.com>
This commit is contained in:
Lucas CHOLLET 2023-03-08 16:13:42 -05:00 committed by Andrew Kaster
parent c09d0c4816
commit 07c6cebbab
5 changed files with 143 additions and 7 deletions

View file

@ -7,6 +7,7 @@
*/
#include "ClipboardHistoryModel.h"
#include <AK/JsonParser.h>
#include <AK/NumberFormat.h>
#include <AK/StringBuilder.h>
#include <LibConfig/Client.h>
@ -117,29 +118,40 @@ void ClipboardHistoryModel::clipboard_content_did_change(DeprecatedString const&
add_item(data_and_type);
}
ErrorOr<void> ClipboardHistoryModel::invalidate_model_and_file(bool rewrite_all)
{
invalidate();
TRY(write_to_file(rewrite_all));
return {};
}
void ClipboardHistoryModel::add_item(const GUI::Clipboard::DataAndType& item)
{
bool has_deleted_an_item = false;
m_history_items.remove_first_matching([&](ClipboardItem& existing) {
return existing.data_and_type.data == item.data && existing.data_and_type.mime_type == item.mime_type;
});
if (m_history_items.size() == m_history_limit)
if (m_history_items.size() == m_history_limit) {
m_history_items.take_last();
has_deleted_an_item = true;
}
m_history_items.prepend({ item, Core::DateTime::now() });
invalidate();
invalidate_model_and_file(has_deleted_an_item).release_value_but_fixme_should_propagate_errors();
}
void ClipboardHistoryModel::remove_item(int index)
{
m_history_items.remove(index);
invalidate();
invalidate_model_and_file(true).release_value_but_fixme_should_propagate_errors();
}
void ClipboardHistoryModel::clear()
{
m_history_items.clear();
invalidate();
invalidate_model_and_file(true).release_value_but_fixme_should_propagate_errors();
}
void ClipboardHistoryModel::config_string_did_change(DeprecatedString const& domain, DeprecatedString const& group, DeprecatedString const& key, DeprecatedString const& value_string)
@ -155,9 +167,84 @@ void ClipboardHistoryModel::config_string_did_change(DeprecatedString const& dom
auto value = value_or_error.value();
if (value < (int)m_history_items.size()) {
m_history_items.remove(value, m_history_items.size() - value);
invalidate();
invalidate_model_and_file(false).release_value_but_fixme_should_propagate_errors();
}
m_history_limit = value;
return;
}
}
ErrorOr<ClipboardHistoryModel::ClipboardItem> ClipboardHistoryModel::ClipboardItem::from_json(JsonObject const& object)
{
if (!object.has("data_and_type"sv) && !object.has("time"sv))
return Error::from_string_literal("JsonObject does not contain necessary fields");
ClipboardItem result;
result.data_and_type = TRY(GUI::Clipboard::DataAndType::from_json(*object.get_object("data_and_type"sv)));
result.time = Core::DateTime::from_timestamp(*object.get_integer<time_t>("time"sv));
return result;
}
ErrorOr<JsonObject> ClipboardHistoryModel::ClipboardItem::to_json() const
{
JsonObject object;
object.set("data_and_type", TRY(data_and_type.to_json()));
object.set("time", time.timestamp());
return object;
}
ErrorOr<void> ClipboardHistoryModel::read_from_file(DeprecatedString const& path)
{
m_path = path;
auto read_from_file_impl = [this]() -> ErrorOr<void> {
auto file = TRY(Core::File::open(m_path, Core::File::OpenMode::Read));
auto buffered_file = TRY(Core::BufferedFile::create(move(file)));
auto buffer = TRY(ByteBuffer::create_uninitialized(PAGE_SIZE));
while (TRY(buffered_file->can_read_line())) {
auto line = TRY(buffered_file->read_line(buffer));
auto object = TRY(JsonParser { line }.parse()).as_object();
TRY(m_history_items.try_append(TRY(ClipboardItem::from_json(object))));
}
return {};
};
auto maybe_error = read_from_file_impl();
if (maybe_error.is_error())
dbgln("Unable to load clipboard history: {}", maybe_error.release_error());
return {};
}
ErrorOr<void> ClipboardHistoryModel::write_to_file(bool rewrite_all)
{
if (m_history_items.is_empty()) {
// This will proceed to empty the file
rewrite_all = true;
}
auto const write_element = [](Core::File& file, ClipboardItem const& item) -> ErrorOr<void> {
if (!item.data_and_type.mime_type.starts_with("text/"sv))
return {};
TRY(file.write_until_depleted(TRY(item.to_json()).to_deprecated_string().bytes()));
TRY(file.write_until_depleted("\n"sv.bytes()));
return {};
};
if (!rewrite_all) {
auto file = TRY(Core::File::open(m_path, Core::File::OpenMode::Write | Core::File::OpenMode::Append));
TRY(write_element(*file, m_history_items.first()));
} else {
auto file = TRY(Core::File::open(m_path, Core::File::OpenMode::Write | Core::File::OpenMode::Truncate));
for (auto const& item : m_history_items) {
TRY(write_element(*file, item));
}
}
return {};
}

View file

@ -31,6 +31,9 @@ public:
struct ClipboardItem {
GUI::Clipboard::DataAndType data_and_type;
Core::DateTime time;
static ErrorOr<ClipboardItem> from_json(JsonObject const& object);
ErrorOr<JsonObject> to_json() const;
};
virtual ~ClipboardHistoryModel() override = default;
@ -41,6 +44,11 @@ public:
void clear();
bool is_empty() { return m_history_items.is_empty(); }
ErrorOr<void> read_from_file(DeprecatedString const& path);
ErrorOr<void> write_to_file(bool rewrite_all);
ErrorOr<void> invalidate_model_and_file(bool rewrite_all);
// ^GUI::Model
virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override;
@ -60,4 +68,6 @@ private:
Vector<ClipboardItem> m_history_items;
size_t m_history_limit;
DeprecatedString m_path;
};

View file

@ -6,6 +6,8 @@
#include "ClipboardHistoryModel.h"
#include <LibConfig/Client.h>
#include <LibCore/Directory.h>
#include <LibCore/StandardPaths.h>
#include <LibCore/System.h>
#include <LibGUI/Action.h>
#include <LibGUI/Application.h>
@ -17,14 +19,22 @@
ErrorOr<int> serenity_main(Main::Arguments arguments)
{
TRY(Core::System::pledge("stdio recvfd sendfd rpath unix"));
TRY(Core::System::pledge("stdio recvfd sendfd rpath unix cpath wpath"));
auto app = TRY(GUI::Application::create(arguments));
auto clipboard_config = TRY(Core::ConfigFile::open_for_app("ClipboardHistory"));
auto const default_path = DeprecatedString::formatted("{}/{}", Core::StandardPaths::data_directory(), "Clipboard/ClipboardHistory.json"sv);
auto const clipboard_file_path = clipboard_config->read_entry("Clipboard", "ClipboardFilePath", default_path);
auto const parent_path = LexicalPath(clipboard_file_path);
TRY(Core::Directory::create(parent_path.dirname(), Core::Directory::CreateDirectories::Yes));
Config::pledge_domain("ClipboardHistory");
Config::monitor_domain("ClipboardHistory");
TRY(Core::System::pledge("stdio recvfd sendfd rpath"));
TRY(Core::System::pledge("stdio recvfd sendfd rpath cpath wpath"));
TRY(Core::System::unveil("/res", "r"));
TRY(Core::System::unveil(parent_path.dirname(), "rwc"sv));
TRY(Core::System::unveil(nullptr, nullptr));
auto app_icon = TRY(GUI::Icon::try_create_default_icon("edit-copy"sv));
@ -36,6 +46,8 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
auto table_view = TRY(main_window->set_main_widget<GUI::TableView>());
auto model = ClipboardHistoryModel::create();
TRY(model->read_from_file(clipboard_file_path));
auto data_and_type = GUI::Clipboard::the().fetch_data_and_type();
if (!(data_and_type.data.is_empty() && data_and_type.mime_type.is_empty() && data_and_type.metadata.is_empty()))
model->add_item(data_and_type);

View file

@ -124,6 +124,29 @@ RefPtr<Gfx::Bitmap> Clipboard::DataAndType::as_bitmap() const
return bitmap;
}
ErrorOr<Clipboard::DataAndType> Clipboard::DataAndType::from_json(JsonObject const& object)
{
if (!object.has("data"sv) && !object.has("mime_type"sv))
return Error::from_string_literal("JsonObject does not contain necessary fields");
DataAndType result;
result.data = object.get_deprecated_string("data"sv)->to_byte_buffer();
result.mime_type = *object.get_deprecated_string("mime_type"sv);
// FIXME: Also read metadata
return result;
}
ErrorOr<JsonObject> Clipboard::DataAndType::to_json() const
{
JsonObject object;
object.set("data", TRY(DeprecatedString::from_utf8(data.bytes())));
object.set("mime_type", mime_type);
// FIXME: Also write metadata
return object;
}
void Clipboard::set_data(ReadonlyBytes data, DeprecatedString const& type, HashMap<DeprecatedString, DeprecatedString> const& metadata)
{
if (data.is_empty()) {

View file

@ -11,6 +11,7 @@
#include <AK/DeprecatedString.h>
#include <AK/Function.h>
#include <AK/HashMap.h>
#include <AK/JsonObject.h>
#include <LibGUI/Forward.h>
#include <LibGfx/Forward.h>
@ -34,6 +35,9 @@ public:
HashMap<DeprecatedString, DeprecatedString> metadata;
RefPtr<Gfx::Bitmap> as_bitmap() const;
static ErrorOr<Clipboard::DataAndType> from_json(JsonObject const& object);
ErrorOr<JsonObject> to_json() const;
};
static ErrorOr<void> initialize(Badge<Application>);