Created
January 17, 2026 22:56
-
-
Save stefanct/0c974c83de94581bdc68b6be921b8bd8 to your computer and use it in GitHub Desktop.
Convert between FossifyOrg's Notes' JSON format and clear text
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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