Changes
12 changed files (+417/-7)
-
-
@@ -31,9 +31,11 @@ ) orelse app_name;// System libraries to link. const system_libraries = [_][]const u8{ "gio-2.0", "glib-2.0", "gtk4", "libadwaita-1", "json-glib-1.0", }; const vala_main = vala_main: {
-
-
-
@@ -16,6 +16,7 @@ wrapGAppsHook4,glib, gtk4, libadwaita, json-glib, }: stdenvNoCC.mkDerivation { pname = "timetracker";
-
@@ -76,5 +77,9 @@ # https://gnome.pages.gitlab.gnome.org/libadwaita/# https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/ # https://valadoc.org/libadwaita-1/index.htm libadwaita # > Library providing (de)serialization support for the JavaScript Object Notation (JSON) format # https://gitlab.gnome.org/GNOME/json-glib json-glib ]; }
-
-
-
@@ -6,6 +6,8 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/.// // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker { public class Application : Adw.Application { public Application() {
-
@@ -13,8 +15,17 @@ Object(application_id: Config.APP_ID);} protected override void activate() { var window = new TimeTracker.Widget.MainWindow(this); Storage.Storage storage; try { storage = Storage.Local.from_dir( File.new_build_filename(Environment.get_user_data_dir(), "timetracker") ); } catch (Error error) { critical(@"Failed to init storage: $(error.message)"); return; } var window = new TimeTracker.Widget.MainWindow(this, storage); window.present(); } }
-
-
src/Event/EventType.vala (new)
-
@@ -0,0 +1,58 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Event { public errordomain EventTypeError { UNKNOWN_EVENT, } public enum EventType { TIMER_DELETED, TIMER_STARTED, TIMER_STOPPED, TIMER_TITLE_CHANGED, UNKNOWN; public Type to_type() throws EventTypeError { switch (this) { case TIMER_DELETED: return typeof(TimerDeleted); case TIMER_STARTED: return typeof(TimerStarted); case TIMER_STOPPED: return typeof(TimerStopped); case TIMER_TITLE_CHANGED: return typeof(TimerTitleChanged); default: throw new EventTypeError.UNKNOWN_EVENT(@"Unknown event type $this."); } } public static EventType from_event(Event event) throws EventTypeError { if (event is TimerDeleted) { return TIMER_DELETED; } if (event is TimerStarted) { return TIMER_STARTED; } if (event is TimerStopped) { return TIMER_STOPPED; } if (event is TimerTitleChanged) { return TIMER_TITLE_CHANGED; } throw new EventTypeError.UNKNOWN_EVENT(@"$(event.get_type().name()) is not a valid event type."); } } }
-
-
-
@@ -10,19 +10,49 @@ using GLib;namespace TimeTracker.Model { public sealed class Model : Object { public ListStore active_timers { get; internal set; } public ListStore stopped_timers { get; internal set; } public ListStore active_timers { get; construct; } public ListStore stopped_timers { get; construct; } public Storage.Storage? storage { get; construct; } public Model() { public Model(Storage.Storage? storage = null) { Object( active_timers: new ListStore(typeof (Timer)), stopped_timers: new ListStore(typeof (Timer)) stopped_timers: new ListStore(typeof (Timer)), storage: storage ); } construct { if (storage != null) { try { var events = storage.load(); foreach (var event in events) { update_internal(event); } } catch (Error error) { log(null, LEVEL_ERROR, @"Loading of events failed: $(error.message)"); } } } // Since Vala's type narrowing is half baked, cast inside branch may or may not be narrowed // down. Explicit cast means compiler fails narrowing at that branch. public void update(Event.Event event) { if (storage != null) { storage.save.begin(event, (_obj, res) => { try { storage.save.end(res); } catch (Error error) { critical(@"Save failed: $(error.message)"); } }); } update_internal(event); } internal void update_internal(Event.Event event) { if (event is Event.TimerStarted) { var timer = new Timer(event.timer_id, event.title, event.created_at); active_timers.append(timer);
-
-
src/Storage/Local.vala (new)
-
@@ -0,0 +1,162 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Storage { public sealed class Local : Object, Storage { private LocalEnvelope? tail { get; set; default = null; } private LocalManifest manifest { get; set; } private File dir { get; set; } private File manifest_file { get; set; } private File events_dir { owned get { return dir.get_child(manifest.events_dir); } } // This is currently sync because Gio.Application's entry point (`activate`) // have to be sync. Implementing loading window or splash-like file-select // dialog is not worth the effort for this prototype. public static Local from_dir(File dir) throws Error { try { dir.make_directory(); } catch (Error error) { if (!(error is IOError.EXISTS)) { throw error; } info(@"Initialized data directory at $(dir.get_parse_name())"); } LocalManifest manifest; var manifest_file = dir.get_child("timetracker.manifest.json"); try { manifest = read_manifest(manifest_file); } catch (Error error) { if (!(error is IOError.NOT_FOUND)) { throw error; } manifest = new LocalManifest(); write_manifest(manifest_file, manifest); info(@"Created manifest file at $(manifest_file.get_parse_name())"); } var events_dir = dir.get_child(manifest.events_dir); try { events_dir.make_directory(); } catch (Error error) { if (!(error is IOError.EXISTS)) { throw error; } info(@"Initialized events directory at $(events_dir.get_parse_name())"); } var storage = new Local() { dir = dir, manifest_file = manifest_file, manifest = manifest, }; return storage; } private static LocalManifest read_manifest(File file) throws Error { var stream = file.read(); var parser = new Json.Parser(); parser.load_from_stream(stream); var node = parser.get_root(); var manifest = Json.gobject_deserialize(typeof(LocalManifest), node) as LocalManifest; assert(manifest != null); return manifest; } private static void write_manifest(File file, LocalManifest manifest) throws Error { FileIOStream io; try { io = file.open_readwrite(); } catch (Error error) { if (!(error is IOError.NOT_FOUND)) { throw error; } io = file.create_readwrite(NONE); } var stream = io.output_stream; var node = Json.gobject_serialize(manifest); var generator = new Json.Generator(); generator.set_root(node); generator.to_stream(stream); } public Event.Event[] load() throws Error { if (manifest.tail == null) { return {}; } return load_events(manifest.tail); } private Event.Event[] load_events(string tail_id) throws Error { var list = new List<Event.Event>(); string? next_id = tail_id; while (next_id != null) { var envelope = load_envelope(next_id); if (manifest.tail == envelope.id) { tail = envelope; } list.prepend(envelope.parse_event()); next_id = envelope.parent; } Event.Event[] result = {}; result.resize((int) list.length()); foreach (var event in list) { result[list.index(event)] = event; } return result; } private LocalEnvelope load_envelope(string id) throws Error { var file = events_dir.get_child(@"$(id).json"); var stream = file.read(); return LocalEnvelope.new_from_stream(stream); } public async void save(Event.Event event) throws Error { var envelope = new LocalEnvelope(event) { parent = tail != null ? tail.id : null, }; var file = events_dir.get_child(@"$(event.event_id).json"); var io = file.create_readwrite(NONE); var stream = io.output_stream; envelope.write_stream(stream); stream.close(); tail = envelope; manifest.tail = event.event_id; write_manifest(manifest_file, manifest); return; } } }
-
-
-
@@ -0,0 +1,38 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Storage.Tests { public void add_local_envelope_tests() { Test.add_func("/local-envelope/serialize-and-deserialize", () => { try { var event = new Event.TimerStarted("Foo"); var envelope = new LocalEnvelope(event); var output = new MemoryOutputStream.resizable(); envelope.write_stream(output); output.close(); var written = output.steal_data(); written.length = (int) output.get_data_size(); var input = new MemoryInputStream.from_data(written); var restored = LocalEnvelope.new_from_stream(input); assert(envelope.event_type == restored.event_type); var restored_event = restored.parse_event() as Event.TimerStarted; assert((restored_event is Event.TimerStarted)); assert_cmpstr(restored_event.title, EQ, "Foo"); } catch (Error error) { assert_no_error(error); } }); } }
-
-
-
@@ -0,0 +1,63 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Storage { private class LocalEnvelope : Object { public string? parent { get; set; default = null; } public string id { get; construct; } public Event.EventType event_type { get; construct; } public string event_data { get; construct; } public LocalEnvelope(Event.Event event) throws Error { var node = Json.gobject_serialize(event); var generator = new Json.Generator(); generator.set_root(node); Object( id: event.event_id, event_data: generator.to_data(null), event_type: Event.EventType.from_event(event) ); } public static LocalEnvelope new_from_stream(InputStream stream) throws Error { var parser = new Json.Parser(); parser.load_from_stream(stream); var node = parser.get_root(); var envelope = Json.gobject_deserialize(typeof(LocalEnvelope), node) as LocalEnvelope; assert(envelope != null); return envelope; } public Event.Event parse_event() throws Error { var type = event_type.to_type(); var parser = new Json.Parser(); parser.load_from_data(event_data); var node = parser.get_root(); var event = Json.gobject_deserialize(type, node) as Event.Event; assert(event != null); return event; } public void write_stream(OutputStream stream) throws Error { var node = Json.gobject_serialize(this); var generator = new Json.Generator(); generator.set_root(node); generator.to_stream(stream); } } }
-
-
-
@@ -0,0 +1,21 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Storage { private class LocalManifest : Object { public uint64 version { get; construct; } public string? tail { get; set; default = null; } public string events_dir { get; set; default = "events"; } public LocalManifest() { Object(version: 1); } } }
-
-
src/Storage/Storage.vala (new)
-
@@ -0,0 +1,19 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 using GLib; namespace TimeTracker.Storage { public interface Storage : Object { // This method is sync because handling async initial load is // not simple task (pending state, error handling ,etc.) public abstract Event.Event[] load() throws Error; public abstract async void save(Event.Event event) throws Error; } }
-
-
-
@@ -12,8 +12,8 @@ namespace TimeTracker.Widget {public class MainWindow : Adw.ApplicationWindow { public Model.Model model { get; construct; } public MainWindow(Gtk.Application app) { Object(application: app, model: new Model.Model()); public MainWindow(Gtk.Application app, Storage.Storage storage) { Object(application: app, model: new Model.Model(storage)); } construct {
-
-
-
@@ -10,6 +10,7 @@ static void main(string[] args) {GLib.Test.init(ref args); TimeTracker.Model.Tests.add_event_tests(); TimeTracker.Storage.Tests.add_local_envelope_tests(); GLib.Test.run(); }
-