diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75a5757 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + version: [stable, unstable, development-target] + container: + image: ghcr.io/elementary/docker:${{ matrix.version }} + + steps: + - uses: actions/checkout@v5 + - name: Install Dependencies + run: | + apt update + apt install -y meson valac libjson-glib-dev + - name: Build + run: | + meson setup build --prefix=/usr + ninja -C build + ninja -C build install + + lint: + runs-on: ubuntu-latest + + container: + image: valalang/lint + + steps: + - uses: actions/checkout@v5 + - name: Lint + run: io.elementary.vala-lint -d . \ No newline at end of file diff --git a/README.md b/README.md index 0968cf7..fc98281 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,58 @@ # libfridge -Simple Vala App Storage Library +A simple, hassle-free storage library to keep your data fresh. +Ready out of the box, comprehensive for vala newcomers, and built to be reliable and out of your way. + +This library primarily contains: + +- StringStorage: Store a string, any size. +- JsonStorage: Store an array of Json.Node or Json.Objects +- Static variables to keep track of your data files + +## Features: +- [x] Smart instancing: Each instance is its own distinct file, and you can let Fridge deal with that. +- [x] Cache: By default a cache lets you access content faster +- [x] Optional error handling: You can connect to the error() signal. If you want to. +- [x] Optional Json: You can rely on pretty pure vala here if you want to keep it light +- [x] Add a contains() method, so people can do if (var in storageinstance) +- [x] Handy: Static values to help you manage your datadir + +Wishlist of features: +- [ ] Integrated debounce saving? +- [ ] More agressive trying to load/save in case of errors +- [ ] Optional SQL storage i think? Something stupid: One key one value. Optional like the json one +- [ ] Save to binary? +- [ ] Make shit async for cases with heavy objects? +- [ ] encrypted storage ? using some combined magic of libsecret and some traditional encrypt library? maybe a salt and a randomly generated password? so user only need unlock their keyring, and the random gen gets pulled out invisibly. + + + +## How to use: Crash course + +All instances are meant to represent a file on disk. Simply declare: + +``` +var mystorage = new Fridge.json_storage(); +``` + +Or any variant depending what you want to store... And you are good to go! +Save by doing: + +``` +mystorage.content = thing_to_save; +``` + +Access it by doing: + +``` +var thing_to_load = mystorage.content; +``` + +## Apps using Fridge: + +- [ ] Jorts (https://github.com/ellie-commons/Jorts) +- [ ] You can expand this list! + ## Build Instructions @@ -24,6 +76,41 @@ To install libfridge: ninja install ``` + +## Add to your meson + +With Fridge installed on your puter, simply add to your dependencies: + +``` + dependency('libfridge-0.1'), +``` + + +## Add to your flatpak project + +Simply copy the in your manifest before your app sources: + +``` + - name: libfridge + buildsystem: meson + sources: + - type: git + url: https://github.com/vala-community/libfridge.git + tag: 0.0.1 + commit: [NOT DONE YET] + x-checker-data: + type: git + tag-pattern: '^([\d.]+)$' +``` + +By default JsonStorage is included. If you do not use it, and wish to skip having a json dependency, you can add immediately after the buildsystem line: + +``` + config-opts: + - -Denable_json=false +``` + + ## Documentation By default, documentation is built by default using [`valadoc`](https://docs.vala.dev/developer-guides/documentation/valadoc-guide.html) diff --git a/demo/demo.vala b/demo/demo.vala new file mode 100644 index 0000000..8818524 --- /dev/null +++ b/demo/demo.vala @@ -0,0 +1,16 @@ +// Testing and demo purposes +// valac --pkg libfridge demo.vala + + public static int main (string[] args) { + + var sto = new Fridge.StringStorage (); + print ("\n" + "Location: " + sto.storage_path + "\n"); + + switch (args[1]) { + case "load": print (sto.content);break; + case "save": sto.content = args[2];break; + default: ;break; + } + + return 0; + } diff --git a/demo/meson.build b/demo/meson.build new file mode 100644 index 0000000..cf477f1 --- /dev/null +++ b/demo/meson.build @@ -0,0 +1,11 @@ + + executable( + 'libfridge-demo', + 'demo.vala', + + dependencies: [ + dependency ('libfridge-0.1') + ], + + install: true, + ) \ No newline at end of file diff --git a/lib/JsonStorage.vala b/lib/JsonStorage.vala new file mode 100644 index 0000000..1a6c276 --- /dev/null +++ b/lib/JsonStorage.vala @@ -0,0 +1,239 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 Stella & Charlie (teamcons.carrd.co) + */ + +/** +* A library intended for basic storage, for beginner and whoever needs only hassle-free basics +* +* Json_storage represents a json file in the app's data folder +* You can use this class to store a Json.Array containing your objects (As Json.Node) +* You can optionally initialize it with with a file name +* +*/ +public class Fridge.JsonStorage : Object { + + /** + * Used to give a unique name for each new instance without file name + * Incremented during object creation, only when no argument has been passed + */ + private static uint8 unnamed_storage_count = 0; + + /** + * This signal gets emitted when the content of the storage has been changed + * This allows you to connect to your storage instance and trigger a function whenever there has been changes + */ + public signal void changed (); + + /** + * This signal gets emitted when there is an error while loading or saving, along with said error + * Connect to this if you wish to handle errors + */ + public signal void error (Error e); + + /** + * Whether the storage is read_only. False by default. + * If true, you cannot set content to anything. It does nothing. + */ + public bool read_only { get; set; default = false;} + + /** + * Whether to keep a duplicate of the storage content to access storage very quickly + * By default this is set to true. Cache is regenerated when saving, and when loading if empty and enabled + * You can force it to be reloaded by using empty_cache, then accessing the storage content + * This feature can be disabled anytime, but make sure to call empty_cache after disabling it to avoid keeping a stale cache in memory + */ + public bool keep_cache { get; set; default = true;} + + /** + * A copy of the storage file content. + */ + private Json.Array? cache; + + /** + * The name of the file saved on disk. This can be set only upon creation + * Should there be no name, a default one will be assigned + */ + public string filename { public get; private set;} + + /** + * The path of the directory where the storage file is saved + * This variable is not meant to be changed, and only as aid if the location is uncertain + */ + public string data_directory { public get; private set;} + + /** + * The full path of the storage file + * This variable is not meant to be changed, and only as aid if the location is uncertain + */ + public string storage_path { public get; private set;} + + /** + * The File object this storage represents + */ + public File file { public get; private set;} + + /** + * Create a representation of a storage file. If there is no file, the storage is considered empty + * There is one optional parameters: + * + * name: the name of the file to save to and load from. By default it is simply "storage.json" + * + * the storage emits a changed() signal whenever + */ + public JsonStorage (string? name = "") { + Object (filename: name); + } + + /** + * Property representing the content of storage on disk + * You can save a Json.Array by invoking: + * + * yourstorageinstance.content = array; + * + * And load the content of the file by doing + * + * var array = yourstorageinstance.content; + * + * You can disable the cache via keep_cache = false + * You can connect handlers to the storage via the changed() signal + * If you expect errors to happen, connect a handler to error() signal + */ + public Json.Array? content { + owned get { return load ();} + set { save (value);} + } + + /*************************************************/ + construct { + + // Allow having several storage files without declaring a single name + // Each get a unique number depending on the order it is declared + // If the order is the same each time, there wouldnt be any storage clash + if (filename == "") { + filename = "storage_%i.json".printf (unnamed_storage_count); + unnamed_storage_count += 1; + } + + data_directory = Environment.get_user_data_dir (); + storage_path = data_directory + "/" + filename; + file = File.new_for_path (storage_path); + check_if_datadir (); + } + + /*************************************************/ + /** + * Converts a Json.Node into a string and take care of saving it + */ + private void save (Json.Array? json_data) { + debug ("[STORAGE] Writing..."); + check_if_datadir (); + + if (read_only) { + warning ("Storage is read_only"); + return; + } + + try { + var generator = new Json.Generator (); + var node = new Json.Node (Json.NodeType.ARRAY); + node.set_array (json_data); + generator.set_root (node); + generator.to_file (storage_path); + if (keep_cache) { cache = json_data;}; + changed (); + + } catch (Error e) { + warning ("[STORAGE] Failed to save to storage: %s", e.message); + error (e); + } + } + + /*************************************************/ + /** + * Grab from storage, into a Json.Node we can parse. Insist if necessary + * We simply return a copy of the cache in the event we track one and it isn't empty + * Should the storage be empty, and thus the cache as well, we still check on-disk + */ + private Json.Array? load () { + debug ("[STORAGE] Loading from storage letsgo"); + check_if_datadir (); + + if (keep_cache && (cache != null)) { + return cache; + } + + var parser = new Json.Parser (); + var array = new Json.Array (); + + try { + parser.load_from_mapped_file (storage_path); + var node = parser.get_root (); + array = node.get_array (); + if (keep_cache) { cache = array;}; + + } catch (Error e) { + warning ("[STORAGE] Failed to load from storage: " + e.message.to_string ()); + error (e); + } + + return array; + } + + /*************************************************/ + /** + * Drop everything. The next time "content" is accessed, it will be read from disk + * If keep_cache is set to true, a new cache will be generated + */ + private void empty_cache () { + debug ("[STORAGE] Emptying cache"); + cache = null; + } + + /*************************************************/ + /** + * Return whether storage is empty + */ + private bool is_empty () { + debug ("[STORAGE] Checking if storage is empty"); + var currently_stored = load (); + return ((currently_stored == null) || (currently_stored.get_elements ().length () == 0)); + } + + /*************************************************/ + /** + * Return whether storage contains input + */ + private bool contains (Json.Node? some_node) { + debug ("[STORAGE] Checking if storage contains element"); + var currently_stored = load (); + + if (currently_stored == null) { + return false; + } + + foreach (var node in currently_stored.get_elements ()) { + if (some_node == node) { + return true; + } + } + + return false; + } + + /*************************************************/ + /** + * Call this to delete the file storage is connected to + */ + public void delete () { + file.delete_async.begin (GLib.Priority.DEFAULT, null, (obj, res) => { + try { + file.trash_async.end (res); + + } catch (Error e) { + this.error (e); + warning (e.message); + } + }); + } +} diff --git a/lib/Static.vala b/lib/Static.vala new file mode 100644 index 0000000..8be5554 --- /dev/null +++ b/lib/Static.vala @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 Stella & Charlie (teamcons.carrd.co) + */ + +namespace Fridge { + private static bool initialized = false; + + /** + * The number of files detected in the data directory + * You need to call Fridge.init () or else this will not be initialized + * + * Access to files will pollute the data directory with temporary files and + * pollute the count if it is done after declaring a couple Storage objects, + * So we can obtain a reliable count only early on. + */ + public static uint8? file_count { get; private set; default = null;} + + + /** + * The name of all files detected in the data directory + * You need to call Fridge.init () or else this will not be initialized + * + * Access to files will pollute the data directory with temporary files and + * pollute the count if it is done after declaring a couple Storage objects, + * So we can obtain a reliable count only early on. + */ + public static string[]? file_list { get; private set; default = null;} + + + /** + * Hi bud + */ + public static void say_hello () { + stdout.printf ("say_hello () called\n"); + } + + /*************************************************/ + /** + * Persistently check for the data directory and create if there is none + * Without this, we risk creating our storage in the void + */ + private static void check_if_datadir () { + debug ("[STORAGE] do we have a data directory?"); + var dir = GLib.File.new_for_path ( Environment.get_user_data_dir ()); + + try { + if (!dir.query_exists ()) { + dir.make_directory (); + debug ("[STORAGE] yes we do now"); + } + } catch (Error e) { + warning ("[STORAGE] Failed to prepare target data directory: %s\n", e.message); + } + } + + /** + * Initializes Fridge. If Fridge has already been initialized, the function will return. + * Retrieves how many files we have in the data directory + */ + public static void init () { + if (initialized) { + return; + } + + check_if_datadir (); + + try { + var data_dir = Dir.open (Environment.get_user_data_dir ()); + string? filename = null; + Fridge.file_count = 0; + Fridge.file_list = {} + + while ((filename = data_dir.read_name ()) != null) { + print (filename); + + Fridge.file_count++; + Fridge.file_list += filename; + } + + } catch (Error e) { + warning ("Cannot read datadir! Is the disk okay? %s\n", e.message); + } + } + + + +} diff --git a/lib/StringStorage.vala b/lib/StringStorage.vala new file mode 100644 index 0000000..a093506 --- /dev/null +++ b/lib/StringStorage.vala @@ -0,0 +1,234 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 Stella & Charlie (teamcons.carrd.co) + */ + +/** +* A library intended for basic storage, for beginner and whoever needs only hassle-free basics +* +* Json_storage represents a json file in the app's data folder +* You can use this class to store a Json.Array containing your objects (As Json.Node) +* You can optionally initialize it with with a file name +* +*/ +public class Fridge.StringStorage : GLib.Object { + + /** + * Used to give a unique name for each new instance without file name + * Incremented during object creation, only when no argument has been passed + */ + private static uint8 unnamed_storage_count = 0; + + /** + * This signal gets emitted when the content of the storage has been changed + * This allows you to connect to your storage instance and trigger a function whenever there has been changes + */ + public signal void changed (); + + /** + * This signal gets emitted when there is an error while loading or saving, along with said error + * Connect to this if you wish to handle errors + */ + public signal void error (Error e); + + /** + * Whether the storage is read_only. False by default. + * If true, you cannot set content to anything. It does nothing. + */ + public bool read_only { get; set; default = false;} + + /** + * Whether to keep a duplicate of the storage content to access storage very quickly + * By default this is set to true. Cache is regenerated when saving, and when loading if empty and enabled + * You can force it to be reloaded by using empty_cache, then accessing the storage content + * This feature can be disabled anytime, but make sure to call empty_cache after disabling it to avoid keeping a stale cache in memory + */ + public bool keep_cache { get; set; default = true;} + + /** + * A copy of the storage file content. + */ + private string? cache; + + /** + * The name of the file saved on disk. This can be set only upon creation + * Should there be no name, a default one will be assigned + */ + public string filename { public get; private set;} + + /** + * The path of the directory where the storage file is saved + * This variable is not meant to be changed, and only as aid if the location is uncertain + */ + public string data_directory { public get; private set;} + + /** + * The full path of the storage file + * This variable is not meant to be changed, and only as aid if the location is uncertain + */ + public string storage_path { public get; private set;} + + /** + * The File object this storage represents + */ + public GLib.File file { public get; private set;} + + /** + * Create a representation of a storage file. If there is no file, the storage is considered empty + * There is one optional parameters: + * + * name: the name of the file to save to and load from. By default it is simply "storage.json" + * + * the storage emits a changed() signal whenever + */ + public StringStorage (string? name = "") { + GLib.Object (filename: name); + } + + /** + * Property representing the content of storage on disk + * You can save a Json.Array by invoking: + * + * yourstorageinstance.content = array; + * + * And load the content of the file by doing + * + * var array = yourstorageinstance.content; + * + * You can disable the cache via keep_cache = false + * You can connect handlers to the storage via the changed() signal + * If you expect errors to happen, connect a handler to error() signal + */ + public string? content { + owned get { return load ();} + set { save (value);} + } + + /*************************************************/ + construct { + + // Allow having several storage files without declaring a single name + // Each get a unique number depending on the order it is declared + // If the order is the same each time, there wouldnt be any storage clash + if (filename == "") { + filename = "storage_%i.json".printf (unnamed_storage_count); + unnamed_storage_count += 1; + } + + data_directory = Environment.get_user_data_dir (); + storage_path = data_directory + "/" + filename; + file = GLib.File.new_for_path (storage_path); + check_if_datadir (); + } + + /*************************************************/ + /** + * Converts a Json.Node into a string and take care of saving it + */ + private void save (string? string_data) { + debug ("[STORAGE] Writing..."); + check_if_datadir (); + + if (read_only) { + warning ("Storage is read_only"); + return; + } + + try { + var storage_file = GLib.File.new_for_path (storage_path); + var dostream = new DataOutputStream ( + storage_file.replace (null, false, GLib.FileCreateFlags.REPLACE_DESTINATION) + ); + + dostream.put_string (string_data); + if (keep_cache) { cache = string_data;}; + changed (); + + } catch (Error e) { + warning ("[STORAGE] Failed to save to storage: %s", e.message); + error (e); + } + } + + /*************************************************/ + /** + * Grab from storage, into a Json.Node we can parse. Insist if necessary + * We simply return a copy of the cache in the event we track one and it isn't empty + * Should the storage be empty, and thus the cache as well, we still check on-disk + */ + private string? load () { + debug ("[STORAGE] Loading from storage letsgo"); + check_if_datadir (); + + if (keep_cache && (cache != null)) { + return cache; + } + + string string_data = null; + + try { + var storage_file = File.new_for_path (storage_path); + var distream = new DataInputStream (storage_file.read (null)); + string_data = distream.read_upto ("", -1, null); + if (keep_cache) { cache = string_data;}; + + } catch (Error e) { + warning ("[STORAGE] Failed to load from storage: " + e.message.to_string()); + error (e); + } + + return string_data; + } + + /*************************************************/ + /** + * Drop everything. The next time "content" is accessed, it will be read from disk + * If keep_cache is set to true, a new cache will be generated + */ + private void empty_cache () { + debug ("[STORAGE] Emptying cache"); + cache = null; + } + + /*************************************************/ + /** + * Return whether storage is empty + */ + private bool is_empty () { + debug("[STORAGE] Checking if storage is empty"); + var currently_stored = load (); + return ((currently_stored == null) || (currently_stored == "")); + } + + /*************************************************/ + /** + * Return whether storage contains input + */ + private bool contains (string? some_string) { + debug ("[STORAGE] Checking if storage contains element"); + var currently_stored = load (); + + // True if storage is null, else false. + if (some_string == null) { + return (currently_stored == null); + } + + return (currently_stored.contains (some_string)); + } + + /*************************************************/ + /** + * Call this to delete the file storage is connected to + */ + public void delete () { + file.delete_async.begin (GLib.Priority.DEFAULT, null, (obj, res) => { + try { + file.trash_async.end (res); + + } catch (Error e) { + this.error (e); + warning (e.message); + } + }); + } +} diff --git a/src/meson.build b/lib/meson.build similarity index 92% rename from src/meson.build rename to lib/meson.build index a7e95a3..d019f22 100644 --- a/src/meson.build +++ b/lib/meson.build @@ -1,7 +1,12 @@ sources = files([ - 'library.vala' + 'Static.vala', + 'StringStorage.vala' ]) +if get_option('enable_json') + sources += files(['JsonStorage.vala']) +endif + libfridge = library( LIBRARY_NAME + '-' + API_VERSION, sources, diff --git a/meson.build b/meson.build index 6bc626e..17bbc95 100644 --- a/meson.build +++ b/meson.build @@ -21,12 +21,42 @@ add_project_arguments(['--enable-experimental'], language: 'vala') g_ir_compiler = find_program('g-ir-compiler', required: false) +if build_machine.system() == 'linux' + vala_os_arg = ['--define=LINUX'] +elif build_machine.system() == 'dragonfly' + vala_os_arg = ['--define=DRAGON_FLY'] +elif build_machine.system() == 'freebsd' + vala_os_arg = ['--define=FREE_BSD'] +elif build_machine.system() == 'netbsd' + vala_os_arg = ['--define=NET_BSD'] +elif build_machine.system() == 'windows' + vala_os_arg = ['--define=WINDOWS'] +else + vala_os_arg = [] +endif + +glib_min_version = '2.50' +if build_machine.system() == 'windows' + gio_os_dep = dependency('gio-windows-2.0', version: '>=' + glib_min_version) +else + gio_os_dep = dependency('gio-unix-2.0', version: '>=' + glib_min_version) +endif + +add_project_arguments( + vala_os_arg, + '--target-glib=' + glib_min_version, + language: ['vala'] +) + libfridge_deps = [ - dependency('glib-2.0'), - dependency('gobject-2.0') + dependency('gio-2.0', version: '>=' + glib_min_version), + gio_os_dep, + dependency('glib-2.0', version: '>=' + glib_min_version), + dependency('gobject-2.0', version: '>=' + glib_min_version), + dependency('json-glib-1.0', required: get_option('enable_json')) ] -subdir('src') +subdir('lib') if get_option('enable_valadoc') valadoc = find_program('valadoc') @@ -44,7 +74,7 @@ if get_option('enable_valadoc') '--force' ], output: meson.project_name(), - build_by_default: true + build_by_default: false ) install_subdir( @@ -52,3 +82,7 @@ if get_option('enable_valadoc') install_dir: get_option('datadir') / 'devhelp' / 'books' ) endif + +if get_option('build_demo') + subdir('demo') +endif diff --git a/meson_options.txt b/meson_options.txt index 3849cf5..36523e6 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1,5 +1,3 @@ -option( - 'enable_valadoc', - type: 'boolean', - value: true -) +option('enable_valadoc', type: 'boolean', value: true) +option('enable_json', type : 'boolean', value : true) +option('build_demo', type : 'boolean', value : true) \ No newline at end of file diff --git a/src/library.vala b/src/library.vala deleted file mode 100644 index 01320d3..0000000 --- a/src/library.vala +++ /dev/null @@ -1,5 +0,0 @@ -namespace Fridge { - public static void say_hello () { - stdout.printf ("say_hello () called\n"); - } -}