Skip to content

Instantly share code, notes, and snippets.

@stefanct
Created January 17, 2026 22:56
Show Gist options
  • Select an option

  • Save stefanct/0c974c83de94581bdc68b6be921b8bd8 to your computer and use it in GitHub Desktop.

Select an option

Save stefanct/0c974c83de94581bdc68b6be921b8bd8 to your computer and use it in GitHub Desktop.
Convert between FossifyOrg's Notes' JSON format and clear text
This is a simple bash script that utilizes jq as a workaround for the lack of human-readable storage format of Fossify's Notes (https://f-droid.org/packages/org.fossify.notes/) as described in this issue:
https://github.com/FossifyOrg/Notes/issues/37
#!/usr/bin/env bash
set -euo pipefail
# todo_convert - sync a human-readable todo list and Fossy Notes' JSON representation
progname="$(basename "$0")"
# Parameter check and usage message
if [ "$#" -ne 2 ]; then
cat >&2 <<USAGE
Usage: $progname JSON_FILE TEXT_FILE
Syncs a human-readable todo file and the respective Fossify Notes' JSON representation.
Exactly two positional arguments are required:
JSON_FILE Path to the JSON file (array of objects with dateCreated (ms), id, isDone, title)
TEXT_FILE Path to the human-readable file (one item per line: "[ ] title" or "[x] title")
The script checks which file was modified most recently, validates that file,
converts it to the other format, and writes the result atomically.
USAGE
exit 2
fi
json_file="$1"
text_file="$2"
# Check dependencies
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not found. Install jq and retry." >&2
exit 3
fi
# Get file modification time in seconds since epoch, or -1 if it does not exist
get_mtime() {
local file="$1"
if [ ! -e "$file" ]; then
echo -1
return
fi
stat -c %Y "$file"
}
# Convert text -> json
text_to_json() {
local in="$1"
local out="$2"
local now_ms=$(( $(date +%s) * 1000 ))
local tmp jqtmp
tmp=$(mktemp) || return 1
jqtmp=$(mktemp) || { rm -f "$tmp"; return 1; }
# start with empty array
jq -n '[]' > "$tmp"
local id=0
local lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno+1))
# trim leading/trailing whitespace
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
# Validate human-readable format: lines like "[ ] title" or "[x] title"
if ! [[ "${line}" =~ ^\[.\][[:blank:]]+[[:graph:]].* ]]; then
echo "Invalid format in ${in} at line ${lineno}: '$line'" >&2
return 1
fi
id=$((id+1))
if [[ "${line}" =~ ^\[\ \] ]]; then
isDone=false
else
isDone=true
fi
title="${line#???* }"
title="${title#"${title%%[![:space:]]*}"}"
title="${title%"${title##*[![:space:]]}"}"
# append object to array using jq (ensures proper escaping)
jq --argjson dateCreated "$now_ms" --argjson id "$id" --arg title "$title" --argjson isDone "$isDone" \
'. + [ { dateCreated: $dateCreated, id: $id, isDone: $isDone, title: $title } ]' \
"$tmp" > "$jqtmp" && mv "$jqtmp" "$tmp"
done < "$in"
mv "$tmp" "$out"
rm -f "$jqtmp" 2>/dev/null || true
echo "Wrote JSON to $out"
}
# Convert json -> text
json_to_text() {
local in="$1"
local out="$2"
# Use jq to produce lines: "[x] title" or "[ ] title"
# Preserve order as in array
jq -r 'map(
if .isDone then "[x] " + .title else "[ ] " + .title end
) | .[]' "$in" > "$out"
echo "Wrote text to $out"
}
mtime_json=$(get_mtime "$json_file")
mtime_text=$(get_mtime "$text_file")
# Decide which is newer
if [ "$mtime_json" -gt "$mtime_text" ]; then
newer="json"
elif [ "$mtime_text" -gt "$mtime_json" ]; then
newer="text"
else
# same mtime: prefer json -> text
newer="json"
fi
# Ensure files exist (create if missing)
if [ "$mtime_json" -eq -1 ]; then
printf '[]' > "$json_file"
fi
if [ "$mtime_text" -eq -1 ]; then
: > "$text_file"
fi
if [ "$newer" = "json" ]; then
# Validate JSON
now_ms=$(( $(date +%s) * 1000 ))
json_error=$(jq --argjson now "$now_ms" '
if type != "array" then
error("top-level must be an array")
else
(
to_entries
| map(
[
# dateCreated: check presence first, then type, then future
(if (.value | has("dateCreated") | not) then
"item " + ((.key+1)|tostring) + ": missing dateCreated"
elif (.value.dateCreated | type != "number") then
"item " + ((.key+1)|tostring) + ": dateCreated must be a number"
elif (.value.dateCreated > $now) then
"item " + ((.key+1)|tostring) + ": dateCreated is in the future"
else
empty
end),
# id: presence, type, positivity
(if (.value | has("id") | not) then
"item " + ((.key+1)|tostring) + ": missing id"
elif (.value.id | type != "number") then
"item " + ((.key+1)|tostring) + ": id must be a number"
elif (.value.id <= 0) then
"item " + ((.key+1)|tostring) + ": id must be positive"
else
empty
end),
# isDone: presence and boolean type
(if (.value | has("isDone") | not) then
"item " + ((.key+1)|tostring) + ": missing isDone"
elif (.value.isDone | type != "boolean") then
"item " + ((.key+1)|tostring) + ": isDone must be boolean"
else
empty
end),
# title: presence, type, non-empty
(if (.value | has("title") | not) then
"item " + ((.key+1)|tostring) + ": missing title"
elif (.value.title | type != "string") then
"item " + ((.key+1)|tostring) + ": title must be a string"
elif (.value.title | length == 0) then
"item " + ((.key+1)|tostring) + ": title must be non-empty"
else
empty
end)
]
| map(select(. != null and . != "")) # remove empty entries
)
# flatten to a single array of strings; add // [] ensures empty result becomes []
| (map(select(length > 0)) | add) // []
) as $errs
| if ($errs | length) > 0 then
error("validation errors:\n " + ($errs | join("\n ")))
else
empty
end
end
' "$json_file" 2>&1 >/dev/null) || true
if [ -n "$json_error" ]; then
printf "JSON validation failed:\n%s\n" "${json_error}" >&2
exit 4
fi
# Convert JSON -> text
tmp_out="$(mktemp "${text_file}.XXXX")"
json_to_text "$json_file" "$tmp_out"
mv "$tmp_out" "$text_file"
# Update json file timestamp to be older than text? Keep as-is.
echo "Converted JSON -> text ($json_file -> $text_file)."
else
# newer is text
tmp_out="$(mktemp "${json_file}.XXXX")"
if ! text_to_json "$text_file" "$tmp_out"; then
exit 5
fi
mv "$tmp_out" "$json_file"
echo "Converted text -> JSON ($text_file -> $json_file)."
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment